Skip to content

Payment System & Credit Purchase Architecture ​

Complete documentation for the Oktivo multi-payment gateway integration. Covers the multi-tenant payment flow, multi-gateway provider abstraction, per-org credential storage, gateway switching, feature gating, webhook handling, and how it connects to the existing credit system.


How It All Fits Together ​

There are two ways credits reach a customer:

  1. Manual distribution SuperAdmin recharges org → Admin distributes to customer (org pool decreases)
  2. Self-service purchase Customer pays real money via configured gateway → credits added directly to customer balance (org pool is NOT affected)

Multi-Gateway Architecture ​

The system supports multiple payment gateways (Cashfree and Razorpay) with per-organization credential storage. Each org can configure credentials for multiple gateways and switch between them.

Key Design Decisions ​

DecisionRationale
Per-org gateway credentials stored in DBWhite-label platform each org has their own payment accounts
activeProvider determines new ordersAllows instant gateway switching without reconfiguring
Pending orders use original providerOrders in-flight must complete with the gateway they started on
Credential retention for pending ordersCan't change credentials while orders are being processed
Single webhook URL per org (all providers)Handler iterates gateways to find matching signature

Credit Flow: Customer Self-Service Purchase ​


Gateway Switching Flow ​


Feature Gate & Enable/Disable Flow ​

LayerControlWho Sets It
Feature Gatepayments in org plan.featuresSuperAdmin
isActive togglePaymentConfig.isActiveOrg Admin
Gateway credentialsPaymentConfig.gateways[provider]Org Admin

Provider Abstraction ​

Adding a New Provider ​

  1. Create services/payment/newprovider.provider.ts implementing PaymentProvider
  2. Register in services/payment/provider.factory.ts
  3. Add provider name to activeProvider enum in PaymentConfig model
  4. Done controller, webhook handler, and frontend all work automatically

Database Models ​

PaymentConfig (one per org) Multi-Gateway Schema ​

PaymentConfig {
  orgUID:             String    (unique index)
  activeProvider:     String    (enum: "cashfree" | "razorpay", default: "cashfree")
  gateways: {
    cashfree?: {
      credentials: { appId: String, secretKey: String }
      webhookSecret:  String
      mode:           String  (enum: "sandbox" | "production")
    }
    razorpay?: {
      credentials: { appId: String, secretKey: String }
      webhookSecret:  String
      mode:           String  (enum: "sandbox" | "production")
    }
  }
  creditPricePerUnit: Number    (min: 0.01)
  minCreditPurchase:  Number    (default: 1000)
  isActive:           Boolean   (default: true)

  // --- Deprecated fields (kept for backward compat during transition) ---
  provider:           String    (enum: "cashfree" | "razorpay" | "stripe")
  credentials:        { appId, secretKey }
  mode:               String
  webhookSecret:      String
}

Key fields:

  • activeProvider determines which gateway processes NEW orders
  • gateways map of per-provider credentials (supports multiple simultaneously)
  • isActive admin toggle to enable/disable all payments for the org
  • Deprecated top-level fields kept for backward compatibility with pre-migration documents

Helper methods on the model:

  • getActiveGatewayConfig() returns credentials for the active provider (with fallback)
  • getGatewayConfig(providerName) returns credentials for a specific provider

File: client_backend/src/models/PaymentConfig.ts

PaymentOrder (one per purchase attempt) ​

FieldTypeDescription
orderUIDstringUnique order ID (pay_ + nanoid(16)), used as receipt
cfOrderIdstringProvider's internal order ID (indexed for webhook lookup)
orgUIDstringWhich org this order belongs to
customerUIDstringWhich customer is buying
providerstringWhich gateway was used (recorded at creation time)
creditsRequestednumberHow many credits the customer wants
amountINRnumberCalculated: credits × creditPricePerUnit
statusenumSee status lifecycle below
cfPaymentIdstringProvider's payment ID (after payment)
paymentMethodstringe.g., "upi", "card", "netbanking"
creditTransactionIdstringLinks to CreditTransaction after credits added
statusHistoryarrayAudit trail of all state transitions
expiresAtDateOrder expires after 30 minutes (cron) or 2-5 min (inline auto-expire)
billingGstinstringCustomer's GSTIN at time of purchase (snapshot)
billingBusinessNamestringCustomer's GST business name (snapshot)
billingPanstringCustomer's PAN if provided (snapshot)
invoiceNumberstringGenerated invoice number (e.g., INV-20260602-DQRJ8E)
invoiceDataobjectFull invoice data (tax breakdown, seller/buyer info)
invoiceSentAtDateTimestamp when invoice email was sent

