Payment Gateway & Transaction System¶
Overview¶
The payment system handles subscriber invoice payments, transaction lifecycle management, webhook event processing, and reconciliation. It is built around a single payment gateway integration (Zwitch) with a mock mode for development and testing.
API versions: v2 (web app) and v3 (mobile app) Currency: INR only Gateway mode: Configurable — mock (dev/test) or zwitch (production)
Data Model¶
CycleInvoice (prisma/schema.prisma:1940)¶
Core invoice tied to a ProgramCycle and EnrolledSubscriber.
| Field | Type | Notes |
|---|---|---|
status | InvoiceStatus | DRAFT \| OPEN \| PAID \| FAILED \| REFUNDED \| CANCELLED \| PARTIALLY_PAID \| OVERDUE |
invoiceNumber | String? | Unique, e.g. INV-202501-00001-A3F2 |
subTotalAmount | Decimal? | Amount before tax/discount/fees |
totalAmount | Decimal? | Final: subTotal + tax - discount + delayFine |
paidAmount | Decimal? | Amount actually paid |
parentInvoiceId | String? | Supports reissuing invoices |
| Relations | transactions[], collectionCharges[] |
CycleTransaction (prisma/schema.prisma:2019)¶
Main transaction table — current state only.
| Field | Type | Notes |
|---|---|---|
idempotencyKey | String | Unique — prevents duplicate transactions |
type | TransactionType | PAYMENT \| REFUND \| CHARGEBACK \| REVERSAL |
status | TransactionStatus | PENDING \| PROCESSING \| COMPLETED \| FAILED \| CANCELLED \| REFUNDED |
grossAmount | Decimal | Amount the user pays |
gatewayFee | Decimal | Gateway fee (default 1% of gross) |
gatewayTaxAmount | Decimal | 18% GST on gateway fee |
platformFee | Decimal | Platform fee (default 0.32% of gross) |
platformTaxAmount | Decimal | Platform tax (currently 0) |
netAmount | Decimal | gross - gatewayFee - gatewayTax - platformFee - platformTax |
paymentProvider | String | zwitch |
gatewayOrderId | String? | Payment token from Zwitch |
gatewayTxnId | String? | Transaction ID from gateway |
attemptCount / maxAttempts | Int | Retry tracking (max 3) |
settlementId | String? | Links to Settlement |
| Relations | logs[], refunds[], webhookEvents[], settlement |
CycleTransactionLog (prisma/schema.prisma:2131)¶
Immutable audit trail — every event is recorded here.
| Field | Notes |
|---|---|
event | TOKEN_CREATED \| GATEWAY_INITIATED \| GATEWAY_REDIRECT \| CUSTOMER_AUTHENTICATED \| PAYMENT_AUTHORIZED \| PAYMENT_CAPTURED \| PAYMENT_FAILED \| ... |
statusBefore / statusAfter | State transition snapshot |
gatewayPayloadRequestId / gatewayPayloadResponseId | Links to GatewayPayload |
gatewayResponse | Minimal gateway data at event time |
triggeredBy | SUBSCRIBER \| SYSTEM \| ADMIN \| WEBHOOK |
GatewayPayload (prisma/schema.prisma:2196)¶
Stores full request/response/webhook payloads separately (large data).
payloadType:REQUEST \| RESPONSE \| WEBHOOK \| ERROR_RESPONSEpayload: Raw JSONheaders: HTTP headers
CycleRefund (prisma/schema.prisma:2253)¶
Refund tracking on transactions.
refundType:FULL \| PARTIALstatus:PENDING \| APPROVED \| PROCESSING \| COMPLETED \| FAILED \| REJECTED \| CANCELLEDreasonCategory:CUSTOMER_REQUEST \| DUPLICATE_CHARGE \| FRAUDULENT \| SERVICE_NOT_DELIVERED \| ...- Links to
originalTransactionandsettlement
WebhookEvent (prisma/schema.prisma:2320)¶
Tracks incoming webhooks.
- Deduplication via
payloadHashand(eventId, provider, attemptNumber)unique constraint - Status:
RECEIVED \| VERIFIED \| PROCESSING \| PROCESSED \| FAILED \| IGNORED - Retry:
retryCount,maxRetries(3),nextRetryAt
Settlement (prisma/schema.prisma:2381)¶
Batch settlements from payment gateway.
- Tracks
totalGrossAmount,totalFees,totalRefunds,netSettlement - Status:
PENDING \| PROCESSING \| COMPLETED \| FAILED \| PARTIALLY_SETTLED - Reconciliation:
reconciledAt,reconciledBy,discrepancy
CollectionCharge (prisma/schema.prisma:3424)¶
Collection charges collected from companies. Links Company, CycleInvoice, CycleTransaction.
CompanyInvoice (prisma/schema.prisma:3453)¶
Billing invoices for companies (separate from CycleInvoice — these are for service charges).
Architecture & Flow¶
Payment Initiation¶
Route (v2/v3)
POST .../invoices/:invoiceId/payments/initiate
│
├─ PaymentsCommand.initiate()
│ ├─ Validate subscriber (not APPLE_APP_REVIEW)
│ ├─ Get invoice details
│ │ ├─ Check invoice status (not PAID/CANCELLED/REFUNDED)
│ │ ├─ Check cycle status (ACTIVE)
│ │ ├─ Check subscriber profile (ACTIVE, email verified, has email/phone)
│ │ └─ Check sub-account configured on program
│ ├─ Validate paying amount equals installment amount
│ ├─ Calculate payment splits via calculatePaymentSplits()
│ ├─ Upsert CycleTransaction (with idempotencyKey + unique constraint)
│ ├─ v2: Call pgPaymentService.createPaymentToken() → Zwitch payment token API
│ ├─ v3: Call pgPaymentService.createUpiIntent() → Zwitch UPI intent API
│ │ └─ Within unitOfWork:
│ │ ├─ Store request/response in GatewayPayload
│ │ ├─ Create CycleTransactionLog
│ │ └─ Update CycleTransaction (gatewayOrderId, status)
│ ├─ Enqueue deferred reconciliation job (RECONCILE_PAYMENT, 2 min delay)
│ └─ Return paymentToken + accessKey + checkoutMode (+ UPI intent links in v3)
│
└─ src: application/use-cases/cycle-invoices/commands/payments/payments-command.ts
Key files:
- Route:
src/interfaces/http/routes/v2/subscribers/invoices/[invoiceId]/payments/payments-routes.ts - Route (v3):
src/interfaces/http/routes/v3/subscribers/invoices/[invoiceId]/payments/payments-routes.ts - Command:
src/application/use-cases/cycle-invoices/commands/payments/payments-command.ts - Helpers:
src/application/use-cases/cycle-invoices/commands/payments/payments-command-helpers.ts - Zwitch service:
src/infrastructure/pg/zwitch/services/payment-service.ts - Mock service:
src/infrastructure/pg/mock/services/payment-service.ts - Fee calculation:
src/domain/helpers/cycle-transaction-helpers.ts - Constants:
src/shared/constants/Pg.ts
Payment Status Check¶
Client GET .../payments/:paymentToken/status
│
├─ PaymentsCommand.statusCheck()
│ ├─ Find transaction by paymentToken + subscriberId
│ ├─ If PENDING/PROCESSING → call reconcilePayment() (gateway status check)
│ └─ Return status + invoice details
│
└─ src: application/use-cases/cycle-invoices/commands/payments/payments-command.ts:398
Webhook Processing¶
Zwitch → POST /webhooks/pg/zwitch
│
├─ [Middleware] zwitchIpWhitelistMiddleware → IP whitelist check
│ └─ src: interfaces/http/middlewares/zwitch-ip.middleware.ts
│
├─ [Middleware] zwitchSignatureVerifyMiddleware → HMAC-SHA256
│ └─ src: interfaces/http/middlewares/zwitch-signature.middleware.ts
│
├─ Route handler → Validates body (discriminated union)
│ └─ src: interfaces/http/webhooks/pg/zwitch/zwitch-routes.ts
│
├─ processPGWebhookEvent() → within unitOfWork:
│ ├─ Dedup: check (eventId, provider, attemptNumber) unique constraint
│ ├─ Store webhook payload in GatewayPayload (type: WEBHOOK)
│ ├─ Determine event type: PAYMENT or TOKEN
│ ├─ Extract transactionId, invoiceId, subscriberId, paidAt
│ ├─ Validate transaction (check terminal statuses)
│ │ └─ Token events can recover from FAILED/CANCELLED
│ │ └─ Payment events CANNOT recover from FAILED/CANCELLED
│ ├─ Create CycleTransactionLog
│ └─ Update CycleTransaction (status, gatewayStatus, paymentMethod, metadata)
│
├─ [Post-commit] Enqueue SAVE_WEBHOOK_EVENT job
└─ [Post-commit] If payment successful → enqueue MARK_INVOICE_AS_PAID job
└─ src: application/use-cases/webhook-events/commands/process-pg-webhook-event/
Key files:
- Webhook routes:
src/interfaces/http/webhooks/pg/zwitch/zwitch-routes.ts - Validators:
src/interfaces/http/webhooks/pg/zwitch/zwitch-validators.ts - Command:
src/application/use-cases/webhook-events/commands/process-pg-webhook-event/process-pg-webhook-event-command.ts - Helpers:
src/application/use-cases/webhook-events/commands/process-pg-webhook-event/process-pg-webhook-event-command-helpers.ts
Reconciliation¶
[Deferred Job] RECONCILE_PAYMENT (2 min delay, exponential backoff, max 5 attempts)
│
├─ reconcilePayment() → PaymentService
│ ├─ Check if already terminal → return
│ ├─ Call pgPaymentService.checkPaymentStatus() → Zwitch GET status API
│ ├─ Within unitOfWork:
│ │ ├─ Store response in GatewayPayload
│ │ ├─ Create CycleTransactionLog
│ │ └─ Update CycleTransaction
│ ├─ If COMPLETED → mark invoice as paid
│ └─ Return terminal status
│
├─ If non-terminal + attempts remain → throw RetryableJobError
└─ If max attempts → failStuckTransaction()
└─ src: application/services/payment/payment.service.ts
[Recurring Job] RECONCILE_STUCK_PAYMENTS (every 15 min)
│
├─ reconcileStuckPayments() → PaymentService
│ └─ Find transactions older than 30 min with PENDING/PROCESSING status
│ └─ For each: attempt gateway reconciliation
│ └─ If no gatewayOrderId or reconciliation fails → mark as FAILED
│
└─ src: application/services/payment/payment.service.ts:197
Key files:
- Application service:
src/application/services/payment/payment.service.ts - Queue service:
src/application/queue/services/payment-queue.service.ts - Queue processor:
src/application/queue/processors/payment.processor.ts - Repository:
src/infrastructure/db/postgres/repositories/cycle-transactions-repository.ts
Fee Structure¶
Fees are computed in src/domain/helpers/cycle-transaction-helpers.ts using constants from src/shared/constants/Pg.ts:
| Component | Rate | Applies To |
|---|---|---|
| Gateway fee | 1% | Gross amount |
| Gateway tax (GST) | 18% | Gateway fee |
| Platform fee | 0.32% | Gross amount |
| Platform tax | 0% | Platform fee (not applied yet) |
Net amount = grossAmount - gatewayFee - gatewayTax - platformFee - platformTax
Queue Jobs¶
BullMQ queue: payment
| Job Name | Trigger | Delay | Max Retries | Priority |
|---|---|---|---|---|
SAVE_WEBHOOK_EVENT | After webhook processed | none | 5 | 1 |
MARK_INVOICE_AS_PAID | After successful payment webhook | none | 3 | 1 |
RECONCILE_PAYMENT | After payment token created | 2 min (exponential backoff) | 5 | 1 |
RECONCILE_STUCK_PAYMENTS | Recurring (every 15 min) | - | - | 2 |
Key files:
- Queue service:
src/application/queue/services/payment-queue.service.ts - Processor:
src/application/queue/processors/payment.processor.ts
Gateway: Zwitch¶
The system integrates with Zwitch, an Indian payment aggregator.
Config interface (src/application/interfaces/config/index.ts:29):
PgBasicConfig {
apiKey: string; // For non-payment APIs (IFSC)
apiSecret: string;
pgApiKey: string; // For payment + sub-account APIs
pgApiSecret: string;
webhookSecret: string; // Webhook signing
paymentServiceUrl: string;
upiIntentUrl: string;
subAccountServiceUrl: string;
ifscServiceUrl: string;
webhookAllowedIps: Set<string>;
}
Mode: mock or zwitch (configured via pg.mode, resolved in di.ts:149)
Gateway operations:
createPaymentToken()— POST to Zwitch to get a payment token (SDK/MOCK checkout mode)createUpiIntent()— POST to Zwitch to get UPI QR/intent links; the responsedata.transaction_idis stored as the payment tokencheckPaymentStatus()— GET to Zwitch status URL by token
Sub-account management:
- Programs have a
subAccountIdfor splitting settlements - Bank accounts have
subAccountId,subAccountStatus(CREATED → PENDING_APPROVAL → ACTIVATED → REJECTED → SUSPENDED → CLOSED) pgSubAccountServiceregistered in DI (di.ts:152)
Refunds¶
The CycleRefund model supports the full refund lifecycle (prisma/schema.prisma:2253):
- Types:
FULL,PARTIAL - Status:
PENDING → APPROVED → PROCESSING → COMPLETED | FAILED | REJECTED | CANCELLED - Reason categories:
CUSTOMER_REQUEST | DUPLICATE_CHARGE | FRAUDULENT | SERVICE_NOT_DELIVERED | QUALITY_ISSUE | ORDER_CANCELLED | SYSTEM_ERROR | OTHER
Status: The DB schema and model are fully defined, but refund initiation (approve/process) is not yet implemented in the application layer. This is future work.
Settlements¶
Batch settlements are tracked via the Settlement model (prisma/schema.prisma:2381):
- Links to
BankAccountfor payout destination - Auto-reconciliation is not yet implemented (manual
reconciledAt/reconciledByfields exist)
DI Registration¶
Payment services registered in src/infrastructure/di.ts:
| Token | Scope | Source |
|---|---|---|
pgConfig | value | Zwitch config |
pgPaymentService | singleton | Zwitch or Mock based on mode |
pgSubAccountService | singleton | Zwitch sub-account service |
paymentProcessor | singleton | BullMQ payment processor |
paymentQueueService | singleton | BullMQ queue |
paymentService | singleton | Application payment service |
webhookEventsRepository | scoped | PostgreSQL |
gatewayPayloadRepository | scoped | PostgreSQL |
cycleTransactionsRepository | scoped | PostgreSQL |
cycleTransactionLogsRepository | scoped | PostgreSQL |
cycleInvoicesRepository | scoped | PostgreSQL |
Security¶
- Webhook IP whitelist: Zwitch webhook endpoints require IPs in
webhookAllowedIps - HMAC-SHA256 signature:
x-zwitch-signature(orx-webhook-signature) verified withpgApiSecret - Payload hash: SHA-256 of raw body stored in
WebhookEvent.payloadHashfor deduplication - Idempotency:
CycleTransaction.idempotencyKeyis unique — prevents duplicate payment token creation across retries
Tests¶
- Unit tests:
src/application/services/payment/payment.service.test.ts - Unit tests:
src/application/queue/services/payment-queue.service.test.ts - Unit tests:
src/application/use-cases/cycle-invoices/commands/payments/payments-command.test.ts - Functional tests:
src/interfaces/http/routes/v3/subscribers/invoices/[invoiceId]/payments/__tests__/payments-routes.test.ts
Key File Reference¶
| Layer | File | Purpose |
|---|---|---|
| Domain | src/domain/entities/paymentStatement.ts | Payment statement entity |
| Domain | src/domain/helpers/cycle-transaction-helpers.ts | Fee split calculation |
| Application | src/application/use-cases/cycle-invoices/commands/payments/payments-command.ts | Payment orchestration |
| Application | src/application/use-cases/webhook-events/commands/process-pg-webhook-event/process-pg-webhook-event-command.ts | Webhook processing |
| Application | src/application/services/payment/payment.service.ts | Reconciliation, mark invoice paid |
| Application | src/application/queue/services/payment-queue.service.ts | BullMQ queue jobs |
| Application | src/application/queue/processors/payment.processor.ts | Job processing |
| Infrastructure | src/infrastructure/pg/zwitch/services/payment-service.ts | Zwitch API client |
| Infrastructure | src/infrastructure/pg/mock/services/payment-service.ts | Mock gateway |
| Infrastructure | src/infrastructure/pg/zwitch/config/zwitch-config.ts | Zwitch config |
| Infrastructure | src/infrastructure/db/postgres/repositories/cycle-transactions-repository.ts | Transaction DB |
| Infrastructure | src/infrastructure/di.ts | DI wiring |
| Interfaces | src/interfaces/http/routes/v2/subscribers/invoices/[invoiceId]/payments/payments-routes.ts | v2 payment routes |
| Interfaces | src/interfaces/http/routes/v3/subscribers/invoices/[invoiceId]/payments/payments-routes.ts | v3 payment routes |
| Interfaces | src/interfaces/http/webhooks/pg/zwitch/zwitch-routes.ts | Webhook handler |
| Interfaces | src/interfaces/http/middlewares/zwitch-ip.middleware.ts | IP whitelist |
| Interfaces | src/interfaces/http/middlewares/zwitch-signature.middleware.ts | HMAC verification |
| Shared | src/shared/constants/Pg.ts | Fee percentages, attempt limits |
| Shared | src/shared/types/pg.types.ts | PG type definitions |
| Config | src/application/interfaces/config/index.ts | App config interfaces |