Skip to content

fix(billing): email the billing contact when a subscription payment fails - #611

Merged
charlesrhoward merged 1 commit into
mainfrom
fix/payment-failed-email
Oct 10, 2026
Merged

charlesrhoward merged 1 commit into
mainfrom
fix/payment-failed-email

Conversation

@charlesrhoward

@charlesrhoward charlesrhoward commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Problem

invoice.payment_failed only set the billing account to past_due. No code path sent an email, so customers whose renewal failed were never told.

Fix

  • lib/billing/invoice-payment-failed-webhook.ts: moved the handler out of the route. It still marks past_due under the same stale-event guard. It then retrieves the current invoice from Stripe and emails the billing contact unless the invoice has since been paid or void.
  • Recipient: invoice.customer_email, which is the Stripe customer's email (the acting user's login email at checkout).
  • CTA: the Stripe hosted_invoice_url, where the customer can pay with a new card. No scope slug is needed.
  • lib/email/send-payment-failed.ts + emails/payment-failed.tsx: a Resend sender and a React Email template that match the existing auth and invite senders. The email shows the amount owed, the next retry date or "no automatic retry", and the pay link. BILLING_FROM_EMAIL sets the from-address and falls back to WAITLIST_FROM_EMAIL.

Delivery guarantees

  • The email is not gated on account.updated_at. Every failure also fires customer.subscription.updated, and that sync bumps updated_at. Under the old guard the email would often have been skipped. Whether to send is decided from the invoice's live Stripe status instead.
  • A failed send throws. The event claim stays unprocessed and the webhook returns 500, so Stripe redelivers it. past_due is written before the send, so a redelivery only retries the email.
  • No double-sends. Each send carries a Resend Idempotency-Key of invoice-payment-failed/<event id>, which Resend honors for 24h. Each failed retry attempt is a separate event, so it gets its own notice.
  • Missing RESEND_API_KEY in production fails closed. It is reported as a delivery failure instead of being logged away. The key is set in Production.
  • No customer_email or hosted URL: the event is acked and logged as payment_failed_email_undeliverable. A redelivery would read the same invoice, so retrying cannot help.

Tests

  • lib/billing/invoice-payment-failed-webhook.test.ts (vitest) covers: happy path, unknown customer, stale guard still emails, dispute freeze still emails, paid and void skip, uncollectible final attempt, missing contact or URL, and a failed send that throws.
  • lib/email/send-payment-failed.test.ts (vitest, fetch stubbed) covers: payload, idempotency header, retry and no-retry copy, Resend error, fail-closed in production, log fallback in dev.
  • tests/unit/stripe-webhook-invoice.test.ts checks the route wiring and that a failed send rejects the event after past_due lands.
  • Must-go-red: with the notify call disabled, 7 vitest and 2 route tests fail.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Note

Medium Risk
Changes the Stripe invoice.payment_failed webhook path and adds customer-facing billing email with retry-on-failure semantics; account status rules are mostly preserved but failed sends can leave events unprocessed until redelivery.

Overview
invoice.payment_failed now notifies the billing contact, not only setting the account to past_due. The handler moves to lib/billing/invoice-payment-failed-webhook.ts and is wired from the Stripe webhook with retrieveInvoice and sendPaymentFailedEmail deps.

After the existing stale-event past_due logic (unchanged for frozen_topups), it re-fetches the invoice from Stripe and sends a transactional Resend email to customer_email with amount, next retry date (or no auto-retry), and hosted_invoice_url. Sends are skipped when the invoice is already paid or void; uncollectible still notifies with no next attempt.

Delivery behavior: email is not blocked by account.updated_at (subscription syncs were skipping notices). Resend idempotency-key invoice-payment-failed/<event id> avoids duplicates on webhook replay; a send failure throws so the event stays unprocessed and Stripe redelivers (after past_due is already written). Missing email or pay URL is logged and acked. BILLING_FROM_EMAIL is documented in .env.example; production without RESEND_API_KEY fails closed.

New emails/payment-failed.tsx template and unit/route tests cover the handler, sender, and webhook wiring.

Reviewed by Cursor Bugbot for commit ad6c117. Bugbot is set up for automated code reviews on this repo. Configure here.

…ails

invoice.payment_failed only flipped the account to past_due; no email was
ever sent. The handler now retrieves the current invoice and, unless it was
paid or voided since, sends a Resend notice to the invoice's customer_email
with the Stripe hosted invoice link, the amount owed, and the next retry
date (or that no retry is scheduled).

A failed send throws so the event stays unprocessed and Stripe redelivers;
a per-event Resend idempotency key stops the redelivery from double-sending.
The email is no longer gated on the account updated_at guard, which
unrelated subscription syncs trip around every failure.
@mogplex

mogplex Bot commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

Mogplex PR Review

Status: No material issues found

The PR is approve-ready: the payment-failure email path is correctly wired through the existing webhook handler with delivery failures throwing so Stripe redelivers, sends deduplicated by a per-event Resend idempotency key, and email sending skipped when the live invoice is already paid or void. The fail-closed behavior for a missing RESEND_API_KEY in production plus thorough unit and route test coverage (including a must-go-red claim) gives high confidence. The only notes are minor and non-blocking.

Suggestions

  • Email dev fallback marks unsent notices as delivered (lib/email/send-payment-failed.ts:L62)
    In lib/email/send-payment-failed.ts:52-71, when RESEND_API_KEY is unset outside production the function returns { ok: true, channel: "log" } after only logging. The caller in lib/billing/invoice-payment-failed-webhook.ts treats any ok: true as delivered and marks the event processed, so a dev/staging environment that later acquires a key will never re-send these notices. The PR description says the key is set in Production, so this is intentional and matches the existing auth-email pattern, but a short comment noting that log-channel sends are permanently skipped (not retried on redelivery) would help future readers.

  • Undeliverable-email logging happens in prod and dev paths inconsistently (lib/billing/invoice-payment-failed-webhook.ts:L62)
    notifyBillingContact in lib/billing/invoice-payment-failed-webhook.ts:59-70 uses console.error for the payment_failed_email_undeliverable case, while the no-key dev path in the sender uses console.warn. This is fine — the undeliverable case is genuinely a data problem worth an error-level signal — just noting the split for consistency awareness; no change required.

  • Route test does not assert event remains unprocessed on send failure (tests/unit/stripe-webhook-invoice.test.ts:L189)
    tests/unit/stripe-webhook-invoice.test.ts:186-204 asserts the email error propagates and past_due was written first, but does not directly verify that markStripeEventProcessed is not called after the throw. The PR description claims the event stays unprocessed and Stripe redelivers; a small assertion on the recorded processed-events list (or a spy on the mark call) would make the delivery-guarantee claim test-enforced rather than implicit. Non-blocking — the rejection assertion implies the route's catch path runs.

View check run

@charlesrhoward
charlesrhoward added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit c1eb3ec Oct 10, 2026
19 of 24 checks passed
@charlesrhoward
charlesrhoward deleted the fix/payment-failed-email branch October 10, 2026 12:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant