Diagnostic Stripe
automatic_tax reports requires_location_inputs on live bills
Stripe Tax is enabled, the integration passes automatic_tax[enabled]=true everywhere, and the totals look right on every invoice anyone has opened. They have been opening the wrong ones. A slice of the customers — the ones whose addresses were never captured properly — have been billed and paid with no tax calculated, and the invoices carrying that fact are already finalized and immutable.
Page GET /v1/invoices?created[gte]=<90 days ago> across every status and read automatic_tax on each. status of requires_location_inputs means the location details on the customer are not valid or not sufficient, so no tax was calculated. status of failed is a Stripe-side calculation error and behaves differently: it is worth retrying before you touch anything.
Read disabled_reason first, though. finalization_requires_location_inputs and finalization_system_error mean automatic tax was switched off at finalization so the invoice could go out, which it did — billed, paid, and untaxed. Those are the ones that already cost money.
Group by customer and confirm with GET /v1/customers/{cus}?expand[]=tax: tax.automatic_tax of unrecognized_location names the customers whose next invoice will do the same thing.
The problem in plain words
Tax that is simply off across the board is a loud problem: every invoice is wrong and somebody notices within a quarter. This is the quiet version. Most invoices calculate correctly, the integration is right, and the Dashboard shows Stripe Tax as active. A minority of customers are silently exempted from the whole mechanism because Stripe cannot tell where they are.
The damage is asymmetric. Where the invoice stuck in draft you can still fix it. Where it finalized without tax, you cannot: finalization freezes the tax lines, the tax IDs and the exemption status onto the document permanently. The invoice was paid, the customer's obligation was discharged at the amount printed on it, and the tax you should have collected is now yours to pay out of margin. Every cycle adds more of them.
It also does not show up as a percentage anyone tracks. Total tax collected goes up month on month because the business is growing. The share of invoices where tax was never calculated is the number that matters, and nothing computes it for you.
Why it happens
automatic_tax.status reports the last calculation, not the configuration. enabled: true with status: requires_location_inputs is a perfectly consistent pair: you asked for tax, Stripe tried, and the customer's location was not good enough to produce one. Any check that reads enabled alone concludes everything is fine.
The customers this happens to arrive in batches. A Checkout configuration that does not require a billing address, an API integration that creates customers with an email and nothing else, a migration from another billing system that dropped the address column. Each of those produces a cohort, and the cohort is invisible until you count by customer rather than by invoice.
failed and requires_location_inputs want opposite responses. failed is Stripe's side: a retry of the finalization may well succeed and editing the customer record achieves nothing. requires_location_inputs is your data: retrying is guaranteed to fail again until the address is fixed. Treating them as one bucket wastes the effort on whichever half you guessed wrong.
The disabled reason outranks the status. When Stripe turns automatic tax off at finalization it records why in disabled_reason, and the invoice proceeds. That is worse than a blocked draft and produces no stuck object, so it has to be checked before the status or it gets filed as the milder problem.
A complete calculation of zero is a different note entirely. status: complete with no tax on the invoice usually means you have no registration in that country, which is a registration problem rather than a location one. Mixing the two produces a report where the two halves need different departments.
The fix, as a flow
The script reads every invoice in a bounded window rather than only the drafts, because the invoices that already finalized untaxed are the half that cannot be repaired and the half that cost money.
How to fix it
Read invoices across every status, not just drafts
The expensive cases are finalized and paid. Filtering to drafts finds only the ones that stuck, which is the half you can still fix and the smaller half of the bill.
Bound the window with created[gte]
Ninety days is enough to cover three monthly cycles and to show whether the rate is rising. Going back further makes the report a history lesson: nothing before the current period can be repaired, only credited.
Check disabled_reason before status
An invoice Stripe disabled tax on has already billed. One that merely reports requires_location_inputs may still be a draft. Read the more expensive field first so the ranking of the report matches the ranking of the damage.
Split failed from requires_location_inputs
They are two findings with two different owners. One is a retry, the other is a data fix on the customer. The script should never print them under the same heading.
Roll up by customer and count the repeats
A customer with one affected invoice may have been fixed since. A customer with four consecutive ones is still broken and will produce a fifth. The repeat count is what turns this from a list of invoices into a list of addresses to collect.
Confirm on the customer object
GET /v1/customers/{cus}?expand[]=tax gives tax.automatic_tax and tax.location. unrecognized_location is the confirmation; tax.location shows what Stripe inferred and from what source, which is usually where the disagreement is.
Decide what to do about the invoices already out
A finalized invoice cannot be amended. The honest options are a credit note and a reissue, or absorbing the tax. Both are decisions with a number attached, which is why the script totals the affected amounts rather than just counting rows.
How to check it worked
Re-run over the same window once the customer addresses are collected. The share of invoices with a non-complete tax status should fall to zero, and no invoice should report tax disabled for want of a location.
python3 stripe_tax_location_status.py --days 90
# clear 0 of 1,204 invoice(s) with an incomplete tax calculation
The full code
One paginated GET over a bounded window — a restricted key with read access to Invoices is enough, and Customers too if you want the confirmation step. The classifier takes the calculation status, the disabled reason and whether the invoice is finalized, because those three decide whether the answer is a retry, an address to collect, or a credit note.
"""Report Stripe invoices where the automatic tax calculation never completed.
Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to Invoices. The repair is printed, never performed, because amending tax
on a customer changes what they owe and a credit note is a legal record.
"""
import argparse
import logging
import os
import sys
import time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_tax_location_status")
API = "https://api.stripe.com/v1"
# An invoice past draft has its tax lines frozen; nothing can be recalculated on
# it, only credited.
FINALIZED = ("open", "paid", "uncollectible", "void")
# States where money has already moved with the wrong tax on it.
BILLED = ("billed-untaxed", "billed-unpriced", "frozen")
def verdict(tax_status, disabled_reason, finalized):
"""Classify one invoice's tax calculation. Pure, so it is testable offline.
`tax_status` is automatic_tax.status, `disabled_reason` is
automatic_tax.disabled_reason, both possibly None. `finalized` says whether
the invoice has left draft, which is the point after which the tax on it
cannot be changed. Returns (state, detail).
"""
if disabled_reason == "finalization_requires_location_inputs":
return ("billed-untaxed",
"automatic tax was switched off at finalization for want of a "
"location: this invoice went out with no tax and no error")
if disabled_reason == "finalization_system_error":
return ("billed-unpriced",
"Stripe could not calculate at finalization and disabled tax to "
"let the invoice through")
if tax_status == "requires_location_inputs":
if finalized:
return ("frozen",
"the location was not resolvable and the invoice is already "
"finalized: the tax on it can no longer be changed")
return ("blocked",
"the calculation cannot run for want of a location; still a "
"draft, so fixing the customer is enough")
if tax_status == "failed":
return ("failed",
"the calculation failed on Stripe's side; retry before assuming "
"the customer record is wrong")
if tax_status == "complete":
return ("complete",
"the calculation ran; zero tax here is a registration question, "
"not a location one")
return ("unknown", "unrecognised automatic_tax.status %r" % (tax_status,))
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 invoices_since(session, days, limit):
"""Page invoices created within the window, every status."""
cutoff = int(time.time() - days * 86400)
out = []
params = {"limit": 100, "created[gte]": cutoff}
while True:
page = get(session, "/invoices", **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 main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=float, default=90,
help="how far back to read invoices")
ap.add_argument("--top", type=int, default=20,
help="how many customers to print")
ap.add_argument("--max-invoices", type=int, default=5000,
help="stop paginating after this many invoices")
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})
seen = 0
by_customer = {}
for inv in invoices_since(s, args.days, args.max_invoices):
seen += 1
tax = inv.get("automatic_tax") or {}
if not tax.get("enabled"):
continue
state, detail = verdict(tax.get("status"), tax.get("disabled_reason"),
inv.get("status") in FINALIZED)
if state in ("complete", "unknown"):
continue
cus = inv.get("customer")
if isinstance(cus, dict):
cus = cus.get("id")
entry = by_customer.setdefault(cus or "<no customer>",
{"n": 0, "amount": 0, "billed": 0,
"state": state, "detail": detail})
entry["n"] += 1
entry["amount"] += inv.get("total") or 0
if state in BILLED:
entry["billed"] += 1
if not by_customer:
log.info("%-15s 0 of %d invoice(s) with an incomplete tax calculation",
"clear", seen)
return 0
affected = sum(e["n"] for e in by_customer.values())
log.warning("%-15s %d customer(s), %d of %d invoice(s), %d in minor units billed",
"tax-incomplete", len(by_customer), affected, seen,
sum(e["amount"] for e in by_customer.values()))
ranked = sorted(by_customer.items(), key=lambda kv: (-kv[1]["billed"], -kv[1]["n"]))
for cus, e in ranked[:args.top]:
log.warning(" %-15s %s %d invoice(s), %d already billed %s",
e["state"], cus, e["n"], e["billed"], e["detail"])
log.warning(" GET %s/customers/%s?expand[]=tax expect "
"tax.automatic_tax = unrecognized_location", API, cus)
if e["state"] != "failed":
log.warning(" repair: POST %s/customers/%s address[country]=.. "
"address[postal_code]=.. tax[validate_location]=immediately",
API, cus)
if len(ranked) > args.top:
log.warning(" ... and %d more customer(s)", len(ranked) - args.top)
log.warning(" invoices already finalized keep the tax they were finalized "
"with; a credit note and a reissue is the only correction")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe invoices where the automatic tax calculation never completed.
*
* Read only. One paginated GET and no writes: give this a RESTRICTED key with
* read access to Invoices. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
// An invoice past draft has its tax lines frozen; nothing can be recalculated on
// it, only credited.
export const FINALIZED = ['open', 'paid', 'uncollectible', 'void'];
// States where money has already moved with the wrong tax on it.
export const BILLED = ['billed-untaxed', 'billed-unpriced', 'frozen'];
/**
* Classify one invoice's tax calculation. Pure, so it is testable offline.
* `finalized` says whether the invoice has left draft.
*/
export function verdict(taxStatus, disabledReason, finalized) {
if (disabledReason === 'finalization_requires_location_inputs') {
return ['billed-untaxed',
'automatic tax was switched off at finalization for want of a location: ' +
'this invoice went out with no tax and no error'];
}
if (disabledReason === 'finalization_system_error') {
return ['billed-unpriced',
'Stripe could not calculate at finalization and disabled tax to let the ' +
'invoice through'];
}
if (taxStatus === 'requires_location_inputs') {
if (finalized) {
return ['frozen',
'the location was not resolvable and the invoice is already finalized: ' +
'the tax on it can no longer be changed'];
}
return ['blocked',
'the calculation cannot run for want of a location; still a draft, so ' +
'fixing the customer is enough'];
}
if (taxStatus === 'failed') {
return ['failed',
"the calculation failed on Stripe's side; retry before assuming the " +
'customer record is wrong'];
}
if (taxStatus === 'complete') {
return ['complete',
'the calculation ran; zero tax here is a registration question, not a ' +
'location one'];
}
return ['unknown', `unrecognised automatic_tax.status ${JSON.stringify(taxStatus)}`];
}
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();
}
export async function invoicesSince(key, days = 90, limit = 5000) {
const cutoff = Math.floor(Date.now() / 1000 - days * 86400);
const out = [];
const params = { limit: 100, 'created[gte]': cutoff };
for (;;) {
const page = await get(key, '/invoices', params);
const data = page.data ?? [];
out.push(...data);
if (data.length === 0 || !page.has_more || out.length >= limit) break;
params.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 days = Number(process.argv[2] ?? 90);
let seen = 0;
const byCustomer = new Map();
for (const inv of await invoicesSince(key, days)) {
seen += 1;
const tax = inv.automatic_tax ?? {};
if (!tax.enabled) continue;
const [state, detail] = verdict(tax.status, tax.disabled_reason,
FINALIZED.includes(inv.status));
if (state === 'complete' || state === 'unknown') continue;
const cus = (typeof inv.customer === 'object' && inv.customer !== null
? inv.customer.id : inv.customer) ?? '<no customer>';
const entry = byCustomer.get(cus) ?? { n: 0, amount: 0, billed: 0, state, detail };
entry.n += 1;
entry.amount += inv.total ?? 0;
if (BILLED.includes(state)) entry.billed += 1;
byCustomer.set(cus, entry);
}
if (byCustomer.size === 0) {
console.log(`${'clear'.padEnd(15)} 0 of ${seen} invoice(s) with an incomplete tax calculation`);
return;
}
const entries = [...byCustomer.entries()]
.sort((a, b) => (b[1].billed - a[1].billed) || (b[1].n - a[1].n));
const affected = entries.reduce((a, [, e]) => a + e.n, 0);
const total = entries.reduce((a, [, e]) => a + e.amount, 0);
console.warn(`${'tax-incomplete'.padEnd(15)} ${entries.length} customer(s), ${affected} of ${seen} invoice(s), ${total} in minor units billed`);
for (const [cus, e] of entries.slice(0, 20)) {
console.warn(` ${e.state.padEnd(15)} ${cus} ${e.n} invoice(s), ${e.billed} already billed ${e.detail}`);
console.warn(` GET ${API}/customers/${cus}?expand[]=tax expect tax.automatic_tax = unrecognized_location`);
if (e.state !== 'failed') {
console.warn(` repair: POST ${API}/customers/${cus} address[country]=.. address[postal_code]=.. tax[validate_location]=immediately`);
}
}
if (entries.length > 20) console.warn(` ... and ${entries.length - 20} more customer(s)`);
console.warn(' invoices already finalized keep the tax they were finalized ' +
'with; a credit note and a reissue is the only correction');
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
Two things are worth freezing here. The disabled reason is read before the status, so an invoice Stripe already billed untaxed cannot be filed as a pending problem. And requires_location_inputs splits on whether the invoice is finalized, because that is the line between a customer to fix and a credit note to write.
from stripe_tax_location_status import verdict
def test_disabled_reason_outranks_the_status():
# Both are set on the same invoice. The disabled reason means the bill went
# out untaxed, which is the more expensive fact and must win.
state, detail = verdict("requires_location_inputs",
"finalization_requires_location_inputs", True)
assert state == "billed-untaxed"
assert "no tax and no error" in detail
def test_a_system_error_disable_is_its_own_state():
state, _ = verdict(None, "finalization_system_error", True)
assert state == "billed-unpriced"
def test_requires_location_inputs_splits_on_finalization():
# Still a draft: fixing the customer is the whole repair.
assert verdict("requires_location_inputs", None, False)[0] == "blocked"
# Already finalized: the tax on the document is immutable.
state, detail = verdict("requires_location_inputs", None, True)
assert state == "frozen"
assert "no longer be changed" in detail
def test_failed_is_stripe_side_and_wants_a_retry():
state, detail = verdict("failed", None, True)
assert state == "failed"
assert "retry" in detail
def test_complete_is_not_a_location_problem():
state, detail = verdict("complete", None, True)
assert state == "complete"
assert "registration" in detail
def test_an_unrecognised_status_is_not_silently_complete():
assert verdict(None, None, True)[0] == "unknown"
assert verdict("pending", None, True)[0] == "unknown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-tax-location-status.mjs';
test('disabled reason outranks the status', () => {
const [state, detail] = verdict('requires_location_inputs',
'finalization_requires_location_inputs', true);
assert.equal(state, 'billed-untaxed');
assert.match(detail, /no tax and no error/);
});
test('a system error disable is its own state', () => {
assert.equal(verdict(null, 'finalization_system_error', true)[0], 'billed-unpriced');
});
test('requires_location_inputs splits on finalization', () => {
assert.equal(verdict('requires_location_inputs', null, false)[0], 'blocked');
const [state, detail] = verdict('requires_location_inputs', null, true);
assert.equal(state, 'frozen');
assert.match(detail, /no longer be changed/);
});
test('failed is Stripe side and wants a retry', () => {
const [state, detail] = verdict('failed', null, true);
assert.equal(state, 'failed');
assert.match(detail, /retry/);
});
test('complete is not a location problem', () => {
const [state, detail] = verdict('complete', null, true);
assert.equal(state, 'complete');
assert.match(detail, /registration/);
});
test('an unrecognised status is not silently complete', () => {
assert.equal(verdict(null, null, true)[0], 'unknown');
assert.equal(verdict('pending', null, true)[0], 'unknown');
});
FAQ
automatic_tax.enabled is true. Why is there no tax on the invoice?
Because enabled describes what you asked for and status describes what happened. requires_location_inputs means Stripe tried to calculate, could not identify a taxing jurisdiction from the customer's details, and produced nothing. The two fields are consistent; only one of them tells you whether tax was actually applied.
What is the difference between failed and requires_location_inputs?
Ownership. failed is an error on Stripe's side and a retry of the finalization is a reasonable first move. requires_location_inputs is a statement about your data: the customer has no resolvable location, and retrying will produce exactly the same result until an address is added.
Can I recalculate tax on an invoice that has already been finalized?
No. Finalization freezes the tax lines, the customer tax IDs and the exemption status onto the invoice, because it is the document the customer and both tax authorities work from. Correcting it means issuing a credit note and reissuing, or absorbing the difference.
Why check disabled_reason before status?
Because it identifies the invoices that were billed rather than blocked. When Stripe disables automatic tax at finalization the invoice proceeds normally and gets paid, so there is no stuck draft and no error for anyone to trip over. Reading status first files those under the milder finding.
Every invoice says complete and there is still no tax. Is this the same bug?
No, and that is why complete is its own state here. A completed calculation of zero usually means you hold no registration in the buyer's country, so Stripe correctly calculated that it should collect nothing. That is a registrations problem with a different repair.
Related field notes
- Draft invoices blocked on customer_tax_location_invalid
- No tax registrations while invoicing many countries
- Customers with no address at all
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.
- The invoice object — Stripe API reference
- Determining customer locations — Stripe Docs
- Stripe Tax and invoicing — Stripe Docs
- The customer object — 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.