Flexprice 税收设计深度解析:Inclusive / Exclusive / Exemption 的计算模型、Schema 与实现落地(FLE-1187)
Flexprice 税收设计深度解析:Inclusive / Exclusive / Exemption 的计算模型、Schema 与实现落地(FLE-1187)
本篇技术指南围绕 Flexprice 开源仓库中的税收设计文档(docs/design/2026-08-17-FLE-1187-taxation-lineitem-inclusive.md)展开,完整讲解 v1 阶段"价内税(inclusive)、价外税(exclusive)、混合计征、客户免税(exemption)"四类场景在发票级别的实现方案。你将掌握:tax_behavior 的字段设计与解析规则、含税价反算(extraction)的数学原理与多税率分摊、豁免客户的计算时序与追溯语义、以及三类发票响应 JSON 的字段含义。文章同时结合 internal/ee/service/tax.go、internal/ee/service/invoice.go、internal/types/tax.go 等源码与迁移文件,给出设计到实现的完整映射。
1. 问题背景与 v1 范围
1.1 现状:税收只支持 exclusive
在 v1 之前,Flexprice 的税收计算是"仅价外税"模式:taxableAmount(inv) = subtotal - discount,税始终在应税金额之上叠加。系统里虽然有 TaxRateType 字段,但它的取值只有 percentage 一种——固定金额税率(fixed-amount tax rates)在 v1 及更早版本中从未被支持。
1.2 v1 目标与范围边界
v1 的目标是在发票级别(invoice level)支持四类场景:inclusive(价内)、exclusive(价外)、mixed(一张发票上混合两种行为)、以及 customer exemption(客户免税)。范围对比如下:
| 在 v1 范围内 | 不在 v1 范围内 |
|---|---|
tax_behavior 挂在 TaxAssociation 上 |
行项目 / 价格级别的税收(line-item / price-level tax) |
| 同一张发票混合 inclusive + exclusive | 折扣下摊到行项目(discount carry-down) |
| 百分比税率,两种行为均支持 | 固定金额税率 |
| 按币种的默认行为(编译内列表) | 每租户可配置的币种默认值 |
| 客户免税 | 反向征收(reverse charge)——枚举已预留、未实现 |
| exclusive 计算不变 | 司法辖区 / nexus 检测 |
| 免税证书验证 |
1.3 核心术语
| 术语 | 定义 |
|---|---|
base |
subtotal − discount,与今天 taxableAmount() 一致,是税的计算基准,作为已知输入 |
inclusive_tax |
已包含在 base 内部的税,通过反向计算回收,是推导值 |
net |
base − inclusive_tax,永远是推导值,绝不作为输入 |
exclusive_tax |
在 net 之上追加计算的税,是推导值 |
base 与 net 不可互换。任何看起来"从 total 反解 base"的公式,实际上解出来的是 net。
2. 数据模型设计(Schema)
2.1 ER 关系与新增字段
设计文档给出了核心 ERD(原始 Mermaid 图见 docs/design/2026-08-17-FLE-1187-taxation-lineitem-inclusive.md),关键实体与新增字段如下:
- CUSTOMER:
tax_treatment(新增)——taxable(默认)或exempt。 - TAX_RATE:
tax_rate_type = percentage(唯一取值)、percentage_value、tax_rate_status。 - TAX_ASSOCIATION:
tax_behavior(新增,inclusive / exclusive / null)、entity_type(tenant / customer / subscription)、entity_id、priority、auto_apply、currency、start_date、end_date。 - INVOICE:
tax_exemption_reason_code(新增,可为 null)、subtotal、total_discount、total_tax(语义变化,见 §6.3)、total。 - TAX_APPLIED:
tax_behavior(新增,应用时冻结)、taxable_amount、tax_amount、currency、applied_at、idempotency_key。
上述 Schema 在仓库中的对应实现见 ent/schema/customer.go、ent/schema/invoice.go、ent/schema/taxrate.go、ent/schema/taxapplied.go、ent/schema/taxassociation.go;枚举定义集中在 internal/types/tax.go。
2.2 枚举与展示标签
// internal/types/tax.go
type TaxBehavior string
const (
TaxBehaviorInclusive TaxBehavior = "inclusive"
TaxBehaviorExclusive TaxBehavior = "exclusive"
)
// TaxTreatment is the customer's tax treatment. Defaults to taxable.
type TaxTreatment string
const (
TaxTreatmentTaxable TaxTreatment = "taxable"
TaxTreatmentExempt TaxTreatment = "exempt"
// "reverse_charge" reserved, not implemented in v1
)
// TaxExemptionReasonCode is stored in invoices.tax_exemption_reason_code and surfaced
// as tax_summary.exemption.reason_code.
type TaxExemptionReasonCode string
const (
TaxExemptionReasonCustomerExempt TaxExemptionReasonCode = "customer_exempt"
TaxExemptionReasonNoTaxConfigured TaxExemptionReasonCode = "no_tax_configured"
// "reverse_charge" reserved, not implemented in v1
)
// DisplayLabel is surfaced as tax_summary.exemption.reason. Derived, never stored.
func (r TaxExemptionReasonCode) DisplayLabel() string {
switch r {
case TaxExemptionReasonCustomerExempt:
return "Customer is tax exempt"
case TaxExemptionReasonNoTaxConfigured:
return "No tax configured"
default:
return string(r)
}
}
TaxBehaviorSource 还记录了行为来源(explicit / default),仅用于日志诊断,不落库。
2.3 字段设计理由
为什么 tax_behavior 挂在 TaxAssociation 而不是 TaxRate? 同一个 18% GST 税率在一个合同下可能是价内、另一个合同下是价外;挂在税率上会迫使每个合同克隆一份税率。因此行为是"关联"的属性而非"税率"的属性。
tax_behavior 可空的原因:tenant 级和 customer 级的关联没有单一币种可解析(它们会被复制到不同币种的订阅下),只有 subscription 级关联才会获得具体值(见 §3 解析规则)。
为什么 TaxApplied.tax_behavior 要在应用时冻结? 关联可能被归档替换,币种列表可能在后续版本变化,但已应用记录必须忠于"当时实际收取"的事实。它是真正的逐行(per-row)数据——同一张发票上可以一行 inclusive、另一行 exclusive。
为什么 Invoice.tax_exemption_reason_code 存"代码"而不是"展示文案"? 响应中暴露两个字段,来自同一列:
| 响应字段 | 来源 |
|---|---|
tax_summary.exemption.reason_code |
存储列原样 |
tax_summary.exemption.reason |
通过 DisplayLabel() 推导,永不存储 |
存展示字符串会复制由代码完全决定的状态、迫使改文案时迁移数据、并阻碍本地化。
为什么 TaxApplied 上没有逐行豁免原因? v1 的豁免是单一的客户级标志、统一生效,所以一张发票上所有被清零的行都是因为同一原因,在发票上声明一次即可。$0 行没有歧义——发票上的 reason code 解释了原因。只有部分豁免(按税率 / 辖区 / 产品)才需要逐行原因,那是 v2 的事情。
为什么 Customer.tax_treatment 放在客户上? 豁免是购买主体的属性——非营利组织无论买什么套餐都免税。放在 TaxAssociation 上需要反复设置;放在 Subscription 上则同一法人可能在一个订阅豁免、另一个订阅不豁免,这与豁免的含义相悖。每个客户默认 taxable,所以存量行无需回填,只靠列默认值即可。
priority 字段:API 接受、校验、持久化,但在解析路径中从未被读取。所有 auto_apply = true 的关联都会生效,没有按 priority 排序的逻辑。v1 保持原样。
2.4 校验规则
| 规则 | 位置 | 理由 |
|---|---|---|
percentage_value 必填,固定金额税率不支持 |
已存在(internal/api/dto/taxrate.go) | 每个税率都是百分比,没有其他种类 |
percentage_value 在 0–100 |
已存在 | — |
| 百分比税率高于 100% 不能是 inclusive | 新增,在关联创建时 | 高于 100% 的价内税率意味着税超过了它推导出的免税价格。数学上提取仍然可算(任何正 r 下 base × r/(100+r) 都小于 base),数值不会崩——拒绝是因为它是配置错误,而非数学失败。在创建时拒绝而非计算时钳制,使它永远不会进入发票 |
| 组合后的价内税率不设 100% 上限 | — | 多个合法税率可能加起来超过 100(60% + 50%)。逐税率检查是护栏,组合值只用于提取 |
tax_treatment = exempt 的客户的订阅不能解析任何税务关联 |
新增,在订阅创建时 | 见 §3.2 |
2.5 币种默认行为
// internal/types (tax.go 之外的设计约定;实现见 internal/types/taxassociation.go 附近)
// ExclusiveTaxCurrencies lists currencies whose default tax behavior is exclusive when
// an association is created without an explicit behavior. Everything else defaults to
// inclusive. Compiled-in convention — no UI, no API, no per-tenant override.
var ExclusiveTaxCurrencies = []string{"USD", "CAD"}
func DefaultTaxBehaviorForCurrency(currency string) TaxBehavior {
if lo.Contains(ExclusiveTaxCurrencies, strings.ToUpper(currency)) {
return TaxBehaviorExclusive
}
return TaxBehaviorInclusive
}
这是一个编译期内置约定:没有 UI、没有 API、没有每租户覆盖。USD 和 CAD 默认价外,其余币种默认价内。切片形式与同一包内 EntityHierarchy 的 internal/types/taxassociation.go 保持一致。
3. 行为解析(Resolution):stamp once 与豁免校验
核心原则:tax_behavior 决定已解析税率如何被处理,绝不决定它是否适用。tenant → customer → subscription 的层级解析逻辑原封不动。
3.1 一次性盖章:订阅关联创建时
PrepareTaxRatesForInvoice 只读取 subscription 实体的关联或显式请求覆盖——从不直接读 customer/tenant 行。订阅从创建起就有具体的 Currency,所以币种列表只在唯一一个币种已知且正在创建的行就是发票实际会读取的行时被查询一次。发票计算阶段完全不再触碰币种列表。
3.2 豁免在订阅创建时校验,而非计算时跳过
如果 customer.tax_treatment = exempt,向该客户的订阅关联任何税务关联都会被拒绝,无论它会解析成什么行为。
这既是产品约定也是技术约定:免税客户永不被征税,其订阅上的税务关联无事可做,只会成为令人困惑的死配置。若其豁免日后被解除,租户届时再主动添加关联——而不是让一个"已解析但惰性"的东西长期静默存在。早期版本只拒绝 inclusive 关联,理由是 exclusive "无害"(对免税客户两者都收敛到 total = base);但"无害"不等于"被需要"——租户无法仅凭查看订阅的关联来判断一个免税客户的 exclusive 关联是否会起作用。全部拒绝保持了不变量简单清晰:免税客户的订阅有零个税务关联。
这为每一个新订阅关闭了"两个不同总额"问题(§8.1)。它覆盖不到"订阅已带税后才变为免税"的客户——那是 tax_treatment 更新事件,不是订阅创建事件。此时由计算阶段的清零兜底(§5)。
3.3 解析时序
设计文档给出了完整的订阅创建时序图(见原文 §4.3),核心流程:
创建订阅(币种已具体)→ 读取 customer.tax_treatment
├─ 请求带 TaxRateOverrides → LinkTaxRatesToEntity(subscription, overrides)
└─ 否则 → 取 customer 关联中 auto_apply=true 的模板(tax_behavior=null)
→ LinkTaxRatesToEntity(subscription, templates)
对每个被链接的关联:
├─ 行为显式给出 → resolved behavior = 输入值
├─ 行为为 null → DefaultTaxBehaviorForCurrency(currency)
│ (列表内 exclusive,否则 inclusive)
├─ 客户免税 → 记录拒绝日志(subscription_id, customer_id, tax_rate_id, resolved_behavior)
│ → 订阅创建被拒绝,无论解析成什么行为
└─ 否则 → 以盖章行为创建行,记录日志(subscription_id, tax_rate_id, currency, behavior, source)
每一次关联创建都会被记录,无论行为来自币种默认还是显式输入;拒绝路径单独记录——该流程没有任何静默环节(§8 日志)。
3.4 回填迁移(Rollout backfill)
在功能启用前,所有存量 TaxAssociation 都必须在一个迁移中被回填 tax_behavior = exclusive。否则每行都是 null,任何在 exclusive 币种列表之外开票的租户都会在部署当天静默地从 exclusive——这个唯一存在过的行为——翻转为 inclusive。
make generate-migration(Ent 的 diff 生成器)做不到这一点:它只发 DDL,从不生成回填 UPDATE;并且在一个非空表上直接执行 ADD COLUMN … NOT NULL 会被拒绝。因此上线采用手工调整的版本化迁移。设计文档中标注的文件名与实际仓库文件略有出入,仓库中实际落地的是 migrations/versioned/postgres/20260831082024_tax_inc_exc_exempt.sql,其做法是:tax_applieds.tax_behavior 先以 nullable 添加、存量行回填 exclusive、再置 NOT NULL——全部在同一个迁移文件内完成,通过版本化迁移 runner 应用(见 migrations/versioned/README.md):
ALTER TABLE "customers" ADD COLUMN IF NOT EXISTS "tax_treatment" character varying(50) NOT NULL DEFAULT 'taxable';
ALTER TABLE "invoices" ADD COLUMN IF NOT EXISTS "tax_exemption_reason_code" character varying(50) NULL;
ALTER TABLE "tax_applieds" ADD COLUMN IF NOT EXISTS "tax_behavior" character varying(50) NULL;
ALTER TABLE "tax_associations" ADD COLUMN IF NOT EXISTS "tax_behavior" character varying(50) NULL;
4. 计算模型(Calculation):exclusive 不变、inclusive 反算、混合级联
税率为纯百分比:固定金额税率不受支持,每个税率只有 percentage_value。这不是计算的偶然前提,而是让 §4.2 的价内提取不变量无条件成立的关键。
4.1 Exclusive —— 与现状完全一致
base 在循环外计算一次,传给每个税率:tax_i = base × r_i / 100(对应 internal/ee/service/tax.go 中 calculateTaxBreakdown 的 exclusive 循环)。各税率独立、结果求和、顺序无关(base×r1/100 + base×r2/100 = base×(r1+r2)/100)。这种独立性正是 inclusive 计算不具备的。
4.2 Inclusive —— 反向提取(extraction)
设 net = 不含税金额,r = 税率百分比。
base = net + (net × r/100) 价格本身已含税
= net × (100+r)/100
net = base × 100/(100+r) 反解 net
tax = base − net
= base − base × 100/(100+r)
= base × [1 − 100/(100+r)]
= base × r/(100+r)
多个百分比税率不能各自独立执行提取。 每次独立提取都隐含声称"该税率独自占据了 base 与 net 之间的全部差额",两个税率会得出两个互相矛盾的 net:
base = 1000, 税率 9% 和 5%
错误 —— 独立提取:
tax@9% = 1000 × 9/109 = 82.57 隐含 net = 917.43
tax@5% = 1000 × 5/105 = 47.62 隐含 net = 952.38
sum = 130.19 两个不同的 net —— 不可能同时为真
正确 —— 合并、提取一次、再分摊:
R = 9 + 5 = 14
total_tax = 1000 × 14/114 = 122.81 隐含 net = 877.19
tax@9% = 122.81 × (9/14) = 78.95
tax@5% = 122.81 × (5/14) = 43.86
分摊与各税率在 R 中的占比成比例,绝不是平均分配。 平均分是 61.41 各自——错误;9% 的行必须承担更多,按 9:5 的比例。
inclusive_tax 永远不可能超过 base。 对任何组合税率 R ≥ 0,R/(100+R) < 1,所以提取总会留下余量——不需要钳制、溢出保护或错误路径。这个性质之所以成立,正因税率是纯百分比:固定金额税率没有这种上限,设计早期草稿曾携带"先提取固定税率"步骤加溢出保护来处理该情况;固定金额税率不再支持后,两者都删除了。
4.3 Mixed —— 级联计算
exclusive 税率作用于 net,而不是原始 base——价内税已经占用了那部分资金。
1. inclusive_tax = 从 base 提取(§4.2)
2. net = base − inclusive_tax
3. exclusive_tax = §4.1 循环作用于 net
4. total = base + exclusive_tax
示例:base = 1000,价内 10%,价外 18%:
inclusive_tax = 1000 × 10/110 = 90.91
net = 909.09
exclusive_tax = 909.09 × 18/100 = 163.64 18% 作用于 net,不是 1000
total = 1000 + 163.64 = 1163.64
只有价外税会移动 total。
在 internal/ee/service/tax.go 的 calculateTaxBreakdown 实现中,这一流程完全对应:先按 taxBehavior 分组,对 inclusive 组求和组合税率后 taxableAmount × R/(100+R) 提取一次,再按 r/R 比例分摊;随后 netTaxableAmount = taxableAmount − inclusiveTax,exclusive 组对 netTaxableAmount 逐个计算。实现注释还明确说明这与 Stripe 的税率排序一致:折扣后的含税价格,其内含税也随折扣缩减。
4.4 取整(Rounding)
| 分组 | 不变量 | 规则 |
|---|---|---|
| Inclusive | net + inclusive_tax == base 精确成立,且各行之和精确等于 inclusive_tax |
组合结果先取整一次,再按比例分摊并对每个份额取整。独立取整的份额可能比取整后的整体差一分钱;该余数加到最大的取整份额上 |
| Exclusive | 不变 | 每个税率取整,再求和 |
余数规则(实现为"最大取整份额"): 平局时按税率顺序取第一条这样的行,因此结果完全由输入决定。base = 100、价内税率 1%、1%、4% 时组合为 5.66;份额取整为 0.94、0.94、3.77,差一分钱,所以 4% 行变为 3.78。
取整发生在豁免应用之前,因此豁免从不与余数分配交互。规则必须固定:一个在不同运行间可变的规则,会让同一张发票被重算时,对相同的总税额产生不同的逐行 TaxApplied 记录,破坏可复现性。源码中对应 inclusiveShare 结构体与"remainder 加到最大份额"逻辑(见 internal/ee/service/tax.go)。
5. 豁免(Exemption):单点覆盖与追溯语义
5.1 双重防线
- 订阅创建时——免税客户的任何关联都被直接拒绝(§3.2)。免税客户的新订阅永远解析不到税务关联。
- 每次发票计算时——无论 (1) 如何,通用公式(§5.3)都会将免税客户的
inclusive_charged与exclusive_charged双双清零。这才是真正保护收入正确性的机制;(1) 的存在是为了让常见场景根本不进入有趣的计算,也绝不留下死配置。
(1) 覆盖不到的:已是含税订阅(inclusive 或 exclusive)后客户才变为免税的场景。其存量关联不受影响(§3.2 只在创建时运行);发票落入 (2),若是 inclusive 关联则执行完整的退税处理(§5.2)——见 §8.1 与 §5.6。
5.2 退税(Backing out the tax)
税额按客户未豁免时计算,然后清零。对 inclusive 税率,提取出的金额也要从客户应付中扣除:inclusive 税率声明标价的一部分是税;如果这笔税不用付,就不应收。
base = 100,单一 10% 税率:
| 应税 | 免税 | |
|---|---|---|
| 10% 价内 | 付 100.00(收取 9.09) | 付 90.91(收取 0) |
| 10% 价外 | 付 110.00(收取 10.00) | 付 100.00(收取 0) |
5.3 通用公式
inclusive_tax = 按 §4.2 提取 无论是否豁免都计算
net = base − inclusive_tax 无论是否豁免都计算
exclusive_tax = §4.1 循环作用于 net 无论是否豁免都计算
exempt = customer.tax_treatment == exempt
total = exempt ? net : base + exclusive_tax
total_tax = exempt ? 0 : inclusive_tax + exclusive_tax
价内税从不移动 total。 base 已包含它——提取的存在是为了报告标价中有多少是税,而不是改变应付金额。唯一能移动 total 的是价外税(加在顶上)和豁免(把价内部分退出来,§5.2)。
早期草稿曾写成 total = net + inclusive_charged + exclusive_charged。这在代数上等价——应税客户时 net + inclusive_tax 直接塌缩回 base——但它"加了一个刚刚减掉的数",读起来像价内税是 total 的一部分。上面的形式是实现的字面写法。applyTaxResultToInvoice(internal/ee/service/invoice.go)正是如此:豁免时 inv.Total 减去 result.InclusiveTax,否则加上 result.ExclusiveTax。
| 场景 | net |
total |
|---|---|---|
| 应税,仅价外 | base |
base + exclusive_tax —— 今天的现状 |
| 应税,仅价内 | base − inclusive_tax |
base |
| 应税,混合 | base − inclusive_tax |
base + exclusive_tax |
| 豁免,仅价外 | base |
base |
| 豁免,仅价内 | base − inclusive_tax |
net |
| 豁免,混合 | base − inclusive_tax |
net |
豁免 + 价内关联时,total 会低于 base,于是 total = base + total_tax 不再成立。这是 v1 有意改变的现存不变量之一。
为什么豁免不能跳过计算: 对免税客户 inclusive_tax 是承重的——total = net 由它定义。exclusive_tax 在豁免时可真正丢弃(被清零且不出现在别处),但仍被计算,为的是让豁免成为末尾的单点覆盖而非计算内部的分支,也让被豁免的金额可用于日志(§8)。
5.4 记录一次,记在发票上
一张没有征税的发票要说明原因,而不是让调用方去推断:
| 情形 | tax_treatment |
taxes[] |
tax_exemption_reason_code |
|---|---|---|---|
| 该订阅未配置税 | taxable |
空 | no_tax_configured |
| 客户豁免 | exempt |
空或 $0 行 |
customer_exempt |
| 正常征税 | taxable |
有数据且非零 | null |
没有这列,"未配置税"和"豁免客户、未解析任何东西"字节级相同——空 taxes[]、零 total_tax,无从区分。tax_exemption_reason_code 是唯一的区分者,它仅在实际征税时为 null。
Customer.tax_treatment 始终是活的事实来源;发票列只是计算时的冻结快照,日后更改客户税务状态不会重写历史发票。
当关联确实为免税客户解析时,TaxApplied 行仍以 tax_amount = 0 写入、保留其 tax_behavior——绝不省略,审计线索记录哪些税率被评估过。
5.5 计算时序
InvoiceService → TaxService.PrepareTaxRatesForInvoice(req)
层级解析 —— 豁免无关
→ CustomerRepo 取 customer.tax_treatment
← 返回带盖章 tax_behavior 的解析税率 + 豁免标志(InvoiceTaxRates)
InvoiceService → TaxService.ApplyTaxesOnInvoice(inv, rates)
空税率走同一条路径 —— 无独立分支
→ 按 tax_behavior 分组
→ inclusive_tax = 从 base 提取(合并、提取一次、分摊)
→ net = base − inclusive_tax
→ exclusive_tax = 对 net 循环
→ 取整、分配余数
若豁免:
→ 两个 charged 均为 0
→ 记录"先算后清零"金额日志
→ 以 0 写入行,保留 tax_behavior(无税率解析则完全跳过)
若应税:
→ 写入真实金额行(无税率解析则完全跳过)
← TaxCalculationResult
InvoiceService:
total = exempt ? net : base + exclusive_charged
盖章 reason_code —— 豁免则 customer_exempt,未应用任何税率则 no_tax_configured,否则 null
解析(§3)与计算(§4)都豁免无关。豁免在计算完之后的唯一一点生效——这防止了 exempt × inclusive × mixed 的专项矩阵。实现中没有独立的"无税率解析"分支:ApplyTaxesOnInvoice / CalculateTaxesOnInvoice 在空税率列表上跑同一套分组-提取-取整路径,自然产出零税和空的 TaxAppliedRecords——§8.2 的结果从通用路径中自然得出,而非特例。同样没有 inclusive_tax > base 的钳制/错误步骤——§4.2 已证明纯百分比税率下不可能发生,因此不存在这样的分支可记录日志。
5.6 现有订阅的税务状态变更 —— 无追溯效果
将客户标记为豁免、或移除该状态,是对 Customer.tax_treatment 的普通状态变更——无迁移、无重算、不尝试修复已存在的发票。这是一条有意的设计原则,而非实现捷径:
tax_treatment 的变更从该客户生成的下一张发票开始生效,绝不改动已开出的发票。
这从设计已有的机制中自然得出——§5.5 的时序每次生成发票都重新读取 customer.tax_treatment。需要改变的是把它作为公开 API 文档中的书面契约明确声明,而非只是代码的偶然属性。
对"客户变为免税时订阅所处的两种状态"的含义:
- 关联为 exclusive,或没有关联。 无需调和。下一张发票计算
exclusive_charged = 0、total = base,与 §5.3 完全一致。本就正确,无需特殊处理。 - 关联为 inclusive。 下一张发票应用 §5.2 的退税处理——
total从base掉到net。这是有意为之,不是缺陷。不希望未来出现该结果的租户,可在同一订阅上有三种行动方式:- 更新订阅的税务关联(
tax_behavior可更新,§7.4); - 将客户移到不同的价外定价套餐;
- 独立于套餐或税务关联,将订阅自身价格覆盖到税前金额——订阅本就支持这种逐客户调整的价格覆盖。
- 更新订阅的税务关联(
系统不会自动执行其中任何一种——这正是 §3.2 建议租户维护独立价内/价外套餐的原因:把"修复这个订阅"变成一次有意的动作,而非容易遗忘的事。
5.7 修正已开出的发票
客户为 taxable 时生成的含价内税的发票,在其后被标记豁免时保持原金额(§5.6)——绝不追溯修改。修正它永远是租户发起的,自动机制不存在:本设计中没有任何东西会在 tax_treatment 变更时自动触发 credit note。
被否决的方案:void + 重新生成(复用已建好的 RecalculateInvoice)。 即使它存在且复用零成本,也不用于此场景。已定稿的发票是发给客户的单据,在多数司法辖区已进入税务账簿——作废并替换是删除记录而非就地修正。作废仍可作为独立的通用发票操作;它不是本修正的机制。
已决定的方案:现有 credit note 机制,原封不动,行项目级。 无新 schema、无新能力、无发票级 credit note。CreditNoteLineItem 一如既往地要求 InvoiceLineItemID(见 ent/schema/creditnote_line_item.go)。租户自行用本文档规定的数学(§4.2/§5.2)算出正确的税额,通过现有流程对选中的发票行项目开 credit note。Flexprice 不把修正归因到特定 TaxApplied 行,也不校验贷记金额与实收金额是否一致——责任在租户。
这是刻意的范围边界:v1 的税是发票级的,没有自然的行项目可挂税专项 credit(§1.2),而为此构建税务感知的 credit note 能力超出范围。若实践中确有需求,属于 Phase 2(税本身变成行项目级之后)的领地。
6. API 响应:三类发票形态
6.1 正常 —— 混合价内与价外
base = 1000,10% 价内,18% 价外:
{
"subtotal": "1000.00",
"total_discount": "0.00",
"total_tax": "254.55",
"tax_summary": {
"inclusive_tax": "90.91",
"exclusive_tax": "163.64",
"total_tax": "254.55",
"exemption": null
},
"total": "1163.64",
"amount_due": "1163.64",
"taxes": [
{
"id": "taxapp_...",
"tax_rate_id": "txr_gst",
"entity_type": "invoice",
"entity_id": "inv_...",
"tax_behavior": "inclusive",
"taxable_amount": "1000.00",
"tax_amount": "90.91",
"currency": "USD"
},
{
"id": "taxapp_...",
"tax_rate_id": "txr_vat",
"entity_type": "invoice",
"entity_id": "inv_...",
"tax_behavior": "exclusive",
"taxable_amount": "909.09",
"tax_amount": "163.64",
"currency": "USD"
}
]
}
价内行的 taxable_amount 是 base;价外行的是 net。这个差异就是 §4.3 级联在响应中的体现。total_tax 是两者的和;total 只被价外部分移动。重建 total 用 tax_summary.exclusive_tax,绝不要用 total_tax(§5.3)。
6.2 豁免
客户被标记豁免、订阅的套餐唯一关联是价外(价内会在订阅创建时被拒绝,§3.2):
{
"subtotal": "1000.00",
"total_discount": "0.00",
"total_tax": "0.00",
"tax_exemption_reason_code": "customer_exempt",
"tax_summary": {
"inclusive_tax": "0",
"exclusive_tax": "0",
"total_tax": "0",
"exemption": {
"reason_code": "customer_exempt",
"reason": "Customer is tax exempt"
}
},
"total": "1000.00",
"amount_due": "1000.00",
"taxes": [
{
"id": "taxapp_...",
"tax_rate_id": "txr_vat",
"entity_type": "invoice",
"entity_id": "inv_...",
"tax_behavior": "exclusive",
"taxable_amount": "1000.00",
"tax_amount": "0.00",
"currency": "USD"
}
]
}
taxes[] 以 $0 填充,因为关联存在——关联总是被创建(§5.1)。只有当免税客户的订阅完全没有税务关联时它才是空的,此时同样携带 reason_code = customer_exempt。reason_code 是存储的枚举;reason 是推导的(§2.3)。
6.3 未配置税
同样的形状,不同的原因——发票说明它为何无税,而非让调用方推断(§5.4):
{
"subtotal": "1000.00",
"total_discount": "0.00",
"total_tax": "0.00",
"tax_exemption_reason_code": "no_tax_configured",
"tax_summary": {
"inclusive_tax": "0",
"exclusive_tax": "0",
"total_tax": "0",
"exemption": {
"reason_code": "no_tax_configured",
"reason": "No tax configured"
}
},
"total": "1000.00",
"amount_due": "1000.00",
"taxes": []
}
exemption 仅在实际征税时为 null。顶层 tax_exemption_reason_code 与 tax_summary.exemption.reason_code 是同一个存储列(§2.3)——Invoice 被内联嵌入响应,所以原始列既平铺可见、也在 tax_summary 内可见。tax_summary.inclusive_tax / exclusive_tax / total_tax 在从未解析或收取任何税率时,是未设置的 decimal.Decimal 的零值("0"),而非按币种精度取整。
6.4 既有响应特征
- 小数以字符串序列化(
swaggertype:"string")。 - 域结构体以
json:",inline"内联,TaxApplied字段平铺出现。 tax_rate为 nil,除非调用方传expand=tax_applied.tax_rate。
buildTaxSummary 的实现位于 internal/api/dto/invoice.go:有 reason code 时直接构造豁免摘要(DisplayLabel() 推导 reason);否则遍历 taxes 按 tax_behavior 分别累计 inclusive / exclusive 并求和。
7. 边界情况(Edge cases)
7.1 同一客户、同一套餐,税务状态随时间变化
免税客户订阅价内税套餐,可能为同一套餐支付 1000.00 或 909.09,取决于他们是在订阅创建时即豁免、还是事后才豁免。§3.2 的校验对每个新订阅拦截第一条路径。第二条——对既有订阅的 tax_treatment 变更——不拦截,而是在下一张发票被正确且有意地处理。完整解析与租户补救选项见 §5.6、§5.7。
7.2 无税率解析
taxes 为空、total_tax 为零、total = base。tax_exemption_reason_code 区分原因——应税客户为 no_tax_configured,豁免客户为 customer_exempt(§5.4)。无税发票上 exemption 永不为 null。
7.3 币种不在 exclusive 列表且未显式指定行为
INR 订阅没有显式 tax_behavior 时被盖章为 inclusive,因为 INR 不在列表中。这是有意但静默的——它改变了客户被收取的金额,而请求中没有任何说明。因此有 source=currency_default 日志(§8)。
更安全的模式:总是显式传 tax_behavior,把币种默认当作不关心的调用方的后备。
同一币种默认也是发票阶段遇到订阅级关联 tax_behavior 为 null 时的后备。那本应不可达——创建总是盖章一个,§3.4 的回填处理其余——所以它按 Error 级别记录(L12),而非当作例行默认。
7.4 发票存在后行为被更改
tax_behavior 在既有关联上可更新。新发票以及任何被重算的发票使用更新后的行为。未被重算的历史发票不受影响——tax_behavior 在应用时冻结到每条 TaxApplied 行上(§2.3),一张永不再碰的发票保留签发时的真值。重算是唯一能改变既有发票 total 的动作,且这是预期行为:重算意味着"反映当前状态",当前状态已包含新行为。UpdateTaxAssociation 在 internal/ee/service/tax.go 中支持对 TaxBehavior 的原地更新。
7.5 一次性发票的裸 tax_rates —— 仅按币种,已知缺口
CreateOneOffInvoice 的裸 tax_rates: [id, ...] 字段(区别于 tax_rate_overrides 或订阅解析的关联)背后没有任何关联——不查 tenant/customer 层级,只按发票自身币种走 DefaultTaxBehaviorForCurrency(req.Currency)。这是既有路径(裸税率 ID,与本功能无关),非 v1 新增;它只是现在也需要某个解析出的行为来计算。
已知缺口,此处不修: 租户配置了 customer 或 tenant 级默认 tax_behavior 时,该路径会忽略它——只查询币种列表,与未指定订阅级关联时的后备相同。正确修复需要走 PrepareTaxRatesForInvoice 已用于关联支撑路径的同一层级解析,超出本次范围。需要一次性发票上按租户行为的租户应改用 tax_rate_overrides(它确实支持显式 tax_behavior),而非裸 tax_rates。
7.6 每个调用方都必须传发票币种
因为币种决定未盖章关联的行为(§2.5、§7.3),任何为 PrepareTaxRatesForInvoice 构造 CreateInvoiceRequest 却把 Currency 留空的调用方,都会对着 "" 解析——空串不在 exclusive 列表中,所以每个此类关联都静默默认 inclusive。这不是理论性失败:它曾是 applyTaxesToInvoice 的一个真实 bug——它只用 SubscriptionID 和 CustomerID 构造请求,把重算路径上的 $10 价外收费变成了 $9.09 价内收费。
这个危害的一般形态值得声明:空币种在这里不是中性输入,而是选边站的输入。 任何为税务解析构造部分 CreateInvoiceRequest 的代码都必须填充 Currency。
8. 日志约定与追踪
约定:使用 internal/logger,ctx 优先,Error 用字面 "error" 键,不用 fmt.Print*。按 AGENTS.md,Warn 仅供启动引导使用,因此恢复与跳过类条件在 Info 级别记录。
以下每一条都必须记录。 无法从日志重建的税务计算是不可调试的——中间值在响应中不存在。每一步复制都单独记录,而非只记录结果(§3.3)。
| # | 消息 | 级别 | 字段 |
|---|---|---|---|
| L1/L2 | tax behavior resolved for subscription association(§3.1) |
Info | subscription_id、tax_rate_id、currency、resolved_behavior、source(explicit | currency_default) |
| L3 | subscription tax association rejected — exempt customer(§3.2) |
Info | subscription_id、customer_id、tax_rate_id |
| L5 | tax computed for invoice(§4) |
Info | invoice_id、taxable_amount、inclusive_tax、exclusive_tax |
| L9 | exemption applied at compute(§5.2) |
Info | invoice_id、customer_id、waived_inclusive_tax、waived_exclusive_tax |
| L11 | rate skipped — missing percentage_value |
Info | invoice_id、tax_rate_id |
| L12 | subscription tax association missing tax_behavior, defaulting from currency(§7.3) |
Error | error、tax_association_id、tax_rate_id、currency、resolved_behavior |
L5 让任何发票的税额都能手工重算:taxable_amount 减 inclusive_tax 得出价外税率作用其上的 net,逐行分摊在 TaxApplied 行上。L9 是免税客户被免除金额的唯一记录——这些金额不出现在响应或 DB 中。L1/L2 与 L3 一起给出每次关联创建尝试(接受或拒绝)的完整轨迹。
L12 是这里唯一的 Error,且是刻意的:它在一个创建与 §3.4 回填本应都已使其不可能的状态上触发,所以要引起注意而非耸耸肩。其余都是例行结果,而 Warn 按 AGENTS.md 仅供启动引导。
编号保留原始 L 号以追溯到早期草稿,因此有缺口。L4(回填行数)属于 SQL 迁移而非服务;L6(钳制/溢出)、L7(价外循环)、L8(取整余数)被删除:L7/L8 的逐行金额已持久化在 TaxApplied 行上,L6 记录的是 inclusive_tax 超过 base——只有固定金额价内税率才会产生,而税率现为纯百分比(§4),inclusive_tax < base 恒成立,故无此分支可记录。L10(盖章 reason code)同样删除:reason code 在 applyTaxResultToInvoice 中由 L5/L9 已可见的数据设置,并随发票本身返回。
9. Phase 2 展望与现状准备
行项目级税收(invoice_line_item 在 invoice 之下),被折扣下摊阻塞。要点:
- 实时解析而非复制下传。
tenant → customer → subscription在创建时复制行;subscription → invoice实时解析、无持久化行(PrepareTaxRatesForInvoice)。invoice → invoice_line_item应扩展实时模式——行在每周期和重算时都会创建,为常见的"全继承"情形持久化每行是浪费写。 - 仅覆盖写。 只有当某行确实覆盖时才写行项目关联行。
- 折扣下摊(阻塞项)。
TotalDiscount今天是单一发票级数字。逐行税需要逐行 base,而逐行 base 需要一个已决断的折扣分摊规则。按行金额成比例是标准做法,但未被假定。 TaxApplied基数。 逐行记录会破坏 PDF 渲染使用的廉价entity_type = invoice汇总。需要带索引的invoice_id。- §4.3 的分组是逐行进行还是保持发票级? 未决。
v1 为就绪做了什么是: 每个计算函数都将 base 作为参数接收,而不是在内部读 inv.Subtotal。Phase 2 对每行以该行的 base 调用同样的函数并汇总结果——数学不变,变的只是谁计算 base 以及结果如何聚合。
10. 设计决策汇总(Q1–Q6)
| # | 问题 | 决议 |
|---|---|---|
| Q1 | 豁免放在哪里? | Customer.tax_treatment(默认 taxable,exempt)——§2.3 |
| Q2 | 同一客户/套餐,两个不同总额 | 已解决——新订阅被校验拦截(§3.2);既有订阅的状态变更绝不触碰历史发票,且在下一张发票被正确处理,此为有意设计(§5.6) |
| Q3 | total_tax 的语义变吗? |
保持两类之和用于报告;total 从 tax_summary.exclusive_tax 重建,绝不用 total_tax(§5.3) |
| Q4 | 取整余数 | 实现期精度处理(§4.4),非设计问题 |
| Q5 | tax_behavior 可更新吗? |
是——新发票与被重算的发票反映之;历史且未重算的发票保留冻结的 tax_behavior(§2.3、§7.4) |
| Q6 | 已开出发票的税如何通过 credit note 退还? | 已解决——现有行项目级 credit note 机制,原封不动,无新能力。void + 重新生成被否决。租户自行计算修正并选择行项目(§5.7) |
Q2 细节。 早期框架把它视为"从三条计算路径间选择以调和两个计算路径"。实际决议拒绝了该框架:并没有两条路径需要调和。customer.tax_treatment 在每次发票生成时被新鲜读取(§5.5)——一直如此——所以每张发票对其生成时的状态天然正确,无需重算、无需让历史一致。订阅创建时的校验(§3.2)预先阻止常见错误;§5.6 将"无追溯效果"保证作为书面契约明确声明;§5.7 覆盖租户仍想修正历史发票的做法。这一切都不需要从最初的 (a)/(b)/(c) 选项中挑选——需要丢弃的正是"必须二选一"的前提。
Q3 细节。 subtotal 已包含任何价内税——行金额从未在不含税的情况下被计算。所以 subtotal = net + inclusive_tax,价内税从未被加到总额上,只有价外税会:
total = subtotal − discount + exclusive_tax
| 场景 | 化简为 |
|---|---|
| 仅价外 | subtotal − discount + exclusive_tax |
| 仅价内 | subtotal − discount(exclusive_tax = 0) |
| 混合 | subtotal − discount + exclusive_tax |
| 豁免且价内存在 | subtotal − discount − inclusive_tax |
total_tax 保持"这张发票上有多少税"的含义(inclusive_tax + exclusive_tax),这正是它被问到的报告性问题。价内税出现后,total = subtotal − discount + total_tax 不再成立——发布前每个用 total_tax 做算术而非报告的消费方都需要审计;算术应落在 tax_summary.exclusive_tax 上。
11. 设计到实现的映射速查
设计文档开篇即给出了设计与代码的对照表,是阅读源码的最佳索引:
| 设计章节 | 代码位置 |
|---|---|
| §4 解析、§8.5 裸税率路径 | PrepareTaxRatesForInvoice(internal/ee/service/tax.go) |
| §4.1 盖章、§3.3 >100% 护栏 | resolveEffectiveTaxBehavior(internal/ee/service/tax.go) |
| §4.2 豁免拒绝 | CreateTaxAssociation 内联(internal/ee/service/tax.go) |
| §5 公式 | calculateTaxBreakdown(internal/ee/service/tax.go) |
| §6.3 豁免覆盖 | CalculateTaxesOnInvoice(internal/ee/service/tax.go) |
| §6.3/§6.4 发票总额 + reason code | applyTaxResultToInvoice(internal/ee/service/invoice.go) |
| §6.5 实时税务状态读取 | PrepareTaxRatesForInvoice 中一次性取客户,经 dto.NewInvoiceTaxRates 配对 |
| §7 响应形态 | buildTaxSummary、WithTaxes(internal/api/dto/invoice.go) |
| §3 Schema | ent/schema/customer.go、ent/schema/invoice.go、ent/schema/taxrate.go、ent/schema/taxapplied.go、ent/schema/taxassociation.go |
| §4.4 回填迁移 | migrations/versioned/postgres/20260831082024_tax_inc_exc_exempt.sql |
| 测试 | internal/ee/service/tax_preview_test.go、internal/api/dto/invoice_test.go |
此外,幂等性由 processTaxApplication(internal/ee/service/tax.go)保证:以 tax_rate_id + entity_id + entity_type 生成幂等键,重算时更新既有 TaxApplied 行的金额与行为而非重复插入——这与 TaxApplied.idempotency_key UK 的 Schema 约束相互印证。
结语
FLE-1187 的设计核心可以浓缩为三条不变量:价内税从不移动总额(只做报告)、豁免是计算完成后的单点覆盖而非分支、tax_behavior 在应用时冻结以保证历史可复现。理解这三条,就能读懂 calculateTaxBreakdown 的分组-提取-分摊流程、applyTaxResultToInvoice 的总额语义以及三类发票响应为什么长成那样。若要继续深挖,推荐从 internal/ee/service/tax_preview_test.go 的用例入手——它把本文的每个数学示例都变成了可执行断言。