Skip to main content

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/payments held a complete Razorpay adapter plus Stripe and Cashfree stubs.
  • hmp-booking-engine called Razorpay directly via src/lib/payment/razorpay.ts.
  • hmp-auth-service called Razorpay Subscriptions via razorpay-subscription.adapter.ts.
  • hmp-frontdesk-service recorded paymentMode but 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 / Refund ledger.
  • Domain events — payment.succeeded, payment.failed, refund.processed, link.paid, terminal.paired.

Design principles​

  1. One service owns all provider calls. No consumer imports Razorpay, Stripe, or Cashfree SDKs. PCI scope is contained to one namespace.
  2. 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.
  3. Consumer ledgers reference payment-service IDs. Folios, orders, and subscription invoices store paymentId only; full attempt history is fetched via PaymentServiceClient.
  4. Event bus for eventual consistency. Payment success updates flow as payment.succeeded events on Redis Streams; folio / reservation / POS services subscribe and flip their own status.
  5. Sync RPC for initiation. Creating a payment, generating a link, and pairing a terminal are synchronous HTTP for immediate UX feedback.
  6. 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.

SubsystemPurpose
ControllersHTTP surface for consumers + webhook receiver
OrchestratorsIdempotency, retry policy, state transitions, event emission
AdaptersOne per provider; stateless; ctx.mode === 'MOCK' short-circuits
Prisma schemaMerchantAccount, Payment, PaymentAttempt, Refund, PaymentLink, TerminalDevice, WebhookEvent, AuditLog, ProviderCatalog
KMS integrationEnvelope 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, TerminalProvider interface 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 Payment ensures only the first payment.captured webhook sets successfulAttemptId; a second is logged to AuditLog as payment.duplicate_success for 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 MerchantAccount at insert time, wrapped by the platform KMS key via kms:Encrypt. Only the wrapped form is stored.
  • Decryption. Adapter fetches DEK via kms:Decrypt only 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 → UPDATE row → AuditLog entry. No dual-key grace period on Day 1.
  • KMS rotation drill. Quarterly: rotate the platform key in KMS, verify all MerchantAccount rows 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:

  1. JWT-bound tenant. JwtAuthGuard populates req.user.tenantId from the token (user calls) or the x-tenant-id header (service-account calls). Controllers never accept tenantId in the body.
  2. Property ownership check. Every endpoint that accepts a propertyId calls assertPropertyTenant(propertyId, tenantId) via PropertyServiceClient — the property must belong to the claimed tenant or the call returns 403. Non-negotiable security boundary.
  3. Composite unique indexes. (tenantId, idempotencyKey) on Payment, (tenantId, code) patterns on catalog lookups — prevents cross-tenant key reuse.
  4. Webhook path opacity. Webhook URLs contain only merchantAccountId (opaque UUID), never tenantId. No tenant-ID enumeration from the public surface.
  5. NetworkPolicy. Ingress to hmp-payment-service pods is restricted to hmp-api-gateway only — no sibling service can bypass the gateway. Egress is whitelisted to provider IP ranges + KMS + Redis.
  6. 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:

  1. Implement the adapter. Create src/adapters/stripe-gateway.adapter.ts implementing GatewayProvider from @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 ProviderError subclasses (ProviderAuthError, ProviderDeclinedError, etc.).
  2. Pass the contract suite. Import the canonical fixtures from @hmp/payments/test and 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.
  3. Register in ProviderCatalog seed. Add one row declaring the provider code, family, displayName, capabilities, supported methods, and enabled regions. The dashboard reads GET /api/provider-catalog to populate the "add account" dropdown.
  4. Wire webhook parsing. Implement verifyAndParseWebhook() to return a canonical ProviderWebhookEvent. Use the provider's HMAC scheme; surface ProviderSignatureError on mismatch.
  5. Write flow-test scenarios. Extend @hmp/payments-simulators with provider-specific fixtures for SUCCESS, DECLINE, TIMEOUT, NETWORK_ERROR, WEBHOOK_DELAYED_5S, SIGNATURE_INVALID.
  6. 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:

  1. Guest abandoned. No action needed — the nightly reconciler will call adapter.fetchPaymentStatus() after 2 hours and set Payment.status = EXPIRED.
  2. Webhook URL misconfigured in provider dashboard. Check GET /api/merchant-accounts/:id shows the webhook URL the tenant should have pasted into Razorpay. Verify by sending a test webhook from Razorpay's dashboard; check WebhookEvent for a new row.
  3. Webhook secret rotated provider-side without KeyNova sync. Signature verification fails silently. WebhookEvent.processingStatus = FAILED accumulates. See the webhook-signature-failures runbook at hmp-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.


  • HTTP surface: API Reference
  • Tenant onboarding: Configure Razorpay
  • Oncall runbooks (live in hmp-infrastructure, not rendered here):
    • hmp-infrastructure/monitoring/runbooks/payments-provider-down.md
    • hmp-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 scope

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.