Modular Payments Platform
Status: Phase 0 Foundation —
hmp-payment-service(port 3014), Direct merchant model, Razorpay Gateway on Day 1. Design spec:hmp-docs/docs/superpowers/specs/2026-04-14-modular-payments-platform-design.md(excluded from the Docusaurus build; read in-repo) Companion docs: API Reference · Tenant Onboarding Guide
The modular payments platform centralises all provider interaction inside one
PCI-isolated service. Consumer services (booking engine, frontdesk, POS,
auth subscriptions) never import a gateway SDK — they call PaymentServiceClient
over HTTPS with a service-account JWT and subscribe to domain events on Redis
Streams.
1. Why this exists
The codebase inherited four disconnected payment paths:
@hmp/paymentsheld a complete Razorpay adapter plus Stripe and Cashfree stubs.hmp-booking-enginecalled Razorpay directly viasrc/lib/payment/razorpay.ts.hmp-auth-servicecalled Razorpay Subscriptions viarazorpay-subscription.adapter.ts.hmp-frontdesk-servicerecordedpaymentModebut never settled a gateway charge.
Each path reimplemented idempotency, webhook dedup, and credential handling. The modular platform replaces those four paths with a single service that owns:
- All provider credentials (AES-GCM envelope-encrypted under KMS).
- All webhook receivers (one URL per
MerchantAccount). - The unified
Payment/PaymentAttempt/Refundledger. - Domain events —
payment.succeeded,payment.failed,refund.processed,link.paid,terminal.paired.
Design principles
- One service owns all provider calls. No consumer imports Razorpay, Stripe, or Cashfree SDKs. PCI scope is contained to one namespace.
- Webhook URLs point only at payment-service. Multi-tenant dispatch is
internal: the path carries
merchantAccountId, the receiver looks up the stored webhook secret and verifies the signature. - Consumer ledgers reference payment-service IDs. Folios, orders, and
subscription invoices store
paymentIdonly; full attempt history is fetched viaPaymentServiceClient. - Event bus for eventual consistency. Payment success updates flow as
payment.succeededevents on Redis Streams; folio / reservation / POS services subscribe and flip their own status. - Sync RPC for initiation. Creating a payment, generating a link, and pairing a terminal are synchronous HTTP for immediate UX feedback.
- Direct merchant model. Each tenant stores its own Razorpay credentials; KeyNova is not merchant-of-record. The schema is Aggregator-ready (future routing rule), but Day 1 is Direct only.
2. System topology
The only ingress to hmp-payment-service is from hmp-api-gateway. Egress is
restricted (via Kubernetes NetworkPolicy) to provider IP ranges + internal
KMS + internal Redis. See §6 for the full network posture.
3. Component responsibilities
3.1 hmp-payment-service (NEW, port 3014)
Owns everything that touches provider credentials or the payment ledger.
| Subsystem | Purpose |
|---|---|
| Controllers | HTTP surface for consumers + webhook receiver |
| Orchestrators | Idempotency, retry policy, state transitions, event emission |
| Adapters | One per provider; stateless; ctx.mode === 'MOCK' short-circuits |
| Prisma schema | MerchantAccount, Payment, PaymentAttempt, Refund, PaymentLink, TerminalDevice, WebhookEvent, AuditLog, ProviderCatalog |
| KMS integration | Envelope encryption: per-row random DEK wrapped by platform KMS key |
3.2 @hmp/payments (REPURPOSED)
Now an interfaces-only package. Adapters have moved into payment-service.
ProviderBase,GatewayProvider,TerminalProviderinterface definitions.- Shared types:
Amount,MerchantContext,ProviderWebhookEvent,ChargeRequest,ChargeResult, etc. - Error taxonomy:
ProviderUnreachableError,ProviderAuthError,ProviderValidationError,ProviderDeclinedError,ProviderRateLimitError,ProviderSignatureError,ProviderDeviceOfflineError,ProviderTimeoutError. - Contract-test fixtures consumed by every adapter.
Consumers depend on this for compile-time typing when they call
PaymentServiceClient. They do not pull in Razorpay / Stripe / Cashfree
SDKs transitively.
3.3 @hmp/service-clients (EXTENDED)
Adds PaymentServiceClient extending BaseServiceClient:
class PaymentServiceClient extends BaseServiceClient {
initiatePayment(req: InitiatePaymentDto): Promise<PaymentDto>
getPayment(id: string): Promise<PaymentDto>
listAttempts(paymentId: string): Promise<PaymentAttemptDto[]>
refund(paymentId: string, req: RefundDto): Promise<RefundDto>
createPaymentLink(req: CreateLinkDto): Promise<PaymentLinkDto>
registerTerminalDevice(req: RegisterDeviceDto): Promise<TerminalDeviceDto>
// Operator admin ops
createMerchantAccount(req: MerchantAccountDto): Promise<MerchantAccountDto>
listMerchantAccounts(filters): Promise<MerchantAccountDto[]>
rotateCredentials(id, req): Promise<MerchantAccountDto>
activateMerchantAccount(id): Promise<MerchantAccountDto>
deactivateMerchantAccount(id): Promise<MerchantAccountDto>
}
Auto-injects Authorization: Bearer $INTERNAL_SERVICE_TOKEN plus forwards
x-tenant-id / x-property-id / x-request-id from AsyncLocalStorage — same
pattern as every other @hmp/service-clients client. See
internal-service-auth.
4. Data flow
4.1 Initiate → adapter → webhook → event
Key invariants:
- Raw body preserved end-to-end. The gateway does not re-encode JSON; providers sign the exact bytes.
- Dedup before verify. Duplicate webhooks (Razorpay re-delivers on 5xx) return 200 without side effects.
- Dual-success guard. Row-level lock on the
Paymentensures only the firstpayment.capturedwebhook setssuccessfulAttemptId; a second is logged toAuditLogaspayment.duplicate_successfor operator review. - Replay window. Webhooks older than 10 minutes (per provider timestamp) are rejected.
4.2 Terminal charge (hardware / sync)
Terminal flows are synchronous: the cashier must see the outcome before
handing the card back. Timeouts do not auto-retry (silent-success risk);
the nightly reconciler calls adapter.fetchPaymentStatus() on attempts stuck
in PENDING_USER_ACTION > 2h.
5. Encryption model
Credentials + webhook secrets are envelope-encrypted per row:
MerchantAccount:
credentialsCiphertext Bytes // AES-256-GCM(plaintext, DEK, iv)
credentialsIv Bytes // 12-byte random nonce
credentialsAuthTag Bytes // 16-byte GCM tag
webhookSecretCiphertext Bytes
webhookSecretIv Bytes
webhookSecretAuthTag Bytes
dekWrapped Bytes // DEK wrapped by KMS
kmsKeyVersion String // full ARN + version, for audit
In-memory only (never logged, redacted in Pino):
{ "key_id": "rzp_live_XXX", "key_secret": "YYYYYYYYYYYYYYYY" }
Lifecycle:
- DEK generation. Random 32 bytes per
MerchantAccountat insert time, wrapped by the platform KMS key viakms:Encrypt. Only the wrapped form is stored. - Decryption. Adapter fetches DEK via
kms:Decryptonly when about to call the provider. Held in memory for the request duration, then discarded. - DEK cache. In-memory per replica, 5-minute TTL, keyed by
MerchantAccount.id. Invalidated on rotation. In load tests we verify hit rate > 95%. - Rotation. Operator clicks "Rotate credentials" → adapter validates new
keys against the provider → encrypt under current KMS key →
UPDATErow →AuditLogentry. No dual-key grace period on Day 1. - KMS rotation drill. Quarterly: rotate the platform key in KMS, verify
all
MerchantAccountrows still decrypt via historical key versions. KMS retains old versions automatically.
Logging redactors are enforced at build time:
Pino redact paths:
req.headers.authorization
req.headers['x-razorpay-signature']
req.body.credentials.*
req.body.webhookSecret
res.body.credentials.*
*.key_secret
*.webhook_secret
*.cardNumber
*.cvv
Unit tests fuzz payloads and fail the build on any leak. No card data is
persisted beyond cardLast4, cardBrand, and cardholder name — SAQ-A scope.
6. Tenant isolation
Every query is scoped by tenantId. Additional layers:
- JWT-bound tenant.
JwtAuthGuardpopulatesreq.user.tenantIdfrom the token (user calls) or thex-tenant-idheader (service-account calls). Controllers never accepttenantIdin the body. - Property ownership check. Every endpoint that accepts a
propertyIdcallsassertPropertyTenant(propertyId, tenantId)viaPropertyServiceClient— the property must belong to the claimed tenant or the call returns 403. Non-negotiable security boundary. - Composite unique indexes.
(tenantId, idempotencyKey)onPayment,(tenantId, code)patterns on catalog lookups — prevents cross-tenant key reuse. - Webhook path opacity. Webhook URLs contain only
merchantAccountId(opaque UUID), nevertenantId. No tenant-ID enumeration from the public surface. - NetworkPolicy. Ingress to
hmp-payment-servicepods is restricted tohmp-api-gatewayonly — no sibling service can bypass the gateway. Egress is whitelisted to provider IP ranges + KMS + Redis. - Separate Postgres schema.
hmp_payments; no other service has credentials. Backups and PITR isolated.
7. Provider extension guide
Adding a new gateway (e.g., Stripe) is a contained change inside
hmp-payment-service:
- Implement the adapter. Create
src/adapters/stripe-gateway.adapter.tsimplementingGatewayProviderfrom@hmp/payments. Every method handles:ctx.mode === 'MOCK'short-circuit returning deterministic fakes.ctx.mode === 'SANDBOX' | 'LIVE'real SDK calls using decrypted credentials.- Mapping provider errors to the
ProviderErrorsubclasses (ProviderAuthError,ProviderDeclinedError, etc.).
- Pass the contract suite. Import the canonical fixtures from
@hmp/payments/testand run them against your adapter. The suite covers the success path for every interface method plus one case of every error kind plus an idempotency-key round-trip. New providers must pass unmodified — no adapter-specific branches allowed in the suite. - Register in
ProviderCatalogseed. Add one row declaring the provider code, family, displayName, capabilities, supported methods, and enabled regions. The dashboard readsGET /api/provider-catalogto populate the "add account" dropdown. - Wire webhook parsing. Implement
verifyAndParseWebhook()to return a canonicalProviderWebhookEvent. Use the provider's HMAC scheme; surfaceProviderSignatureErroron mismatch. - Write flow-test scenarios. Extend
@hmp/payments-simulatorswith provider-specific fixtures forSUCCESS,DECLINE,TIMEOUT,NETWORK_ERROR,WEBHOOK_DELAYED_5S,SIGNATURE_INVALID. - Update docs. Add the provider to the Tenant Onboarding Guide with per-provider dashboard screenshots.
The consumer services (booking engine, frontdesk, etc.) see zero code
changes: they still call PaymentServiceClient.initiatePayment(...), and the
operator picks the new provider from the dashboard once credentials are
stored.
8. For operators — troubleshooting appendix
Payment stuck in PENDING_USER_ACTION
Guest started checkout but the webhook never arrived. Three causes, in order of frequency:
- Guest abandoned. No action needed — the nightly reconciler will call
adapter.fetchPaymentStatus()after 2 hours and setPayment.status = EXPIRED. - Webhook URL misconfigured in provider dashboard. Check
GET /api/merchant-accounts/:idshows the webhook URL the tenant should have pasted into Razorpay. Verify by sending a test webhook from Razorpay's dashboard; checkWebhookEventfor a new row. - Webhook secret rotated provider-side without KeyNova sync. Signature
verification fails silently.
WebhookEvent.processingStatus = FAILEDaccumulates. See thewebhook-signature-failuresrunbook athmp-infrastructure/monitoring/runbooks/webhook-signature-failures.md.
MerchantAccount deactivated unexpectedly
Consecutive ProviderAuthErrors from the provider auto-deactivate the
account. Causes:
- Tenant rotated their keys in Razorpay dashboard but didn't update KeyNova.
- Razorpay suspended the merchant (KYC escalation, compliance review).
Mitigation: tenant rotates credentials via Settings → Payment Providers →
Rotate after fixing the upstream issue. First charge after rotation
re-validates the account; on success, isActive = true.
Duplicate refund fired
Refund table has unique (paymentId, providerRefundId). Duplicate
refund requests — whether from retries or concurrent operator clicks —
collapse to one row. Check AuditLog for refund.duplicate_ignored entries.
Provider down
Error rate > 5% over 5m triggers the payments-provider-down runbook
(hmp-infrastructure/monitoring/runbooks/payments-provider-down.md).
Temporary mitigation: operator sets the affected MerchantAccount to
isActive = false via dashboard. Guests see "payments temporarily
unavailable" in booking engine, and the reservation flow falls back to
pay-at-property for properties that allow it. Reactivate once the provider
status page is green.
9. Where to read next
- HTTP surface: API Reference
- Tenant onboarding: Configure Razorpay
- Oncall runbooks (live in
hmp-infrastructure, not rendered here):hmp-infrastructure/monitoring/runbooks/payments-provider-down.mdhmp-infrastructure/monitoring/runbooks/webhook-signature-failures.md
- Service-account auth: internal-service-auth
- Cross-service reads: cross-service-communication
- Design rationale:
hmp-docs/docs/superpowers/specs/2026-04-14-modular-payments-platform-design.md(excluded from site build) - Phase 0 plan:
hmp-docs/docs/superpowers/plans/2026-04-14-modular-payments-platform-phase-0-foundation.md(excluded from site build)
Phase 0 ships the foundation — scaffold, ledger, Razorpay Gateway adapter,
webhook receiver, dashboard UI for MerchantAccount CRUD, and the
PaymentServiceClient. Consumer migration (booking-engine, auth-service
subscriptions) is Phase 1+ and feature-flag gated. The booking engine still
uses its direct Razorpay SDK until PAYMENTS_V2_BOOKING_ENGINE flips.