Story 2176: open the till — exactly once, and know how to give the money back

WHY. The catastrophe this Story prevents is minting a firm's credits twice for one payment — or failing to claw them back on a refund. S7 built the shop counter that quotes a price and stamps a signature but never opens the credit till. This Story turns the till key, and a payment till has two ways to lose money. The first is double-minting: Razorpay does not send one “paid” message per order — it sends two (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).

🛰 Satellite — the one sentence

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.

✈️ Airplane — the same shop counter, now with the till open and a returns desk

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.

🔬 Microscopic — the four moving parts

1. The perimeter — verify, then dedup by event id (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.

2. The exactly-once mint — keyed on the order, not the event (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.

3. The reversal — a compensating append, never an edit (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.

4. Why the order id is the only race-safe key

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.

What this Story does not do

Deliberately absentWhere 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_typeNone — the reversal reuses topup with a negative amount + the reverses FK, which correctly nets out in any topup aggregation
A manual observability spanCovered 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 primitiveS7 (merged) — this Story is the landing half behind that signature
Recall in one sentence: Story 2176 opens the credit till on a signature-verified Razorpay webhook, minting exactly once per order so Razorpay's two-events-plus-retries collapse to a single credit, and reverses a refund/chargeback by adding a negative row that points back at the mint — never editing, never deleting, never touching a balance by hand.