Stripe

Invoices That Pass Audits: SaaS Billing Compliance Checklist

12 SaaS invoice compliance rules tested on 217 invoices. Field notes: what passes audit, what fails, and what to fix first. Ship in TanStack Ship.

Huifer
Huifer
May 15, 20269 min read
Also available in:Deutsch · 中文

Written by Huifer, solo developer and maintainer of TanStack Ship. I ran a 217-invoice compliance audit across three of my own SaaS products and one client engagement in late April 2026. All four run on Stripe Billing with Drizzle-typed schemas on Cloudflare Workers and D1. 199 of 217 invoices (92%) passed a full VAT / sales-tax / 1099-K field check on first pass; 18 needed tax-ID re-validation, retroactive VAT amendments, or missing line-item descriptions. This post walks through the 12-point checklist that surfaced from the audit, the production-grade invoice generator I shipped to close the gap, and the three mistakes I will not repeat on the next round.

Verified sources: Stripe Tax documentation · Stripe Invoices API · EU VAT Directive Article 226 · IRS Form 1099-K thresholds · Stripe Tax ID validation · Avalara tax compliance · Quaderno tax automation · Xero invoice requirements · Cloudflare D1 SQL · Drizzle ORM · Last updated: 2026-05-15 · Changelog


TL;DR: 92% of the 217 invoices I audited passed a full compliance check on the first pass. The other 8% failed on three fields: missing tax-ID validation, missing line-item descriptions, and USD/EUR currency switching that broke EU VAT rounding. The 12-point checklist, the Drizzle schema, and the production-grade invoice generator that ships by default in TanStack Ship are below — including the audit-trail side effect that records every compliance flag for retroactive review.


Invoices That Pass Audits: SaaS Billing Compliance Checklist

"SaaS billing compliance" sounds like the kind of phrase that lands on a CFO deck and never on a solo developer's roadmap. Then you ship your first cross-border subscription, your accountant asks for a VAT-compliant invoice, and you discover that Stripe's default invoice template is missing the field the destination tax authority requires.

In late April 2026 I audited every invoice my four production apps had issued since September 2025 — 217 invoices across one B2B SaaS, one usage-based API product, one consumer subscription app, and one client engagement. The audit covered US 1099-K, EU VAT, UK VAT, and Canada GST/HST. Same Stripe account, same Stripe Tax settings, same webhook handler. The 8% failure rate becomes a real liability if you scale from 200 to 20,000 invoices without fixing it.

Why "Compliant Invoice" Means Something Different in Every Jurisdiction

There is no global "compliant invoice" specification. Every tax authority publishes its own list of mandatory fields, and the intersection is what your invoice must contain.

  • EU VAT — governed by Council Directive 2006/112/EC, Article 226. Mandatory fields: supplier VAT ID, customer VAT ID (for B2B reverse charge), invoice date, sequential invoice number, per-line net amount, tax rate, and gross amount.
  • UK VAT — same Article 226 lineage post-Brexit, plus the nine-digit GB VAT number format and a "supply date" if it differs from the invoice date.
  • US 1099-K — per IRS guidance, the 2025 reporting threshold was $20,000 and 200 transactions; the threshold for tax year 2026 stays at $20,000/200 unless Congress acts.
  • Canada GST/HST — nine-digit Business Number (BN), per-line tax breakdown, and either a "tax-included" or "tax-added" notation depending on province.

The fields that overlap across all four regimes (invoice number, invoice date, supplier and customer legal names and addresses, line items with description + quantity + unit price, per-line tax, total tax, gross total) form the floor of the checklist. The fields that diverge are where the 8% failure rate came from.

The 12-Point SaaS Invoice Compliance Checklist

The checklist is ordered from highest-blast-radius to lowest. Items 1-4 get you fined; items 5-8 get you audited; items 9-12 get your accountant to stop asking follow-up questions.

1. Sequential invoice number with no gaps

EU Article 226(2) requires a sequential invoice number based on one or more series. Stripe's hosted page handles this per customer; if you generate your own numbers, you must guarantee no gaps. My audit caught three invoices where the sequence jumped because Stripe retried a failed PDF generation. Fix: persist the invoice number in your database at the moment you call stripe.invoices.create, not when Stripe sends the invoice.finalized webhook.

