Skip to content

Diagnostic Stripe

EU business invoices with no VAT number miss reverse charge

A German customer's finance team refuses the invoice. It has their company name on it, it has VAT added to it, and it has no VAT number and no reverse-charge notice anywhere on the document — so as far as their accounts payable is concerned it is a consumer receipt, and they cannot reclaim anything against it. Stripe did nothing wrong: with no tax ID on the customer, a business buyer is a consumer.

Read-only key Python and Node.js Tests included
Padlock on laptop with light trails
Photo by FlyD on Unsplash
The short answer

Page GET /v1/invoices?status=paid&created[gte]=<180 days ago> and flag the rows where customer_tax_ids is empty, customer_address.country is an EU member state, customer_tax_exempt is none, and tax was actually charged. That combination is a business being billed as a consumer.

Then check the ones that do have an ID: GET /v1/customers/{cus}/tax_ids and read verification.status. An unverified or unavailable eu_vat is a number VIES did not confirm, which is not a number you want to be defending in an audit.

Both are urgent for the same reason: finalization freezes customer_tax_ids and customer_tax_exempt onto the invoice permanently. Adding the VAT number tomorrow does nothing for the documents already sent.

The problem in plain words

Cross-border B2B inside the EU normally moves the VAT obligation to the buyer. The seller invoices without VAT, states that the reverse charge applies, and the buyer accounts for it themselves. Stripe Tax will do this automatically, but it decides based on the tax ID attached to the customer and the jurisdictions involved. No tax ID, no business buyer, no reverse charge.

So the invoice goes out with local VAT on it. The buyer has been charged something like nineteen or twenty-one per cent that they did not owe, on a document that cannot support a reclaim because it names no VAT number. Their options are to eat it or to come back to you, and the ones who understand the rules come back.

The part that makes this expensive rather than annoying is timing. The tax IDs and the exemption status on an invoice are captured at finalization and never change afterwards. A customer who sends you their VAT number in month seven has six months of invoices that are permanently wrong, and each one needs a credit note and a reissue to correct.

Checkoutwithout tax IDcollection neverenabledCustomer looksB2Cno tax ID on fileVAT addedreverse chargeskippedInvoicefinalizedtax IDs frozen onitBuyer cannotreclaiminvoice rejected
Every step succeeds. The only trace is an empty array on a document that should have carried a VAT number and a reverse charge notice.

Why it happens

The tax ID is what identifies a business, and it is optional everywhere. Checkout does not collect tax IDs unless you switch it on. The billing portal does not let customers add one unless you allow that field. The API accepts a customer with an address and no tax ID without complaint. Every default in the product produces a consumer.

Nothing about the invoice looks wrong from your side. The tax calculated, the status is complete, the payment succeeded and the revenue is higher than it would have been with the reverse charge applied. There is no error, no failed calculation and no unusual field; the only signal is an empty array on a document that should not have one.

An unverified number is worse than an obvious gap. A tax ID whose verification.status is unverified was submitted and not confirmed by VIES, and one that is unavailable could not be checked at all. Both look like coverage in a list of customers with tax IDs, and neither is something you would want to rely on if the treatment of those invoices is ever questioned.

Zero tax with no ID is a different finding and gets conflated with this one. If an EU invoice carries no tax and no tax ID, reverse charge is not what happened: it is far more likely you hold no registration in that country and Stripe correctly calculated nothing. Same-looking invoice, entirely different repair.

Nobody checks, because the customers who care tell you. The ones who complain get fixed one at a time through support. The ones who do not complain quietly keep paying VAT they do not owe, and they are the majority, which is exactly why the count only appears when something goes looking for it.

The fix, as a flow

The script reads the tax IDs frozen onto each paid invoice, then checks the verification status of the ones that exist, because an unconfirmed VAT number counts as coverage in every list except an audit.

GET /v1/invoicesstatus=paid, EU customer_addressVerified tax IDreverse charge availablecustomer_tax_exempt reversealready handledID present, unverifiedVIES did not confirm itNo ID, VAT chargedbusiness billed as a consumerNo ID, no VATregistration question instead
No ID with VAT charged is money the buyer did not owe. No ID with no VAT is a missing registration, and a different team's work.

How to fix it

Bound the window and read paid invoices

A hundred and eighty days covers two VAT quarters in most member states, which is the horizon over which a correction is still a routine credit note rather than a filing amendment. Paid invoices are the ones where money moved.

