PAYATHON 2026

如何将不同 account types 关联到 Payments 和 Invoices?

支付老李

结论

建议建立统一的主体表,例如 party,并将 agency 和 customer 作为它的子类型。invoice 和 payment 统一引用 party.id:

  • invoice.bill_to_party_id 表示被开票方。
  • payment.payer_party_id 表示付款方。
  • agency.party_id、customer.party_id 采用“主键同时也是外键”的一对一关系。
  • Payment 与 Invoice 的核销关系单独保存在 payment_allocation 中。

这个表不适合命名为 payee。在会计语境中,payee 通常是收款方,而这里的主体既可能付款,也可能接收账单。party、billing_party 或 account 都更准确。

如果 party 只保存主键,查询名称时确实需要 JOIN 子表。这并不违反规范化原则,但也说明名称、状态等共有属性更适合放在 party 中。多数列表查询因此只需访问一个表,读取类型特有的信息时再 JOIN 子表。

推荐的数据模型

party
├── agency
└── customer

invoice  ── bill_to_party_id ──> party
payment  ── payer_party_id   ──> party

payment ── payment_allocation ── invoice

建模时需要分清以下业务概念:

  • Customer:被保险人、投保人或直接购买者。
  • Agency:销售渠道或代收机构。
  • Billing party:实际承担账单的主体。
  • Payer:实际付款的主体。
  • Payment allocation:记录某笔付款核销了哪些 Invoice。

保单由 Agency 销售,不代表 Invoice 一定开给 Agency。销售渠道和结算主体应分别建模。

PostgreSQL 示例

下面使用 PostgreSQL 语法。其他关系型数据库也可以采用相同的结构,但自增列、约束和触发器的语法可能不同。

CREATE TYPE party_type AS ENUM ('agency', 'customer');

