Skip to main content

BF-008 — Payment Capture + Refund

Business flow (cross-EPIC user journey). Authored 2026-05-25 as Wave 2 of the RTM Scale-Out, following the PLAYBOOK shape established by BF-001.

Spans EP-015 Integrations & Third-Party Connectors (Razorpay adapter, webhook surface) and EP-005B Folios & Billing (folio settlement, credit note). The seam between the two — payment.captured published by payment-service → consumed by frontdesk PaymentCapturedConsumer → folio negative-payment row → optional credit-note CRN IRN on refund — is the primary BF-008 narrative.

Per step: story → code → test → pre/post conditions → notes. Steps lacking a catalog story are tagged [SME-PROPOSED] and surface as drift findings. Cross-references use the per-EPIC D-NNN IDs from EP-015-rtm.md and EP-005B-rtm.md.

Personas​

PersonaRoleSource
Guest (B2C / B2B)End-user paying via Razorpay Checkout / payment link / UPI QRhmp-booking-engine/ + uc-guest-payment.yaml (proposed)
Front-desk agentInitiates terminal charge, posts manual cash refund, triggers credit noteUS-005-04 (Folio Payment Recording) + US-005B-05 (Refund Management)
Manager / AccountsApproves refunds > tenant threshold, signs off credit-note issueUS-005B-02 (Folio Void & Credit Note)
Razorpay (provider)Captures funds; emits payment.captured / payment.failed / refund.processed / qr_code.credited webhooksEP-015 US-015-001
Webhook-receiver (payment-service)Verifies HMAC, dedups, dispatchesEP-015 US-015-005
Folio consumer (frontdesk)Resolves externalReferenceType=FOLIO|RESERVATION, posts negative-paymentEP-005B US-005-04
NIC e-Invoice IRPIssues credit-note IRN for refunds against B2B IRN-bearing invoicesEP-015 US-015-004 / EP-005B US-005-11

Entry points​

EntryURLBacked by
Guest checkout (booking engine)https://<slug>.book.<env>.keynova.ai/checkout/paymenthmp-booking-engine/src/lib/payment/razorpay.ts → backend proxy POST /api/booking-engine/payments/initiate-v2
Front-desk charge / link / QR / terminalhttps://dashboard.<env>.keynova.ai/front-desk/folio/[id]hmp-web-dashboard/src/app/(dashboard)/front-desk/folio/[id]/_components/RefundModal.tsx + CreatePaymentLinkSheet.tsx
Refund (front-desk)https://dashboard.<env>.keynova.ai/front-desk/folio/[id] → "Refund" buttonRefundModal.tsx → POST /api/payments/refund
Razorpay webhook callbackhttps://api.<env>.keynova.ai/api/webhooks/razorpay-gateway/:merchantAccountIdhmp-payment-service/src/webhooks/webhook.controller.ts
Credit-note dashboardhttps://dashboard.<env>.keynova.ai/front-desk/refunds/credit-noteshmp-frontdesk-service/src/refunds/refund-ledger.controller.ts

The 12-step journey​

Step 1: Payment intent created​

  • Story: US-015-001 (Razorpay Payment Gateway Integration) + US-005-04 (Folio Payment Recording, on the booking-engine-proxy path).
  • Code: Guest path: hmp-booking-engine/src/lib/payment/razorpay.ts → POST /api/booking-engine/payments/initiate-v2 on hmp-reservation-service/src/payments/payments.service.ts (API-key-gated @Public) → PaymentServiceClient.initiatePayment (service-account JWT) → hmp-payment-service/src/payments/payment.controller.ts:initiate (@Roles('ADMIN','SUPER_ADMIN','FRONTDESK_AGENT','BOOKING_ENGINE_SERVICE')) → PaymentOrchestratorService.initiatePayment (atomic Payment + first PaymentAttempt INSIDE one $transaction).
  • Test: hmp-payment-service/src/payments/payment-orchestrator.service.spec.ts :: initiate happy-path + idempotency-key-replay → returns existing Payment row. payment-flow.e2e-spec.ts :: Step 2 — assert response shape {paymentId, providerRef, ...} and Payment row state. Flow: payments.flow.spec.ts :: Scenario 3 — initiate → SUCCEEDED within 3s.
  • Pre-conditions: Active MerchantAccount for (tenantId, propertyId) with encrypted Razorpay credentials. Demo-tenant guard (Plan 4 2026-05-13) — rzp_live_* keys forbidden when Tenant.isDemoTenant=true.
  • Post-conditions: Payment row status=PENDING + first PaymentAttempt status=INITIATED with providerReference=null (will be populated in Step 2). idempotencyKey unique per (tenantId, idempotencyKey).
  • Notes: Guests never hold tenantId / merchantAccountId — the booking-engine proxy resolves them from the reservation row. Direct guest → payment-service is blocked (no @Public() on payment-service controller, by design — see CLAUDE.md "PCI scope" note).