Filter to EU destinations using the invoice's own address

Use customer_address.country from the invoice, not the customer's current address. The invoice records where the customer was when it was finalized, and that is the address the tax treatment was based on.

Read customer_tax_exempt before the ID list

reverse means the reverse charge was applied and the invoice is correct. exempt means no VAT was due for another reason. Only none is the case where a missing ID actually cost the customer money, and checking it first keeps correctly handled invoices out of the report.

Separate the taxed from the untaxed

An EU invoice with no ID and VAT on it is a business charged as a consumer. An EU invoice with no ID and no VAT is almost certainly a missing registration. Both are findings; they belong on different lists and go to different people.

Check verification on the IDs that do exist

GET /v1/customers/{cus}/tax_ids and read verification.status per ID. verified is the only reassuring value. pending is fine for something added minutes ago and suspicious on something added last year.

Collect the numbers at the point of sale, not afterwards

Enable tax ID collection in Checkout and allow the tax ID field in the billing portal. Both are configuration rather than code, and both stop the problem at the only moment when the invoice has not been written yet.

Correct the invoices already sent, deliberately

Finalized invoices cannot be edited, so correcting one means a credit note and a reissue. Total the exposure before deciding how far back to go; for small amounts the honest answer is often to fix it going forward and tell the customer why.

How to check it worked

Re-run after tax ID collection is switched on. New EU business invoices should carry either a verified tax ID or a reverse exemption, and the count of taxed invoices with no ID should stop growing.

python3 stripe_eu_vat_ids.py --days 180
# clear       0 of 412 EU invoice(s) billed to a business as a consumer

The full code

One paginated GET over invoices, plus one small GET per flagged customer for the verification status — a restricted key with read access to Invoices and Customers is enough. The classifier is pure and takes the four fields the invoice already carries plus the verification status, because the difference between charged VAT it did not owe and zero tax for want of a registration is the difference between a credit note and a different note entirely.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 114 Stripe fixes, free and open source.
stripe_eu_vat_ids.py
"""Report EU business invoices billed with VAT because no tax ID was on file.

Read only. Paginated GETs and no writes: give this a RESTRICTED key with read
access to Invoices and Customers. The repair is printed, never performed, because
a credit note is a legal record and reissuing an invoice rebills a customer.
"""
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_eu_vat_ids")

API = "https://api.stripe.com/v1"

# The 27 member states. Reverse charge is an intra-EU mechanism, so a country
# outside this set is a different question with a different answer.
EU = frozenset("AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL "
               "PT RO SK SI ES SE".split())

# Verification results that are not a confirmation. `pending` is normal for a few
# minutes and a problem after a few months.
UNCONFIRMED = ("unverified", "unavailable", "pending")


