PAYATHON 2026

Database Design:如何设计唯一的 Billing Association?

支付老李

结论

建议把“结算主体”建模为独立实体,不要在 Distributors 中同时使用可空的 PaymentTermsId 和 BillingAccountId。

关系可以设计为:

  • 每个 Distributor 必须且只能关联一个 BillingAccount。
  • 多个 Distributor 可以共享同一个 BillingAccount。
  • PaymentTerms 属于 BillingAccount,不属于普通 Distributor。
  • 独立结算的 distributor 也创建自己的 BillingAccount,无需用 NULL 表示“使用自己的条款”。

数据库结构由此可以保证,共享同一结算账户的 distributors 使用相同的 payment terms,不必依赖应用层同步数据。

推荐的数据模型

Distributor ── N:1 ── BillingAccount ── N:1 ── PaymentTerms

示例:

CREATE TABLE PaymentTerms (
    PaymentTermsId BIGINT PRIMARY KEY,
    Name           VARCHAR(100) NOT NULL,
    DueDays        INTEGER NOT NULL,
    CHECK (DueDays >= 0)
);

CREATE TABLE BillingAccounts (
    BillingAccountId BIGINT PRIMARY KEY,
    AccountName      VARCHAR(200) NOT NULL,
    PaymentTermsId   BIGINT NOT NULL,

    CONSTRAINT FK_BillingAccounts_PaymentTerms
        FOREIGN KEY (PaymentTermsId)
        REFERENCES PaymentTerms (PaymentTermsId)
);

CREATE TABLE Distributors (
    DistributorId   BIGINT PRIMARY KEY,
    Name            VARCHAR(200) NOT NULL,
    BillingAccountId BIGINT NOT NULL,

    CONSTRAINT FK_Distributors_BillingAccounts
        FOREIGN KEY (BillingAccountId)
        REFERENCES BillingAccounts (BillingAccountId)
);

CREATE INDEX IX_Distributors_BillingAccountId
    ON Distributors (BillingAccountId);

假设某个 corporate account 统一处理三家门店的账单,数据可以这样保存:

INSERT INTO PaymentTerms (
    PaymentTermsId,
    Name,
    DueDays
)
VALUES (1, 'Net 30', 30);

INSERT INTO BillingAccounts (
    BillingAccountId,
    AccountName,
    PaymentTermsId
)
VALUES (100, 'Example Stores Corporate Billing', 1);

INSERT INTO Distributors (
    DistributorId,
    Name,
    BillingAccountId
)
VALUES
    (1001, 'Example Store - Shanghai', 100),
    (1002, 'Example Store - Beijing', 100),
    (1003, 'Example Store - Shenzhen', 100);

查询 distributor 的有效 payment terms 时,不需要判断多个可空字段:

SELECT
    d.DistributorId,
    d.Name,
    ba.BillingAccountId,
    pt.PaymentTermsId,
    pt.Name AS PaymentTermsName,
    pt.DueDays
FROM Distributors AS d
JOIN BillingAccounts AS ba
    ON ba.BillingAccountId = d.BillingAccountId
JOIN PaymentTerms AS pt
    ON pt.PaymentTermsId = ba.PaymentTermsId
WHERE d.DistributorId = :DistributorId;

为什么不建议 Solution #1

Solution #1 在两个地方表达了 payment terms:

  • Distributors.PaymentTermsId 保存一份。
  • billing association 又表示应该使用另一个 distributor 的 payment terms。

这样会出现两个事实来源。如果 corporate account 的 payment terms 发生变化,所有关联 distributor 的 PaymentTermsId 都要同步更新。仅靠普通外键或 CHECK constraint,数据库很难保证这些跨行数据始终一致。

例如,下面的数据在结构上可能完全合法,但在业务上互相冲突:

Store.PaymentTermsId             = Net 15
CorporateAccount.PaymentTermsId  = Net 30
Store.BillingDistributorId       = CorporateAccount

应用代码、批处理、数据导入工具和人工 SQL 都要执行相同的同步逻辑,维护风险较高。