CREATE TABLE party (
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    party_type      party_type NOT NULL,
    display_name    text NOT NULL,
    billing_status  text NOT NULL DEFAULT 'active',
    created_at      timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE agency (
    party_id        bigint PRIMARY KEY
                    REFERENCES party(id) ON DELETE RESTRICT,
    agency_code     text NOT NULL UNIQUE,
    license_number  text
);

CREATE TABLE customer (
    party_id        bigint PRIMARY KEY
                    REFERENCES party(id) ON DELETE RESTRICT,
    customer_number text NOT NULL UNIQUE,
    first_name      text,
    last_name       text
);

CREATE TABLE invoice (
    id                  bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    bill_to_party_id    bigint NOT NULL
                        REFERENCES party(id) ON DELETE RESTRICT,
    invoice_number      text NOT NULL UNIQUE,
    issued_at           date NOT NULL,
    due_at              date,
    total_amount        numeric(18, 2) NOT NULL
                        CHECK (total_amount >= 0),
    currency_code       char(3) NOT NULL,
    status              text NOT NULL
);

CREATE TABLE payment (
    id                  bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payer_party_id      bigint NOT NULL
                        REFERENCES party(id) ON DELETE RESTRICT,
    received_at         timestamptz NOT NULL,
    amount              numeric(18, 2) NOT NULL
                        CHECK (amount > 0),
    currency_code       char(3) NOT NULL,
    external_reference  text
);

CREATE TABLE payment_allocation (
    payment_id      bigint NOT NULL
                    REFERENCES payment(id) ON DELETE CASCADE,
    invoice_id      bigint NOT NULL
                    REFERENCES invoice(id) ON DELETE RESTRICT,
    allocated_amount numeric(18, 2) NOT NULL
                     CHECK (allocated_amount > 0),

    PRIMARY KEY (payment_id, invoice_id)
);

CREATE INDEX invoice_bill_to_party_idx
    ON invoice (bill_to_party_id);

CREATE INDEX payment_payer_party_idx
    ON payment (payer_party_id);

CREATE INDEX payment_allocation_invoice_idx
    ON payment_allocation (invoice_id);

现实中常见以下情况,因此 payment_allocation 很有必要:

  • 一笔 Payment 核销多张 Invoice。
  • 一张 Invoice 由多笔 Payment 分期支付。
  • Payment 已经收到,但暂时没有指定对应的 Invoice。
  • Payment 金额超过 Invoice,需要保留未分配余额。

除非业务能够明确保证始终是“一笔 Payment 对应一张 Invoice”,否则不建议直接在 payment 中保存 invoice_id。

创建 Agency 或 Customer

创建主体及其子类型记录时,应在同一个事务内完成:

BEGIN;

WITH new_party AS (
    INSERT INTO party (party_type, display_name)
    VALUES ('agency', 'Example Insurance Agency')
    RETURNING id
)
INSERT INTO agency (party_id, agency_code, license_number)
SELECT id, 'AG-10027', 'LIC-88991'
FROM new_party;

COMMIT;

创建 Customer 的方式相同:

BEGIN;

WITH new_party AS (
    INSERT INTO party (party_type, display_name)
    VALUES ('customer', '张三')
    RETURNING id
)
INSERT INTO customer (
    party_id,
    customer_number,
    first_name,
    last_name
)
SELECT id, 'CU-20481', '三', '张'
FROM new_party;

COMMIT;

之后创建 Payment 时,无须扫描 agency 和 customer:

INSERT INTO payment (
    payer_party_id,
    received_at,
    amount,
    currency_code,
    external_reference
)
VALUES (
    12345,
    CURRENT_TIMESTAMP,
    1200.00,
    'CNY',
    'BANK-20260831-001'
);

数据库会通过 payment.payer_party_id -> party.id 外键检查付款主体是否存在。

如何列出所有可能的付款方

如果公共展示字段保存在 party 中,一次查询即可:

SELECT
    id,
    party_type,
    display_name
FROM party
WHERE billing_status = 'active'
ORDER BY display_name;

只有查询类型特有字段时才需要 JOIN:

SELECT
    p.id,
    p.party_type,
    p.display_name,
    a.agency_code,
    c.customer_number
FROM party AS p
LEFT JOIN agency AS a
    ON a.party_id = p.id
LEFT JOIN customer AS c
    ON c.party_id = p.id
WHERE p.billing_status = 'active'
ORDER BY p.display_name;

一条 SQL 中包含多个 JOIN,不等于应用程序执行了多次 table select。查询是否高效,主要取决于索引、过滤条件、数据量和执行计划,而不是涉及了多少张表。

如果 party 只有主键,没有 display_name 等公共信息,列表查询就只能通过 UNION ALL 或多个 JOIN 获取名称:

SELECT
    p.id,
    'agency' AS party_type,
    a.agency_name AS display_name
FROM party AS p
JOIN agency AS a
    ON a.party_id = p.id

UNION ALL

SELECT
    p.id,
    'customer' AS party_type,
    c.customer_name AS display_name
FROM party AS p
JOIN customer AS c
    ON c.party_id = p.id;

这种设计仍然符合规范化要求,但不方便频繁查询主体列表。是否应该把 display_name、账单地址、联系方式或结算状态上移到 party,应看这些字段在不同主体类型中是否具有一致的语义,而不是单纯为了减少 JOIN 而复制数据。

需要补充的完整性约束

只使用上述外键,还无法完全保证以下条件:

  1. party_type = 'agency' 的记录一定存在于 agency。
  2. 同一个 party 不会同时出现在 agency 和 customer 中。
  3. 子表类型始终与 party.party_type 一致。

“父类型必须且只能对应一个子类型”通常需要通过以下方式之一保证:

  • 所有写入都通过受控的 stored procedure 完成。
  • 使用数据库 trigger 检查。
  • 在应用服务层执行检查,并配合事务。
  • 调整键结构,让子表外键同时包含 id 和类型字段。

例如,可以使用复合键加强类型一致性:

ALTER TABLE party
    ADD CONSTRAINT party_id_type_unique
    UNIQUE (id, party_type);

ALTER TABLE agency
    ADD COLUMN party_type party_type
    NOT NULL DEFAULT 'agency'
    CHECK (party_type = 'agency');

ALTER TABLE agency
    ADD CONSTRAINT agency_party_fk
    FOREIGN KEY (party_id, party_type)
    REFERENCES party (id, party_type);

customer 可以采用相同方式,并将类型限制为 'customer'。不过,要保证“每个 party 必须存在于某一个子表”,通常仍需使用事务化写入、延迟约束或 trigger。

不推荐的设计

两组独立外键

例如:

invoice.agency_id
invoice.customer_id

这种设计有两个问题:

  • 必须保证两个字段中恰好有一个非 NULL。
  • 每增加一种结算主体,都要修改 invoice、payment 和相关查询。

虽然可以增加约束:

CHECK (
    (agency_id IS NOT NULL AND customer_id IS NULL)
    OR
    (agency_id IS NULL AND customer_id IS NOT NULL)
)

但这种结构不易扩展,也容易让付款、退款、余额和对账逻辑出现重复。

entity_type + entity_id

例如:

payer_type = 'agency'
payer_id   = 123

这是常见的 polymorphic association,但普通外键无法验证 payer_id 是否真的存在于指定表中。Agency 或 Customer 被删除或修改后,可能留下孤立记录,因此不适合核心财务数据模型。

仅使用 joining tables

分别建立 agency_invoice、customer_invoice、agency_payment 和 customer_payment,会把同一种关系拆成多套结构。除非一张 Invoice 本来就允许同时分配给多个结算主体,否则没有必要建立这些 joining tables。

更贴近会计场景的扩展

如果同一个 Customer 或 Agency 可以拥有多个结算账户,例如需要区分币种、分公司或付款条件,可以在 party 与交易之间增加 billing_account:

CREATE TABLE billing_account (
    id                  bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    party_id            bigint NOT NULL
                        REFERENCES party(id) ON DELETE RESTRICT,
    account_number      text NOT NULL UNIQUE,
    currency_code       char(3) NOT NULL,
    payment_terms_days  integer NOT NULL DEFAULT 0
                        CHECK (payment_terms_days >= 0),
    status              text NOT NULL DEFAULT 'active'
);

然后让 invoice 和 payment 引用 billing_account.id。这种结构适用于以下情况:

  • 一个主体有多个应收账户。
  • 不同币种需要分别结算。
  • Agency 的不同营业单位需要独立对账。
  • 需要保存信用额度、账期或账户余额。

如果当前业务中每个 Agency 或 Customer 始终只有一个结算身份,直接引用 party 就够了,无须过早增加 billing_account。

注意事项

  • Invoice 上的金额、币种、开票对象和地址通常应保存交易发生时的快照。不要完全依赖主体表中的当前信息,否则客户改名或修改地址后,历史 Invoice 也可能随之变化。
  • Payment 的付款方不一定是 Invoice 的被开票方。如果允许 Agency 代 Customer 付款,就不要强制两者相同。
  • 金额应使用 numeric/decimal,不要使用 float。
  • 对已经参与财务交易的主体,删除时应优先改为停用状态,不要级联删除 Invoice 或 Payment。
  • display_name 适合搜索和列表展示,具有法律效力的名称则可以作为 Invoice 快照单独保存。
  • 如果 Agency 和 Customer 将来可能是同一个法律主体承担的不同角色,可以进一步拆出 party_role,不必限制一个 party 只能属于一种类型。

总体而言,使用 party 超类型和 agency、customer 子类型,既合适也符合规范化原则。设计重点不是消除所有 JOIN,而是把公共身份数据放在 party 中,把类型专属数据留在子表,同时分别表达开票、付款和核销这三种不同的关系。

备注:内容仅供参考。