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:
- Manual distribution SuperAdmin recharges org → Admin distributes to customer (org pool decreases)
- 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
| Decision | Rationale |
|---|---|
| Per-org gateway credentials stored in DB | White-label platform each org has their own payment accounts |
activeProvider determines new orders | Allows instant gateway switching without reconfiguring |
| Pending orders use original provider | Orders in-flight must complete with the gateway they started on |
| Credential retention for pending orders | Can'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
| Layer | Control | Who Sets It |
|---|---|---|
| Feature Gate | payments in org plan.features | SuperAdmin |
| isActive toggle | PaymentConfig.isActive | Org Admin |
| Gateway credentials | PaymentConfig.gateways[provider] | Org Admin |
Provider Abstraction
Adding a New Provider
- Create
services/payment/newprovider.provider.tsimplementingPaymentProvider - Register in
services/payment/provider.factory.ts - Add provider name to
activeProviderenum in PaymentConfig model - 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:
activeProviderdetermines which gateway processes NEW ordersgatewaysmap of per-provider credentials (supports multiple simultaneously)isActiveadmin 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)
| Field | Type | Description |
|---|---|---|
orderUID | string | Unique order ID (pay_ + nanoid(16)), used as receipt |
cfOrderId | string | Provider's internal order ID (indexed for webhook lookup) |
orgUID | string | Which org this order belongs to |
customerUID | string | Which customer is buying |
provider | string | Which gateway was used (recorded at creation time) |
creditsRequested | number | How many credits the customer wants |
amountINR | number | Calculated: credits × creditPricePerUnit |
status | enum | See status lifecycle below |
cfPaymentId | string | Provider's payment ID (after payment) |
paymentMethod | string | e.g., "upi", "card", "netbanking" |
creditTransactionId | string | Links to CreditTransaction after credits added |
statusHistory | array | Audit trail of all state transitions |
expiresAt | Date | Order expires after 30 minutes (cron) or 2-5 min (inline auto-expire) |
billingGstin | string | Customer's GSTIN at time of purchase (snapshot) |
billingBusinessName | string | Customer's GST business name (snapshot) |
billingPan | string | Customer's PAN if provided (snapshot) |
invoiceNumber | string | Generated invoice number (e.g., INV-20260602-DQRJ8E) |
invoiceData | object | Full invoice data (tax breakdown, seller/buyer info) |
invoiceSentAt | Date | Timestamp 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.
| Field | Type | Description |
|---|---|---|
customerUID | string | Customer who owns these billing details |
orgUID | string | Organization context |
gstin | string | 15-character GSTIN (mandatory for recharge) |
gstBusinessName | string | Legal business name from GST registration |
gstBusinessAddress | string | Registered business address (optional) |
gstState | string | State derived from GSTIN state code |
pan | string | 10-character PAN (optional) |
panHolderName | string | Name on PAN card (optional) |
isGstVerified | boolean | Whether GSTIN format + checksum passed |
isPanVerified | boolean | Whether 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)
| Method | Path | Middleware | Description |
|---|---|---|---|
GET | /admin/payment-config | requireFeatureAccess(managePayments) | Get config (secrets masked) |
PUT | /admin/payment-config | requireFeatureAccess(managePayments) | Create/update config |
GET | /admin/payment-orders | requireFeatureAccess(managePayments) | List org payment orders |
Customer Endpoints (JWT + feature gate)
| Method | Path | Middleware | Description |
|---|---|---|---|
GET | /payments/pricing | requireFeatureAccess(purchaseCredits) | Get pricing + isActive + activeProvider |
POST | /payments/create-order | requireFeatureAccess(purchaseCredits) | Create payment order |
GET | /payments/verify/:orderUID | requireFeatureAccess(purchaseCredits) | Check/poll order status |
GET | /payments/orders | requireFeatureAccess(purchaseCredits) | List customer's orders |
GET | /payments/billing-details | requireFeatureAccess(purchaseCredits) | Get customer's stored GST/PAN details |
POST | /payments/billing-details | requireFeatureAccess(purchaseCredits) | Save/update GST/PAN billing details |
POST | /payments/validate-gstin | requireFeatureAccess(purchaseCredits) | Real-time GSTIN validation (format + checksum) |
Webhook Endpoint (no auth called by providers)
| Method | Path | Description |
|---|---|---|
POST | /payments/webhook/:orgUID | Receives webhooks from any configured provider |
Webhook Handling (Multi-Provider)
Webhook Security
| Check | Cashfree | Razorpay |
|---|---|---|
| Signature algorithm | HMAC-SHA256 (timestamp + body) | HMAC-SHA256 (body) |
| Signature header | x-cashfree-signature | x-razorpay-signature |
| Replay protection | Timestamp freshness (5 min) | N/A (signature-only) |
| Org validation | Order orgUID vs webhook URL orgUID | Same |
| Amount validation | Confirmed amount vs order amountINR | Same |
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:
- Looks up order by receipt (our orderUID)
- If order status is
paid→ return PAID - If not, fetches payments on the order via
orders.fetchPayments(orderId) - If any payment has
status: "captured"→ return PAID
This ensures payments are detected even without webhooks.
Validation Rules
| Validation | When | Error |
|---|---|---|
| Gateway switch without credentials | PUT with activeProvider change | 400 "Cannot switch: credentials not configured" |
| Incomplete credentials on save | PUT with gateways[x].credentials | 400 "Incomplete gateway credentials" + field errors |
| Credential change with pending orders | PUT changing appId for a gateway | 400 "Cannot update: N pending orders" |
| Payment system disabled | POST /create-order when isActive=false | 400 "Payment system is currently disabled" |
| Feature gate | Any payment route without payments feature | 403 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:
| Field | Value |
|---|---|
| Template | gst-invoice |
| Subject | "GST Invoice {INV-NUMBER} {N} credits purchased" |
| Content | Order ID, credits added, amount paid, payment method, GSTIN, business name, date |
| Attachment | PDF invoice (GST-compliant tax invoice with full tax breakdown) |
| Failure handling | Non-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.invoiceDatafor record-keeping - Idempotent: won't re-send if
invoiceSentAtis 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
| Variable | Required | Description |
|---|---|---|
WEBHOOK_BASE_URL | No | Public 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:
npx tsx src/scripts/migrate-payment-config-multi-gateway.ts [--dry-run]What it does:
- Moves
credentials,mode,webhookSecretintogateways[provider] - Sets
activeProviderto currentprovidervalue - Sets
isActivetotrue - 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 Case | How It's Handled |
|---|---|
| Customer clicks "Pay" multiple times | Rate limit (5s cooldown) + inline auto-expire stale orders (2/5 min) |
| Webhook and verify endpoint race condition | Atomic $nin status transitions + serviceRef idempotency |
| Amount tampering | Provider-confirmed amount validated against order amount |
| Gateway switch with pending orders | Pending orders continue using original provider's credentials |
| Credential change with pending orders | Blocked with 400 until pending orders complete/expire |
| Webhook from unknown provider | Iterate all gateways; reject if no signature matches |
| Webhook can't resolve orderUID | Fallback lookup by provider order ID (cfOrderId) |
| Stale orders never completed | Inline auto-expire (2 min created, 5 min pending) + cron fallback (30 min) |
| Credit addition fails after payment | payment_success state preserved; verify endpoint retries |
| Org pool depleted between order and payment | Mark credits_failed; needs manual refund |
| Feature gate removed after order created | Verify endpoint still works (no feature gate on webhook) |
| Email service down | Non-blocking try/catch; payment still succeeds |
| No billing preference on first purchase | Order 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 GSTIN | Cross-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 fails | Non-blocking try/catch; credits still added, invoice can be retried |
| Invoice already sent (duplicate webhook) | Idempotent: checks invoiceSentAt before regenerating |
| Email attachment encoding through BullMQ | Attachments pre-encoded to base64 before queueing (survives JSON serialization) |
| Customer updates GST after purchase | Order has billing snapshot; invoice uses order-time data, not current |
Testing Locally
Setup Steps
- Configure gateway credentials in admin panel (
/admin/payment-config) - Set
WEBHOOK_BASE_URLin env to your tunnel URL (e.g.,https://yourname.oktivo.com) - In provider dashboard, add webhook URL:
{WEBHOOK_BASE_URL}/payments/webhook/{orgUID} - Set the same webhook secret in both provider dashboard and admin panel
- Make a test payment webhook arrives via tunnel, or polling detects payment via API
Razorpay Test Credentials
- Use
rzp_test_*keys from Razorpay Dashboard → Settings → API Keys - Test cards: Razorpay Testing Docs
- UPI:
success@razorpayfor success,failure@razorpayfor failure
Cashfree Test Credentials
- Use sandbox keys from Cashfree Dashboard → Developers → API Keys
- Test UPI:
success@upi/failure@upi - Test cards: Cashfree Testing Docs