Diagnostic Stripe
draft invoices blocked on customer_tax_location_invalid
A handful of customers have not been billed since Stripe Tax was switched on, and their subscriptions all read active. Their renewal invoices were created on schedule, priced correctly, and then refused at the last step: Stripe Tax cannot work out where the customer is, so it will not finalize the invoice, and an unfinalized invoice is never sent and never charged.
List GET /v1/invoices?status=draft and read last_finalization_error.code. The value customer_tax_location_invalid means finalization was attempted and rejected because Stripe Tax could not resolve the customer's location: a country is needed outside the US, a five digit ZIP inside it, and a province or postal code in Canada.
Two nearby fields carry the same root cause at different stages. automatic_tax.status of requires_location_inputs means the calculation itself cannot run. automatic_tax.disabled_reason of finalization_requires_location_inputs means Stripe gave up and switched automatic tax off so the invoice could go out — which it then does, with no tax on it and no error anywhere.
Confirm the cause per customer with GET /v1/customers/{cus}?expand[]=tax and look for tax.automatic_tax of unrecognized_location.
The problem in plain words
This is the failure mode that looks most like everything working. The subscription is active, so the customer keeps their access and appears in every retention metric. The invoice exists, so the line items and amounts are all there to look at. Nothing is past_due, because an invoice that never finalized was never owed.
What is missing is the finalization, and with it the invoice number, the PDF, the hosted page, the email and any possibility of collection. The customer is being served for free and does not know it. You are not chasing them, because from the billing system's point of view they were never billed.
It concentrates, too. The customers affected are the ones whose addresses were captured badly, which usually means one signup path, one import, or one country. So it is rarely a stray invoice; it is a cohort, and the cohort keeps generating a new stuck draft every renewal.
Why it happens
Stripe Tax needs a location before it will calculate, and finalization needs a calculation. With automatic_tax[enabled]=true the tax amount is part of the invoice total, so the invoice cannot become immutable until that number exists. No resolvable address means no number, which means no finalization. That ordering is the whole mechanism.
The address requirement is not one rule. Outside the US a country is generally enough. In the US it is a five digit postal code, because rates vary below state level. In Canada a province or postal code is needed. An address that satisfies your shipping validator can still fail all three.
The error lives on the invoice, not in your logs. last_finalization_error is a field on the invoice object. Nothing raises, nothing pages, and the invoice.finalization_failed event only helps if you subscribed to it, which most integrations did not because it did not exist as a concern until Stripe Tax was enabled.
The fallback is worse than the block, and quieter. Stripe can disable automatic tax on the invoice rather than leaving it stuck, recording finalization_requires_location_inputs in disabled_reason. The invoice then finalizes and gets paid with zero tax on it. That is a collected invoice with a wrong total and a liability you now owe out of your own margin, and it produces no stuck draft for anyone to notice.
Fixing the invoice does not fix the customer. Finalizing this month's draft with tax disabled clears the symptom and leaves the address broken, so next month produces another one. The repair belongs on the customer record.
The fix, as a flow
The script reads the finalization error and the automatic tax fields off every draft, then rolls the findings up by customer, because the thing that needs fixing is an address rather than an invoice.
How to fix it
Page all draft invoices and read last_finalization_error
Do not filter by age here. Unlike a stranded draft, a tax-blocked one is a finding from the first attempt: the finalization already happened and already failed, so waiting adds nothing but more of them.
Separate the blocked from the merely stranded
A draft with auto_advance false was never going to finalize and is a different note with a different repair. A draft with auto_advance true and a finalization error tried and was refused. Reporting them together produces a list nobody can action.
Look for the disabled_reason as hard as for the error
finalization_requires_location_inputs on automatic_tax.disabled_reason is the same broken address with the opposite outcome: the invoice finalizes untaxed instead of sticking. It costs real money and leaves no stuck draft behind, so a check that only looks at drafts with errors will never see it.
Roll the findings up by customer
The unit of repair is the customer address, not the invoice. One customer with six months of blocked renewals is one fix and six finalizations; six customers with one each is six fixes. The report should say which of those you have.
Confirm on the customer before changing anything
GET /v1/customers/{cus}?expand[]=tax returns tax.automatic_tax and tax.location. unrecognized_location confirms the diagnosis and tax.location shows what Stripe did manage to infer, which is usually an IP-derived country that disagrees with the address on file.
Fix the address, then finalize, in that order
Update the customer with a full address and validate it, then finalize the drafts. Doing it the other way round either fails again or finalizes untaxed. If an address genuinely cannot be obtained, disabling automatic tax on that one invoice before finalizing is a deliberate choice with a cost, not a workaround.
Close the intake that produced the bad addresses
Checkout can be told to require a billing address, and the billing portal can be configured to let customers update their own. Without one of those, the cohort refills.
How to check it worked
Re-run the script after the customer records are fixed and the drafts finalized. No draft should carry a tax finalization error, and no invoice should report tax disabled for want of a location.
python3 stripe_tax_blocked_drafts.py
# clear 0 of 118 draft invoice(s) blocked on tax location
The full code
One paginated GET against /v1/invoices filtered to drafts — a restricted key with read access to Invoices is enough. The classifier takes the four fields that distinguish the outcomes, and their order matters: the finalization error is read before the disabled reason, the disabled reason before the calculation status, and auto_advance last, so a draft that is both stranded and tax-blocked is reported as the thing you can actually fix.
"""Report Stripe draft invoices that cannot finalize because Stripe Tax cannot
locate the customer.
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 finalizing an
invoice sends a real bill and disabling tax on one changes what is owed.
"""
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_tax_blocked_drafts")
API = "https://api.stripe.com/v1"
TAX_LOCATION_ERROR = "customer_tax_location_invalid"
TAX_DISABLED_FOR_LOCATION = "finalization_requires_location_inputs"
# States that mean a human has to touch the customer record or the invoice.
ACTIONABLE = ("tax-location", "tax-dropped", "needs-address", "tax-failed")
def verdict(error_code, tax_status, disabled_reason, auto_advance):
"""Classify one draft invoice. Pure, so the rules can be tested without a network.
`error_code` is last_finalization_error.code, `tax_status` is
automatic_tax.status, `disabled_reason` is automatic_tax.disabled_reason.
Any of them may be None. Returns (state, detail).
"""
if error_code == TAX_LOCATION_ERROR:
return ("tax-location",
"finalization was attempted and refused: Stripe Tax cannot "
"resolve this customer's location")
if disabled_reason == TAX_DISABLED_FOR_LOCATION:
return ("tax-dropped",
"Stripe switched automatic tax off so this invoice can finalize; "
"it will be billed and paid with no tax on it")
if tax_status == "requires_location_inputs":
return ("needs-address",
"the tax calculation cannot run for want of a location; no "
"finalization attempt has failed yet, but one will")
if tax_status == "failed":
return ("tax-failed",
"the calculation failed on Stripe's side; retry the finalization "
"before editing the customer")
if error_code:
return ("other-error",
"finalization is failing for a reason that is not tax: %s"
% (error_code,))
if not auto_advance:
return ("not-advancing",
"auto_advance is false: this draft is outside the collection "
"workflow rather than blocked by tax")
return ("clear", "no tax finalization problem recorded on this draft")
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 drafts(session, limit):
"""Page every draft invoice. Newest first, as Stripe sends them."""
out = []
params = {"status": "draft", "limit": 100}
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 classify(inv):
"""Pull the four fields off an invoice and hand them to verdict()."""
err = inv.get("last_finalization_error") or {}
tax = inv.get("automatic_tax") or {}
return verdict(err.get("code"), tax.get("status"), tax.get("disabled_reason"),
bool(inv.get("auto_advance")))
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--top", type=int, default=20,
help="how many customers to print")
ap.add_argument("--max-invoices", type=int, default=2000,
help="stop paginating after this many drafts")
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 drafts(s, args.max_invoices):
seen += 1
state, detail = classify(inv)
if state not in ACTIONABLE:
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, "state": state,
"detail": detail, "first": inv.get("id")})
entry["n"] += 1
entry["amount"] += inv.get("amount_due") or 0
if not by_customer:
log.info("%-13s 0 of %d draft invoice(s) blocked on tax location",
"clear", seen)
return 0
at_stake = sum(e["amount"] for e in by_customer.values())
log.warning("%-13s %d customer(s), %d draft(s), %d in minor units uncollected",
"tax-blocked", len(by_customer),
sum(e["n"] for e in by_customer.values()), at_stake)
ranked = sorted(by_customer.items(), key=lambda kv: -kv[1]["amount"])
for cus, e in ranked[:args.top]:
log.warning(" %-13s %s %d draft(s) %d %s",
e["state"], cus, e["n"], e["amount"], e["detail"])
log.warning(" GET %s/customers/%s?expand[]=tax read "
"tax.automatic_tax and tax.location", API, cus)
log.warning(" repair: POST %s/customers/%s address[country]=.. "
"address[postal_code]=.. tax[validate_location]=immediately",
API, cus)
log.warning(" then: POST %s/invoices/%s/finalize", API, e["first"])
if len(ranked) > args.top:
log.warning(" ... and %d more customer(s)", len(ranked) - args.top)
log.warning(" fix the customer before the invoice; finalizing first either "
"fails again or bills with no tax on it")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe draft invoices that cannot finalize because Stripe Tax cannot
* locate the customer.
*
* 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';
export const TAX_LOCATION_ERROR = 'customer_tax_location_invalid';
export const TAX_DISABLED_FOR_LOCATION = 'finalization_requires_location_inputs';
// States that mean a human has to touch the customer record or the invoice.
export const ACTIONABLE = ['tax-location', 'tax-dropped', 'needs-address', 'tax-failed'];
/**
* Classify one draft invoice. Pure, so the rules can be tested without a network.
* Any of the first three arguments may be null or undefined.
*/
export function verdict(errorCode, taxStatus, disabledReason, autoAdvance) {
if (errorCode === TAX_LOCATION_ERROR) {
return ['tax-location',
'finalization was attempted and refused: Stripe Tax cannot resolve this ' +
"customer's location"];
}
if (disabledReason === TAX_DISABLED_FOR_LOCATION) {
return ['tax-dropped',
'Stripe switched automatic tax off so this invoice can finalize; it will ' +
'be billed and paid with no tax on it'];
}
if (taxStatus === 'requires_location_inputs') {
return ['needs-address',
'the tax calculation cannot run for want of a location; no finalization ' +
'attempt has failed yet, but one will'];
}
if (taxStatus === 'failed') {
return ['tax-failed',
"the calculation failed on Stripe's side; retry the finalization before " +
'editing the customer'];
}
if (errorCode) {
return ['other-error',
`finalization is failing for a reason that is not tax: ${errorCode}`];
}
if (!autoAdvance) {
return ['not-advancing',
'auto_advance is false: this draft is outside the collection workflow ' +
'rather than blocked by tax'];
}
return ['clear', 'no tax finalization problem recorded on this draft'];
}
/** Pull the four fields off an invoice and hand them to verdict(). */
export function classify(inv) {
const err = inv.last_finalization_error ?? {};
const tax = inv.automatic_tax ?? {};
return verdict(err.code, tax.status, tax.disabled_reason, Boolean(inv.auto_advance));
}
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 drafts(key, limit = 2000) {
const out = [];
const params = { status: 'draft', limit: 100 };
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;
}
let seen = 0;
const byCustomer = new Map();
for (const inv of await drafts(key)) {
seen += 1;
const [state, detail] = classify(inv);
if (!ACTIONABLE.includes(state)) 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, state, detail, first: inv.id };
entry.n += 1;
entry.amount += inv.amount_due ?? 0;
byCustomer.set(cus, entry);
}
if (byCustomer.size === 0) {
console.log(`${'clear'.padEnd(13)} 0 of ${seen} draft invoice(s) blocked on tax location`);
return;
}
const entries = [...byCustomer.entries()].sort((a, b) => b[1].amount - a[1].amount);
const drafted = entries.reduce((a, [, e]) => a + e.n, 0);
const atStake = entries.reduce((a, [, e]) => a + e.amount, 0);
console.warn(`${'tax-blocked'.padEnd(13)} ${entries.length} customer(s), ${drafted} draft(s), ${atStake} in minor units uncollected`);
for (const [cus, e] of entries.slice(0, 20)) {
console.warn(` ${e.state.padEnd(13)} ${cus} ${e.n} draft(s) ${e.amount} ${e.detail}`);
console.warn(` GET ${API}/customers/${cus}?expand[]=tax read tax.automatic_tax and tax.location`);
console.warn(` repair: POST ${API}/customers/${cus} address[country]=.. address[postal_code]=.. tax[validate_location]=immediately`);
console.warn(` then: POST ${API}/invoices/${e.first}/finalize`);
}
if (entries.length > 20) console.warn(` ... and ${entries.length - 20} more customer(s)`);
console.warn(' fix the customer before the invoice; finalizing first either ' +
'fails again or bills with no tax on it');
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
These tests pin an order rather than a set of outcomes. A blocked draft usually carries several of these fields at once, and reading auto_advance too early turns a fixable address into a note about a stranded invoice, while reading disabled_reason too late hides the case where Stripe already billed the customer with no tax on it.
from stripe_tax_blocked_drafts import verdict
def test_the_finalization_error_is_the_headline():
state, detail = verdict("customer_tax_location_invalid",
"requires_location_inputs", None, True)
assert state == "tax-location"
assert "cannot resolve" in detail
def test_tax_dropped_is_reported_even_though_the_invoice_will_finalize():
# No error and no stuck draft: Stripe disabled tax so the bill could go out.
# It is the expensive case and the one nothing else surfaces.
state, detail = verdict(None, "requires_location_inputs",
"finalization_requires_location_inputs", True)
assert state == "tax-dropped"
assert "no tax on it" in detail
def test_requires_location_inputs_alone_is_a_warning_not_an_error():
state, _ = verdict(None, "requires_location_inputs", None, True)
assert state == "needs-address"
def test_a_stripe_side_failure_is_not_the_customers_address():
state, detail = verdict(None, "failed", None, True)
assert state == "tax-failed"
assert "retry the finalization" in detail
def test_a_non_tax_finalization_error_is_kept_separate():
state, detail = verdict("invoice_payment_intent_requires_action", None, None, True)
assert state == "other-error"
assert "not tax" in detail
def test_auto_advance_is_read_last():
# Both true at once: the tax problem is the one a human can act on, so a
# stranded draft with a tax error must not be filed as merely stranded.
assert verdict("customer_tax_location_invalid", None, None, False)[0] == "tax-location"
assert verdict(None, None, None, False)[0] == "not-advancing"
def test_a_healthy_draft_is_clear():
assert verdict(None, "complete", None, True)[0] == "clear"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict, classify } from './stripe-tax-blocked-drafts.mjs';
test('the finalization error is the headline', () => {
const [state, detail] = verdict('customer_tax_location_invalid',
'requires_location_inputs', null, true);
assert.equal(state, 'tax-location');
assert.match(detail, /cannot resolve/);
});
test('tax dropped is reported even though the invoice will finalize', () => {
const [state, detail] = verdict(null, 'requires_location_inputs',
'finalization_requires_location_inputs', true);
assert.equal(state, 'tax-dropped');
assert.match(detail, /no tax on it/);
});
test('requires_location_inputs alone is a warning, not an error', () => {
assert.equal(verdict(null, 'requires_location_inputs', null, true)[0], 'needs-address');
});
test('a Stripe side failure is not the customer address', () => {
const [state, detail] = verdict(null, 'failed', null, true);
assert.equal(state, 'tax-failed');
assert.match(detail, /retry the finalization/);
});
test('a non tax finalization error is kept separate', () => {
const [state, detail] = verdict('invoice_payment_intent_requires_action', null, null, true);
assert.equal(state, 'other-error');
assert.match(detail, /not tax/);
});
test('auto_advance is read last', () => {
assert.equal(verdict('customer_tax_location_invalid', null, null, false)[0], 'tax-location');
assert.equal(verdict(null, null, null, false)[0], 'not-advancing');
});
test('classify reads the nested invoice fields', () => {
const inv = {
last_finalization_error: { code: 'customer_tax_location_invalid' },
automatic_tax: { status: 'requires_location_inputs' },
auto_advance: true,
};
assert.equal(classify(inv)[0], 'tax-location');
assert.equal(classify({ auto_advance: true, automatic_tax: { status: 'complete' } })[0], 'clear');
});
FAQ
What address does Stripe Tax actually need?
Enough to identify a taxing jurisdiction. Outside the US a country is generally sufficient. Inside the US it needs a five digit postal code, because rates vary below state level. Canada needs a province or a postal code. An address good enough to ship to can still be too vague to tax against.
Why is the subscription still active if the invoice never finalized?
Because subscription status tracks payment, and there was never a payment to fail. An unfinalized invoice is not owed, so nothing moves the subscription to past_due or unpaid. Access continues and every retention dashboard counts the customer as healthy.
Is it safe to just disable automatic tax on the stuck invoice?
It unsticks it, and it bills the customer with no tax on them. If you had an obligation in their jurisdiction you now owe it out of your own margin, and the invoice is a legal record you cannot amend afterwards. It is a defensible choice for a customer whose address is genuinely unobtainable, and a bad default.
Stripe disabled tax on some invoices by itself. Is that the same problem?
The same cause with the opposite symptom. automatic_tax.disabled_reason of finalization_requires_location_inputs means Stripe chose to let the invoice through untaxed rather than leave it stuck. There is no draft to find and no error to read, which is exactly why the script looks for the field rather than for stuck invoices.
How do I stop new customers arriving without an address?
Require a billing address in Checkout rather than treating it as optional, and allow address updates in the billing portal so customers can correct their own. Validating the location when the customer is created turns a silent finalization failure months later into an error at signup, where somebody is still paying attention.
Related field notes
- Draft invoices sit for months and never finalize
- automatic_tax reports requires_location_inputs
- 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.
- Stripe Tax and invoicing — Stripe Docs
- Determining customer locations — Stripe Docs
- The invoice object — Stripe API reference
- Invoice workflow transitions — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.