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.
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.
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.
| Table | What 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.
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.
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.
| Case | Behaviour |
|---|---|
| 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. |
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.
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.
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:
['applied','applied'] != ['applied','replay']'true' != 'false'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.