def verdict(country, invoice_tax_ids, tax_exempt, tax_amount, verification):
    """Classify one paid invoice. Pure, so the rules can be tested without a network.

    `invoice_tax_ids` is the invoice's customer_tax_ids array, frozen at
    finalization. `tax_exempt` is customer_tax_exempt. `tax_amount` is the tax
    actually charged in minor units. `verification` is the status of the
    customer's tax ID, or None when there is not one. Returns (state, detail).
    """
    if country not in EU:
        return ("out-of-scope",
                "%s is outside the EU: the reverse charge does not apply here"
                % (country or "no country on the invoice",))
    if tax_exempt == "reverse":
        return ("reverse-charge",
                "billed under the reverse charge; the buyer accounts for the VAT")
    if tax_exempt == "exempt":
        return ("exempt",
                "recorded as exempt, so no VAT was due and none was charged")
    if not invoice_tax_ids:
        if tax_amount:
            return ("charged-vat",
                    "no customer_tax_ids on the invoice and %d in tax charged: a "
                    "business was billed as a consumer" % tax_amount)
        return ("no-id-no-vat",
                "no tax ID and no VAT either; that is a registration question "
                "rather than a reverse charge one")
    if verification in UNCONFIRMED:
        return ("unverified",
                "a tax ID is on the invoice but its verification status is %r: "
                "not a number to rely on" % (verification,))
    return ("ok", "a verified tax ID is recorded on the invoice")


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 paid_invoices(session, days, limit):
    """Page paid invoices created within the window."""
    cutoff = int(time.time() - days * 86400)
    out = []
    params = {"status": "paid", "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 verification_status(session, customer_id, cache):
    """Weakest verification status across a customer's tax IDs, or None."""
    if customer_id in cache:
        return cache[customer_id]
    status = None
    try:
        ids = get(session, "/customers/%s/tax_ids" % customer_id,
                  limit=10).get("data", [])
    except requests.HTTPError:
        ids = []
    for tid in ids:
        s = (tid.get("verification") or {}).get("status")
        if s in UNCONFIRMED:
            status = s
            break
        status = status or s
    cache[customer_id] = status
    return status


def tax_charged(inv):
    """Total tax on the invoice in minor units, across every tax line."""
    return sum(t.get("amount") or 0 for t in (inv.get("total_taxes") or []))


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=float, default=180,
                    help="how far back to read paid invoices")
    ap.add_argument("--top", type=int, default=20,
                    help="how many invoices 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})

    cache = {}
    eu_seen = 0
    findings = []
    for inv in paid_invoices(s, args.days, args.max_invoices):
        country = ((inv.get("customer_address") or {}).get("country") or "")
        if country not in EU:
            continue
        eu_seen += 1
        cus = inv.get("customer")
        if isinstance(cus, dict):
            cus = cus.get("id")
        ids = inv.get("customer_tax_ids") or []
        verification = verification_status(s, cus, cache) if ids and cus else None
        state, detail = verdict(country, ids, inv.get("customer_tax_exempt"),
                                tax_charged(inv), verification)
        if state in ("charged-vat", "unverified", "no-id-no-vat"):
            findings.append((state, inv.get("id", "<no id>"), cus, country,
                             tax_charged(inv), detail))

    if not findings:
        log.info("%-13s 0 of %d EU invoice(s) billed to a business as a consumer",
                 "clear", eu_seen)
        return 0

    charged = [f for f in findings if f[0] == "charged-vat"]
    log.warning("%-13s %d of %d EU invoice(s) flagged, %d charged VAT with no "
                "tax ID, %d in minor units", "no-tax-id", len(findings), eu_seen,
                len(charged), sum(f[4] for f in charged))

    findings.sort(key=lambda f: -f[4])
    for state, inv_id, cus, country, tax, detail in findings[:args.top]:
        log.warning("  %-13s %s  %s  %s  %d  %s",
                    state, inv_id, cus, country, tax, detail)
        if state == "charged-vat":
            log.warning("      repair: POST %s/tax_ids  type=eu_vat  "
                        "value=%s123456789  owner[type]=customer  owner[customer]=%s",
                        API, country, cus)
            log.warning("      the invoice itself is frozen: correct it with a "
                        "credit note and a reissue, not an edit")
    if len(findings) > args.top:
        log.warning("  ... and %d more", len(findings) - args.top)
    log.warning("  then switch on tax ID collection in Checkout and allow the "
                "tax_id field in the billing portal, or the list refills")
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-eu-vat-ids.mjs
/**
 * Report EU business invoices billed with VAT because no tax ID was on file.
 *
 * Read only. Paginated GETs and no writes: give this a RESTRICTED key with read
 * access to Invoices and Customers. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

// The 27 member states. Reverse charge is an intra-EU mechanism, so a country
// outside this set is a different question with a different answer.
export const EU = new Set(('AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT ' +
  'LU MT NL PL PT RO SK SI ES SE').split(' '));

// Verification results that are not a confirmation. `pending` is normal for a
// few minutes and a problem after a few months.
export const UNCONFIRMED = ['unverified', 'unavailable', 'pending'];

/**
 * Classify one paid invoice. Pure, so the rules can be tested without a network.
 * `invoiceTaxIds` is the invoice's customer_tax_ids array, frozen at finalization.
 */
export function verdict(country, invoiceTaxIds, taxExempt, taxAmount, verification) {
  if (!EU.has(country)) {
    return ['out-of-scope',
      `${country || 'no country on the invoice'} is outside the EU: the reverse ` +
      'charge does not apply here'];
  }
  if (taxExempt === 'reverse') {
    return ['reverse-charge',
      'billed under the reverse charge; the buyer accounts for the VAT'];
  }
  if (taxExempt === 'exempt') {
    return ['exempt', 'recorded as exempt, so no VAT was due and none was charged'];
  }
  if (!invoiceTaxIds || invoiceTaxIds.length === 0) {
    if (taxAmount) {
      return ['charged-vat',
        `no customer_tax_ids on the invoice and ${taxAmount} in tax charged: a ` +
        'business was billed as a consumer'];
    }
    return ['no-id-no-vat',
      'no tax ID and no VAT either; that is a registration question rather than ' +
      'a reverse charge one'];
  }
  if (UNCONFIRMED.includes(verification)) {
    return ['unverified',
      `a tax ID is on the invoice but its verification status is ` +
      `${JSON.stringify(verification)}: not a number to rely on`];
  }
  return ['ok', 'a verified tax ID is recorded on the invoice'];
}

/** Total tax on the invoice in minor units, across every tax line. */
export function taxCharged(inv) {
  return (inv.total_taxes ?? []).reduce((a, t) => a + (t.amount ?? 0), 0);
}

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 paidInvoices(key, days = 180, limit = 5000) {
  const cutoff = Math.floor(Date.now() / 1000 - days * 86400);
  const out = [];
  const params = { status: 'paid', 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 verificationStatus(key, customerId, cache) {
  if (cache.has(customerId)) return cache.get(customerId);
  let status = null;
  try {
    const { data = [] } = await get(key, `/customers/${customerId}/tax_ids`, { limit: 10 });
    for (const tid of data) {
      const s = tid.verification?.status;
      if (UNCONFIRMED.includes(s)) { status = s; break; }
      status = status ?? s ?? null;
    }
  } catch {
    status = null;
  }
  cache.set(customerId, status);
  return status;
}

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] ?? 180);
  const cache = new Map();
  let euSeen = 0;
  const findings = [];

  for (const inv of await paidInvoices(key, days)) {
    const country = inv.customer_address?.country ?? '';
    if (!EU.has(country)) continue;
    euSeen += 1;
    const cus = (typeof inv.customer === 'object' && inv.customer !== null
      ? inv.customer.id : inv.customer) ?? null;
    const ids = inv.customer_tax_ids ?? [];
    const verification = ids.length && cus
      ? await verificationStatus(key, cus, cache) : null;
    const tax = taxCharged(inv);
    const [state, detail] = verdict(country, ids, inv.customer_tax_exempt, tax, verification);
    if (['charged-vat', 'unverified', 'no-id-no-vat'].includes(state)) {
      findings.push({ state, id: inv.id ?? '<no id>', cus, country, tax, detail });
    }
  }

  if (findings.length === 0) {
    console.log(`${'clear'.padEnd(13)} 0 of ${euSeen} EU invoice(s) billed to a business as a consumer`);
    return;
  }

  const charged = findings.filter((f) => f.state === 'charged-vat');
  const chargedTax = charged.reduce((a, f) => a + f.tax, 0);
  console.warn(`${'no-tax-id'.padEnd(13)} ${findings.length} of ${euSeen} EU invoice(s) flagged, ${charged.length} charged VAT with no tax ID, ${chargedTax} in minor units`);

  findings.sort((a, b) => b.tax - a.tax);
  for (const f of findings.slice(0, 20)) {
    console.warn(`  ${f.state.padEnd(13)} ${f.id}  ${f.cus}  ${f.country}  ${f.tax}  ${f.detail}`);
    if (f.state === 'charged-vat') {
      console.warn(`      repair: POST ${API}/tax_ids  type=eu_vat  value=${f.country}123456789  owner[type]=customer  owner[customer]=${f.cus}`);
      console.warn('      the invoice itself is frozen: correct it with a credit note and a reissue, not an edit');
    }
  }
  if (findings.length > 20) console.warn(`  ... and ${findings.length - 20} more`);
  console.warn('  then switch on tax ID collection in Checkout and allow the ' +
               'tax_id field in the billing portal, or the list refills');
  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 case that has to be separated is an EU invoice with no tax ID and no tax on it. It looks identical to the expensive one in every field a quick check would read, and it is not the same finding at all: nobody was overcharged, and the reason there is no VAT is almost certainly that you hold no registration in that country. Merging the two produces a report where most of the rows are somebody else's problem.

test_stripe_eu_vat_ids.py
from stripe_eu_vat_ids import verdict


def test_outside_the_eu_is_not_a_reverse_charge_question():
    state, _ = verdict("US", [], "none", 800, None)
    assert state == "out-of-scope"
    assert verdict("", [], "none", 800, None)[0] == "out-of-scope"


def test_an_eu_business_with_no_id_and_vat_charged_is_the_finding():
    state, detail = verdict("DE", [], "none", 1900, None)
    assert state == "charged-vat"
    assert "1900" in detail


def test_no_id_and_no_vat_is_a_registration_question():
    # Same empty tax ID list, no money lost by the customer, different owner.
    state, detail = verdict("FR", [], "none", 0, None)
    assert state == "no-id-no-vat"
    assert "registration" in detail


def test_reverse_charge_is_checked_before_the_id_list():
    # customer_tax_exempt can carry the treatment even where the invoice's
    # frozen tax ID array reads empty.
    assert verdict("NL", [], "reverse", 0, None)[0] == "reverse-charge"
    assert verdict("NL", [], "exempt", 0, None)[0] == "exempt"


def test_an_unconfirmed_id_is_not_coverage():
    for status in ("unverified", "unavailable", "pending"):
        state, detail = verdict("IT", [{"type": "eu_vat"}], "none", 0, status)
        assert state == "unverified"
        assert status in detail


def test_a_verified_id_is_the_only_clean_result():
    assert verdict("ES", [{"type": "eu_vat"}], "none", 0, "verified")[0] == "ok"
stripe-eu-vat-ids.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict, taxCharged } from './stripe-eu-vat-ids.mjs';