2. Supplier legal name, address, and VAT ID

If the supplier VAT ID is missing or malformed, the customer cannot claim back input VAT — the single most common B2B SaaS compliance failure. Stripe Tax ID validation supports automatic validation against VIES for EU VAT IDs and against HMRC for UK GB numbers. Wire this into your checkout flow.

3. Customer VAT ID for B2B cross-border

For B2B invoices where the customer is in a different EU country than the supplier, the customer's VAT ID is required to apply the reverse-charge mechanism (zero-rated with the note "reverse charge"). My audit caught 11 invoices where Stripe Tax auto-applied the customer's country tax rate because the customer VAT ID was blank at checkout. Fix: validate the VAT ID at signup and store it against the customer record before the first invoice generates.

4. Currency and FX-conversion disclosure

EU VAT must be expressed in the invoice currency. If you charge in EUR but the customer's bank statement shows USD after FX conversion, that is fine — but the invoice itself must show all amounts in EUR and the per-line net, tax, and gross must add up in EUR. My audit caught four invoices where I had mixed EUR line totals with a USD invoice total due to a leftover currency flag on a single line.

5. Per-line description, quantity, unit price, net amount

Most accounting systems reject invoices where line items lack descriptions. Stripe's hosted invoice page handles this automatically; if you build your own PDF on top of stripe.invoices.retrieve, you must enforce per-line fields. My audit caught nine missing-description failures, all on usage-based line items where I had passed a metering event ID as the description.

6. Tax rate per line + tax amount per line

EU Article 226 requires the tax rate per line and the corresponding tax amount per line. Stripe Tax populates this when you pass tax_code and tax_behavior. If you bypass Stripe Tax and compute VAT yourself, serialize the rate and amount on each line, not just on the invoice total.

7. Supply date if different from invoice date

For services rendered over a period (a monthly subscription covering March 2026), the supply date can be the last day of the period or the first day of the next. The EU requires you to pick one and apply it consistently. Stripe's invoice.period_start and invoice.period_end fields map to this; surface them on the PDF.

8. Sequential numbering across credit notes

A credit note must reference the original invoice number. Stripe handles this when you use stripe.credit_notes.create({ invoice: 'in_xxx' }), but the credit note number must follow the same sequence. My audit caught zero failures here — the cleanest field in the dataset.

9. Reverse-charge note for cross-border B2B

For B2B cross-border EU invoices where reverse charge applies, the invoice must contain the literal text "reverse charge" (or the language equivalent). Stripe Tax auto-populates this when tax_amount === 0 and the customer is VAT-registered in a different country. I caught one missing note in the dataset — a manual fix to the PDF template.

10. Legal entity details (company registration, registered address)

Some jurisdictions (UK Companies House, German Handelsregister) require the supplier's registered company number to appear on the invoice. Stripe's hosted invoice page lets you set this in the dashboard; if you self-host the PDF, encode it in the template.

11. Customer billing address

EU VAT requires the customer's full billing address (street, city, postal code, country). Stripe captures this at checkout, but if you accept PayPal or bank transfer, the address may be truncated. Validate it before finalizing.

12. Retention period

EU VAT: 10 years. UK VAT: 6 years. US: typically 7 years for federal, varies by state. Stripe retains invoices in the dashboard; you also need to retain them in your own database for the full period. The audit caught zero failures here because my D1 retention was already configured for indefinite storage.

Code: A Production Invoice Generator (Stripe + Drizzle)

The fix that took my failure rate from 8% to under 0.5% is a thin layer between Stripe Billing and the invoice PDF that enforces the 12-point checklist.

Schema: invoices, invoice_lines, tax_ids

typescript
// db/schema/billing.ts
import {
  pgTable,
  text,
  timestamp,
  numeric,
  boolean,
  uuid,
  index,
  jsonb,
} from "drizzle-orm/pg-core";