除非 distributor 的 payment terms 是有意保留的历史快照,否则不应保存这种能够通过关联关系计算出来的重复数据。

Solution #2 可以使用,但不够理想

Solution #2 比 Solution #1 更合理,因为它没有重复保存相同的 payment terms。不过,这个方案仍有几个问题。

两个可空字段形成互斥状态

按照业务规则,每行只能处于以下两种状态之一:

有 PaymentTermsId,没有 BillingAccountId

或者:

没有 PaymentTermsId,有 BillingAccountId

因此至少要添加以下约束:

CHECK (
    (PaymentTermsId IS NOT NULL AND BillingAccountId IS NULL)
    OR
    (PaymentTermsId IS NULL AND BillingAccountId IS NOT NULL)
)

即使添加了这个约束,普通约束通常仍无法阻止以下情况:

  • distributor 引用自己。
  • A 的 billing account 是 B,而 B 的 billing account 又是 A。
  • A 指向 B,B 再指向 C,形成多级结算链。
  • 被引用的 billing account 本身没有 PaymentTermsId。

这些规则通常需要通过 trigger、受控存储过程或应用层校验来保证。

查询逻辑容易扩散

每次获取 payment terms 时,都要判断条款来自当前记录还是关联记录:

SELECT
    COALESCE(d.PaymentTermsId, billing.PaymentTermsId) AS EffectivePaymentTermsId
FROM Distributors AS d
LEFT JOIN Distributors AS billing
    ON billing.DistributorId = d.BillingAccountId
WHERE d.DistributorId = :DistributorId;

如果允许多级引用,查询就需要递归,还必须处理循环引用。报表、接口和数据导出增多后,很容易有调用方遗漏这套特殊规则。

NULL 承担了额外的业务含义

在这个方案中,PaymentTermsId IS NULL 并不表示“没有 payment terms”,而是表示“到另一个 distributor 查找”。这与“每个 distributor 始终都有 payment terms”的业务描述并不完全一致,也让字段本身更难理解。

如果必须使用自引用

如果业务明确规定“结算主体一定也是 distributor”,同时又不希望增加 BillingAccounts 表,可以使用非空的自引用列,让独立结算的 distributor 指向自己:

CREATE TABLE Distributors (
    DistributorId       BIGINT PRIMARY KEY,
    Name                VARCHAR(200) NOT NULL,
    BillingDistributorId BIGINT NOT NULL,
    PaymentTermsId      BIGINT NULL,

    CONSTRAINT FK_Distributors_BillingDistributor
        FOREIGN KEY (BillingDistributorId)
        REFERENCES Distributors (DistributorId),

    CONSTRAINT FK_Distributors_PaymentTerms
        FOREIGN KEY (PaymentTermsId)
        REFERENCES PaymentTerms (PaymentTermsId)
);

具体约定如下:

  • 自己负责结算时,设置 BillingDistributorId = DistributorId,并填写 PaymentTermsId。
  • 由其他 distributor 结算时,指向负责结算的 distributor,自身的 PaymentTermsId 必须为 NULL。
  • 不允许多级代理,被引用行的 BillingDistributorId 必须指向自身。

示意数据:

DistributorId | Name              | BillingDistributorId | PaymentTermsId
--------------+-------------------+----------------------+---------------
100           | Corporate Account | 100                  | 1
101           | Store A           | 100                  | NULL
102           | Store B           | 100                  | NULL
200           | Independent Store | 200                  | 2

这种设计消除了 BillingAccountId IS NULL 这种特殊情况,但普通声明式约束仍很难完整保证“被引用行必须是根结算账户”和“禁止循环引用”。

可以通过视图统一查询方式,避免各个调用方分别实现相同的规则:

CREATE VIEW DistributorEffectivePaymentTerms AS
SELECT
    d.DistributorId,
    d.Name,
    billing.DistributorId AS BillingDistributorId,
    billing.PaymentTermsId
FROM Distributors AS d
JOIN Distributors AS billing
    ON billing.DistributorId = d.BillingDistributorId;