test('outside the EU is not a reverse charge question', () => {
  assert.equal(verdict('US', [], 'none', 800, null)[0], 'out-of-scope');
  assert.equal(verdict('', [], 'none', 800, null)[0], 'out-of-scope');
});

test('an EU business with no ID and VAT charged is the finding', () => {
  const [state, detail] = verdict('DE', [], 'none', 1900, null);
  assert.equal(state, 'charged-vat');
  assert.match(detail, /1900/);
});

test('no ID and no VAT is a registration question', () => {
  const [state, detail] = verdict('FR', [], 'none', 0, null);
  assert.equal(state, 'no-id-no-vat');
  assert.match(detail, /registration/);
});

test('reverse charge is checked before the ID list', () => {
  assert.equal(verdict('NL', [], 'reverse', 0, null)[0], 'reverse-charge');
  assert.equal(verdict('NL', [], 'exempt', 0, null)[0], 'exempt');
});

test('an unconfirmed ID is not coverage', () => {
  for (const status of ['unverified', 'unavailable', 'pending']) {
    const [state, detail] = verdict('IT', [{ type: 'eu_vat' }], 'none', 0, status);
    assert.equal(state, 'unverified');
    assert.match(detail, new RegExp(status));
  }
});

test('a verified ID is the only clean result', () => {
  assert.equal(verdict('ES', [{ type: 'eu_vat' }], 'none', 0, 'verified')[0], 'ok');
});