Step 2: Provider checkout created (Razorpay order)​

  • Story: US-015-001 (Razorpay Payment Gateway Integration).
  • Code: hmp-payment-service/src/adapters/razorpay/razorpay-gateway.adapter.ts:createHostedCheckout — calls Razorpay POST /v1/orders with payment_capture: 1 (mandatory auto-capture per CLAUDE.md AP-T12-003 Gotcha) + X-Razorpay-Idempotency-Key: <idempotencyKey> (NOT X-Payment-Idempotency-Key — the wrong header was silently ignored). Returns {orderId, providerRef}. PaymentAttempt.providerReference populated.
  • Test: hmp-payment-service/src/adapters/razorpay/razorpay-gateway.adapter.spec.ts :: createHostedCheckout builds correct body + headers; razorpay-error-mapping.spec.ts :: maps Razorpay error codes (BAD_REQUEST_CARD vs RATE_LIMITED vs GATEWAY_DOWN).
  • Pre-conditions: Step 1 completed; MerchantAccount.credentials.key_id valid; payment_capture: 1 set (omitting it would leave funds in Razorpay authorized state requiring a separate capture call).
  • Post-conditions: Razorpay Order exists with amount (paise), currency, receipt=<paymentId>, payment_capture=1. PaymentAttempt.providerReference = order_xxx. Payment.metadata retains provider='razorpay-gateway' + mode='HOSTED_CHECKOUT'.
  • Notes: For UPI QR (Step 3c) and payment-link (Step 3b) flows, the adapter calls POST /v1/payments/qr_codes or POST /v1/payment_links instead. Same idempotency header convention.
  • Story: [SME-PROPOSED] — US-015-NEW-CHECKOUT-MODES "Payment surface modes (hosted redirect / payment link OTP / UPI QR / POS terminal)". The four producer paths exist but no single catalog row enumerates them.
  • Code: 4 surfaces:
    • (3a) Hosted redirect — Guest browser loads https://checkout.razorpay.com/v1/checkout.js, enters card / netbanking / wallet → posts to Razorpay → 302 back to /booking/confirmation?razorpay_payment_id=...&razorpay_order_id=...&razorpay_signature=.... Front-end calls POST /api/payments/:id/verify-razorpay-redirect (PaymentOrchestratorService.verifyRazorpayRedirect — verifies HMAC on (orderId|paymentId) → emits payment.captured via transactional outbox in same $transaction).
    • (3b) Payment link — PaymentLinksService.createLink → guest receives SMS/WhatsApp short URL → opens hosted page → pays. Razorpay fires payment_link.paid webhook. OTP gate: payment-link-otp.service.ts (6-digit OTP, 5-min TTL, max 3 wrong attempts, per-slug rate limit, per-phone daily cap — RBI-relevant guest-identity check).
    • (3c) UPI QR — POST /api/upi-qr creates Razorpay QR code with expiresAt = folio.checkOutDate + 24h (per BF-008 SME-PROPOSED). Guest scans → pays via UPI app → Razorpay fires qr_code.credited webhook.
    • (3d) POS terminal — Front-desk taps "Charge to Terminal" → terminal-charge.worker.ts calls Razorpay POS / Pinelabs Plutus ECR → terminal prompts guest (tap / dip / insert). Worker polls until terminal returns approval code + last4. SUCCEEDED branch emits BOTH payment.succeeded (legacy) AND payment.captured (canonical).
  • Test: hmp-payment-service/src/payments/payment-orchestrator.service.spec.ts (3a verify-redirect HMAC + outbox emit). hmp-payment-service/src/payment-links/payment-links.service.spec.ts (3b). hmp-infrastructure/testing/flow-tests/src/flows/upi-qr.flow.spec.ts 4 scenarios (3c). terminal-folio-settlement.flow.spec.ts 6 scenarios (3d).
  • Pre-conditions: Step 2 completed; Razorpay Order (or QR / Link) exists.
  • Post-conditions: Guest interaction completes; Razorpay has captured the funds (or failed); webhook is in flight.
  • Notes: The four surfaces are functionally equivalent at the payment.captured boundary — same payload shape ({tenantId, paymentId, attemptId?, providerRef, providerCode, externalReferenceType, externalReferenceId, amount, currency, source}) flowing through the same consumer (PaymentCapturedConsumer). This unification is the load-bearing decision of the Folio Payment Reconciliation plan (2026-05-19).