File: client_backend/src/models/PaymentOrder.ts

BillingDetails (one per customer per org) ​

Stores GST/PAN details for customers making credit purchases. Created once on first recharge, then reused for all subsequent purchases.

FieldTypeDescription
customerUIDstringCustomer who owns these billing details
orgUIDstringOrganization context
gstinstring15-character GSTIN (mandatory for recharge)
gstBusinessNamestringLegal business name from GST registration
gstBusinessAddressstringRegistered business address (optional)
gstStatestringState derived from GSTIN state code
panstring10-character PAN (optional)
panHolderNamestringName on PAN card (optional)
isGstVerifiedbooleanWhether GSTIN format + checksum passed
isPanVerifiedbooleanWhether PAN format validated

Validation rules:

  • GSTIN: format regex + state code check + Luhn mod-36 checksum
  • PAN: format regex + entity type validation
  • Cross-validation: if both provided, PAN must match characters 3-12 of GSTIN
  • Unique index on { customerUID, orgUID } one record per customer per org

File: client_backend/src/models/BillingDetails.ts

Order Status Lifecycle ​


API Endpoints ​

Admin Endpoints (JWT + admin role + feature gate) ​

MethodPathMiddlewareDescription
GET/admin/payment-configrequireFeatureAccess(managePayments)Get config (secrets masked)
PUT/admin/payment-configrequireFeatureAccess(managePayments)Create/update config
GET/admin/payment-ordersrequireFeatureAccess(managePayments)List org payment orders

Customer Endpoints (JWT + feature gate) ​

MethodPathMiddlewareDescription
GET/payments/pricingrequireFeatureAccess(purchaseCredits)Get pricing + isActive + activeProvider
POST/payments/create-orderrequireFeatureAccess(purchaseCredits)Create payment order
GET/payments/verify/:orderUIDrequireFeatureAccess(purchaseCredits)Check/poll order status
GET/payments/ordersrequireFeatureAccess(purchaseCredits)List customer's orders
GET/payments/billing-detailsrequireFeatureAccess(purchaseCredits)Get customer's stored GST/PAN details
POST/payments/billing-detailsrequireFeatureAccess(purchaseCredits)Save/update GST/PAN billing details
POST/payments/validate-gstinrequireFeatureAccess(purchaseCredits)Real-time GSTIN validation (format + checksum)

Webhook Endpoint (no auth called by providers) ​

MethodPathDescription
POST/payments/webhook/:orgUIDReceives webhooks from any configured provider

Webhook Handling (Multi-Provider) ​

Webhook Security ​

CheckCashfreeRazorpay
Signature algorithmHMAC-SHA256 (timestamp + body)HMAC-SHA256 (body)
Signature headerx-cashfree-signaturex-razorpay-signature
Replay protectionTimestamp freshness (5 min)N/A (signature-only)
Org validationOrder orgUID vs webhook URL orgUIDSame
Amount validationConfirmed amount vs order amountINRSame

Webhook Configuration ​

For Cashfree:

  • Dashboard → Developers → Webhooks
  • URL: https://{backend-domain}/payments/webhook/{orgUID}
  • Events: PAYMENT_SUCCESS_WEBHOOK, PAYMENT_FAILED_WEBHOOK

For Razorpay:

  • Dashboard → Settings → Webhooks
  • URL: https://{backend-domain}/payments/webhook/{orgUID}
  • Events: payment.captured, payment.failed, order.paid
  • Secret: same value as stored in PaymentConfig.gateways.razorpay.webhookSecret

Verification Flow (Polling Fallback) ​

When webhooks can't reach the backend (e.g., localhost dev), the frontend polls GET /payments/verify/:orderUID which calls the provider's API directly:

Cashfree: Fetches order status from /pg/orders/{orderId}

