Diagnostic Stripe
invoiced subscriptions with no days_until_due never age
The finance team asks for an aged receivables report and the answer comes back empty, which everyone reads as good news. It is not: these invoices are not current, they are undateable. days_until_due was never set on the subscriptions that generate them, so Stripe writes each invoice with due_date null, and an invoice with no due date cannot be overdue, cannot trigger a reminder, and cannot age into any bucket at all.
List GET /v1/subscriptions?collection_method=send_invoice&status=all and flag every row where days_until_due is null. That field is the only thing that populates due_date on the invoices a send_invoice subscription generates; there is no account-level default that fills it in later.
Corroborate on the invoices themselves with GET /v1/invoices?collection_method=send_invoice&status=open and count the rows whose due_date is null. Those are already issued and already unanchored: the reminder schedule and the 30, 60 and 90 day subscription actions all measure from a due date these invoices do not have.
The problem in plain words
collection_method=send_invoice is the right choice for customers who pay through an accounts-payable process rather than a card on file. Stripe finalizes the invoice, emails it, and waits. Everything after that — the reminder emails, the past-due subscription action, any aging report you or Stripe builds — is arithmetic on due_date.
When days_until_due is null, due_date is null, and that arithmetic has no operand. The invoice does not become overdue at 30 days or at 300. It stays open, indefinitely, in the same bucket as one issued this morning. The customer, for their part, received a bill with no payment terms printed on it, which their accounts-payable system will happily park at the bottom of a queue that is sorted by due date.
The result is receivable that is invisible from both ends. You cannot see it aging because Stripe has nothing to age it against, and they are not chasing it because nothing told them when it was owed.
Why it happens
The field is optional and has no default. days_until_due is only meaningful for send_invoice, it is rejected for charge_automatically, and it is simply absent unless the create call passes it. A subscription created by an integration written for card customers and later switched to invoicing almost always has it null, because the code path that sets it never existed.
Nothing complains. Creating a send_invoice subscription with no days_until_due is a valid call that returns 200. Finalizing its invoices is valid. Emailing them is valid. The only artefact is a null in a field nobody reads, on an object nobody opens once it is working.
Turning on reminders does not fix it retroactively, or prospectively. Reminder emails are scheduled relative to the due date, so enabling them in the Dashboard changes nothing for a subscription that will keep producing undated invoices. Two separate repairs are needed and teams usually only do the visible one.
Zero is a legitimate value and looks like absence. days_until_due=0 means due on receipt, which is a real term some businesses use. Any check written as "if not days_until_due" folds that into the missing case and reports a correctly configured subscription as broken, which is how these checks lose their audience.
Terms longer than the billing period compound quietly. Net 45 on a monthly subscription means invoice two is issued and emailed before invoice one is due. The customer is permanently at least one invoice behind, and every report of "overdue" undercounts by a whole cycle.
The fix, as a flow
The script reads the terms off every send_invoice subscription and the due dates off the invoices they have already produced, so the configuration and the damage it has done are one report.
How to fix it
Page every send_invoice subscription, in every status
Use status=all rather than active. A subscription that is past_due or unpaid is exactly the one you want to see here, and the default filter hides it. collection_method=send_invoice is a server-side filter, so this stays one cheap paginated read.
Distinguish null from zero before you count anything
Null means no due date will ever be written. Zero means due on receipt. They are different configurations with different repairs, and a truthiness check cannot tell them apart. Compare against null explicitly.
Read the billing interval off the subscription item
items.data[0].price.recurring.interval and interval_count give the length of the billing period. Terms at or beyond that length mean the next invoice arrives before the current one is due, which is a real finding even when days_until_due is set.
Count the invoices that are already undated
GET /v1/invoices?collection_method=send_invoice&status=open, then filter client side for due_date null. This is the damage already done, as opposed to the damage the configuration will keep doing. It is also the number that gets a ticket prioritised.
Set the terms on the subscription, then switch on reminders
days_until_due is updatable on an existing subscription and takes effect on the next invoice. The reminder schedule is a Dashboard setting under Billing then Invoices, and it is worth doing in that order: reminders on a subscription that still writes null due dates change nothing.
Deal with the already-issued invoices separately
An invoice that is already finalized has its due_date baked in, so fixing the subscription does not reach back. Those need a resend, a manual due date, or an honest write-off, and that is a decision per invoice rather than a loop.
How to check it worked
Re-run the script. Every send_invoice subscription should report terms, and the count of open invoices with a null due date should stop growing.
python3 stripe_days_until_due.py
# clear 0 of 34 send_invoice subscription(s) without terms
The full code
Two paginated GETs and nothing else — a restricted key with read access to Subscriptions and Invoices is enough, and is what you should give it. The classifier is pure and takes the collection method, the terms, the billing interval in days and the count of already-undated invoices, because the difference between null and 0 is the whole point of the note and it deserves a test rather than a comment.
"""Report Stripe send_invoice subscriptions that write invoices with no due date.
Read only. Two paginated GETs and no writes: give this a RESTRICTED key with read
access to Subscriptions and Invoices. The repair is printed, never performed,
because changing payment terms changes what a customer owes and when.
"""
import argparse
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_days_until_due")
API = "https://api.stripe.com/v1"
# Roughly how many days each recurring interval is worth. Used only to compare
# payment terms against the billing period, so month lengths do not matter.
INTERVAL_DAYS = {"day": 1, "week": 7, "month": 30, "year": 365}
def verdict(collection_method, days_until_due, interval_days, undated_open_invoices):
"""Classify one subscription. Pure, so the rules can be tested without a network.
`days_until_due` is the raw field: None when absent, and 0 is a real value
meaning due on receipt. `interval_days` is the billing period in days, or
None if it could not be read. `undated_open_invoices` is how many open
invoices this subscription already has with a null due_date.
Returns (state, detail).
"""
if collection_method != "send_invoice":
return ("automatic",
"collection_method is %r: Stripe charges the payment method on "
"file, so days_until_due does not apply" % (collection_method,))
if days_until_due is None:
if undated_open_invoices:
return ("undated",
"days_until_due is null and %d open invoice(s) already have "
"due_date null: nothing can mark them overdue"
% undated_open_invoices)
return ("unanchored",
"days_until_due is null, so every invoice this subscription "
"writes will have due_date null and can never age")
if days_until_due == 0:
return ("on-receipt",
"net 0, due on receipt: a real term, not a missing one")
if interval_days and days_until_due >= interval_days:
return ("overlapping",
"net %d on a %d day billing period: the next invoice is issued "
"before this one is due" % (days_until_due, interval_days))
return ("dated",
"net %d; due_date is set and the past due machinery has something "
"to measure from" % days_until_due)
def get(session, path, **params):
r = session.get(API + path, params=params, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from Stripe: the key is wrong, or is for the other mode")
r.raise_for_status()
return r.json()
def page_all(session, path, limit, **params):
"""Page a list endpoint until it runs out or the cap is reached."""
out = []
params = dict(params, limit=100)
while True:
page = get(session, path, **params)
data = page.get("data", [])
out.extend(data)
if not data or not page.get("has_more") or len(out) >= limit:
break
params["starting_after"] = data[-1]["id"]
return out
def interval_days(sub):
"""Billing period length in days, or None when the price cannot be read."""
items = (sub.get("items") or {}).get("data") or []
if not items:
return None
recurring = (items[0].get("price") or {}).get("recurring") or {}
unit = INTERVAL_DAYS.get(recurring.get("interval"))
if not unit:
return None
return unit * (recurring.get("interval_count") or 1)
def undated_open_invoices(session, limit):
"""Count open send_invoice invoices with a null due_date, per subscription.
The list endpoint has no server-side filter for due_date, so the comparison
is client side over invoices Stripe has already narrowed by status and
collection method.
"""
counts = {}
for inv in page_all(session, "/invoices", limit,
status="open", collection_method="send_invoice"):
if inv.get("due_date") is None:
sub = inv.get("subscription")
if isinstance(sub, dict):
sub = sub.get("id")
counts[sub] = counts.get(sub, 0) + 1
return counts
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--top", type=int, default=20,
help="how many individual subscriptions to print")
ap.add_argument("--max-rows", type=int, default=2000,
help="stop paginating each list after this many objects")
args = ap.parse_args()
key = os.environ.get("STRIPE_API_KEY")
if not key:
log.error("set STRIPE_API_KEY (use a restricted, read-only key)")
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + key})
undated = undated_open_invoices(s, args.max_rows)
subs = page_all(s, "/subscriptions", args.max_rows,
collection_method="send_invoice", status="all")
if not subs:
log.info("no send_invoice subscriptions for this key's mode")
return 0
rows = []
for sub in subs:
state, detail = verdict(sub.get("collection_method"),
sub.get("days_until_due"),
interval_days(sub),
undated.get(sub.get("id"), 0))
rows.append((sub.get("id", "<no id>"), state, detail))
bad = [r for r in rows if r[1] in ("undated", "unanchored", "overlapping")]
if not bad:
log.info("%-11s 0 of %d send_invoice subscription(s) without terms",
"clear", len(rows))
return 0
log.warning("%-11s %d of %d send_invoice subscription(s) need terms",
"unterm", len(bad), len(rows))
for sub_id, state, detail in bad[:args.top]:
log.warning(" %-11s %s %s", state, sub_id, detail)
log.warning(" repair: POST %s/subscriptions/%s days_until_due=30",
API, sub_id)
if len(bad) > args.top:
log.warning(" ... and %d more", len(bad) - args.top)
log.warning(" then Dashboard > Settings > Billing > Invoices: enable the "
"reminder emails and set the past due subscription action")
log.warning(" invoices already finalized keep their null due_date; those "
"need a resend or a write off, one at a time")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe send_invoice subscriptions that write invoices with no due date.
*
* Read only. Two paginated GETs and no writes: give this a RESTRICTED key with
* read access to Subscriptions and Invoices. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
// Roughly how many days each recurring interval is worth. Used only to compare
// payment terms against the billing period, so month lengths do not matter.
export const INTERVAL_DAYS = { day: 1, week: 7, month: 30, year: 365 };
/**
* Classify one subscription. Pure, so the rules can be tested without a network.
* `daysUntilDue` is the raw field: null when absent, and 0 is a real value.
*/
export function verdict(collectionMethod, daysUntilDue, intervalDays, undatedOpenInvoices) {
if (collectionMethod !== 'send_invoice') {
return ['automatic',
`collection_method is ${JSON.stringify(collectionMethod)}: Stripe charges ` +
'the payment method on file, so days_until_due does not apply'];
}
if (daysUntilDue === null || daysUntilDue === undefined) {
if (undatedOpenInvoices) {
return ['undated',
`days_until_due is null and ${undatedOpenInvoices} open invoice(s) already ` +
'have due_date null: nothing can mark them overdue'];
}
return ['unanchored',
'days_until_due is null, so every invoice this subscription writes will ' +
'have due_date null and can never age'];
}
if (daysUntilDue === 0) {
return ['on-receipt', 'net 0, due on receipt: a real term, not a missing one'];
}
if (intervalDays && daysUntilDue >= intervalDays) {
return ['overlapping',
`net ${daysUntilDue} on a ${intervalDays} day billing period: the next ` +
'invoice is issued before this one is due'];
}
return ['dated',
`net ${daysUntilDue}; due_date is set and the past due machinery has ` +
'something to measure from'];
}
/** Billing period length in days, or null when the price cannot be read. */
export function intervalDays(sub) {
const items = sub.items?.data ?? [];
if (items.length === 0) return null;
const recurring = items[0].price?.recurring ?? {};
const unit = INTERVAL_DAYS[recurring.interval];
if (!unit) return null;
return unit * (recurring.interval_count ?? 1);
}
async function get(key, path, params = {}) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (res.status === 401) {
throw new Error('401 from Stripe: the key is wrong, or is for the other mode');
}
if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
return res.json();
}
async function pageAll(key, path, limit, params = {}) {
const out = [];
const q = { ...params, limit: 100 };
for (;;) {
const page = await get(key, path, q);
const data = page.data ?? [];
out.push(...data);
if (data.length === 0 || !page.has_more || out.length >= limit) break;
q.starting_after = data[data.length - 1].id;
}
return out;
}
async function main() {
const key = process.env.STRIPE_API_KEY;
if (!key) {
console.error('set STRIPE_API_KEY (use a restricted, read-only key)');
process.exitCode = 2;
return;
}
const maxRows = Number(process.argv[2] ?? 2000);
const undated = new Map();
for (const inv of await pageAll(key, '/invoices', maxRows,
{ status: 'open', collection_method: 'send_invoice' })) {
if (inv.due_date === null || inv.due_date === undefined) {
const sub = typeof inv.subscription === 'object' && inv.subscription !== null
? inv.subscription.id : inv.subscription;
undated.set(sub, (undated.get(sub) ?? 0) + 1);
}
}
const subs = await pageAll(key, '/subscriptions', maxRows,
{ collection_method: 'send_invoice', status: 'all' });
if (subs.length === 0) {
console.log("no send_invoice subscriptions for this key's mode");
return;
}
const rows = subs.map((sub) => ({
id: sub.id ?? '<no id>',
v: verdict(sub.collection_method, sub.days_until_due, intervalDays(sub),
undated.get(sub.id) ?? 0),
}));
const bad = rows.filter((r) => ['undated', 'unanchored', 'overlapping'].includes(r.v[0]));
if (bad.length === 0) {
console.log(`${'clear'.padEnd(11)} 0 of ${rows.length} send_invoice subscription(s) without terms`);
return;
}
console.warn(`${'unterm'.padEnd(11)} ${bad.length} of ${rows.length} send_invoice subscription(s) need terms`);
for (const r of bad.slice(0, 20)) {
console.warn(` ${r.v[0].padEnd(11)} ${r.id} ${r.v[1]}`);
console.warn(` repair: POST ${API}/subscriptions/${r.id} days_until_due=30`);
}
if (bad.length > 20) console.warn(` ... and ${bad.length - 20} more`);
console.warn(' then Dashboard > Settings > Billing > Invoices: enable the ' +
'reminder emails and set the past due subscription action');
console.warn(' invoices already finalized keep their null due_date; those ' +
'need a resend or a write off, one at a time');
process.exitCode = 1;
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing key, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The test that earns its place is the one for days_until_due=0. Written the obvious way, the null check is a truthiness check, zero falls through it, and a subscription with due-on-receipt terms is reported as having no terms at all. That single false positive is enough for someone to decide the report is noise and stop reading it, which costs more than the bug did.
from stripe_days_until_due import verdict
def test_charge_automatically_is_not_a_finding():
# days_until_due is rejected for charge_automatically, so a null there is
# correct rather than missing.
state, detail = verdict("charge_automatically", None, 30, 0)
assert state == "automatic"
assert "does not apply" in detail
def test_null_terms_with_no_invoices_yet_is_unanchored():
state, detail = verdict("send_invoice", None, 30, 0)
assert state == "unanchored"
assert "can never age" in detail
def test_null_terms_with_undated_invoices_names_the_damage():
state, detail = verdict("send_invoice", None, 30, 7)
assert state == "undated"
assert "7" in detail
def test_zero_days_is_a_real_term_not_a_missing_one():
# The whole reason the null check is explicit: `if not days_until_due`
# would report due-on-receipt as unconfigured.
state, _ = verdict("send_invoice", 0, 30, 0)
assert state == "on-receipt"
def test_terms_at_or_past_the_billing_period_overlap():
assert verdict("send_invoice", 30, 30, 0)[0] == "overlapping"
assert verdict("send_invoice", 29, 30, 0)[0] == "dated"
def test_an_unreadable_interval_does_not_invent_an_overlap():
assert verdict("send_invoice", 45, None, 0)[0] == "dated"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict, intervalDays } from './stripe-days-until-due.mjs';
test('charge_automatically is not a finding', () => {
const [state, detail] = verdict('charge_automatically', null, 30, 0);
assert.equal(state, 'automatic');
assert.match(detail, /does not apply/);
});
test('null terms with no invoices yet is unanchored', () => {
const [state, detail] = verdict('send_invoice', null, 30, 0);
assert.equal(state, 'unanchored');
assert.match(detail, /can never age/);
});
test('null terms with undated invoices names the damage', () => {
const [state, detail] = verdict('send_invoice', null, 30, 7);
assert.equal(state, 'undated');
assert.match(detail, /7/);
});
test('zero days is a real term, not a missing one', () => {
assert.equal(verdict('send_invoice', 0, 30, 0)[0], 'on-receipt');
});
test('terms at or past the billing period overlap', () => {
assert.equal(verdict('send_invoice', 30, 30, 0)[0], 'overlapping');
assert.equal(verdict('send_invoice', 29, 30, 0)[0], 'dated');
});
test('an unreadable interval does not invent an overlap', () => {
assert.equal(verdict('send_invoice', 45, null, 0)[0], 'dated');
});
test('the billing period is read off the first subscription item', () => {
const sub = { items: { data: [{ price: { recurring: { interval: 'month', interval_count: 3 } } }] } };
assert.equal(intervalDays(sub), 90);
assert.equal(intervalDays({ items: { data: [] } }), null);
});
FAQ
Can I set a default due date for all invoices instead?
Not as a field that back-fills days_until_due. The due date on a subscription invoice comes from the subscription's days_until_due, so a subscription created without it keeps writing invoices with due_date null until that subscription is updated. Setting it once per subscription is the repair; setting it on every create path is the fix.
Why does days_until_due not work on my card subscriptions?
Because there is nothing to wait for. Under collection_method=charge_automatically Stripe attempts the payment method on file as soon as the invoice finalizes, so there is no period during which the invoice is merely owed. The field is only accepted for send_invoice.
Will enabling reminder emails fix the invoices already out there?
No. Reminders are scheduled relative to due_date, and these invoices do not have one. Enabling reminders helps every invoice issued after the subscription is fixed, and does nothing at all for the ones already finalized.
Can I add a due date to an invoice that is already finalized?
due_date is set at finalization and is not something you can revise afterwards on a finalized invoice. In practice the options are to resend it with a stated deadline outside Stripe, to void and reissue, or to mark it uncollectible if it is old enough that chasing it is theatre.
Is days_until_due=0 a mistake?
No, it means due on receipt, and plenty of businesses bill that way. It is worth pinning in a test because the natural way to check for a missing value in either language treats zero as missing, and a checker that cries wolf on correctly configured subscriptions gets ignored within a week.
Related field notes
- Open invoices are weeks past due_date and nobody chases
- past_due subscriptions keep their access forever
- Dunning ran out of retries and no attempt is scheduled
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Collection method — Stripe Docs
- The subscription object — Stripe API reference
- The invoice object — Stripe API reference
- List all subscriptions — Stripe API reference
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.