如果数据库支持 trigger,也可以在写入时拒绝多级引用和循环关系。不过,trigger 的具体语法取决于数据库产品,需要分别针对 PostgreSQL、SQL Server、MySQL 或 Oracle 实现。

迁移建议

从旧结构迁移到独立的 BillingAccounts 模型时,可以按以下顺序进行:

  1. 为每个独立结算主体创建一条 BillingAccounts 记录。
  2. 将该主体原有的 PaymentTermsId 写入对应的 billing account。
  3. 将连锁门店等关联 distributor 指向 corporate billing account。
  4. 为没有共享结算关系的 distributor 创建专属 billing account。
  5. 验证每个 distributor 都有且只有一个有效关联。
  6. 将 Distributors.BillingAccountId 改为 NOT NULL。
  7. 确认所有查询都已改用 billing account,再移除旧的 Distributors.PaymentTermsId。

迁移后,可以用下面的查询检查是否存在无效关联:

SELECT d.DistributorId
FROM Distributors AS d
LEFT JOIN BillingAccounts AS ba
    ON ba.BillingAccountId = d.BillingAccountId
WHERE ba.BillingAccountId IS NULL;

正常情况下,这条查询不应返回任何记录。

还需要注意的业务问题

发票应保存历史快照

当前的 payment terms 可能发生变化,但已经开具的发票通常不应随之改变。因此,生成发票时应保存当时使用的条款或关键计算结果:

CREATE TABLE Invoices (
    InvoiceId              BIGINT PRIMARY KEY,
    DistributorId          BIGINT NOT NULL,
    BillingAccountId       BIGINT NOT NULL,
    PaymentTermsId         BIGINT NOT NULL,
    InvoiceDate            DATE NOT NULL,
    DueDate                DATE NOT NULL,

    FOREIGN KEY (DistributorId)
        REFERENCES Distributors (DistributorId),

    FOREIGN KEY (BillingAccountId)
        REFERENCES BillingAccounts (BillingAccountId),

    FOREIGN KEY (PaymentTermsId)
        REFERENCES PaymentTerms (PaymentTermsId)
);

这里的 PaymentTermsId 不是无意义的重复字段,而是交易发生时的历史记录。如果 PaymentTerms 允许原地修改,还应保存条款版本,或者直接保存 DueDate 等不可变结果。

Billing Association 可能有生效时间

如果 distributor 在不同时期可能归属不同的 corporate account,只保存当前的 BillingAccountId 就无法回答历史问题。此时应使用带有效期的关联表:

CREATE TABLE DistributorBillingAssignments (
    DistributorId   BIGINT NOT NULL,
    BillingAccountId BIGINT NOT NULL,
    EffectiveFrom   DATE NOT NULL,
    EffectiveTo     DATE NULL,

    PRIMARY KEY (DistributorId, EffectiveFrom),

    FOREIGN KEY (DistributorId)
        REFERENCES Distributors (DistributorId),

    FOREIGN KEY (BillingAccountId)
        REFERENCES BillingAccounts (BillingAccountId),

    CHECK (
        EffectiveTo IS NULL
        OR EffectiveTo > EffectiveFrom
    )
);

还要保证同一个 distributor 的有效期不能重叠。具体实现取决于数据库。部分数据库支持 exclusion constraint,其他数据库可能需要 trigger 或事务内校验。

如果系统只关心当前关系,直接在 Distributors 中保存非空的 BillingAccountId 会更简单。

最终选择

在给出的两个方案中,Solution #2 优于 Solution #1,因为它至少避免了 payment terms 的重复同步。不过从长期维护来看,更清晰的做法是把 BillingAccount 设为独立实体:

Distributors.BillingAccountId NOT NULL
BillingAccounts.PaymentTermsId NOT NULL

这个模型直接表达了“一名 distributor 只有一个结算归属,多个 distributors 可以共享同一结算主体”的业务规则,也避免了可空字段组合、自引用循环和 payment terms 不一致等问题。

备注:内容仅供参考。