Razorpay:

  1. Looks up order by receipt (our orderUID)
  2. If order status is paid → return PAID
  3. If not, fetches payments on the order via orders.fetchPayments(orderId)
  4. If any payment has status: "captured" → return PAID

This ensures payments are detected even without webhooks.


Validation Rules ​

ValidationWhenError
Gateway switch without credentialsPUT with activeProvider change400 "Cannot switch: credentials not configured"
Incomplete credentials on savePUT with gateways[x].credentials400 "Incomplete gateway credentials" + field errors
Credential change with pending ordersPUT changing appId for a gateway400 "Cannot update: N pending orders"
Payment system disabledPOST /create-order when isActive=false400 "Payment system is currently disabled"
Feature gateAny payment route without payments feature403 Forbidden

Frontend Architecture ​

Admin: PaymentConfigPage ​

Customer: BuyCredits / AddCredits ​


Email Notifications ​

After a successful credit purchase (status transitions to credits_added), a GST invoice email is sent to the customer via the BullMQ email queue:

FieldValue
Templategst-invoice
Subject"GST Invoice {INV-NUMBER} {N} credits purchased"
ContentOrder ID, credits added, amount paid, payment method, GSTIN, business name, date
AttachmentPDF invoice (GST-compliant tax invoice with full tax breakdown)
Failure handlingNon-blocking email/invoice failure doesn't affect payment flow

GST Invoice (PDF) ​

Every successful purchase generates a GST-compliant tax invoice as a PDF attachment:

  • Generated using PDFKit (pure Node.js, no browser dependency)
  • Contains: seller/buyer details, GSTIN, HSN/SAC code, line items, CGST/SGST or IGST split
  • Inter-state detection: compares first 2 digits of buyer/seller GSTIN to determine tax type
  • Invoice number format: INV-YYYYMMDD-XXXXXX (date + random suffix)
  • Stored in PaymentOrder.invoiceData for record-keeping
  • Idempotent: won't re-send if invoiceSentAt is already set

GST Billing Details Flow ​

Rules:

  • Customer must choose billing type ("individual" or "company") before first purchase
  • GST is mandatory only for company billing type; individual can purchase without GSTIN
  • PAN is optional but cross-validated against GSTIN if provided
  • Only asked on first recharge stored and reused for subsequent purchases
  • GSTIN validated offline: format regex + Indian state code + Luhn mod-36 checksum
  • Billing snapshot captured on each order (in case customer updates details later)

Environment Variables ​

