SaaS 发票合规清单:通过审计的 12 步检查清单

对 217 张发票测试了 12 条 SaaS 发票合规规则。实地笔记:通过审计的内容、失败的内容,以及优先修复项尽在 TanStack Ship。

Huifer
Huifer
May 15, 202610 min read
其他语言:Deutsch · English

本文由 Huifer 撰写,TanStack Ship 的独立开发者。 2026 年 4 月下旬,我对三个自有 SaaS 产品和一个客户项目发出的全部发票进行了一次合规审计,合计 217 张。这四套系统均运行在 Stripe Billing 上,Schema 由 Drizzle 编写,部署在 Cloudflare Workers 和 D1 上。其中 199 张发票(92%)在首次检查时即通过完整的 VAT / 销售税 / 1099-K 字段核验;其余 18 张需要重新验证税号、补填行项目描述,或发起追溯性 VAT 修订。本文将详细梳理这次审计总结出的 12 步检查清单、用于封堵漏洞的生产级发票生成器,以及下一轮我不会再犯的三类错误。

已核实来源:Stripe Tax 文档 · 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 · 最后更新:2026-05-15 · 更新日志


TL;DR: 我审计的 217 张发票中,92% 在首次检查时即通过完整合规验证。其余 8% 在三个字段上失败:税号验证缺失、行项目描述缺失,以及 USD/EUR 币种切换破坏了 EU VAT 舍入规则。完整的 12 步检查清单、Drizzle schema,以及默认集成在 TanStack Ship 中的生产级发票生成器(含合规标志审计追踪副作用)均在下方——审计追踪记录每次合规异常,供事后追溯审查。


SaaS 发票合规清单:通过审计的 12 步检查清单

"SaaS 发票合规"听起来像是只会出现在 CFO 演示文稿里、永远不会出现在独立开发者路线图上的词。但当你发出第一张跨境订阅发票、你的会计要求你出具一份符合 VAT 规范的发票时,你就会发现 Stripe 默认发票模板 缺少目的地税务机关要求的那一个字段。

2026 年 4 月底,我审计了四个生产应用自 2025 年 9 月以来发出的所有发票——合计 217 张,涵盖一个 B2B SaaS、一个使用量计费 API 产品、一个消费者订阅 App 和一个客户项目。审计覆盖美国 1099-K、欧盟 VAT、英国 VAT 和加拿大 GST/HST。四套系统使用同一 Stripe 账户、同样的 Stripe Tax 配置和同一套 webhook 处理器。如果不修复这个漏洞,从 200 张发票扩展到 2 万张时,8% 的失败率就会变成真实的负债。

为什么每个辖区对"合规发票"的定义都不同

不存在全球统一的"合规发票"规范。每个税务机关都会发布自己的必填字段清单,而发票必须包含的是所有规范的重叠部分。

  • 欧盟 VAT — 由 2006/112/EC 理事会指令第 226 条 管辖。必填字段:供应商 VAT 号、客户 VAT 号(B2B 反向征收用)、发票日期、顺序发票号、每行净额、税率和总额。
  • 英国 VAT — 脱欧后沿用同一条第 226 条,另需九位格式的 GB VAT 号,以及在不同于发票日期时填写"供给日期"。
  • 美国 1099-K — 按 IRS 指引,2025 纳税年度申报门槛为 20,000 美元和 200 笔交易;2026 纳税年度的门槛维持 20,000 美元/200 笔,除非国会另有行动。
  • 加拿大 GST/HST — 九位商业编号(BN)、按行分列的税额明细,以及根据省份不同标注"含税"或"加税"字样。

四个规范体系共同要求的字段(发票号、发票日期、供应商和客户的法定名称及地址、含描述/数量/单价的行项目、按行税额、总税额、总计)构成了检查清单的底线。真正导致 8% 失败率的是那些各辖区要求不同的字段。

SaaS 发票合规 12 步检查清单

清单按爆炸半径从大到小排序。1-4 条会招致罚款;5-8 条会引发审计;9-12 条让你的会计停止追问。

1. 无断号的顺序发票号

欧盟第 226(2) 条 要求基于一个或多个系列的顺序发票号。Stripe 托管页面按客户自动处理;如果自行生成编号,必须保证无断号。审计中发现三张发票因 Stripe 重试失败的 PDF 生成导致编号跳跃。修复方法:在调用 stripe.invoices.create 时立即将发票号持久化到数据库,而不是等 Stripe 发送 invoice.finalized webhook 后再处理。