test('tax is summed across every tax line on the invoice', () => {
  assert.equal(taxCharged({ total_taxes: [{ amount: 190 }, { amount: 10 }] }), 200);
  assert.equal(taxCharged({}), 0);
});

FAQ

Why did Stripe charge VAT to a business customer?

Because nothing on the customer said it was a business. Stripe Tax applies the reverse charge based on a tax ID being present and the jurisdictions involved; with an empty tax ID list the sale is treated as B2C and local VAT is added. The company name on the address makes no difference to the calculation.

Can I add the VAT number to an invoice that already went out?

No. customer_tax_ids and customer_tax_exempt are copied onto the invoice at finalization and are fixed there, because the invoice is the document both parties file against. Attaching the tax ID to the customer fixes every future invoice and none of the past ones; correcting an old one means a credit note and a reissue.

What does an unverified tax ID mean?

That the number was submitted and VIES did not confirm it. unavailable means the check could not be performed at all, and pending means it is still in flight, which is unremarkable for a minute and worth investigating after a month. Only verified is a confirmation, and it is the only status worth counting as coverage.

Some EU invoices have no tax ID and no VAT. Is that the same problem?

No, and the script keeps them apart. Zero VAT with no tax ID usually means you hold no active tax registration in that country, so Stripe calculated correctly and collected nothing. Nobody was overcharged, and the repair is a registration rather than a tax ID.

How do I stop collecting business customers without their VAT number?

Enable tax ID collection in Checkout so it is asked for at the point of sale, and allow the tax_id field in the billing portal so a customer who forgot can add it themselves. Both are configuration. Anything that relies on a support conversation will catch only the customers who already knew they were being overcharged.

Related field notes

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.