VariableRequiredDescription
WEBHOOK_BASE_URLNoPublic URL for webhook display (e.g., https://api.example.com). Falls back to request host.

No Razorpay/Cashfree keys in env all credentials are stored per-org in the database.


Migration ​

A migration script exists for transitioning existing PaymentConfig documents from the old flat schema to the new multi-gateway schema:

bash
npx tsx src/scripts/migrate-payment-config-multi-gateway.ts [--dry-run]

What it does:

  • Moves credentials, mode, webhookSecret into gateways[provider]
  • Sets activeProvider to current provider value
  • Sets isActive to true
  • Removes deprecated top-level fields
  • Idempotent safe to run multiple times

Backend Code Map ​

client_backend/src/
├── controllers/
│   ├── payment.controller.ts       # All payment API handlers + webhook + processSuccessfulPayment + invoice
│   └── billing.controller.ts       # GST/PAN billing details CRUD + GSTIN validation endpoint
├── services/
│   ├── payment/
│   │   ├── provider.interface.ts   # PaymentProvider contract + types (incl. billingGstin)
│   │   ├── cashfree.provider.ts    # Cashfree implementation (passes GSTIN in order_tags)
│   │   ├── razorpay.provider.ts    # Razorpay implementation (passes GSTIN in notes)
│   │   └── provider.factory.ts     # Provider resolution by name
│   ├── invoice.service.ts          # Invoice data builder + HTML/text formatters + tax calculation
│   ├── pdf.service.ts              # PDF invoice generation using PDFKit (no browser needed)
│   ├── credit.service.ts           # purchaseCredits() method
│   ├── email/
│   │   └── email.service.ts        # BullMQ email queue (pre-encodes attachments for serialization)
│   └── scheduler.service.ts        # Cron: expire stale orders every 5 min
├── models/
│   ├── PaymentConfig.ts            # Per-org multi-gateway config
│   ├── PaymentOrder.ts             # Individual purchase order tracking (incl. billing + invoice fields)
│   └── BillingDetails.ts           # Customer GST/PAN storage (one per customer per org)
├── utils/
│   └── gst.utils.ts                # GSTIN checksum + PAN validation + state lookup + cross-validation
├── routes/
│   └── payment.routes.ts           # Route definitions + feature gate middleware (incl. billing routes)
├── scripts/
│   └── migrate-payment-config-multi-gateway.ts  # DB migration script
└── types/
    └── index.ts                    # AVAILABLE_FEATURES, FEATURE_MAP (payments)
app/src/
├── pages/
│   ├── customer/
│   │   ├── BuyCreditsPage.tsx      # Standalone buy credits (provider-aware + GST form integration)
│   │   └── AddCreditsPage.tsx      # Unified credits page (balance + buy + history)
│   └── admin/
│       └── PaymentConfigPage.tsx   # Multi-gateway admin config
├── components/features/payment/
│   ├── GSTBillingForm.tsx          # GST/PAN collection form (shown on first recharge)
│   ├── CashfreeCredentialForm.tsx  # Per-gateway credential form
│   └── RazorpayCredentialForm.tsx  # Per-gateway credential form
├── services/
│   ├── payment.service.ts          # Admin payment API client (incl. billing details)
│   └── credit.service.ts           # Customer payment API client (incl. billing + validate-gstin)
├── lib/
│   ├── paymentSdk.ts              # Dynamic Razorpay SDK loader
│   └── features.ts                # FEATURES.PAYMENTS + UI_FEATURE_MAP
└── types/
    └── payment.ts                  # PaymentConfigData, GatewayProvider, etc.

Production Edge Cases ​

Edge CaseHow It's Handled
Customer clicks "Pay" multiple timesRate limit (5s cooldown) + inline auto-expire stale orders (2/5 min)
Webhook and verify endpoint race conditionAtomic $nin status transitions + serviceRef idempotency
Amount tamperingProvider-confirmed amount validated against order amount
Gateway switch with pending ordersPending orders continue using original provider's credentials
Credential change with pending ordersBlocked with 400 until pending orders complete/expire
Webhook from unknown providerIterate all gateways; reject if no signature matches
Webhook can't resolve orderUIDFallback lookup by provider order ID (cfOrderId)
Stale orders never completedInline auto-expire (2 min created, 5 min pending) + cron fallback (30 min)
Credit addition fails after paymentpayment_success state preserved; verify endpoint retries
Org pool depleted between order and paymentMark credits_failed; needs manual refund
Feature gate removed after order createdVerify endpoint still works (no feature gate on webhook)
Email service downNon-blocking try/catch; payment still succeeds
No billing preference on first purchaseOrder creation blocked with BILLING_DETAILS_REQUIRED code; frontend shows form
Invalid GSTIN (typo/spam)Offline checksum validation (Luhn mod-36) rejects immediately
PAN doesn't match GSTINCross-validation: chars 3-12 of GSTIN must equal PAN
Concurrent billing detail saves (race)Unique index on {customerUID, orgUID} + 409 Conflict handled gracefully
Invoice generation failsNon-blocking try/catch; credits still added, invoice can be retried
Invoice already sent (duplicate webhook)Idempotent: checks invoiceSentAt before regenerating
Email attachment encoding through BullMQAttachments pre-encoded to base64 before queueing (survives JSON serialization)
Customer updates GST after purchaseOrder has billing snapshot; invoice uses order-time data, not current

Testing Locally ​

Setup Steps ​

  1. Configure gateway credentials in admin panel (/admin/payment-config)
  2. Set WEBHOOK_BASE_URL in env to your tunnel URL (e.g., https://yourname.oktivo.com)
  3. In provider dashboard, add webhook URL: {WEBHOOK_BASE_URL}/payments/webhook/{orgUID}
  4. Set the same webhook secret in both provider dashboard and admin panel
  5. Make a test payment webhook arrives via tunnel, or polling detects payment via API

Razorpay Test Credentials ​

Cashfree Test Credentials ​

Last updated: