Story 2165: teach the credit ledger the word “provider” before Razorpay arrives

WHY. The catastrophe this Story prevents is double-crediting a firm that pays once. The append-only ledger’s exactly-once guarantee for purchases is a single unique index on stripe_event_id — a column that names Stripe and only Stripe. The moment the Razorpay rail (S7–S9) starts minting credits, its event ids have nowhere to land: they cannot be deduped by a Stripe-shaped key, and Razorpay is worse than Stripe here — it fires two events (payment.captured and order.paid) with distinct ids for one payment. Without a provider-agnostic idempotency key, the second event looks new and mints a second top-up: the firm pays ₹1,000 and gets ₹2,000 of credits. This Story is the S6 schema prep — a purely additive migration that generalizes the dedup key to (provider, provider_event_id) and adds the per-purchase expires_at the 1-year top-up expiry needs, without changing one byte of Stripe behaviour, the append-only trigger, or the debit idempotency.

🛰 Satellite — the one sentence

Three additive columns on billing.credit_ledger — provider (defaulted 'stripe', so every existing row and every unchanged Stripe write is tagged for free), provider_event_id (the provider-agnostic successor to stripe_event_id), and expires_at (per-purchase top-up expiry) — plus a compound-unique (provider, provider_event_id) index that becomes the exactly-once key the Razorpay rail will mint against, all landed beside the still-live Stripe key.

✈️ Airplane — a second lock beside the first, not a replaced lock

Picture the ledger’s purchase door with one lock: a key cut for Stripe (uq_credit_ledger_stripe_event). You cannot fit a Razorpay key into a Stripe-shaped keyhole, and you must not change the Stripe lock while paying customers are walking through it. So this Story fits a second, universal lock beside the first — (provider, provider_event_id) — whose keyhole is shaped “which provider + which event”. During cutover both locks are engaged: Stripe keeps using its old key (its writes are literally unchanged), and Razorpay will use the new universal one. Nobody re-cuts the Stripe key, nobody touches the append-only bar across the door (the trigger); we only add a keyhole that any future provider fits.

🔬 Microscopic — the three moving parts

1. provider — a tag that backfills itself, with no UPDATE

The column is NOT NULL DEFAULT 'stripe'. That default does two jobs at once. On ADD COLUMN, Postgres fills every existing row with 'stripe' — the ticket’s required backfill, achieved with zero UPDATE statements. And because the live Stripe writer (record_topup) never names provider, its INSERT stays byte-for-byte valid and its new rows still land as 'stripe'. This is the reason the append-only trigger is never touched: the RFD’s S3 kill-criterion says a backfill that has to disable the append-only trigger should be split into a coordinated deploy — so we designed the backfill to need no mutation at all. A CHECK constrains the tag to ('stripe','razorpay').

2. provider_event_id + the compound-unique key

A nullable varchar(128), the provider-agnostic successor to stripe_event_id. It is not backfilled: existing Stripe rows keep deduping on the old key, which stays live through the whole cutover (its retirement is a later coordinated migration, RFD §11). The new key is:

CREATE UNIQUE INDEX uq_credit_ledger_provider_event
  ON billing.credit_ledger (provider, provider_event_id)
  WHERE provider_event_id IS NOT NULL;

Compound so a cross-provider event-id coincidence is a structural non-collision (F15): even if Stripe and Razorpay ever emitted the same id string, ('stripe', id) and ('razorpay', id) are distinct rows. Partial (WHERE provider_event_id IS NOT NULL) so the millions of allowance/debit/grant rows that will never carry a provider event id — and the null-backfilled existing Stripe rows — do not fight over a single NULL slot. The runtime mint stays a NOT EXISTS insert (ADR-158), never read-then-write:

INSERT INTO billing.credit_ledger (..., provider, provider_event_id)
SELECT :firm_id, 'topup', ..., :provider, :provider_event_id
WHERE NOT EXISTS (
  SELECT 1 FROM billing.credit_ledger
  WHERE provider = :provider AND provider_event_id = :provider_event_id);
-- 0 rows == already processed == idempotent success

3. expires_at — per-purchase expiry, allowance untouched

A nullable timestamptz. Purchased top-ups carry the RFD §5.1 topup_expiry='1_year' stamp; a NULL means “never expires”, which is exactly right for existing top-ups (they predate the expiry policy) and for allowance/grant rows (their expiry is the existing monthly reset, D-10, not a timestamp). Expiry itself is only a column here: the buy-side sets it at write time (S7–S9) and the projection’s read predicate enforces it (§4 invariant 5). The hot allowance-first debit draw is not modified.

How “done” was proved at the system boundary

A green pytest against a fixture would have missed the real questions. The new tests/integration/test_credit_ledger_provider_schema.py exercises the columns against a real DB: the provider default backfills on an unchanged Stripe-shaped write; the compound-unique rejects a duplicate (razorpay, event); the same event id under a different provider does not collide; the NOT EXISTS guard no-ops on redelivery; expires_at persists on a top-up row; and the append-only trigger still blocks an UPDATE. The migration round-trips (upgrade → downgrade base → upgrade) and alembic check reports no drift against the mirrored ORM entity.

What this Story deliberately does NOT do

Left outWhy
Any Razorpay webhook / verify / mint codeS7–S9 own the rail; S6 is schema-only so those stories have somewhere to write.
Dropping stripe_event_id or its indexBoth stay live through cutover; retirement is a later coordinated migration (RFD §11). Additive, zero-downtime.
Backfilling provider_event_id from stripe_event_idNot needed: existing rows keep deduping on the old key. Copying the history is the §11 retirement migration’s job, done just before the old column is dropped.
Enforcing / reading expires_atThe projection read predicate (§4 inv. 5) and the buy-side stamp are later stories; the hot debit draw is untouched here.
Touching the append-only trigger, even transientlyThe self-backfilling default means no UPDATE, so the trigger is never disabled — honouring the RFD §3 kill-criterion.
Recall in one sentence: Story-2165 adds a self-backfilling provider tag, a provider-agnostic provider_event_id with a compound-unique (provider, provider_event_id) key, and a per-purchase expires_at to billing.credit_ledger — the exactly-once keyhole and expiry column the Razorpay rail (S7–S9) needs — entirely additively, with Stripe’s writes, its dedup key, the append-only trigger, and the debit idempotency all left untouched.