2. 供应商法定名称、地址和 VAT 号

若供应商 VAT 号缺失或格式错误,客户将无法抵扣进项 VAT——这是 B2B SaaS 最常见合规失败原因。Stripe Tax ID validation 支持自动通过 VIES 验证欧盟 VAT 号及通过 HMRC 验证英国 GB 编号。将此接入结账流程。

3. B2B 跨境客户的 VAT 号

对于供应商与客户处于不同欧盟国家的 B2B 发票,需提供客户 VAT 号才能适用反向征收机制(税率为零并标注"reverse charge")。审计中发现 11 张发票因结账时客户 VAT 号字段为空,Stripe Tax 自动应用了客户所在国税率。修复方法:在注册时验证 VAT 号,并在首张发票生成前将其存储在客户记录中。

4. 币种及外汇转换披露

欧盟 VAT 须以发票币种表示。若以 EUR 收费,但客户银行对账单显示经外汇转换后的 USD,则无问题——但发票本身须以 EUR 显示所有金额,且每行净额、税额和总额须在 EUR 下核算正确。审计中发现四张发票因某行遗留的 currency 标志导致 EUR 行合计与 USD 发票总额混用。

5. 每行描述、数量、单价和净额

大多数会计系统会拒绝缺少描述的行项目发票。Stripe 托管发票页面自动处理;若在 stripe.invoices.retrieve 基础上自建 PDF,则必须强制每行填写完整字段。审计中九次缺失描述的失败均发生在使用量计费行项目上——我将计量事件 ID 误填为了描述。

6. 每行税率和每行税额

欧盟第 226 条要求每行税率和对应税额。Stripe Tax 在传入 tax_code 和 tax_behavior 时自动填充。若绕过 Stripe Tax 自行计算 VAT,须在每行而非仅在发票总计上序列化税率和税额。

7. 与发票日期不同的供给日期

对于跨期服务(如覆盖 2026 年 3 月的月度订阅),供给日期可为该期间最后一天或下月第一天。欧盟要求选定其一并保持一致应用。Stripe 的 invoice.period_start 和 invoice.period_end 字段对应此项,应在 PDF 上显示。

8. 贷项通知单顺序编号

贷项通知单须引用原始发票号。Stripe 在使用 stripe.credit_notes.create({ invoice: 'in_xxx' }) 时自动处理,但贷项通知单编号须遵循同一顺序。审计中此处零失败——数据集中最干净的字段。

9. 跨境 B2B 反向征收说明

适用反向征收的跨境欧盟 B2B 发票须包含字面文本"reverse charge"(或对应语言)。Stripe Tax 在 tax_amount === 0 且客户在另一国家进行了 VAT 登记时自动填充。审计中仅发现一张缺失此说明——手动修复 PDF 模板即可。

10. 法律实体信息(公司注册号、注册地址)

部分辖区(英国 Companies House、德国 Handelsregister)要求发票显示供应商的注册公司编号。Stripe 托管发票页面可在仪表板中设置;若自托管 PDF,则将其编码到模板中。

11. 客户账单地址

欧盟 VAT 要求提供客户完整账单地址(街道、城市、邮政编码、国家)。Stripe 在结账时捕获此信息,但若接受 PayPal 或银行转账,地址可能被截断。须在最终确定前完成验证。

12. 保留期限

欧盟 VAT:10 年。英国 VAT:6 年。美国:联邦通常 7 年,各州规定不同。Stripe 在仪表板中保留发票;你也须在自己的数据库中完整保留对应期限。审计中此处零失败,因为我的 D1 保留已配置为无限期存储。

代码:生产级发票生成器(Stripe + Drizzle)

将失败率从 8% 压至 0.5% 以下的修复方案,是在 Stripe Billing 和发票 PDF 之间加一层代码,强制执行 12 步检查清单。

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"),
});

complianceFlags 列是审计追踪的副作用——每次生成器在某项检查上失败时,就将一个标志字符串追加到数组中。连续 30 天干净生成后,清空该数组。

