Story 2714: Razorpay payment core — the money rail

Covers T2a of RFD-BILL-003: the billing.payment_order evidence table, webhook dedupe and raw-body HMAC, the two new ledger grant types, rate card v2026-07-20, the refund/chargeback lifecycle, and the hourly reconciliation sweep. It does NOT build the outbound Razorpay client (Story-2717 owns the non-VPC egress Lambda), the mandate rail (Story-2719/2721), grant codes (Story-2715), or any signup surface (Story-2713/2716). The contract implemented here was frozen by Spike-2709.

Why this exists

A payment rail invites exactly two failure modes, and both are silent. The first is crediting something that was never paid — a forged webhook, a tampered amount, a replayed confirmation. The second is failing to credit something that was — Razorpay retries a failing webhook for 24 hours and then disables the endpoint, which needs a manual dashboard re-enable. Nothing else in this system notices that. Every design choice below is aimed at one of those two.

1. The ledger stays the only balance store

No second balance store, per RFD-BILL-002. A captured payment appends one ordinary allowance_grant event with grant_type='online_purchase' into the existing offline_purchase bucket (customer label “Purchased Pages”). allocate_and_record_usage(), the burn order, and the bucket vocabulary are untouched.

Migration 0194 adds two grant types and the CHECKs that make each provable:

online_purchase  → bucket = offline_purchase, payment_method NOT NULL,
                   external_reference NOT NULL, received_date NOT NULL,
                   amount_micros > 0, valid_until NOT NULL,
                   granted_by_staff_user_id IS NULL   -- machine-minted

campaign_grant   → bucket = offline_purchase, amount_micros = 0,
                   grant_reason NOT NULL, external_reference NOT NULL,
                   valid_until NOT NULL,
                   granted_by_staff_user_id IS NULL

Both invert offline_purchase's staff requirement: nobody is acting, so a staff id would be a lie. STAFF_GRANT_TYPES and the admin DTO's Literal are deliberately unchanged — letting staff hand-append either would turn the admin API into a way to manufacture money events.

2. The UUID defect Spike-2709 found

D10 specifies business idempotency keys as strings (razorpay:payment:pay_XYZ). page_billing_ledger.idempotency_key is UUID, so those keys cannot be stored as written. The fix derives rather than widening the column:

business_key("razorpay:payment:pay_XYZ") = uuid5(_BILLING_NS, canonical)

The canonical string is stored verbatim in billing.payment_order.business_idempotency_key (TEXT); the ledger stores its UUID. Reconciliation reads the string, UNIQUE (firm_id, idempotency_key) enforces the UUID. The rejected alternative — a second key column on an append-only money table — would have left one key authoritative and one decorative.

3. Evidence, never balance

TableWhat it is
billing.payment_order One provider payment attempt. Server-derived expected_amount_paise, the captured amount, the state machine, and a pointer to the ledger event that is the truth. Mutable by design — the ledger is not, and that difference is the point.
billing.payment_provider_event Webhook dedupe on x-razorpay-event-id, plus the sha256 of the raw body — never the body, so no card data or PII lands at rest.

A new dedupe table rather than ops.processed_webhook_events: that table's primary key column is literally svix_id, its outcome CHECK carries Clerk-only values, and its RLS is staff-only — while payment events must be firm-attributable. Storing razorpay:evt_… in a column named svix_id would make the schema lie about itself.

4. Verify the bytes, then trust them

X-Razorpay-Signature is a hex HMAC-SHA256 over the raw body. verify_webhook takes bytes and never a dict, because parsing first and re-serialising to verify would authenticate a body we invented rather than the one that arrived. The x-razorpay-event-id is read only after the signature verifies, so an unsigned caller cannot seed the dedupe table with an id it chose.

5. The firm comes from the order, never the payload

staff context   → claim the event id, resolve the order, drive its state
DROP to firm    → SET app.is_staff = false; app.current_firm_id = order.firm_id
                  INSERT the allowance_grant   ← RLS refuses any other firm
staff context   → finalize the event outcome

Left in staff context, the ledger's staff_grant_append_policy would OR-permit an insert for any firm and SB-48 would pass only because our code happened to be right. Dropping to firm scope makes the database the thing that refuses.

6. Refunds and chargebacks (D17)

CaseBehaviour
Full refund or lost dispute Appends a compensating reversal that fully mirrors the grant. No consumption guard — unlike a staff mis-grant, a chargeback is the bank taking money back after the pages were spent.
Balance goes negative Representable and shown as pages owed, never clamped to zero. intake_block() refuses intake until it is positive again.
Refund arrives before the credit Parked with review_flag='refund_before_credit'. No negative is posted against pages the customer never received; the capture handler nets it when the credit lands.
Partial refund Not auto-reversed. The ledger's correction guard requires a reversal to mirror its grant exactly. Inventing a partial mechanism to slip around it would be the second balance store RFD-BILL-002 forbids, so this is flagged for staff instead.
Velocity / spend caps Until an account has payment history: at most 5 orders and ₹50,000 per rolling 24h.

7. The sweep is the only detector (D23)

src/workers/payment_reconciliation runs hourly. Captures with evidence but no ledger event are credited late. Orders the provider never resolved are flagged orphan_capture and logged loudly — resolving those needs an outbound API call that lives in the egress Lambda Story-2717 owns, so they are alerted rather than guessed at. The pramaan.billing.payment_uncredited_captures gauge is what an alarm thresholds on: “this stopped being zero” is what a disabled webhook looks like from the inside.

8. Paise and micros

Razorpay speaks paise (1e2); the ledger speaks micros (1e6). Widening is always exact; narrowing asserts divisibility and raises rather than rounds, because RFD-BILL-003 §7's proof of correctness is a paisa-exact reconciliation and a silent rounding would make that proof pass on a wrong number.

9. Rate card v2026-07-20

India, version 2: individual ₹20/page; firm ₹25,000/seat/month, min 3 seats, 2,500 pooled pages/seat/month, ₹15/page overage, onboarding_pages = 0. That last value required relaxing rate_card_account_shape_valid from > 0 to >= 0 on the firm branch — D3's onboarding-zero law is a constraint change, not a seed change. The Onboarding Allowance is a sales-led annual-conversion grant, not a self-serve entitlement (D7 amended).

Proof. Nine pytest-bdd scenarios (SB-04, SB-13, SB-15, SB-16, SB-33, SB-34, SB-35, SB-48, SB-52) run against a real Postgres in the real RLS scopes, plus 23 unit tests for the pure boundaries. Three planted negatives were observed red before being reverted:

The third one initially stayed green, which exposed that SB-48 was proving the RLS policy but not that the service uses it. The assertion was strengthened until the mutation went red. Also green: alembic check, upgrade/downgrade/upgrade round-trip on a clean volume, the full 2,428-test unit suite, the domain-events / model-placement / observability gates, and make openapi.