ADR-103: One owner for payment rows on the invoice timeline
Date: 2026-07-28 Status: Accepted Supersedes the payment half of: ADR-020 (timeline dedup), ADR-102 §Rendering
Summary
Section titled “Summary”The invoice timeline shows payments from one source only: the charge-attempt rows in invoice_charge_attempts (ADR-102). Stripe webhook rows are still stored for idempotency and replay, but they are no longer displayed. Both settle paths write the attempt’s outcome in the same transaction that marks the invoice paid or failed, so the two cannot disagree. This replaced four overlapping rules for matching webhook rows to charges, two of them guesses by time window or list position, which had already caused duplicate rows. Status: active; it supersedes the payment-row rules of ADR-020 and the rendering part of ADR-102.
Context
Section titled “Context”ADR-102 made a charge attempt a first-class fact, but kept the Stripe webhook rows as a fallback display tier for invoices that predate the table. That left FOUR mechanisms answering one question — which row represents this charge?
- lifecycle dedup: drop
payment_intent.succeededwhenpaid_atis set; droppayment_intent.canceledwhenvoided_atis (within a window); mergeFailedPaymentTwins: pair a Stripe failure to its dunning row by PaymentIntent id — falling back to positional index for older rows;foldEmailIntoStripeFailed: pair the payment-failed email to that Stripe row by a 2-minute time window;- the ADR-102 precedence chain layered on top.
Two of those are heuristics, and this family has already produced real
bugs (#619 duplicate resolved rows, #623 a fold that missed a fourth
reason-spelling). Meanwhile the underlying data says the redundancy is
unnecessary: measured on the walkthrough tenant, every charge produces
exactly one attempt row and 2–3 rows of Stripe chatter
(created, then succeeded/failed).
Decision
Section titled “Decision”Payments render from one owner: invoice_charge_attempts. The
Stripe webhook table is what it always was — ingestion infrastructure
for idempotency and replay — and stops being a display source.
Two supports make that safe:
- The outcome is resolved inside the settle transaction. Both
settle transitions (
MarkPaidCardSettlementTransition,MarkPaymentFailedReportingTransition) upsert the attempt in their own tx, so the attempt fact and the invoice’s state can never disagree. A settle for a PaymentIntent Hardcount never minted (hosted checkout) INSERTS the row there — that path needs no chokepoint. Insert-time identity (trigger_source,sim_effective_at) is preserved by the ON CONFLICT: the chokepoint knows those, a settle does not. - A loud invariant replaces the silent fallback. A card-paid
invoice with no succeeded attempt (and no
out_of_band:marker) logs a warning naming the missing fact, rather than quietly substituting a webhook row. (Amended 2026-10-23, #1370: an offline payment no longer writes theout_of_band:marker, nor the succeeded attempt row the settle used to upsert under it. It setsinvoices.paid_out_of_bandand leaves the PaymentIntent empty, so “card-paid” is simply “has a PaymentIntent”.)
Rendering keeps exactly two suppressions, both exact-keyed:
- a dunning row already carrying the attempt’s PaymentIntent absorbs it (the campaign row is the richer telling of the same charge) and lifts the attempt’s verbatim provider reason + amount onto itself;
- a succeeded attempt on a paid invoice defers to the “Invoice paid” lifecycle row — the superset, since credits, an offline payment, or a $0 total all pay an invoice with no charge at all.
pending attempts never render (in flight — the attention banner owns
that); a succeeded attempt on a NOT-paid invoice does, because that
shape is an anomaly worth seeing.
Same-instant ordering gains one rank: a charge attempt sorts above the dunning campaign it caused, so a decline and its “Payment recovery started” never render effect-before-cause.
Consequences
Section titled “Consequences”- Deleted:
dropCanceledForVoid,foldEmailIntoStripeFailed,mergeFailedPaymentTwins,withinWindow,describeStripeEvent,relevantStripeEvents, and the ADR-102 three-tier precedence — with the tests that guarded them. Both heuristics (time window, positional index) are gone. payment_intent.canceledno longer renders. A void already shows “Invoice voided”, and an abandoned checkout is an attempt that never completed — provider bookkeeping, not an invoice event. This also retires the cause-blind “Payment canceled” row flagged during the ADR-102 review.- The payment-failed email is now its own row rather than a sub-line folded onto the failure. It is a distinct fact (we told the customer) with its own timestamp; merging it required the time window. On a live invoice it sorts directly after the failure.
- Accepted cost: invoices charged before ADR-102 lose their payment rows — they have webhooks but no attempts, and no backfill can invent an attempt that was never recorded. Acceptable at zero customers with fixtures we own; it would not be in production, which is an argument for doing this now rather than later.
Alternatives rejected
Section titled “Alternatives rejected”- Keep the fallback tier (status quo): preserves history for old invoices but keeps four reconciliation mechanisms and two heuristics alive forever, on the surface operators trust to explain money.
- Backfill attempts from webhook rows: the webhook payload knows the PI and outcome but not the trigger or the billing-axis instant, so the backfilled rows would be a different, weaker fact wearing the same name — the kind of half-truth ADR-101 Phase 4 deliberately refused.
- Render both and de-duplicate at read time: that is precisely the four-mechanism design being removed.