FlexPrice 税收继承与覆盖流程深度解析:从客户、订阅到发票的三层计税架构

原创2026-10-09 10:27:271,829 阅读

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)内完成批量关联。该方法同时支持两条路径:

  1. TaxRateOverrides:遍历覆盖项,先逐个 Validate(),再调用 CreateTaxAssociation 创建关联(含优先级与自动应用标记);
  2. 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 列表 > 订阅自身关联,具体分支为:

  1. req.TaxRateOverrides 非空:按覆盖项解析税率,行为(TaxBehavior)显式给定,否则默认 exclusive;
  2. req.TaxRates 非空:按裸税率 ID 解析,行为默认 exclusive(源码注释明确这是已知缺口:不咨询租户/客户层级,如需行为语义请用 tax_rate_overrides);
  3. req.SubscriptionID 非空:过滤订阅级税关联,条件为 EntityType=subscription、EntityID=订阅ID、AutoApply=true,且可选按 PeriodStart/PeriodEnd 做时间区间重叠过滤;每个关联的 TaxBehavior 与其税率成对保留;
  4. 以上均不满足:返回空税率集。

与此同时,客户是否免税也在这一环节一并解析: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 税收继承设计提炼为四个可复用的模式:

  1. 模板化继承:租户/客户层的税关联是“模板”,订阅创建时仅拷贝 AutoApply=true 且已发布的关联,且原样保留 Priority 与 AutoApply,形成稳定的三层继承链;
  2. 显式覆盖优先:每一层都先检查请求级 tax_rate_overrides,有覆盖则跳过继承,保证“就近原则”(高优先级配置遮蔽低层级默认值);
  3. 变更即快照:税率一旦被关联或应用即进入“只读”状态,历史发票的税额语义通过 TaxApplied 记录与唯一索引被固化;
  4. 幂等落库:税额应用以 ScopeTaxApplication 幂等键驱动“存在即更新、否则创建”,配合数据库唯一索引双保险,天然支持发票重算与重放。

如需进一步深入,建议继续阅读:税收引擎设计文档、税服务完整实现、税关联 DTO 与校验、订阅变更加税/减税实现 以及 发票计税调用链。

登录后查看全文
flexprice