Step 4: Razorpay captures and sends webhook​

  • Story: US-015-005 (Webhook Configuration for External Systems).
  • Code: Razorpay's webhook scheduler POSTs payment.captured event JSON to https://api.<env>.keynova.ai/api/webhooks/razorpay-gateway/:merchantAccountId with header X-Razorpay-Signature: <hex>. Endpoint: hmp-payment-service/src/webhooks/webhook.controller.ts (@Public). main.ts registers body-parser.raw({ type: 'application/json' }) on /api/webhooks BEFORE the global JSON parser — Razorpay signs the EXACT raw bytes; any JSON re-serialisation invalidates the HMAC.
  • Test: payments.flow.spec.ts :: Scenario 3 — signed webhook with signRazorpayWebhook(rawBody, secret) returns 200; poll Payment status to SUCCEEDED within 3s.
  • Pre-conditions: Step 3 completed; Razorpay's outbound IP is reachable (no Nginx/WAF block); MerchantAccount.webhookSecret matches what was registered in the Razorpay dashboard.
  • Post-conditions: Raw-body bytes available at req.rawBody for HMAC verification. Subsequent steps proceed.
  • Notes: Razorpay retries failed webhooks up to 24h at exponential backoff. If our endpoint is down for >24h, the webhook is permanently lost — Layer-2 reconciliation (Step 4b/AP-T12-004) is the recovery path.

