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.
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.
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.
provider — a tag that backfills itself, with no UPDATEThe 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').
provider_event_id + the compound-unique keyA 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
expires_at — per-purchase expiry, allowance untouchedA 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.
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.
| Left out | Why |
|---|---|
| Any Razorpay webhook / verify / mint code | S7–S9 own the rail; S6 is schema-only so those stories have somewhere to write. |
Dropping stripe_event_id or its index | Both stay live through cutover; retirement is a later coordinated migration (RFD §11). Additive, zero-downtime. |
Backfilling provider_event_id from stripe_event_id | Not 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_at | The 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 transiently | The self-backfilling default means no UPDATE, so the trigger is never disabled — honouring the RFD §3 kill-criterion. |
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.