payment.captured and order.paid), and retries either one until it
gets a 200. Treat each arrival as “credit them” and a ₹1,000 purchase mints
₹2,000, ₹3,000, ₹4,000 of free wallet. The second is the refund that never
reverses: money leaves the company bank account via Razorpay, but if the ledger still shows the
credits, the firm spends money we already returned. This Story makes the mint exactly-once
per order no matter how many events arrive, and makes a refund a compensating
entry — a new negative row that points at the row it cancels, never an edit or a
delete of the original (ADR-158: the ledger is append-only truth, and you reverse history by
adding to it, never by rewriting it).
A signature-verified POST /webhooks/razorpay that, on a payment-success event, mints
the order's credits into the top-up bucket exactly once per order (three
layered guards collapse Razorpay's dual/retried events into a single mint) with a config-sourced
expiry, and on a refund/chargeback appends a negative ledger row carrying a
reverses foreign key to the mint it cancels — never an update, never a delete,
never a direct balance edit.
Back to S7's credit shop. The clerk quoted a price, handed over a numbered ticket, and sent the firm to the payment window. Now the payment window slides “paid” slips back — and here is the twist that makes this hard: the window sends the same sale on two different slips (“card captured” and “order settled”), and if it doesn't see the clerk nod, it slides the same slip back again a minute later. The clerk must open the till and count out credits once for the sale, no matter how many slips describe it. So the clerk keys the till on the order number written on the ticket, not on the slip: the first slip for order #1234 opens the till; every later slip for #1234 — different slip, same order — finds the till already counted for that order and does nothing. Alongside, the clerk keeps a logbook of every slip id seen, so an identical slip that arrives twice is waved off at the door before the till is even touched. And there is now a returns desk: when the payment window reports it refunded a firm, the clerk does not tear the original sale out of the ledger — that would be forging history. Instead the clerk writes a new line, in red, for the negative amount, with an arrow drawn back to the exact sale it undoes. The books always foot; anyone can read forward and see the sale, then the reversal.
webhooks_razorpay.py)The route first authenticates the raw body with S7's verify_razorpay_signature (the
webhook secret loaded from Secrets Manager, never hardcoded); a bad or missing signature is a
401. It then opens its own DB session under staff context and records the
Razorpay event id in ops.processed_webhook_events with
INSERT … ON CONFLICT DO NOTHING RETURNING — the same perimeter-ledger primitive
the Clerk webhook uses. If the id was seen before (the same slip redelivered), the insert
returns no row and the handler returns {replay: true} without running the mint. This is
guard #1: it stops an identical event from being processed twice.
record_purchase_topup)Here is the subtlety guard #1 alone can't cover: payment.captured and
order.paid are different events with different ids, so both clear the
perimeter. The mint therefore keys its idempotency on the order id: it appends the
ledger row with provider = 'razorpay', provider_event_id = order_id, and
ON CONFLICT (provider, provider_event_id) DO NOTHING against the S6 compound-unique index.
The first event for order #1234 mints; the second — a genuinely different event, same order —
hits the conflict, writes nothing, and returns False. That single structural fact
is the order-fulfilled guard (guards #2 + #3): a dual event mints
once, race-safe, with no read-then-write. Credits come from the order's
credits note (what was bought, never inferred from money); expires_at is
now + purchased_credit_expiry_days read from the firm's plan
(billing.plan, config — a 365-day fallback so a mint never fails on a config read).
The append and the projection bump to firm_credit_balance commit together in one
transaction; the writer sets its own firm RLS scope and restores it.
record_refund_reversal)On refund.processed (a settled refund) or payment.dispute.lost (a lost
chargeback), the writer finds the original mint by its order id and appends a new row
whose millicredits is the negative of the mint, carrying a reverses
foreign key (migration 0164, a self-FK) to the mint row's id. The mint is never touched — the
credit_ledger append-only trigger would reject an UPDATE/DELETE anyway (ADR-158). The
projection is drawn down by the same magnitude. Idempotent by (razorpay, refund_id), so a
redelivered refund reverses once. One neat consequence: the writer doesn't need the firm id on the
refund payload — it reads the mint cross-firm under the staff SELECT policy the route
already set, learns the firm from the mint row, then scopes the firm-isolated negative append to it.
A tempting alternative is a “have we minted this order yet?” NOT EXISTS
check before inserting. It is not race-safe: two concurrent events could both read
“no” and both insert. The only thing that makes one-mint-per-order true under concurrency
is a unique index keyed on the order, which is exactly what
(provider, provider_event_id = order_id) gives — the guard is enforced by the
database, not by hopeful application logic.
| Deliberately absent | Where it lives / why |
|---|---|
| Proportional partial-refund split (map refunded paise → credits) | Reverses the mint in full for now; a partial-refund story adds the split if a real partial refund appears (marked ponytail:) |
| A new event_type | None — the reversal reuses topup with a negative amount + the reverses FK, which correctly nets out in any topup aggregation |
| A manual observability span | Covered by the route HTTP span + DB spans (ADR-124 T1); no approved billing-webhook manual span name exists yet — documented exemption, same posture as the Stripe webhook |
| Order-create / pricing / signature primitive | S7 (merged) — this Story is the landing half behind that signature |