amount_unset: Stripe's 7-Day Auto-Switch Never Fires
This article may contain affiliate links. Its content is not affected by advertising.
In short
The daily fallback's target predicate requires amount != null, so a contract with no amount set never switches to invoicing no matter how many days pass — the card payment window simply never closes.
Conclusion
At sign time, the branch that says “open a card payment window, and auto-switch to invoicing if it’s unpaid after 7 days” also fired for contracts with no amount set. But the predicate function the daily fallback uses to filter its targets required amount != null, so a contract with no amount set got filtered out by that condition every day, and the auto-switch the notice promised never actually happened.
Symptom
In this system, signing a contract triggers an auto-billing branch. Contracts with payment_method of card (confirmed) or NULL (unconfirmed, card-preferred) don’t get an invoice issued at sign time — instead a “window” opens where they can pay via the card CTA on /p. Here’s what the notification looked like when that window opened (before the fix, at commit e548ef59):
// Card payment window (PR-P3): 'card' (confirmed) and NULL (unconfirmed = card-preferred,
// excluding sponsors / org-affiliated) skip auto-issuing an invoice at sign time and instead
// open a window to pay via the /p card CTA (paid once Checkout → webhook completes).
// NULL switches to the invoicing rail automatically via the daily cron
// (invoiceStaleCardContracts) after CARD_INVOICE_FALLBACK_DAYS days unpaid. While is_enabled
// is OFF there's no card flow, so it falls back to immediate invoicing as before (staged rollout).
if (
ct != null &&
opensCardPaymentWindow(ct) &&
(await isStripeEnabled())
) {
await notifySlack(
ct.payment_method === "card"
? `💳 Signed (${source}): holding off on invoicing, awaiting card payment (card confirmed) ${link}`
: `💳 Signed (${source}): holding off on invoicing, awaiting card payment (auto-switches to invoice after ${CARD_INVOICE_FALLBACK_DAYS} days unpaid) ${link}`
);
return;
}
if (ct?.amount == null) {
await notifySlack(
`⚠️ Signed but no amount set — auto-billing skipped. Set an amount and bill manually. ${slackLink(
"/admin/contracts",
"Open contract"
)}`
);
return;
}
opensCardPaymentWindow(ct) only looks at payment_method and company_id — it never checks the amount. So a contract with no amount set still entered this branch first, and got the “auto-switches to invoice after 7 days unpaid” notice. The amount-unset check was only placed after it.
Cause
The thing that actually performs the “auto-switch in 7 days” is the daily cron invoiceStaleCardContracts, whose target-selection predicate lives in a separate file:
/**
* Whether a row is a fallback target (paired with the sign-time skip condition —
* both should depend on this one predicate).
* - payment_method NULL only ('card' = confirmed, never switches to invoicing;
* 'invoice' = never skipped in the first place)
* - **hold (provisional) rows are excluded** (2026-07-25 audit #2): booking's forced 'card'
* was a single-layer safeguard. Even if a NULL-hold row somehow exists, this also
* honors "don't invoice until review is confirmed" on the invoicing-rail side.
* - Org-affiliated (billed_to is another company, i.e. consolidated billing) is invoiced
* immediately at sign time, so it's excluded here too
* - No amount set means no invoice can be issued (handled manually, same as at sign time)
* - signed_date is on or before cutoff (today - N days)
*/
export function isCardInvoiceFallbackTarget(
c: CardFallbackContract,
cutoff: string
): boolean {
return (
c.payment_method == null &&
c.payment_capture !== "hold" &&
c.amount != null &&
!!c.company_id &&
(!c.billed_to_company_id || c.billed_to_company_id === c.company_id) &&
!!c.signed_date &&
c.signed_date <= cutoff
);
}
c.amount != null is baked directly into the target condition. The comment’s reasoning — “no amount set means no invoice can be issued” — is correct on its own. But it also means that the window promised at sign time (“this will auto-switch in 7 days”) and the set of rows the daily cron actually treats as switchable were never the same set to begin with. A contract with no amount set gets excluded from this cron’s target set every single day; no reminder and no invoice ever runs for it.
Worse, an amount-unset contract couldn’t self-resolve by paying with a card either. Here’s how the card Checkout session gets created:
const gross = contractGrossAmount(ct.amount as number, Boolean(ct.tax_included));
if (gross == null) return { ok: false, error: "amount_unset" };
If ct.amount is null, contractGrossAmount returns null, and the Checkout session is never created at all. In other words, a contract with no amount set had no way forward through sign-time auto-billing, the daily fallback, or card Checkout — while the “auto-switches in 7 days” notice kept living a life of its own.
The fix
The fallback side’s target condition (amount != null) was left untouched. What changed was the order of the sign-time branches — the amount-unset check was moved ahead of the card-payment-window check (after the fix, at commit fa65d1ba):
// A contract with no amount set can't be processed automatically through *any* rail
// (card Checkout fails with amount_unset; the 7-day fallback also can't invoice without
// an amount), so it's routed to manual handling before the card-payment-window check
// (Reviewer P2-1: if the window branch runs first, it announces "will auto-switch" and
// then nothing actually happens).
if (ct?.amount == null) {
await notifySlack(
`⚠️ Signed but no amount set — auto-billing skipped. Set an amount and bill manually. ${slackLink(
"/admin/contracts",
"Open contract"
)}`
);
return;
}
// Card payment window (PR-P3): 'card' (confirmed) and NULL (unconfirmed = card-preferred,
// excluding sponsors / org-affiliated) skip auto-issuing an invoice at sign time and instead
// open a window to pay via the /p card CTA (paid once Checkout → webhook completes).
// NULL switches to the invoicing rail automatically via the daily cron
// (invoiceStaleCardContracts) after CARD_INVOICE_FALLBACK_DAYS days unpaid. While is_enabled
// is OFF there's no card flow, so it falls back to immediate invoicing as before (staged rollout).
if (opensCardPaymentWindow(ct) && (await isStripeEnabled())) {
await notifySlack(
ct.payment_method === "card"
? `💳 Signed (${source}): holding off on invoicing, awaiting card payment (card confirmed) ${link}`
: `💳 Signed (${source}): holding off on invoicing, awaiting card payment (auto-switches to invoice after ${CARD_INVOICE_FALLBACK_DAYS} days unpaid) ${link}`
);
return;
}
Now a contract with no amount set never enters the “auto-switch in 7 days” window in the first place — signing it produces only the manual-handling Slack notice. The condition for opening the card payment window and the condition the daily cron actually processes are both now aligned to “amount is set.”
Preventing a repeat
When the condition that “opens” a window and the predicate that “closes” it (by selecting its targets) live in separate places, it’s easy to add a branch that looks safe on its own while quietly disagreeing with the other side. Here, opensCardPaymentWindow never checked the amount, while isCardInvoiceFallbackTarget required it — and that asymmetry is exactly what split the comment’s promise of “auto-switches in 7 days” from what the code actually did. Whenever you add a condition that opens a window, open the paired “closing” predicate right next to it and check that both sets actually match.
Frequently asked questions
Q1Where did the "auto-switch in 7 days" notice come from?
The sign-time handler (contract-signed.ts) posted a fixed Slack message whenever it entered the branch that opens the card payment window. Any contract that satisfied the branch condition got the same notice, whether or not it had an amount set.
Q2Why did only amount-unset contracts fail to close the window?
The predicate function the daily cron uses to select targets, isCardInvoiceFallbackTarget, required amount != null. Contracts with no amount set were filtered out by that condition every single day, so neither a reminder nor an invoice was ever issued for them.
Q3Could the customer at least pay by card instead?
No. The card Checkout path also failed at the point where contractGrossAmount returned null, returning an amount_unset error and refusing to create a session. A contract with no amount set had no way through — not the sign-time auto-billing, not the daily fallback, and not card Checkout.
Q4Was the fix to change the fallback's target condition?
No. The fallback side's amount != null condition was left untouched. The fix reordered the sign-time branches instead, so that a contract with no amount set is routed to manual handling before it can ever enter the card payment window branch.
Q5Did this affect production customers?
The sources show it was implemented and deployed the same day (07-12), then fixed in the same commit after a reviewer flagged it (P2-1). Whether any amount-unset × NULL contracts actually sat in this state in production isn't recorded, so this article makes no claim either way.
Environment verified
- Next.js 16.2.7 / stripe ^22.3.1 / @supabase/supabase-js ^2.106.2
- Implemented 2026-07-12, fixed the same day after reviewer feedback (P2-1)
What this article is based on
- TypeScript file lines 133-165commit e548ef5
- TypeScript file lines 133-164commit fa65d1b
- TypeScript file lines 22-44commit 7eb2911
- TypeScript file lines 15-45commit fa65d1b
- TypeScript file lines 50-65commit 8e1f480
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.