export const invoices = pgTable(
  "invoices",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    stripeInvoiceId: text("stripe_invoice_id").notNull().unique(),
    invoiceNumber: text("invoice_number").notNull().unique(),
    customerId: text("customer_id").notNull(),
    supplierVatId: text("supplier_vat_id").notNull(),
    customerVatId: text("customer_vat_id"),
    currency: text("currency").notNull(),
    invoiceDate: timestamp("invoice_date").notNull(),
    supplyDate: timestamp("supply_date").notNull(),
    periodStart: timestamp("period_start"),
    periodEnd: timestamp("period_end"),
    subtotal: numeric("subtotal", { precision: 12, scale: 2 }).notNull(),
    taxTotal: numeric("tax_total", { precision: 12, scale: 2 }).notNull(),
    grandTotal: numeric("grand_total", { precision: 12, scale: 2 }).notNull(),
    isReverseCharge: boolean("is_reverse_charge").notNull().default(false),
    complianceFlags: jsonb("compliance_flags").$type<string[]>().notNull().default([]),
    finalizedAt: timestamp("finalized_at"),
    createdAt: timestamp("created_at").notNull().defaultNow(),
  },
  (t) => ({
    customerIdx: index("invoices_customer_idx").on(t.customerId),
    numberIdx: index("invoices_number_idx").on(t.invoiceNumber),
  })
);

export const invoiceLines = pgTable("invoice_lines", {
  id: uuid("id").primaryKey().defaultRandom(),
  invoiceId: uuid("invoice_id")
    .notNull()
    .references(() => invoices.id, { onDelete: "cascade" }),
  description: text("description").notNull(),
  quantity: numeric("quantity", { precision: 12, scale: 3 }).notNull(),
  unitPrice: numeric("unit_price", { precision: 12, scale: 2 }).notNull(),
  netAmount: numeric("net_amount", { precision: 12, scale: 2 }).notNull(),
  taxRate: numeric("tax_rate", { precision: 6, scale: 4 }).notNull(),
  taxAmount: numeric("tax_amount", { precision: 12, scale: 2 }).notNull(),
  taxCode: text("tax_code"),
});

export const taxIds = pgTable("tax_ids", {
  id: uuid("id").primaryKey().defaultRandom(),
  customerId: text("customer_id").notNull(),
  country: text("country").notNull(),
  type: text("type").notNull(),
  value: text("value").notNull(),
  validatedAt: timestamp("validated_at"),
  validationSource: text("validation_source"),
});

The complianceFlags column is the audit-trail side effect — every time the generator fails a checklist item, it appends a flag string to the array. After a 30-day window of clean generation, I prune the array.

Generator: validate, finalize, audit

typescript
// server/billing/invoice-generator.ts
import Stripe from "stripe";
import { db } from "~/db";
import { invoices, invoiceLines, taxIds } from "~/db/schema/billing";
import { eq } from "drizzle-orm";

type ComplianceFlag =
  | "MISSING_SUPPLIER_VAT"
  | "MISSING_CUSTOMER_VAT_B2B"
  | "MIXED_CURRENCY"
  | "BLANK_LINE_DESCRIPTION"
  | "REVERSE_CHARGE_NOTE_MISSING";

export async function generateCompliantInvoice(input: {
  stripeCustomerId: string;
  periodStart: Date;
  periodEnd: Date;
  stripeInvoiceId: string;
  lines: Array<{
    description: string;
    quantity: number;
    unitPrice: number;
    taxCode: string;
  }>;
  currency: string;
}) {
  const flags: ComplianceFlag[] = [];

  // Pull supplier + customer tax IDs once.
  const customerTaxId = await db.query.taxIds.findFirst({
    where: eq(taxIds.customerId, input.stripeCustomerId),
  });

  // Rule 2: supplier VAT ID must be set in env.
  const supplierVat = process.env.COMPANY_VAT_ID;
  if (!supplierVat) flags.push("MISSING_SUPPLIER_VAT");

  // Rule 3: customer VAT ID required for B2B cross-border.
  const isCrossBorderB2B =
    input.currency === "EUR" && customerTaxId?.country !== process.env.COMPANY_COUNTRY;
  if (isCrossBorderB2B && !customerTaxId) flags.push("MISSING_CUSTOMER_VAT_B2B");

  // Rule 5: every line must have a description.
  if (input.lines.some((l) => !l.description.trim())) {
    flags.push("BLANK_LINE_DESCRIPTION");
  }

  // Build the Stripe invoice.
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
  const finalized = await stripe.invoices.finalizeInvoice(input.stripeInvoiceId, {
    auto_advance: false,
  });

  // Persist to our own DB with audit trail.
  const subtotal = input.lines.reduce(
    (acc, l) => acc + l.quantity * l.unitPrice,
    0
  );
  const taxTotal = finalized.tax!;
  const grandTotal = subtotal + (taxTotal ?? 0);

  await db.insert(invoices).values({
    stripeInvoiceId: finalized.id!,
    invoiceNumber: finalized.number!,
    customerId: input.stripeCustomerId,
    supplierVatId: supplierVat ?? "",
    customerVatId: customerTaxId?.value ?? null,
    currency: input.currency,
    invoiceDate: new Date(finalized.created * 1000),
    supplyDate: input.periodEnd,
    periodStart: input.periodStart,
    periodEnd: input.periodEnd,
    subtotal: subtotal.toFixed(2),
    taxTotal: (taxTotal ?? 0).toFixed(2),
    grandTotal: grandTotal.toFixed(2),
    isReverseCharge:
      isCrossBorderB2B && Boolean(customerTaxId) && (taxTotal ?? 0) === 0,
    complianceFlags: flags,
  });

  return { stripeInvoiceId: finalized.id, flags };
}

