ADR-023: Post-decline rendering — one banner, distinct emails, interactive suppression
Status: Accepted (amended 2026-10-11) Date: 2026-05-05
Summary
Section titled “Summary”Each moment after a failed payment has its own email and its own timeline label. A finalized invoice with no card sends payment_setup_request, and a declined charge sends payment_failed, which links to the hosted invoice page. The invoice page shows one attention banner for a decline, with Update payment method and Retry payment. A decline on the hosted Pay page sends no email, because the customer already sees the error. Amended 2026-10-11: such a hosted-page decline also no longer marks the invoice failed or starts dunning; only its own charge-attempt row records it. Status: active, amended.
Context
Section titled “Context”Operator-reported audit of an invoice that had hit a card-decline surfaced four overlapping rendering issues, all caused by the post-decline lifecycle moment being modeled as identical to the finalize-no-PM moment:
-
Two “Payment failed” banners on the invoice detail page. The ADR-009 unified attention banner emitted
payment.declinedwith only[retry_payment]action; the SPA had a hardcoded<Card>block atInvoiceDetail.tsx:893-937(pre-ADR-009 leftover) showing the same headline plus an Update Payment Method button. Two banners, same state, overlapping CTAs. -
Two “Customer notified — payment method required” rows in the activity timeline. Both came from
email_type='payment_update_request', but were triggered by two different events:noPaymentMethodNotifierAdapterat finalize (no PM on file).Stripe.handlePaymentFailedafter a charge declined. Same email type → same timeline label → operator can’t tell them apart.
-
Customer email after interactive Pay decline is noise. Customer clicks Pay on the hosted invoice page, card declines, customer sees “Your card was declined” inline on the page. Hardcount then sends them an email saying the same thing. The customer was watching the transaction; the email tells them what they just saw.
-
Same email body for two different lifecycle moments. The
SendPaymentUpdateRequesttemplate was used both for “set up your first payment method” (finalize-no-PM) and “your card was declined” (post-decline). One copy, two semantically different moments.
The root cause is one design choice: the codebase had a single
payment_update_request email type and a single sender method
serving two different lifecycle moments. That collapses to identical
rendering everywhere downstream (timeline label, email subject/body,
attention-banner action set).
Decision
Section titled “Decision”Distinct primitives per lifecycle moment:
| Moment | Email type | Trigger | Timeline label | Attention banner CTA |
|---|---|---|---|---|
| Invoice finalized, no PM on file | payment_setup_request (renamed from payment_update_request) |
noPaymentMethodNotifierAdapter |
“Customer notified — set up payment method” | (already correct) Resend payment link / Open customer page |
| Invoice charge attempted, declined (auto-charge OR interactive Pay*) | payment_failed (existing) |
Stripe.SettleFailed, from whichever reports the decline first: the charge response, the webhook or the reconciler (ADR-049 amendment 2026-10-11) |
“Payment-failed email sent to customer” | Update payment method / Retry payment |
| Dunning retries | payment_failed / dunning_warning / dunning_escalation |
dunning notifier | (existing) | (existing) |
* With one suppression: when the PI’s hardcount_purpose metadata is
hosted_invoice_pay (interactive customer-on-page flow), no email is
sent. The customer is staring at the same failure inline.
Implementation
Section titled “Implementation”Email layer (internal/email/):
TypePaymentUpdateRequestconstant renamed toTypePaymentSetupRequest(“payment_setup_request”). Kept stable as a tag once renamed.Sender.SendPaymentUpdateRequest→SendPaymentSetupRequest.OutboxSender.SendPaymentUpdateRequest→SendPaymentSetupRequest.- Dispatcher’s
EmailDelivererinterface renamed accordingly + the switch case. - The
SendPaymentFailedmethod already existed (used by dunning) — no new template needed for the post-decline path; we just point the new consumer at the existing one. outbox.goSQL filter expanded to includepayment_setup_request(replacing the old name).
Payment layer (internal/payment/):
EmailPaymentUpdateinterface deleted; newEmailPaymentFailedinterface (SendPaymentFailed(ctx, tenantID, to, customerName, invoiceNumber, reason, publicToken)— same shape asdunning.EmailNotifier, soOutboxSenderalready satisfies it).Stripe.SetEmailPaymentUpdate→SetEmailPaymentFailed(no longer takespaymentUpdateURL— post-decline email points at the long- lived hosted invoice URL viainv.PublicToken, same as dunning).Stripe.SetTokenServiceremoved: only the no-PM-at-finalize email needed single-use tokens; the post-decline email goes to the hosted invoice page.handlePaymentFailed:- Reads
data.object.metadata.hardcount_purposefrom the webhook payload via newpiPurposeFromPayloadhelper. - If
hardcount_purpose == "hosted_invoice_pay": skip email, log INFO. - Otherwise: dispatch
SendPaymentFailedwithinv.PublicToken. - Still fires
event.payment.failedon the dispatcher and starts dunning regardless of suppression — only the customer email is suppressed (suppression is about avoiding duplicate notification, not about disabling the dunning state machine). - Amended 2026-10-11 (ADR-049 amendment): a hosted-page decline no
longer fires
payment.failed, marks the invoice failed or starts dunning. Hardcount never waits on a hosted attempt, so its failure is recorded on its own attempt row only. The suppression above now applies to dunning-retry attempts.
- Reads
API wiring layer (internal/api/):
noPaymentMethodNotifierAdapterswitched to a narrow consumer-side interfacepaymentSetupEmailSender(callsSendPaymentSetupRequest).- Router wires
paymentSetupEmailandpaymentFailedEmailseparately, both pointing at the same outbox/SMTP sender.
Attention banner (internal/domain/invoice_attention.go):
- New
AttentionActionUpdatePaymentMethodconstant. classifyPaymentFailureemits actions[update_payment_method, retry_payment](primary first).
SPA:
web-v2/src/lib/api.ts:AttentionActionunion gainsupdate_payment_method.web-v2/src/components/InvoiceAttention.tsx: newUpdatePaymentMethodButtoncallsapi.updatePaymentMethod(customer_id, return_url)withwindow.location.origin + window.location.pathnameasreturn_url(per ADR-022).retry_paymentaction wired to the existingonChargeNowcallback (same Collect endpoint).web-v2/src/pages/InvoiceDetail.tsx: hardcoded “Payment failed”<Card>block at lines 893-937 deleted. The unified attention banner is now the single surface.internal/invoice/handler.go: timeline-label switchcase "payment_setup_request"returns “Customer notified — set up payment method”; the oldpayment_update_requestcase removed.
Consequences
Section titled “Consequences”Operator’s invoice detail page on a declined invoice:
- One banner, not two. Critical-severity attention card with
[Update payment method, Retry payment]actions. - Activity timeline rows are distinct and meaningful: “Customer notified — set up payment method” at finalize (if applicable) → “Payment-failed email sent to customer” after the decline (if auto-charge — suppressed if interactive).
Customer’s email inbox:
- Set-up-PM email: “Set up your payment method for invoice X” → links
to the tokenized
/update-paymentSPA route (single-use token). - Decline email: “Your payment for invoice X was declined” → links to the long-lived hosted invoice URL where they can update PM and retry. Same template dunning uses for retry warnings.
Interactive Pay flow:
- No email after decline. Customer sees the failure inline; we don’t pile on a duplicate notification.
Pre-launch with no production data: no migration needed. The
payment_update_request constant is removed entirely (rename, not
add+delete) — there are no email_outbox rows to migrate. Same for
EmailPaymentUpdate Go interface (no external API surface).
Alternatives considered
Section titled “Alternatives considered”- Keep one email type, vary subject/body via payload field. Adds a conditional inside the template instead of two templates. Saves one outbox-type tag at the cost of muddying the lifecycle distinction in row data — operators investigating delivery issues would have to inspect the payload to know which moment the email was for. Two tags are clearer for ops without costing anything in code.
- Add a “send despite interactive” flag for operators. Skipped — no observed scenario where an operator wants to email a customer about a decline they just saw inline. If this becomes a real need, it’s a tenant_settings flag, not a code change.
- Surface “skipped (interactive)” as its own timeline row. Decided against: the hosted invoice page already shows the decline state inline; a “skipped” row is internal plumbing, not operator-relevant signal. The slog INFO line is the right surface for that.