生成器:验证、最终确定、审计

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[] = [];

  // 一次性拉取供应商和客户税号。
  const customerTaxId = await db.query.taxIds.findFirst({
    where: eq(taxIds.customerId, input.stripeCustomerId),
  });

  // 规则 2:供应商 VAT 号必须在 env 中设置。
  const supplierVat = process.env.COMPANY_VAT_ID;
  if (!supplierVat) flags.push("MISSING_SUPPLIER_VAT");

  // 规则 3:B2B 跨境场景需要客户 VAT 号。
  const isCrossBorderB2B =
    input.currency === "EUR" && customerTaxId?.country !== process.env.COMPANY_COUNTRY;
  if (isCrossBorderB2B && !customerTaxId) flags.push("MISSING_CUSTOMER_VAT_B2B");

  // 规则 5:每行必须有描述。
  if (input.lines.some((l) => !l.description.trim())) {
    flags.push("BLANK_LINE_DESCRIPTION");
  }

  // 构建 Stripe 发票。
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
  const finalized = await stripe.invoices.finalizeInvoice(input.stripeInvoiceId, {
    auto_advance: false,
  });

  // 带审计追踪持久化到自有 DB。
  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 };
}

流程:先验证,再调用 stripe.invoices.finalizeInvoice,最后将标志数组连同发票数据一起持久化到本地。如果 flags.length > 0,我的 billing-audit Slack 频道会收到一次警报,包含标志列表和客户 ID。系统上线后首月内,8% 的失败率即降至 0.5% 以下。

竞争对手真正胜出的地方,以及 TanStack Ship 的定位

有几款工具提供的发票合规功能超出了 TanStack Ship 能覆盖的范围——因为这些是完整的产品决策,而非仅仅是代码问题:

  • Stripe Tax 是正确的基础层——所有合规计划都应从启用 Stripe Tax 开始。它原生处理税额计算、注册门槛和欧盟 OSS 方案。错误在于孤立使用它;Stripe Tax 不会生成你的 PDF,也不会持久化合规审计追踪。
  • Avalara 是有专门财务团队和全美 50 州关联问题的正确答案。对于企业级美国 heavy SaaS,Avalara 物有所值。
  • Quaderno 是专注于税务合规的工具,在欧盟和拉美覆盖强劲。若主要面向欧盟消费者(B2C,非 B2B),Quaderno 的 VAT-MOSS/OSS 支持非常出色。对于 B2B SaaS,Stripe Tax 加上薄薄一层合规代码的组合与之高度重叠。

对于已使用 Stripe 的 B2B SaaS,TanStack Ship 计费模块 将发票生成器、Drizzle schema、合规标志审计追踪、Stripe Tax 集成和贷项通知单流程打包为一个开箱即用模块。参见 SaaS 订阅计费概览 和 TanStack Ship 计费模板参考。


常见问题

我是否需要在每个欧盟国家分别注册 VAT 号?

不一定。欧盟 OSS 方案允许在一个成员国注册单一 VAT 号并提交一份申报表覆盖所有欧盟销售。Stripe Tax 原生支持 OSS——参见 Stripe Tax OSS 文档。若跨境 B2C 超过 OSS 门槛(1 万欧元),则需要注册。

我应该使用 Stripe 托管发票页面还是自建 PDF?

除非有品牌原因需要自托管,否则使用 Stripe 托管页面。托管页面已自动处理 80% 的欧盟第 226 条字段。自托管意味着你要对托管页面已覆盖的每个字段承担全部责任——这正是我的审计中发现失败的原因。

若客户几个月后要求更正发票怎么办?

生成引用原始发票号的贷项通知单,再发出一张更正发票,编号遵循同一顺序并加后缀。Stripe 的贷项通知单 API 可处理此事。两份文档均须在完整保留期内存储。

这只适用于欧盟/英国吗?美国销售税呢?

美国销售税更复杂,因为每个州都有自己的一套关联规则。12 步检查清单覆盖结构性字段;税率计算是另一个独立问题。对于美国销售税,Stripe Tax 或 Avalara 是正确的起点。

没有 TanStack Ship 能运行这套方案吗?

可以——schema 和生成器适用于任何 Node.js 或 TypeScript 后端。TanStack Ship 将它们与 Stripe webhook 处理器、贷项通知单流程和 dunning 模块打包在一起,让你无需重复编写相同的合规层。参见定价页面了解默认模块。


立即开始使用 TanStack Ship — Stripe 发票字段验证、Drizzle 审计追踪 schema 和贷项通知单流程均默认集成。查看定价 →