FlexPrice 税收继承与覆盖流程深度解析:从客户、订阅到发票的三层计税架构
FlexPrice 税收继承与覆盖流程深度解析:从客户、订阅到发票的三层计税架构
本篇技术指南以 FlexPrice 开源仓库中的 税收继承与覆盖流程设计文档 为核心骨架,结合 税服务实现、订阅税关联逻辑 与 数据库约束脚本 等源码证据,完整讲解 FlexPrice 中税收(Tax)从**客户(Customer)→ 订阅(Subscription)→ 发票(Invoice)**的三层继承与覆盖模型。读完本文,你将掌握 FlexPrice 税收模块的实体模型、AutoApply 继承语义、LinkTaxRatesToEntity 的覆盖处理机制、基于幂等键的税额落库,以及税率的更新/删除保护规则与异常回退策略。
一、整体架构:三层实体、一条继承链
FlexPrice 的税收体系围绕三个核心实体展开:TaxRate(税率定义)、TaxAssociation(税率与实体的绑定关系)、TaxApplied(已应用到发票上的税额记录)。三者之间的关系可以从流程图中直接读出:
CustomerTaxAssoc -.->|Inherits| SubTaxAssoc:客户级税关联在创建订阅时被继承复制为订阅级税关联;SubTaxAssoc -.->|Applies to| TaxAppliedRecord:订阅级税关联在生成发票时被应用为发票上的税额记录。
这一条 客户 → 订阅 → 发票 的继承链,正是整个税收模块的主干。对应的层级顺序在代码中有明确声明:
// internal/types/taxassociation.go
var EntityHierarchy = []TaxRateEntityType{
TaxRateEntityTypeTenant, // tenant(租户)层
TaxRateEntityTypeCustomer, // customer(客户)层
TaxRateEntityTypeSubscription, // subscription(订阅)层
}
三个实体类型加上发票层的 TaxRateEntityTypeInvoice,完整定义在 internal/types/taxrate.go 中。层级顺序决定了覆盖(Override)的优先级:越靠前的层级优先级越高,低层级的配置不会覆盖高层级的显式配置。
原文档流程图
原文档 以 Mermaid 流程图完整描述了该模型,以下是其核心内容(略作格式整理以便阅读):
flowchart TD
%% Tax Override & Inheritance Patterns
subgraph "Tax Inheritance & Override Patterns"
direction TB
CustCreate[Customer Creation] --> CustTaxOver{Has Tax Rate<br/>Overrides?}
CustTaxOver -->|Yes| ProcessCustOver[Process Customer<br/>Tax Overrides]
ProcessCustOver --> MixedOverrides{Mixed Overrides?<br/>New + Existing}
MixedOverrides -->|Yes| ResolveNewTax[Create New Tax Rates]
MixedOverrides -->|No| UseExistingTax[Use Existing Tax Rates]
ResolveNewTax --> LinkCustTax[Link Tax Rates to Customer]
UseExistingTax --> LinkCustTax
LinkCustTax --> CustTaxAssoc[Customer Tax Associations<br/>Priority, AutoApply]
SubCreate[Subscription Creation] --> SubTaxOver{Has Tax Rate<br/>Overrides?}
SubTaxOver -->|Yes| ProcessSubOver[Process Subscription<br/>Tax Overrides]
ProcessSubOver --> SubTaxAssoc[Subscription Tax Associations]
SubTaxOver -->|No| InheritFromCust[Inherit from Customer]
InheritFromCust --> GetCustTax[Get Customer Tax Associations<br/>WHERE AutoApply = true]
GetCustTax --> CopyToSub[Copy to Subscription<br/>Same Priority & AutoApply]
CopyToSub --> SubTaxAssoc
InvCreate[Invoice Creation] --> InvType{Invoice Type?}
InvType -->|One-off| InvTaxOver{Has Tax Rate<br/>Overrides?}
InvTaxOver -->|Yes| ProcessInvOver[Process Invoice<br/>Tax Overrides]
ProcessInvOver --> ApplyInvTax[Apply Taxes to Invoice]
InvType -->|Subscription| GetSubTax[Get Subscription<br/>Tax Associations<br/>WHERE AutoApply = true]
GetSubTax --> ApplySubTax[Apply Subscription<br/>Taxes to Invoice]
ApplyInvTax --> TaxCalc[Tax Calculation & Application]
ApplySubTax --> TaxCalc
TaxCalc --> IdempotencyCheck[Idempotency Check<br/>ScopeTaxApplication]
IdempotencyCheck --> TaxAppliedRecord[Create/Update<br/>Tax Applied Record]
CustTaxAssoc -.->|Inherits| SubTaxAssoc
SubTaxAssoc -.->|Applies to| TaxAppliedRecord
end
二、核心数据模型:TaxRate 与 TaxAssociation
1. TaxRate(税率定义)
税率是税收计算的最小单位,其领域模型定义在 internal/domain/tax/model.go,关键字段包括:
| 字段 | 说明 |
|---|---|
Code |
税率唯一编码,创建税关联时通过 tax_rate_code 引用 |
TaxRateType |
税率类型,当前仅支持 percentage(百分比税率),固定金额税率不支持 |
TaxRateStatus |
状态:ACTIVE / INACTIVE,创建时默认置为 ACTIVE |
Scope |
作用域:INTERNAL(内部操作)、EXTERNAL(面向客户)、ONETIME(一次性发票) |
PercentageValue |
百分比数值,类型为 *decimal.Decimal |
状态与作用域的合法取值定义在 internal/types/taxrate.go。从源码注释“Fixed-amount tax rates are not supported”可以推断,当前版本的税率引擎是纯百分比模型,所有税额计算均围绕 PercentageValue 展开。
2. TaxAssociation(税率关联)
TaxAssociation 是继承链上的“胶水”,它把一条税率绑定到某个具体实体,并携带解析顺序与自动应用语义。领域模型见 internal/domain/taxassociation/model.go:
Priority:解析优先级,数值越小优先级越高,schema 默认值为100(见 ent/schema/taxassociation.go);AutoApply:是否自动应用,schema 默认值为true(见 ent/schema/taxassociation.go)。这是继承发生时“拷贝哪些关联”的过滤条件;Currency:币种,订阅层关联的币种与订阅一致;StartDate/EndDate:生效区间,发票计税时可按周期过滤;TaxBehavior:inclusive(价内税)或exclusive(价外税),缺失时默认exclusive。
从 schema 的 Indexes(ent/schema/taxassociation.go)可以看到两个关键索引:主查找索引 (tenant_id, environment_id, entity_type, entity_id, status) 与税率反向查找索引 (tenant_id, environment_id, tax_rate_id)——前者支撑“按实体查税关联”的继承流程,后者支撑“按税率查哪些实体在用”的更新/删除保护。
三、客户层级:覆盖(Override)与继承的起点
流程图的第一段是客户创建流程:Customer Creation --> Has Tax Rate Overrides?。
- 有覆盖:进入
Process Customer Tax Overrides,随后判断是否为“混合覆盖”(同时包含新税率与既有税率):- 混合 →
Create New Tax Rates(先创建新税率); - 非混合 →
Use Existing Tax Rates(直接使用既有税率); - 最终统一
Link Tax Rates to Customer,形成客户级税关联,携带Priority与AutoApply。
- 混合 →
- 无覆盖:客户创建时不传
tax_rate_overrides,后续订阅创建时将从客户(乃至租户)层继承默认税率。
这一分支在 API 层的 DTO 中有明确注释(internal/api/dto/taxassociation.go):TaxRateOverride 的作用是“覆盖特定实体的税率——如果你在创建客户请求中传入 tax_overrides,就把税率关联到客户;否则继承租户税率”。它本质上是把已存在的税率链接到实体,而不是定义新税率本身。
客户层创建的 DTO 字段如下(internal/api/dto/taxassociation.go):
type TaxRateOverride struct {
TaxRateCode string `json:"tax_rate_code" binding:"required"` // 必填:税率编码
Priority int `json:"priority" binding:"omitempty"` // 解析优先级,越小越优先
Currency string `json:"currency" binding:"required"` // 必填:币种
AutoApply bool `json:"auto_apply" binding:"omitempty" default:"true"`
Metadata map[string]string `json:"metadata" binding:"omitempty"`
TaxBehavior *types.TaxBehavior `json:"tax_behavior,omitempty"`
}
无论是“创建新税率”还是“复用既有税率”,最终都经由 TaxService.LinkTaxRatesToEntity 在一个事务(DB.WithTx)内完成批量关联。该方法同时支持两条路径:
TaxRateOverrides:遍历覆盖项,先逐个Validate(),再调用CreateTaxAssociation创建关联(含优先级与自动应用标记);ExistingTaxAssociations:对既有关联逐一读取税率、取得其Code,然后把相同的Priority、AutoApply、Currency、Metadata、TaxBehavior复制到目标实体——这正是流程图LinkCustTax节点的实现。
四、订阅层级:显式覆盖优先,否则继承客户
订阅创建时的分支逻辑在流程图第二段:Subscription Creation --> Has Tax Rate Overrides?。
实现这一逻辑的是 subscriptionService.handleTaxRateLinking:
- 有覆盖:调用
taxService.LinkTaxRatesToEntity(EntityType=subscription, EntityID=sub.ID, TaxRateOverrides=req.TaxRateOverrides),将请求中的覆盖项直接绑定到订阅; - 无覆盖:查询客户级税关联:
filter := types.NewNoLimitTaxAssociationFilter()
filter.EntityType = types.TaxRateEntityTypeCustomer
filter.EntityID = sub.CustomerID
filter.AutoApply = lo.ToPtr(true) // WHERE AutoApply = true
filter.Status = lo.ToPtr(types.StatusPublished)
tenantTaxAssociations, err := taxService.ListTaxAssociations(ctx, filter)
随后把查到的客户税关联作为 ExistingTaxAssociations 传给 LinkTaxRatesToEntity,原样拷贝 Priority 与 AutoApply 到订阅层——即流程图中 Inherit from Customer --> Get Customer Tax Associations WHERE AutoApply = true --> Copy to Subscription (Same Priority & AutoApply) 的完整落地。这里可以提炼两条关键继承语义:
- 只继承
AutoApply = true的关联:被显式关闭自动应用的税率不会下沉到订阅; - 状态过滤
published:仅已发布状态的关联参与继承,软删除/归档的关联被排除。
订阅创建之后,还存在“订阅变更期加税/减税”的运维路径(internal/ee/service/subscription_modification_tax.go):executeAddTax 会校验税率必须为 ACTIVE,并检查同一税率在当前生效区间内是否已存在关联(防重复),随后以 Priority=100、AutoApply=true、币种取订阅币种的方式创建订阅级关联;executeRemoveTax 则校验关联确属于该订阅且未过期后删除。
五、发票层级:一次性发票 vs 订阅发票
发票创建时先判断发票类型(流程图第三段 Invoice Creation --> Invoice Type?):
- One-off(一次性发票):若请求带
tax_rate_overrides,走Process Invoice Tax Overrides直接应用; - Subscription(订阅发票):
Get Subscription Tax Associations WHERE AutoApply = true,把订阅级税关联应用到发票。
上述“解析该客户适用哪些税率、每种税率带什么行为、客户是否免税”的完整逻辑集中在 TaxService.PrepareTaxRatesForInvoice,其解析优先级从代码注释可以明确读出:
"Rate overrides win over raw tax_rate IDs, which win over the subscription's own associations."
即请求级覆盖(overrides)> 原始税率 ID 列表 > 订阅自身关联,具体分支为:
req.TaxRateOverrides非空:按覆盖项解析税率,行为(TaxBehavior)显式给定,否则默认exclusive;req.TaxRates非空:按裸税率 ID 解析,行为默认exclusive(源码注释明确这是已知缺口:不咨询租户/客户层级,如需行为语义请用tax_rate_overrides);req.SubscriptionID非空:过滤订阅级税关联,条件为EntityType=subscription、EntityID=订阅ID、AutoApply=true,且可选按PeriodStart/PeriodEnd做时间区间重叠过滤;每个关联的TaxBehavior与其税率成对保留;- 以上均不满足:返回空税率集。
与此同时,客户是否免税也在这一环节一并解析:NewInvoiceTaxRates 会把客户 TaxTreatment == exempt 记录为 InvoiceTaxRates.Exempt(internal/api/dto/taxassociation.go),保证“税率的解析结果”与“免税标记”永远来自同一时刻读取的客户数据。
六、税额计算与幂等落库
无论是哪种发票,最终都汇入同一段收尾:Tax Calculation & Application --> Idempotency Check (ScopeTaxApplication) --> Create/Update Tax Applied Record。
1. 计算:CalculateTaxesOnInvoice
CalculateTaxesOnInvoice 只算不写,产出 TaxCalculationResult:
InclusiveTax:价内税,从含税金额中反推,不计入发票总额;ExclusiveTax:价外税,在应税金额之上累加,是唯一改变发票总额的部分;TotalTaxAmount:实际收取的税额 = 价内 + 价外(客户免税时归零);Exempt:免税标记;TaxAppliedRecords:逐条税率的应用记录(内存中构建,供预览使用)。
核心算式在 calculateTaxBreakdown,其排序语义对齐 Stripe:先合并所有价内税率,用 amount * rate / (100 + rate) 反推含在金额中的税,再按各自税率比例拆分(四舍五入的尾差归入最大份额,保证行和等于整税);随后价外税在“应税金额扣除价内税后的净额”上计算,避免对价内部分重复征税。税率缺少 percentage_value 的条目会在 buildRateLines 中被直接跳过。
2. 落库:ApplyTaxesOnInvoice 与幂等
ApplyTaxesOnInvoice 调用计算函数后,逐条走 processTaxApplication,幂等键按 ScopeTaxApplication 生成:
idempGen := idempotency.NewGenerator()
idempotencyKey := idempGen.GenerateKey(idempotency.ScopeTaxApplication, map[string]interface{}{
"tax_rate_id": taxRate.ID,
"entity_id": inv.ID,
"entity_type": string(types.TaxRateEntityTypeInvoice),
})
先按幂等键查已有 TaxApplied 记录:存在则更新(刷新应税金额、税额、行为与应用时间),不存在则新建并写入幂等键。这对应流程图 Idempotency Check --> Create/Update Tax Applied Record,保证同一发票重复触发计税不会产生重复税额。数据库层还有第二道保险——tax_applieds 表上的唯一索引 idx_entity_tax_rate_id (tenant_id, environment_id, entity_type, entity_id, tax_rate_id)(见 migrations/baseline/postgres_baseline_20260819.sql)。
七、校验与级联约束:税率更新的“使用中保护”
流程图的第二个子图专门描述校验与级联检查,全部在源码中有对应实现。
1. 税率更新保护
Tax Rate Update --> Check Tax Rate Usage --> Has Tax Associations? --> BLOCK UPDATE。
UpdateTaxRate 在更新前做两次“使用中检查”:
- 查税关联:
TaxAssociationFilter.TaxRateIDs=[id],只要有任意关联存在,即返回错误"tax rate is being used in tax assignments, cannot update"; - 查税应用记录:
TaxAppliedFilter.TaxRateIDs=[id],只要有任意TaxApplied记录,同样拒绝更新。
也就是说:一旦税率被任何实体引用或已被应用到发票,其名称、编码、描述、状态均不可修改,避免历史发票的税额语义被悄然改变。
2. 税率删除保护(软删除)
Tax Rate Delete --> Check Tax Applied Records --> Has Applied? --> BLOCK DELETE / Allow Soft Delete。
DeleteTaxRate 只检查活跃关联(Status=published):存在活跃关联则拒绝删除并给出友好提示“请先移除所有关联”;否则调用仓库的 Delete,由仓库层完成归档(archive)而非物理删除,对应流程图的 Allow Soft Delete。
3. 防重复的唯一约束
Duplication Prevention --> Unique Constraint (tenant_id + environment_id + entity_type + entity_id + tax_rate_id)。
这条约束在迁移脚本中有确切定义:
-- migrations/baseline/postgres_baseline_20260819.sql
CREATE UNIQUE INDEX unique_entity_tax_mapping ON public.tax_associations
USING btree (tenant_id, environment_id, entity_type, entity_id, tax_rate_id)
WHERE ((status)::text = 'published'::text);
注意它是部分唯一索引——仅对 published 状态生效,因此同一税率可以被软删除后重新绑定,但同一实体上不能同时存在两条已发布且指向同一税率的关联。约束冲突在服务层被转换为用户可读的友好错误信息(见流程图 Constraint Violation --> Handle Constraint Error --> Return User-Friendly Message)。
4. 覆盖与继承的冲突校验
Override vs Inherited Validation --> Prevent Same Tax Rate Both Inherited & Overridden。流程图要求同一税率不能既被继承又被覆盖。这与订阅创建的代码逻辑一致:handleTaxRateLinking 中两个分支互斥(if len(req.TaxRateOverrides) > 0 {…} else if req.TaxRateOverrides == nil {…继承…}),一旦走覆盖路径就不再执行继承拷贝;而订阅变更路径的 executeAddTax 也会通过区间重叠检查拦截“同一税率已在订阅上生效”的重复添加。
5. 关联删除
Tax Association Delete --> Validate Deletion --> Cleanup Applied Records?。服务层的 DeleteTaxAssociation 先校验关联存在再执行删除;与它配套的是订阅变更路径中 executeRemoveTax 的“关联归属校验”(必须是本订阅的关联、未过期)。
八、错误处理与回退策略
流程图第四个区域给出四类异常的处理策略,逐一对应到源码行为:
| 异常场景 | 流程图策略 | 源码佐证 |
|---|---|---|
| 税率不存在 | Skip Tax Application(跳过计税) |
PrepareTaxRatesForInvoice 在无税率解析结果时返回空税率集,ApplyTaxesOnInvoice 对空集直接返回 TotalTaxAmount=0 与空记录 |
| 校验失败 | Return Validation Error + Log Error Details |
服务层大量 ierr.NewError(...).Mark(ierr.ErrValidation) 与 s.Logger.Info/Error 成对出现 |
| 约束冲突 | Handle Constraint Error → 用户友好消息 |
唯一索引冲突被转为带 WithHint 的友好提示(如删除保护时的“请先移除所有关联”) |
| 计税异常 | Recalculation Fallback → Apply Zero Tax |
计算函数对缺失 percentage_value 的税率直接跳过;对免税客户统一将 TotalTaxAmount 归零 |
值得强调的是,免税(Exempt)并不是计算逻辑的分支,而是计算结果之上的统一覆盖:CalculateTaxesOnInvoice 中注释明确写道 "Tax is computed the same way for everyone; exemption only zeroes what is charged",即价内/价外税额照常计算,仅在最终收费时归零(internal/ee/service/tax.go)。免税原因码 customer_exempt / no_tax_configured 定义在 internal/types/tax.go。
九、税率状态与作用域判定
流程图的最后一个子图给出两套派生逻辑:
1. 状态判定
Tax Rate Status Calculation:
ValidFrom > Now→INACTIVE(尚未生效);ValidTo < Now→INACTIVE(已过期);- 否则 →
ACTIVE。
状态常量 ACTIVE / INACTIVE 定义在 internal/types/taxrate.go。创建时新税率默认置为 ACTIVE(internal/ee/service/tax.go),且创建/添加税关联前都会强制校验税率为 ACTIVE(见 CreateTaxAssociation 与 executeAddTax),非活跃税率无法被关联使用。
2. 作用域判定
Tax Rate Scope Check:
INTERNAL:内部操作使用;EXTERNAL:面向客户;ONETIME:一次性发票专用。
三个作用域常量定义在 internal/types/taxrate.go,并在 TaxRateFilter 中作为查询条件开放(filter.Scope)。
十、设计要点小结
结合原文档与源码,可以把 FlexPrice 税收继承设计提炼为四个可复用的模式:
- 模板化继承:租户/客户层的税关联是“模板”,订阅创建时仅拷贝
AutoApply=true且已发布的关联,且原样保留Priority与AutoApply,形成稳定的三层继承链; - 显式覆盖优先:每一层都先检查请求级
tax_rate_overrides,有覆盖则跳过继承,保证“就近原则”(高优先级配置遮蔽低层级默认值); - 变更即快照:税率一旦被关联或应用即进入“只读”状态,历史发票的税额语义通过
TaxApplied记录与唯一索引被固化; - 幂等落库:税额应用以
ScopeTaxApplication幂等键驱动“存在即更新、否则创建”,配合数据库唯一索引双保险,天然支持发票重算与重放。
如需进一步深入,建议继续阅读:税收引擎设计文档、税服务完整实现、税关联 DTO 与校验、订阅变更加税/减税实现 以及 发票计税调用链。