Step 5: Webhook HMAC validated (raw body)​

  • Story: US-015-005 (Webhook Configuration for External Systems).
  • Code: hmp-payment-service/src/webhooks/webhook-dispatcher.service.ts:verifyAndParseWebhook — computes HMAC-SHA256(rawBody, merchantAccount.webhookSecret) via razorpay-signature.ts, compares timing-safe to X-Razorpay-Signature header. Mismatch → 401 INVALID_SIGNATURE (no row written).
  • Test: webhook-dispatcher.service.spec.ts :: signature mismatch returns 401; payments.flow.spec.ts :: Scenario 4 — forgeInvalidSignature() returns 401.
  • Pre-conditions: Step 4 completed; raw body bytes intact (no middleware re-serialisation upstream).
  • Post-conditions: Verified webhook payload parsed into typed object; safe to persist.
  • Notes: Signature verification runs BEFORE any DB write or external lookup — denies amplification attacks. The raw-body trap (Express's default JSON middleware re-serialising) is the #1 source of "webhook works in local but fails in prod" — the platform's main.ts ordering is non-negotiable.

Step 6: Webhook dedup (providerEventId)​

  • Story: US-015-005 (Webhook Configuration for External Systems).
  • Code: WebhookDispatcherService.dispatch performs an INSERT into WebhookEvent with unique index on (providerType, providerEventId). Razorpay sends each event with a unique id: evt_xxx field; Razorpay's own retries reuse the same id, so duplicate deliveries collide on the unique index. Returns {ok: true, deduped: true, webhookEventId, eventType} without re-processing.
  • Test: payments.flow.spec.ts :: Scenario 5 — identical second delivery returns {deduped: true}; capturedAt unchanged on Payment row.
  • Pre-conditions: Step 5 completed; WebhookEvent row schema present (Prisma migration applied).
  • Post-conditions: New WebhookEvent row with processingStatus=RECEIVED (or dedup short-circuit). Layer-1 reconciliation (WebhookReconcileService, every 10 min) re-dispatches rows stuck in RECEIVED > 5 min.
  • Notes: The dedup-window has TWO layers: (a) the unique-index real-time dedup above, (b) the cron re-dispatch of stuck rows (AP-T12-004 Layer 1). Both must hold.

Step 7: Payment row updated (SUCCEEDED), attempt CAPTURED​

  • Story: US-015-001 (Razorpay Payment Gateway Integration) + US-005-04 (Folio Payment Recording).
  • Code: WebhookDispatcherService routes payment.captured to RazorpayWebhookHandler → looks up PaymentAttempt by providerReference → atomic update inside $transaction: Payment.status=SUCCEEDED, Payment.successfulAttemptId=<id>, Payment.capturedAt=now(); PaymentAttempt.status=CAPTURED, PaymentAttempt.providerPaymentRef=<razorpay_payment_id>. The same $transaction enqueues payment.captured via the transactional outbox (PaymentOutboxService.writeEvent).
  • Test: payment-flow.e2e-spec.ts :: assert Payment.status === 'SUCCEEDED' after webhook + assert event published on evt:payment.captured Redis Stream. payments.flow.spec.ts :: Scenario 3 polls to SUCCEEDED within 3s.
  • Pre-conditions: Step 6 completed; Payment + PaymentAttempt rows exist from Step 1; concurrent processing protected by the unique-index dedup of Step 6.
  • Post-conditions: Terminal-state Payment row. PaymentOutbox row enqueued with eventName='payment.captured' and status=PENDING.
  • Notes: Idempotency: re-reading the Payment inside the $transaction short-circuits if a concurrent webhook / redirect-verify / reconcile-cron already made it terminal — no double-post, no duplicate payment.captured (per CLAUDE.md AP-T12-004 Layer 2 note).

Step 8: payment.captured event published (transactional outbox)​

  • Story: US-015-001 (Razorpay Payment Gateway Integration) + US-005-04 (Folio Payment Recording).
  • Code: PaymentOutboxService.tick (every 5s, @Interval) drains payment_outbox with FOR UPDATE SKIP LOCKED, publishes to Redis Streams evt:payment.captured via KafkaService.publish, marks row PUBLISHED, bumps attempts on failure. After maxAttempts=8 failures → DEAD_LETTER for operator triage.
  • Test: payment-outbox.service.spec.ts (drain happy + retry + DLQ); payment-flow.e2e-spec.ts reads evt:payment.captured from a separate Redis connection and asserts payload shape.
  • Pre-conditions: Step 7 completed; relay enabled (PAYMENT_OUTBOX_RELAY_DISABLED unset).
  • Post-conditions: Event visible on evt:payment.captured stream with payload {tenantId, paymentId, attemptId?, providerRef, providerCode, externalReferenceType, externalReferenceId, amount, currency, source}.
  • Notes: Three of the four capture surfaces (3b payment-link, 3c UPI QR, 3d terminal) currently emit via legacy fire-and-forget PaymentEventPublisher.publish(), NOT the outbox. The rare crash window between tx-commit and publish is reconciled by Razorpay's own webhook retry semantics (the webhook path independently catches the missed capture). Migration to outbox for all four is a tracked follow-up.

Step 9: Frontdesk PaymentCapturedConsumer posts to folio​

  • Story: US-005-04 (Folio Payment Recording).
  • Code: hmp-frontdesk-service/src/folios/consumers/payment-captured.consumer.ts — subscribes to Redis Stream evt:payment.captured. For each event:
    1. Resolves folio via externalReferenceType=FOLIO|RESERVATION (if FOLIO, look up directly; if RESERVATION, find the open folio for that reservation).
    2. Tenant-mismatch defense: rejects if folio.reservation.property.tenantId !== event.tenantId (reads tenantId from the folio's property, NOT from the event — same AP-T07-002 lesson as PosFolioChargeConsumer).
    3. Maps providerCode to PaymentMode (razorpay-gateway → CARD, razorpay-upi-qr → UPI, pinelabs-terminal → CARD_TERMINAL, etc.).
    4. Calls FoliosService.postPayment(dto, 'system:payment-captured-consumer', ...) — creates negative-amount FolioTransaction(PAYMENT) + Payment row + updates folio balance.
    5. Dedup: FolioTransaction.reference = folio:{folioId}:payment-captured:{paymentId} — Razorpay's unique payment_id short-circuits duplicate XADDs.
    6. On SETTLED-folio race: XACK + log (no rethrow) — the folio was already closed by a faster path; intentionally no-op.
  • Test: payment-captured.consumer.spec.ts :: 20 cases covering all FOLIO/RESERVATION resolution paths, tenant-mismatch, providerCode mapping, dedup key shape, SETTLED-folio race.
  • Pre-conditions: Step 8 completed; consumer subscribed; folio in OPEN state.
  • Post-conditions: Negative-amount FolioTransaction(PAYMENT) posted; folio balance decremented by captured amount; Payment row in frontdesk-DB linked to folio.
  • Notes: This consumer IS the seam between EP-015 and EP-005B. Pre-2026-05-19 the seam was a manual reconciliation gap; the Folio Payment Reconciliation plan closed it end-to-end (CLAUDE.md Gotcha 27).

Step 10: Folio balance updated → folio.posted event (FU-55 dual-emit)​

  • Story: US-005-03 (Folio Creation & Charges).
  • Code: hmp-frontdesk-service/src/folios/folios.service.ts:postPayment runs the balance update inside $transaction; emits BOTH legacy folio.charge.posted AND canonical folio.posted (FU-55 dual-emit, 2026-05-23) carrying analytics-segmentation fields (segment, countryCode, roomTypeCode, ratePlanCode, roomNights, taxAmount). Without the dual-emit, 5 analytics MVs were empty (root cause of the cross-EPIC analytics gap).
  • Test: folios.service.spec.ts :: postPayment balance arithmetic (balance = totalCharges + sum(payments) where payments are signed). fu-55-folio-event-dual-emit.spec.ts :: 8 cases on canonical+legacy dual-emit.
  • Pre-conditions: Step 9 completed; folio OPEN; FolioLock acquired (Redis distributed lock prevents concurrent posts to same folio).
  • Post-conditions: Folio.balance updated. If balance reaches zero AND checkout pipeline is running, the folio settles → IRN auto-fire (checkout.service.ts:436) — see BF-005 for the checkout journey continuation.
  • Notes: paymentRef idempotency is enforced at the consumer's FolioTransaction.reference dedup (Step 9 #5), NOT at a unique DB constraint on Payment.paymentRef. EP-005B D-004 proposes adding the DB-level guard; both must hold long-term.

Step 11: (Refund path) refund request → Razorpay → refund.processed webhook → folio adjustment​

  • Story: US-005B-05 (Refund Management) + US-015-001 (Razorpay Payment Gateway Integration).
  • Code: Refund request → RefundModal.tsx → POST /api/payments/refund with {paymentId, idempotencyKey, amount, reason} → PaymentOrchestratorService.refund:
    1. Validates: Payment status must be SUCCEEDED or PARTIALLY_REFUNDED; requested ≤ remaining (over-refund check); idempotency key not reused on this Payment.
    2. Calls RazorpayGatewayAdapter.refund → Razorpay POST /v1/payments/:id/refund with X-Razorpay-Idempotency-Key (CLAUDE.md AP-T12-003).
    3. Atomic in $transaction: creates Refund row, updates Payment.status to PARTIALLY_REFUNDED or REFUNDED, increments Payment.refundedAmount.
    4. Razorpay async-completes the refund (T+5-7 business days for cards per RBI/2019-20/45 DPSS.CO.PD.No.629/02.01.014/2019-20; T+1 for UPI P2M) and fires refund.processed webhook → handler updates Refund.status=COMPLETED + emits payment.refunded (or refund.processed topic).
    5. Frontdesk consumer (proposed RefundProcessedConsumer — currently the refund-side equivalent of PaymentCapturedConsumer is undocumented per CLAUDE.md Gotcha 27 note; the refund flow today writes the negative-amount folio row at refund-request time, not webhook-completion time).
  • Test: payment-orchestrator.service.spec.ts :: refund happy + over-refund 400; payments.flow.spec.ts :: Scenario 7 — partial refund (3000 paise of 10000) → paymentStatus=PARTIALLY_REFUNDED; full refund (7000 remaining) → REFUNDED; over-refund (1 paise extra) → 4xx. terminal-folio-settlement.flow.spec.ts :: Scenario 4 — terminal capture → refund → Refund row carries terminalDeviceId.
  • Pre-conditions: Step 9 (or earlier capture-completed step) for the original payment; refund authorised (manager-role gate for amounts > tenant threshold per US-005B-05 SME-PROPOSED).
  • Post-conditions: Refund row (status PROCESSING → COMPLETED after webhook); Payment.refundedAmount incremented; folio carries negative-amount FolioTransaction(PAYMENT, transactionCode='REFUND') (P0-1/P0-3 contract).
  • Notes: Cash refund is the documented gap (US-005B-05 SME-PROPOSED): needs PettyCashDisbursement row + manager approval + receipt-print. Today: postPayment accepts negative-amount but no automated PettyCashDisbursement linkage.

Step 12: (Credit-note path) IRN generated via NIC for refund against B2B IRN-bearing invoice​

  • Story: US-015-004 (GST E-Invoice API NIC Portal) + US-005-11 (GST E-Invoice Generation) + US-005B-02 (Folio Void & Credit Note).
  • Code: Front-desk creates credit-note → RefundLedgerService.createCreditNote (manager+ role) → RefundLedgerService.issueCreditNote (after approval) → EInvoiceService.generateCreditNote(folioId, creditNoteNumber, ...) (AP-T08-007, queue-routed). EInvoiceJobProcessor cron picks up documentType='CRN' row, calls NIC IRP POST /eivital/v1.04/invoice with CRN payload, on COMPLETED writes CreditNote.creditNoteIrn + status=IRN_GENERATED. Idempotency: findActiveCreditNoteJob(folioId, creditNoteNumber) matches QUEUED/PROCESSING/COMPLETED (DEAD_LETTER/FAILED intentionally NOT matched so a permanently-failed CRN can be re-queued).
  • Test: einvoice.service.spec.ts :: generateCreditNote queue-routing + idempotency (AP-T08-007); einvoice-job.processor.spec.ts :: INV-vs-CRN branch; refund-ledger.service.spec.ts :: credit-note CRUD + approval lifecycle + IRN linkage.
  • Pre-conditions: Step 11 completed for the original folio's payment; the original folio carries a valid folio.irn (B2B path — refund of B2C invoice without IRN does NOT require a CRN IRN, per NIC's B2C QR-on-receipt mandate); refund is "real" (not a void within the 24-hour cancellation window — within that window, cancelIrn(originalIrn) is the correct path, not a CRN).
  • Post-conditions: CreditNote.creditNoteIrn populated; signed QR + ack-number persisted; PDF re-renders with the CRN IRN visible; GST liability adjusted in the next GSTR-1 filing.
  • Notes: Demo-tenant guard (Plan 4) — if Tenant.isDemoTenant=true, CRN submission is SKIPPED (no NIC API call, no real GST liability). Compliance reports use the SKIPPED_DEMO_TENANT bucket — see EP-005B D-007 for the audit gap (no EInvoiceJob row today; observable only via structured log).

Failure modes worth testing​

#ScenarioExpected behaviourCoverage todayOwning story
F1Webhook delivered with invalid HMAC signature401 INVALID_SIGNATURE; no WebhookEvent row; no state changepayments.flow.spec.ts Scenario 4 — PASS; webhook-dispatcher.service.spec.tsUS-015-005
F2Duplicate webhook delivery (same providerEventId)200 {deduped: true}; capturedAt unchanged; no double-postpayments.flow.spec.ts Scenario 5 — PASSUS-015-005
F3Webhook never arrived (Razorpay outage / our endpoint down >24h)Layer-2 PaymentReconcileService (every 15 min) queries Razorpay fetchPaymentStatus for non-terminal Payment rows past 30-min grace window; promotes to SUCCEEDED + emits payment.captured with source='reconcile-cron'payment-reconcile.service.spec.ts; AP-T12-004US-015-001
F4Partial refund (< original capture)Payment.status=PARTIALLY_REFUNDED; remaining refundable = amount - refundedAmount; subsequent over-refund 400payments.flow.spec.ts Scenario 7 — TBDUS-005B-05
F5Over-refund (refund > captured amount or > remaining)4xx REFUND_EXCEEDS_PAYMENTS with {totalPayments, alreadyRefunded, requestedRefund, maxAllowedRefund}payments.flow.spec.ts Scenario 7 step 4 — accepts any 4xx; payment-orchestrator.service.spec.ts :: over-refund 400US-005B-05
F6Razorpay provider outage / 5xx during initiateAdapter maps to GATEWAY_DOWN; payment-orchestrator returns 503; Payment row stays PENDING; retry path = caller re-initiate with FRESH idempotency key (creates new Payment + new first Attempt against same MerchantAccount)payments.flow.spec.ts Scenario 6 — TBD; razorpay-error-mapping.spec.tsUS-015-001
F7Currency mismatch (folio in INR, payment in USD) — multi-currency hotelAdapter rejects at initiate (Razorpay India accepts INR only for domestic capture); for international flows, Currency.service conversion at posting time; FEMA RBI 8(2) requires rate-on-date-of-payment for non-resident guestsEP-005B D-007 SME-PROPOSED; no dedicated spec todayUS-015-011 (Multi-Currency Billing)
F8Demo tenant supplies rzp_live_* Razorpay key400 DEMO_TENANT_REQUIRES_SANDBOX_KEY from MerchantAccountService.create / rotateCredentials (Plan 4 2026-05-13)demo-tenant.service.spec.ts :: 7 cases; merchant-account.service.spec.ts :: 4 casesUS-015-001
F9Concurrent webhook + redirect-verify for same PaymentBoth transactions attempt the same atomic update; second one re-reads inside $transaction, sees terminal state, short-circuits — no double-post of payment.captured, no duplicate folio-payment row (folio-side dedup at FolioTransaction.reference)payment-orchestrator.service.spec.ts :: idempotency; payment-captured.consumer.spec.ts :: dedup-key short-circuitUS-015-001 / US-005-04
F10Payment-link OTP brute-forcepayment-link-otp.service.ts enforces max 3 wrong attempts per link + per-phone daily cap + 5-min TTL; RBI-relevant guest-identity gate before high-value link paymentspayment-links.flow.spec.ts (8 scenarios); OTP spec coverage TBDEP-015 D-008 SME-PROPOSED (referenced via US-015-001)

Day-2 hardening (post-pilot, NOT in BF-008 scope)​

Items typically tackled in the first 30 days post Property #1 go-live. Out of BF-008 narrative scope but flagged so the recipient teams (payment-service, frontdesk, NIC integration) know the queue:

  • Cash refund linkage — wire postPayment(negative) to auto-create PettyCashDisbursement row (US-005B-05 SME-PROPOSED).
  • Refund SLA emit — refund.sla.breached topic for ops alerting when refund > 7 business days (DPDPA art. 8(3); EP-002 D-010 reuse).
  • DB-level paymentRef unique constraint — Prisma partial unique index Payment(folioId, paymentMode, paymentRef) WHERE paymentRef IS NOT NULL (EP-005B D-004 SME-PROPOSED).
  • UPI VPA capture on refund routing — persist Payment.payerUpiId from qr_code.credited webhook for auto-refund routing (EP-005B D-? SME-PROPOSED).
  • SKIPPED_DEMO_TENANT enum on EInvoiceJob.status — make demo-tenant skips queryable in SQL instead of greppable in logs (EP-005B D-007 P0).
  • Outbox migration for terminal + payment-link emit paths — close the rare tx-commit-vs-publish crash window (currently reconciled by Razorpay webhook retry, but the outbox path is the load-bearing pattern long-term).
  • Stripe roadmap — defer until ≥5 international-card properties onboard (EP-015 D-009 SME-PROPOSED).
  • Multi-currency margin + RBI ref rate — replace OpenExchangeRates with RBI Reference Rate for FEMA-compliant foreign-currency invoices (EP-015 D-005 SME-PROPOSED).
  • Section 269ST cash limit guardrails — Income-tax Act §269ST forbids cash receipt > INR 2L per transaction / per day / per event. The petty-cash refund path must enforce this on outbound disbursements too.
  • Razorpay-fee deduction reconciliation — Razorpay deducts MDR (~2% for cards, ~0.5% for UPI) before settling to merchant bank account. Payment.amount is gross; the net-settlement amount diverges. Future story: nightly RazorpaySettlementReconciler that maps Razorpay daily settlement reports to platform-side Payment rows.

Drift summary​

The 9 BF-008-surfaced drifts that the SME pass must adjudicate. (P0 = blocks Property #1 launch; P1 = ships next sprint; P2 = backlog.) IDs cross-reference EP-015-rtm.md and EP-005B-rtm.md.

IDTypeSeverityDescriptionSurfaced by stepCross-ref
BF-008 D-001Catalog-gapP0The four capture surfaces (hosted redirect / payment link / UPI QR / POS terminal) are functionally distinct user surfaces but there is no US-015-NEW-CHECKOUT-MODES catalog row enumerating them. US-015-001 treats them as implementation details; the booking-engine + dashboard each only know about a subset.Step 3EP-015 D-008 (scattered-ownership)
BF-008 D-002Cross-service-handoffP0The refund-side payment.refunded (or refund.processed) consumer in frontdesk has no documented contract today — only payment.captured is wired end-to-end (CLAUDE.md Gotcha 27). Today the refund-folio row is written at refund-request time, NOT at webhook-completion time, leaving a window where the folio shows a refund the bank hasn't settled.Step 11EP-005B D-? (new)
BF-008 D-003Story-incompleteP1RBI T+7 refund SLA (DPSS.CO.PD.No.629/02.01.014/2019-20) is NOT encoded as an AC anywhere; Refund.expectedCompletionAt field doesn't exist; no refund.sla.breached emit.Step 11EP-015 [SME-PROPOSED] under US-015-001; EP-002 D-010 (refundSlaHours)
BF-008 D-004Test-vs-codeP1payments.flow.spec.ts Scenarios 6+7 ship in Phase 1+ per the file's own NOTE; today flows-7+8 are at TBD pass-rate. The cross-EPIC refund path (Step 11+12) has no green flow-test today. Property #1 launch needs at least the happy capture + happy refund + over-refund-reject flows green.Step 7, 11EP-015 D-007 P0 carry-over
BF-008 D-005Catalog-numbering-collisionP0US-015-001 (Razorpay) catalog row says "Partial" but the full Modular Payments Platform shipped per platform CLAUDE.md (Phase 0/1/2 + Workstream A) with 33 spec files, dedicated payment-service, demo-tenant guard, two-layer reconciliation. Reviewer trusting the catalog reopens already-shipped work.All stepsEP-015 D-002 P0
BF-008 D-006Use-case-missingP1No uc-refund-management.yaml exists in _data/use-cases/. The refund journey (Step 11) is the most-frequently-litigated surface in hospitality (chargebacks, no-show disputes, OTA cancellation reversals) and has zero use-case YAML coverage.Step 11EP-005B D-002 + US-005B-05 [SME-PROPOSED]
BF-008 D-007Story-incompleteP0Credit-note IRN path (Step 12) does NOT guard against the NIC 24-hour cancellation window — within 24h the correct path is cancelIrn(originalIrn), not a CRN. Story body for US-015-004 mentions the window but no service-layer guard exists. A B2B refund issued within 24h today generates an unnecessary CRN that wastes an IRN call AND leaves an orphaned original IRN.Step 12EP-015 D-006 P1 → escalate to P0 (financial impact)
BF-008 D-008Audit-gapP1Demo-tenant skip on payment.captured → folio post is logged but not recorded as a SKIPPED_DEMO_TENANT enum on any table — compliance reports can't query "how many demo-tenant captures were intentionally not posted to NIC?".Step 9, 12EP-005B D-007 P0 (einvoice side); BF-008 extends to payment-service side too
BF-008 D-009Catalog-vs-codeP2The four payment.* event names (payment.captured, payment.succeeded, payment.failed, payment.refunded) live ONLY in the PaymentEventName union (src/events/payment-event-publisher.service.ts) — there is no cross-service EventTopics enum. A consumer in a downstream service (channel-service, crm-service) has no compile-time contract; typos like 'payment.capture' would silently never match.Step 8EP-015 D-008 (webhook registry); BF-008 extends to internal event registry

Cross-EPIC narrative summary​

StepSpansWhy cross-EPIC
1 → 3EP-015 (Razorpay adapter, MerchantAccount, demo-tenant guard) + booking-engine + reservation-serviceThe guest-facing surface lives in three repos but the trust boundary is the API-key-gated booking-engine proxy.
4 → 8EP-015 (webhook + dispatcher + outbox)Single-EPIC mechanical pipeline.
9 → 10EP-015 → EP-005BTHE SEAM. payment.captured Redis Stream is the contract. Tenant-mismatch defense reads from folio.reservation.property.tenantId (NOT event.tenantId — AP-T07-002 lesson). Dedup at FolioTransaction.reference.
11EP-005B (refund-ledger) + EP-015 (Razorpay refund adapter)Bi-directional cross-EPIC. Frontdesk owns the refund-request; payment-service owns the provider call; frontdesk owns the resulting folio adjustment. The webhook-completion-back-to-folio loop is the documented gap (BF-008 D-002).
12EP-015 (NIC e-Invoice adapter) + EP-005B (refund-ledger + credit-note linkage)The B2B compliance close-out. CRN IRN linkage is shipped (AP-T08-007); the 24-h window guard is the gap (BF-008 D-007).

The "payment.captured as the canonical cross-EPIC contract" decision (Folio Payment Reconciliation plan, 2026-05-19) is BF-008's load-bearing finding — it unified four producer paths through one consumer and made the EP-015 → EP-005B handoff testable end-to-end for the first time. The refund-side equivalent (BF-008 D-002) is the next deliverable to close the loop fully.