Repository navigation
fix(billing): email the billing contact when a subscription payment fails - #611
Conversation
…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 PR ReviewStatus: 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
|
Problem
invoice.payment_failedonly set the billing account topast_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 markspast_dueunder the same stale-event guard. It then retrieves the current invoice from Stripe and emails the billing contact unless the invoice has since beenpaidorvoid.invoice.customer_email, which is the Stripe customer's email (the acting user's login email at checkout).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_EMAILsets the from-address and falls back toWAITLIST_FROM_EMAIL.Delivery guarantees
account.updated_at. Every failure also firescustomer.subscription.updated, and that sync bumpsupdated_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.past_dueis written before the send, so a redelivery only retries the email.Idempotency-Keyofinvoice-payment-failed/<event id>, which Resend honors for 24h. Each failed retry attempt is a separate event, so it gets its own notice.RESEND_API_KEYin production fails closed. It is reported as a delivery failure instead of being logged away. The key is set in Production.customer_emailor hosted URL: the event is acked and logged aspayment_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,fetchstubbed) 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.tschecks the route wiring and that a failed send rejects the event afterpast_duelands.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Note
Medium Risk
Changes the Stripe
invoice.payment_failedwebhook 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_failednow notifies the billing contact, not only setting the account topast_due. The handler moves tolib/billing/invoice-payment-failed-webhook.tsand is wired from the Stripe webhook withretrieveInvoiceandsendPaymentFailedEmaildeps.After the existing stale-event
past_duelogic (unchanged forfrozen_topups), it re-fetches the invoice from Stripe and sends a transactional Resend email tocustomer_emailwith amount, next retry date (or no auto-retry), andhosted_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). Resendidempotency-keyinvoice-payment-failed/<event id>avoids duplicates on webhook replay; a send failure throws so the event stays unprocessed and Stripe redelivers (afterpast_dueis already written). Missing email or pay URL is logged and acked.BILLING_FROM_EMAILis documented in.env.example; production withoutRESEND_API_KEYfails closed.New
emails/payment-failed.tsxtemplate 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.