Stripe fallbackToInvoice counted an unsent email as issued
This article may contain affiliate links. Its content is not affected by advertising.
In short
fallbackToInvoice discarded issueInvoiceAndEmail's return value and returned only issued:true, so callers kept reporting 'sent' even when the email never went out.
The short version
Renewal charges default to off-session charging of the saved card. When there is no card on file, SCA is required, or the card is declined, the renewal automatically falls back to the legacy invoice rail (fallbackToInvoice), so the customer always has a way to pay by bank transfer.
The fallback function, however, never distinguished between “the invoice was issued” and “the email actually sent.” It calls issueInvoiceAndEmail, which returns a boolean for whether the email send succeeded — but fallbackToInvoice discarded that return value entirely. As soon as the “unbilled -> billed” claim on the DB succeeded, it always returned issued: true. Slack notifications and the renewal reporting both treated that as “sent,” regardless of whether the email ever reached anyone.
A return type that dropped a field
Before the fix, fallbackToInvoice looked like this:
export async function fallbackToInvoice(
admin: SupabaseClient,
contractId: string
): Promise<{ issued: boolean }> {
// ...conditional UPDATE claiming unbilled -> billed...
if (!claimed) return { issued: false }; // already billed/paid — don't double-issue
await issueInvoiceAndEmail(admin, contractId, null);
return { issued: true };
}
The await issueInvoiceAndEmail(...) line has nothing on its left side. That function is designed to return a boolean rather than throw for any of “no recipient on file,” “invoice link creation failed,” or “the send itself failed” — but nobody was reading that boolean. As long as claimed (the DB claim) succeeded, issued: true came back no matter what happened next.
The caller, issueRenewalFallback in src/lib/stripe/renewal.ts, posted to Slack based on issued alone:
await notifySlack(`${notifyPrefix}${issued ? "" : "(already billed/paid)"} ${link}`);
If issued is true, the exact same “sent” message goes out unconditionally. Whether the email actually arrived never enters this path at all.
invoiceSuccessor in contract-renewal.ts compounded this: it added every fallback case straight into result.invoiced (the count of invoices successfully issued), on the assumption that a successful DB claim meant a successful invoice. Renewals whose email never went out were still counted as billed.
Three signals, all green, nothing delivered
The failure mode is simple to describe. If the recipient address is missing, the invoice-link creation fails, or the send itself fails, all of the following still happen at once:
- the DB’s
payment_statusstays at “billed” - Slack posts the same “invoice sent” message
- the renewal report counts one more “invoice issued”
The customer received nothing, and every internal signal says “handled.” The only way this surfaces is a customer asking why no invoice arrived.
The fix: carry sent all the way through
The fix widened fallbackToInvoice’s return type to { issued: boolean; sent: boolean } and passed issueInvoiceAndEmail’s own return value straight through as sent.
export async function fallbackToInvoice(
admin: SupabaseClient,
contractId: string
): Promise<{ issued: boolean; sent: boolean }> {
// ...
if (!claimed) return { issued: false, sent: false };
// sent=false (no recipient, link creation failed, or send failed) still
// leaves the billed claim in place — pass the real outcome through instead
// of letting the caller assume success.
const sent = await issueInvoiceAndEmail(admin, contractId, null);
return { issued: true, sent };
}
issueRenewalFallback now branches its Slack message on both issued and sent:
// billed but unsent still needs a human to check — don't report it as delivered.
const status = !issued ? "(already billed/paid)" : sent ? "" : "(email not sent -> needs manual check)";
await notifySlack(`${notifyPrefix}${status} ${link}`);
return { ok: true, charged: false, fellBack: true, sent, reason };
And invoiceSuccessor now only increments result.invoiced when sent is true. A successful DB claim and a billable, reportable invoice are now two separate conditions.
What the fix actually changed
The whole fix is a type-level change: widen one return value and carry it through two more call sites. fallbackToInvoice, issueRenewalFallback, and invoiceSuccessor can no longer conflate “the DB claim succeeded” with “the email was delivered.” The pure invoice rail (the non-fallback path) already checked sent before counting — this fix only brought the card-to-invoice fallback path up to the same bar.
Frequently asked questions
Q1What was wrong with fallbackToInvoice's return value?
It returned only { issued: boolean }, discarding the return value of issueInvoiceAndEmail, the function that sends the email. As soon as the DB claim succeeded it always returned issued:true, so a missing recipient, a failed link, or a failed send all looked like a successful delivery to the caller.
Q2How was this fixed?
The return type was widened to { issued: boolean; sent: boolean }, passing through issueInvoiceAndEmail's own result as sent. issueRenewalFallback now flags the Slack message when sent is false, and invoiceSuccessor only counts the renewal when sent is true.
Q3What is the invoice fallback path?
Renewals default to off-session card charging. When there is no card on file, SCA is required, or the card is declined, the renewal falls back to the legacy invoice rail so the customer can still pay by bank transfer.
Environment verified
- stripe ^22.3.1 / @supabase/supabase-js ^2.106.2 (kimiteras-portal)
- Fixed in production 2026-09-30 (commit 677e267)
What this article is based on
- TypeScript file lines 120-138commit 7e7ea38
- TypeScript file lines 120-140commit 677e267
- TypeScript file lines 1-44commit 677e267
- TypeScript file lines 58-101commit 677e267
Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.