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.capturedpublished by payment-service → consumed by frontdeskPaymentCapturedConsumer→ 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-EPICD-NNNIDs fromEP-015-rtm.mdandEP-005B-rtm.md.
Personas
| Persona | Role | Source |
|---|---|---|
| Guest (B2C / B2B) | End-user paying via Razorpay Checkout / payment link / UPI QR | hmp-booking-engine/ + uc-guest-payment.yaml (proposed) |
| Front-desk agent | Initiates terminal charge, posts manual cash refund, triggers credit note | US-005-04 (Folio Payment Recording) + US-005B-05 (Refund Management) |
| Manager / Accounts | Approves refunds > tenant threshold, signs off credit-note issue | US-005B-02 (Folio Void & Credit Note) |
| Razorpay (provider) | Captures funds; emits payment.captured / payment.failed / refund.processed / qr_code.credited webhooks | EP-015 US-015-001 |
| Webhook-receiver (payment-service) | Verifies HMAC, dedups, dispatches | EP-015 US-015-005 |
| Folio consumer (frontdesk) | Resolves externalReferenceType=FOLIO|RESERVATION, posts negative-payment | EP-005B US-005-04 |
| NIC e-Invoice IRP | Issues credit-note IRN for refunds against B2B IRN-bearing invoices | EP-015 US-015-004 / EP-005B US-005-11 |
Entry points
| Entry | URL | Backed by |
|---|---|---|
| Guest checkout (booking engine) | https://<slug>.book.<env>.keynova.ai/checkout/payment | hmp-booking-engine/src/lib/payment/razorpay.ts → backend proxy POST /api/booking-engine/payments/initiate-v2 |
| Front-desk charge / link / QR / terminal | https://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" button | RefundModal.tsx → POST /api/payments/refund |
| Razorpay webhook callback | https://api.<env>.keynova.ai/api/webhooks/razorpay-gateway/:merchantAccountId | hmp-payment-service/src/webhooks/webhook.controller.ts |
| Credit-note dashboard | https://dashboard.<env>.keynova.ai/front-desk/refunds/credit-notes | hmp-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-v2onhmp-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
MerchantAccountfor(tenantId, propertyId)with encrypted Razorpay credentials. Demo-tenant guard (Plan 4 2026-05-13) —rzp_live_*keys forbidden whenTenant.isDemoTenant=true. - Post-conditions:
Paymentrowstatus=PENDING+ firstPaymentAttemptstatus=INITIATEDwithproviderReference=null(will be populated in Step 2).idempotencyKeyunique 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 RazorpayPOST /v1/orderswithpayment_capture: 1(mandatory auto-capture per CLAUDE.md AP-T12-003 Gotcha) +X-Razorpay-Idempotency-Key: <idempotencyKey>(NOTX-Payment-Idempotency-Key— the wrong header was silently ignored). Returns{orderId, providerRef}.PaymentAttempt.providerReferencepopulated. - Test:
hmp-payment-service/src/adapters/razorpay/razorpay-gateway.adapter.spec.ts::createHostedCheckoutbuilds 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_idvalid;payment_capture: 1set (omitting it would leave funds in Razorpayauthorizedstate requiring a separate capture call). - Post-conditions: Razorpay
Orderexists withamount(paise),currency,receipt=<paymentId>,payment_capture=1.PaymentAttempt.providerReference = order_xxx.Payment.metadataretainsprovider='razorpay-gateway'+mode='HOSTED_CHECKOUT'. - Notes: For UPI QR (Step 3c) and payment-link (Step 3b) flows, the adapter calls
POST /v1/payments/qr_codesorPOST /v1/payment_linksinstead. Same idempotency header convention.
Step 3: Guest interacts (one of redirect / link / UPI QR / terminal)
- 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 callsPOST /api/payments/:id/verify-razorpay-redirect(PaymentOrchestratorService.verifyRazorpayRedirect— verifies HMAC on(orderId|paymentId)→ emitspayment.capturedvia transactional outbox in same$transaction). - (3b) Payment link —
PaymentLinksService.createLink→ guest receives SMS/WhatsApp short URL → opens hosted page → pays. Razorpay firespayment_link.paidwebhook. 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-qrcreates Razorpay QR code withexpiresAt = folio.checkOutDate + 24h(per BF-008 SME-PROPOSED). Guest scans → pays via UPI app → Razorpay firesqr_code.creditedwebhook. - (3d) POS terminal — Front-desk taps "Charge to Terminal" →
terminal-charge.worker.tscalls Razorpay POS / Pinelabs Plutus ECR → terminal prompts guest (tap / dip / insert). Worker polls until terminal returns approval code + last4. SUCCEEDED branch emits BOTHpayment.succeeded(legacy) ANDpayment.captured(canonical).
- (3a) Hosted redirect — Guest browser loads
- 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.ts4 scenarios (3c).terminal-folio-settlement.flow.spec.ts6 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.capturedboundary — 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.capturedevent JSON tohttps://api.<env>.keynova.ai/api/webhooks/razorpay-gateway/:merchantAccountIdwith headerX-Razorpay-Signature: <hex>. Endpoint:hmp-payment-service/src/webhooks/webhook.controller.ts(@Public).main.tsregistersbody-parser.raw({ type: 'application/json' })on/api/webhooksBEFORE 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 withsignRazorpayWebhook(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.webhookSecretmatches what was registered in the Razorpay dashboard. - Post-conditions: Raw-body bytes available at
req.rawBodyfor 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— computesHMAC-SHA256(rawBody, merchantAccount.webhookSecret)viarazorpay-signature.ts, compares timing-safe toX-Razorpay-Signatureheader. Mismatch → 401INVALID_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.tsordering is non-negotiable.
Step 6: Webhook dedup (providerEventId)
- Story: US-015-005 (Webhook Configuration for External Systems).
- Code:
WebhookDispatcherService.dispatchperforms an INSERT intoWebhookEventwith unique index on(providerType, providerEventId). Razorpay sends each event with a uniqueid: evt_xxxfield; 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};capturedAtunchanged on Payment row. - Pre-conditions: Step 5 completed;
WebhookEventrow schema present (Prisma migration applied). - Post-conditions: New
WebhookEventrow withprocessingStatus=RECEIVED(or dedup short-circuit). Layer-1 reconciliation (WebhookReconcileService, every 10 min) re-dispatches rows stuck inRECEIVED > 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:
WebhookDispatcherServiceroutespayment.capturedtoRazorpayWebhookHandler→ looks upPaymentAttemptbyproviderReference→ atomic update inside$transaction:Payment.status=SUCCEEDED,Payment.successfulAttemptId=<id>,Payment.capturedAt=now();PaymentAttempt.status=CAPTURED,PaymentAttempt.providerPaymentRef=<razorpay_payment_id>. The same$transactionenqueuespayment.capturedvia the transactional outbox (PaymentOutboxService.writeEvent). - Test:
payment-flow.e2e-spec.ts:: assertPayment.status === 'SUCCEEDED'after webhook + assert event published onevt:payment.capturedRedis Stream.payments.flow.spec.ts:: Scenario 3 polls to SUCCEEDED within 3s. - Pre-conditions: Step 6 completed;
Payment+PaymentAttemptrows exist from Step 1; concurrent processing protected by the unique-index dedup of Step 6. - Post-conditions: Terminal-state
Paymentrow.PaymentOutboxrow enqueued witheventName='payment.captured'andstatus=PENDING. - Notes: Idempotency: re-reading the Payment inside the
$transactionshort-circuits if a concurrent webhook / redirect-verify / reconcile-cron already made it terminal — no double-post, no duplicatepayment.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) drainspayment_outboxwithFOR UPDATE SKIP LOCKED, publishes to Redis Streamsevt:payment.capturedviaKafkaService.publish, marks rowPUBLISHED, bumpsattemptson failure. AftermaxAttempts=8failures →DEAD_LETTERfor operator triage. - Test:
payment-outbox.service.spec.ts(drain happy + retry + DLQ);payment-flow.e2e-spec.tsreadsevt:payment.capturedfrom a separate Redis connection and asserts payload shape. - Pre-conditions: Step 7 completed; relay enabled (
PAYMENT_OUTBOX_RELAY_DISABLEDunset). - Post-conditions: Event visible on
evt:payment.capturedstream 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 Streamevt:payment.captured. For each event:- Resolves folio via
externalReferenceType=FOLIO|RESERVATION(ifFOLIO, look up directly; ifRESERVATION, find the open folio for that reservation). - 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 asPosFolioChargeConsumer). - Maps
providerCodetoPaymentMode(razorpay-gateway → CARD,razorpay-upi-qr → UPI,pinelabs-terminal → CARD_TERMINAL, etc.). - Calls
FoliosService.postPayment(dto, 'system:payment-captured-consumer', ...)— creates negative-amountFolioTransaction(PAYMENT)+Paymentrow + updates folio balance. - Dedup:
FolioTransaction.reference = folio:{folioId}:payment-captured:{paymentId}— Razorpay's uniquepayment_idshort-circuits duplicate XADDs. - On SETTLED-folio race: XACK + log (no rethrow) — the folio was already closed by a faster path; intentionally no-op.
- Resolves folio via
- 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
OPENstate. - Post-conditions: Negative-amount
FolioTransaction(PAYMENT)posted; folio balance decremented by captured amount;Paymentrow 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:postPaymentruns the balance update inside$transaction; emits BOTH legacyfolio.charge.postedAND canonicalfolio.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.balanceupdated. 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:
paymentRefidempotency is enforced at the consumer'sFolioTransaction.referencededup (Step 9 #5), NOT at a unique DB constraint onPayment.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/refundwith{paymentId, idempotencyKey, amount, reason}→PaymentOrchestratorService.refund:- Validates: Payment status must be
SUCCEEDEDorPARTIALLY_REFUNDED;requested ≤ remaining(over-refund check); idempotency key not reused on this Payment. - Calls
RazorpayGatewayAdapter.refund→ RazorpayPOST /v1/payments/:id/refundwithX-Razorpay-Idempotency-Key(CLAUDE.md AP-T12-003). - Atomic in
$transaction: createsRefundrow, updatesPayment.statustoPARTIALLY_REFUNDEDorREFUNDED, incrementsPayment.refundedAmount. - 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.processedwebhook → handler updatesRefund.status=COMPLETED+ emitspayment.refunded(orrefund.processedtopic). - Frontdesk consumer (proposed
RefundProcessedConsumer— currently the refund-side equivalent ofPaymentCapturedConsumeris 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).
- Validates: Payment status must be
- 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 carriesterminalDeviceId. - 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:
Refundrow (statusPROCESSING→COMPLETEDafter webhook);Payment.refundedAmountincremented; folio carries negative-amountFolioTransaction(PAYMENT, transactionCode='REFUND')(P0-1/P0-3 contract). - Notes: Cash refund is the documented gap (US-005B-05 SME-PROPOSED): needs
PettyCashDisbursementrow + manager approval + receipt-print. Today:postPaymentaccepts negative-amount but no automatedPettyCashDisbursementlinkage.
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).EInvoiceJobProcessorcron picks updocumentType='CRN'row, calls NIC IRPPOST /eivital/v1.04/invoicewith CRN payload, on COMPLETED writesCreditNote.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::generateCreditNotequeue-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.creditNoteIrnpopulated; 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 theSKIPPED_DEMO_TENANTbucket — see EP-005B D-007 for the audit gap (noEInvoiceJobrow today; observable only via structured log).
Failure modes worth testing
| # | Scenario | Expected behaviour | Coverage today | Owning story |
|---|---|---|---|---|
| F1 | Webhook delivered with invalid HMAC signature | 401 INVALID_SIGNATURE; no WebhookEvent row; no state change | payments.flow.spec.ts Scenario 4 — PASS; webhook-dispatcher.service.spec.ts | US-015-005 |
| F2 | Duplicate webhook delivery (same providerEventId) | 200 {deduped: true}; capturedAt unchanged; no double-post | payments.flow.spec.ts Scenario 5 — PASS | US-015-005 |
| F3 | Webhook 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-004 | US-015-001 |
| F4 | Partial refund (< original capture) | Payment.status=PARTIALLY_REFUNDED; remaining refundable = amount - refundedAmount; subsequent over-refund 400 | payments.flow.spec.ts Scenario 7 — TBD | US-005B-05 |
| F5 | Over-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 400 | US-005B-05 |
| F6 | Razorpay provider outage / 5xx during initiate | Adapter 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.ts | US-015-001 |
| F7 | Currency mismatch (folio in INR, payment in USD) — multi-currency hotel | Adapter 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 guests | EP-005B D-007 SME-PROPOSED; no dedicated spec today | US-015-011 (Multi-Currency Billing) |
| F8 | Demo tenant supplies rzp_live_* Razorpay key | 400 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 cases | US-015-001 |
| F9 | Concurrent webhook + redirect-verify for same Payment | Both 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-circuit | US-015-001 / US-005-04 |
| F10 | Payment-link OTP brute-force | payment-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 payments | payment-links.flow.spec.ts (8 scenarios); OTP spec coverage TBD | EP-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-createPettyCashDisbursementrow (US-005B-05 SME-PROPOSED). - Refund SLA emit —
refund.sla.breachedtopic for ops alerting when refund > 7 business days (DPDPA art. 8(3); EP-002 D-010 reuse). - DB-level
paymentRefunique constraint — Prisma partial unique indexPayment(folioId, paymentMode, paymentRef) WHERE paymentRef IS NOT NULL(EP-005B D-004 SME-PROPOSED). - UPI VPA capture on refund routing — persist
Payment.payerUpiIdfromqr_code.creditedwebhook for auto-refund routing (EP-005B D-? SME-PROPOSED). SKIPPED_DEMO_TENANTenum onEInvoiceJob.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.amountis gross; the net-settlement amount diverges. Future story: nightlyRazorpaySettlementReconcilerthat maps Razorpay daily settlement reports to platform-sidePaymentrows.
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.
| ID | Type | Severity | Description | Surfaced by step | Cross-ref |
|---|---|---|---|---|---|
| BF-008 D-001 | Catalog-gap | P0 | The 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 3 | EP-015 D-008 (scattered-ownership) |
| BF-008 D-002 | Cross-service-handoff | P0 | The 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 11 | EP-005B D-? (new) |
| BF-008 D-003 | Story-incomplete | P1 | RBI 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 11 | EP-015 [SME-PROPOSED] under US-015-001; EP-002 D-010 (refundSlaHours) |
| BF-008 D-004 | Test-vs-code | P1 | payments.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, 11 | EP-015 D-007 P0 carry-over |
| BF-008 D-005 | Catalog-numbering-collision | P0 | US-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 steps | EP-015 D-002 P0 |
| BF-008 D-006 | Use-case-missing | P1 | No 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 11 | EP-005B D-002 + US-005B-05 [SME-PROPOSED] |
| BF-008 D-007 | Story-incomplete | P0 | Credit-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 12 | EP-015 D-006 P1 → escalate to P0 (financial impact) |
| BF-008 D-008 | Audit-gap | P1 | Demo-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, 12 | EP-005B D-007 P0 (einvoice side); BF-008 extends to payment-service side too |
| BF-008 D-009 | Catalog-vs-code | P2 | The 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 8 | EP-015 D-008 (webhook registry); BF-008 extends to internal event registry |
Cross-EPIC narrative summary
| Step | Spans | Why cross-EPIC |
|---|---|---|
| 1 → 3 | EP-015 (Razorpay adapter, MerchantAccount, demo-tenant guard) + booking-engine + reservation-service | The guest-facing surface lives in three repos but the trust boundary is the API-key-gated booking-engine proxy. |
| 4 → 8 | EP-015 (webhook + dispatcher + outbox) | Single-EPIC mechanical pipeline. |
| 9 → 10 | EP-015 → EP-005B | THE 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. |
| 11 | EP-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). |
| 12 | EP-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.