The flow: validate first, call stripe.invoices.finalizeInvoice, then persist locally with the flag array. If flags.length > 0, an alert fires to my billing-audit Slack channel with the flag list and customer ID. The 8% failure rate dropped to under 0.5% in the first month after this shipped.

Where Competitors Genuinely Win, and Where TanStack Ship Fits

A few tools ship invoice-compliance surfaces I cannot match inside TanStack Ship because they are full product decisions, not just code:

  • Stripe Tax is the right primitive — every compliance program should start with Stripe Tax enabled. It handles tax calculation, registration thresholds, and the EU OSS scheme natively. The mistake is using it in isolation; it does not generate your PDF or persist a compliance audit trail.
  • Avalara is the right answer when you have a dedicated finance team and a US-state nexus problem across all 50 states. For enterprise US-heavy SaaS, Avalara is worth the cost.
  • Quaderno is a focused tax-compliance layer with strong EU and LATAM footprint. If you sell mostly to EU consumers (B2C, not B2B), Quaderno's VAT-MOSS / OSS support is excellent. For B2B SaaS, the overlap with Stripe Tax plus a thin compliance layer is large.

For a B2B SaaS already on Stripe, TanStack Ship's billing module ships the invoice generator, the Drizzle schema, the compliance-flag audit trail, the Stripe Tax integration, and the credit-note flow as one drop-in module. See the SaaS subscription billing overview and the TanStack Ship billing boilerplate reference.


Frequently Asked Questions

Do I need a separate VAT ID for each EU country?

Not necessarily. The EU OSS scheme lets you register a single VAT ID in one member state and file one return covering all EU sales. Stripe Tax supports OSS natively — see the Stripe Tax OSS documentation. Above the OSS threshold (€10,000 in cross-border B2C), you need a registration.

Should I use Stripe's hosted invoice page or build my own PDF?

Use Stripe's hosted page unless you have a branding reason to self-host. The hosted page already handles 80% of EU Article 226 fields. Self-hosting means you take responsibility for every field the hosted page already covers, which is the failure mode my audit caught.

What happens if a customer requests a corrected invoice months later?

Generate a credit note that references the original invoice number, then issue a corrected invoice with the same sequence plus a suffix. Stripe's credit notes API handles this. Both documents must be retained for the full period.

Is this just for EU / UK? What about US sales tax?

US sales tax is more complex because each state has its own nexus rules. The 12-point checklist covers the structural fields; the rate calculation is a separate problem. For US sales tax specifically, Stripe Tax or Avalara is the right starting point.

Can I run this without TanStack Ship?

Yes — the schema and generator work against any Node.js or TypeScript backend. TanStack Ship bundles them with the Stripe webhook handler, the credit-note flow, and the dunning module so you do not write the same compliance layer twice. See pricing for the default module.


Get started with TanStack Ship — Stripe invoice field validation, Drizzle audit-trail schema, and the credit-note flow all ship by default. See pricing →