DEV Community

Afaq Shahid Khan
Afaq Shahid Khan

Posted on

Implementing Tap Payments in a SaaS Subscription System (Node.js + Express + Sequelize)

When building a SaaS product with paid plans, payment handling is never just about charging a card. You need subscriptions, renewals, upgrades, proration, invoices, webhooks, and failure handling β€” all working reliably.

In this post, I’ll walk through how to integrate Tap Payments into a Node.js + Express + Sequelize backend for a band-based subscription model, using a real production-style architecture.

This guide focuses on backend design, security, and reliability, not just β€œmaking a payment work”.

🧱 Architecture Overview

Our system is built around these core components:

  • Express (TypeScript) β€” API layer
  • Sequelize β€” relational data modeling
  • Tap Payments API β€” card payments
  • Webhook-based state sync β€” payment truth source
  • Band-based licensing β€” users pay per seat band

High-level flow:

Client β†’ Backend β†’ Tap Checkout
β†˜ Webhook β†’ Backend β†’ DB




## πŸ“¦ Subscription & Payment Data Model

### Subscription (Account-level)

Each account has **exactly one subscription**:

```

ts
Subscription
- accountId
- bandSize
- subscriptionType (trial | paid | suspended)
- startDate / endDate
- unitPrice
- totalAmount
- status


Enter fullscreen mode Exit fullscreen mode

SubscriptionPayment (Immutable Ledger)

Every charge creates a payment record:


ts
SubscriptionPayment
- subscriptionId
- tapChargeId
- amount
- currency
- paymentMethod (card | invoice)
- status (pending | paid | failed)
- paidAt


Enter fullscreen mode Exit fullscreen mode

Key principle:

Subscription state changes ONLY after payment confirmation (via webhook).


πŸ” Tap API Client (Centralized & Secure)

We isolate Tap API communication in a single client:


ts
const tapAxios = axios.create({
  baseURL: "https://api.tap.company/v2",
  headers: {
    Authorization: `Bearer ${process.env.TAP_SECRET_KEY}`,
    "Content-Type": "application/json",
  },
});


Enter fullscreen mode Exit fullscreen mode

This avoids:

  • Token leaks
  • Duplicate logic
  • Inconsistent headers

πŸ’³ Creating a Charge (Backend-Controlled)

When a user activates or upgrades a subscription:

  1. Create subscription & pending payment in DB
  2. Create a Tap charge
  3. Redirect user to Tap checkout

ts
export const createTapCharge = async (
  amount: number,
  subscriptionPaymentId: string,
  customer: Customer,
  redirectUrl: string
) => {
  return tapAxios.post("/charges", {
    amount,
    currency: "OMR",
    customer,
    source: { id: "src_card" },
    redirect: { url: redirectUrl },
    metadata: {
      subscriptionPaymentId,
      type: "subscription",
    },
  });
};


Enter fullscreen mode Exit fullscreen mode

Why metadata matters

We store subscriptionPaymentId inside Tap metadata.

This allows:

  • Webhook β†’ DB mapping
  • Idempotent processing
  • No guessing which payment belongs to which subscription

πŸ” Redirect-Based Flow (User Experience)

After charge creation, backend returns:


json
{
  "paymentUrl": "https://tap.company/checkout/...",
  "chargeId": "chg_xxx"
}


Enter fullscreen mode Exit fullscreen mode

Frontend simply redirects the user.


πŸͺ Webhooks: The Source of Truth

Never trust frontend redirects alone.

Why Webhooks?

  • Users may close the tab
  • Network failures happen
  • Redirects can be spoofed

Secure Webhook Verification

Tap signs every webhook payload.


ts
const expectedSignature = crypto
  .createHmac("sha256", TAP_WEBHOOK_SECRET)
  .update(rawBody)
  .digest("hex");


Enter fullscreen mode Exit fullscreen mode

Only verified requests are processed.


πŸ”„ Webhook Processing Logic

On webhook receipt:

  1. Verify signature
  2. Extract subscriptionPaymentId
  3. Update payment status
  4. Update subscription if payment succeeded

ts
if (status === "CAPTURED") {
  await payment.update({ status: "paid", paidAt: new Date() });

  await subscription.update({
    status: "active",
    subscriptionType: "paid",
    endDate: nextYear,
  });
}


Enter fullscreen mode Exit fullscreen mode

Important rule:

Subscription state is updated ONLY inside webhook logic.


πŸ“ˆ Subscription Upgrade with Proration

Upgrades are prorated based on remaining time:


ts
const remainingYears =
  (subscription.endDate.getTime() - Date.now()) /
  (365.25 * 24 * 60 * 60 * 1000);

const proratedAmount =
  additionalUsers * UNIT_PRICE * remainingYears;


Enter fullscreen mode Exit fullscreen mode

This creates:

  • A new pending payment
  • A new Tap charge
  • No disruption to current billing cycle

πŸ“‰ Downgrades (Deferred)

Downgrades:

  • Apply on next renewal
  • Do NOT refund automatically
  • Prevent abuse near renewal date

ts
if (isWithinLockoutWindow(subscription.endDate)) {
  throw new Error("Cannot downgrade near renewal");
}


Enter fullscreen mode Exit fullscreen mode

🧾 Invoice-Based Billing (Enterprise Ready)

For corporate customers:

  • No Tap charge created
  • Payment marked as invoice
  • Admin manually confirms payment

ts
await SubscriptionPayment.create({
  paymentMethod: "invoice",
  status: "paid",
});


Enter fullscreen mode Exit fullscreen mode

This allows:

  • Offline billing
  • Bank transfers
  • Enterprise workflows

🚫 Automatic Suspension for Non-Payment

A scheduled job checks overdue subscriptions:


ts
if (nextPaymentDue < cutoffDate) {
  await subscription.update({ status: "suspended" });
}


Enter fullscreen mode Exit fullscreen mode

This ensures:

  • Fair enforcement
  • Predictable access control
  • No manual intervention

πŸ” Authorization & Safety

Critical actions are protected:

  • Only Account Owners can:

    • Activate plans
    • Upgrade/downgrade
  • All subscription updates use DB transactions

  • Payments are immutable records


βœ… Key Takeaways

βœ” Backend owns all payment logic
βœ” Webhooks are the source of truth
βœ” Metadata connects payments to business logic
βœ” Subscriptions β‰  payments
βœ” Design for upgrades, failures, and audits


πŸš€ Final Thoughts

Tap Payments integrates cleanly into modern SaaS backends when you treat payments as state machines, not API calls.

If you’re building:

  • Multi-tenant SaaS
  • Seat-based pricing
  • Annual subscriptions
  • Enterprise billing

This architecture will scale with confidence.

Top comments (0)