PAYATHON 2026

Order/Invoice/Payment 数据库建模与多次付款设计

支付阿杰

结论

不要把 Payment 直接绑定到单个 Order,也不要假设一张 invoice 只能对应一次 payment。

更合适的模型是:

  • Order 记录客户购买了什么,以及商家准备如何履约。
  • Invoice 记录某个时点正式确认的应收金额。
  • Payment 记录一次实际支付交易。
  • PaymentAllocation 记录一笔 payment 的金额冲抵了哪些 invoices。

关系如下:

Order 1 ── N OrderLineItem
Order N ── N Invoice
Invoice 1 ── N InvoiceLineItem

Payment N ── N Invoice
       通过 PaymentAllocation 关联

这种模型可以支持:

  • 一个 order 分多次开票、分多次付款;
  • 一张 invoice 分多次付款;
  • 一笔 payment 支付多张 invoices;
  • 一笔 payment 间接覆盖多个 orders;
  • 后续增加 fee、折扣、税费或退款。

如果系统规模不大,并且每张 invoice 永远只属于一个 order,可以先把 order_id 放在 Invoice 中。不过,Payment 与 Invoice 之间仍建议通过 allocation 表关联,不要直接在 Payment 中保存 invoice_id。

Order、Invoice 和 Payment 的职责

Order:交易与履约对象

Order 记录客户的购买内容和履约状态,通常包括:

  • 商品及购买数量;
  • 下单时的价格;
  • 收货地址;
  • 配送方式;
  • 取消、发货、完成等状态;
  • 当前预估金额或最终金额。

订单可能在业务过程中发生变化,例如增加服务费、调整运费或取消某件商品。它主要回答:

客户买了什么,商家应当交付什么?

Invoice:应收凭证

Invoice 记录已经确认并要求客户支付的金额,主要回答:

客户因哪些项目,目前欠多少钱?

正式签发后的 invoice 通常不应直接修改。需要调整时,应创建新的 invoice、debit note 或 credit note,具体使用哪种文档取决于当地财税规则。

数据库中的 Invoice 是否等同于具有法律效力的税务发票,也需要结合当地法规判断,不能脱离具体司法辖区下结论。

Payment:资金交易

Payment 记录客户实际发起的一次支付,包括:

  • 支付金额和币种;
  • 支付渠道或支付账户;
  • 支付服务商交易号;
  • 成功、失败、处理中、退款等状态;
  • 交易时间。

它回答:

实际收到了哪一笔钱?

订单总额、应收金额和实付金额是三个不同的概念,不应混用同一个字段。

是否需要同时保留两套 LineItem

通常需要同时保留 OrderLineItem 和 InvoiceLineItem。两者用途不同,并非重复数据。

OrderLineItem 是履约数据,例如:

2 × Product A
1 × Product B

InvoiceLineItem 是计费快照,例如:

2 × Product A             200.00
Shipping fee               20.00
Additional handling fee    15.00

创建 invoice 时,可以从订单项目复制商品名称、单价、税率和数量。这种复制是必要的,因为 invoice 需要保留开票时的信息。以后即使商品改名或目录价格发生变化,历史 invoice 也不应跟着改变。

可以在 InvoiceLineItem 中保留一个可选的来源引用:

order_line_item_id BIGINT NULL

但不能要求每个 invoice item 都对应一个 order item。运费、手续费、税额和人工调整未必来自商品行。

推荐的数据模型

下面是一个简化模型。实际使用时,还需要根据税务、币种和支付服务商的要求补充字段。

CREATE TABLE orders (
    id              BIGINT PRIMARY KEY,
    customer_id     BIGINT NOT NULL,
    currency        CHAR(3) NOT NULL,
    status          VARCHAR(30) NOT NULL,
    estimated_total DECIMAL(19, 4) NOT NULL,
    created_at      TIMESTAMP NOT NULL
);

CREATE TABLE order_line_items (
    id          BIGINT PRIMARY KEY,
    order_id    BIGINT NOT NULL REFERENCES orders(id),
    product_id  BIGINT NOT NULL,
    description VARCHAR(255) NOT NULL,
    unit_price  DECIMAL(19, 4) NOT NULL,
    quantity    DECIMAL(19, 4) NOT NULL
);

CREATE TABLE invoices (
    id              BIGINT PRIMARY KEY,
    invoice_number  VARCHAR(50) NOT NULL UNIQUE,
    customer_id     BIGINT NOT NULL,
    currency        CHAR(3) NOT NULL,
    status          VARCHAR(30) NOT NULL,
    issued_at       TIMESTAMP NOT NULL,
    due_at          TIMESTAMP NULL,
    total_amount    DECIMAL(19, 4) NOT NULL
);

CREATE TABLE invoice_line_items (
    id                 BIGINT PRIMARY KEY,
    invoice_id         BIGINT NOT NULL REFERENCES invoices(id),
    order_line_item_id BIGINT NULL REFERENCES order_line_items(id),
    type               VARCHAR(30) NOT NULL,
    description        VARCHAR(255) NOT NULL,
    unit_price         DECIMAL(19, 4) NOT NULL,
    quantity           DECIMAL(19, 4) NOT NULL,
    line_amount        DECIMAL(19, 4) NOT NULL
);

CREATE TABLE invoice_orders (
    invoice_id BIGINT NOT NULL REFERENCES invoices(id),
    order_id   BIGINT NOT NULL REFERENCES orders(id),
    PRIMARY KEY (invoice_id, order_id)
);

CREATE TABLE payments (
    id                       BIGINT PRIMARY KEY,
    customer_id              BIGINT NOT NULL,
    currency                 CHAR(3) NOT NULL,
    amount                   DECIMAL(19, 4) NOT NULL,
    status                   VARCHAR(30) NOT NULL,
    provider                 VARCHAR(50) NOT NULL,
    provider_transaction_id  VARCHAR(255) NULL,
    received_at              TIMESTAMP NULL,
    UNIQUE (provider, provider_transaction_id)
);

CREATE TABLE payment_allocations (
    id               BIGINT PRIMARY KEY,
    payment_id       BIGINT NOT NULL REFERENCES payments(id),
    invoice_id       BIGINT NOT NULL REFERENCES invoices(id),
    allocated_amount DECIMAL(19, 4) NOT NULL,
    created_at       TIMESTAMP NOT NULL,
    UNIQUE (payment_id, invoice_id),
    CHECK (allocated_amount > 0)
);

如果确定一张 invoice 只服务于一个 order,可以删除 invoice_orders,改为:

ALTER TABLE invoices
ADD COLUMN order_id BIGINT NOT NULL REFERENCES orders(id);

如果需要让一张 invoice 汇总多个 orders,使用 invoice_orders 更合适。

后续增加 fee 时如何处理

假设订单最初确认的商品金额是 100.00,客户已经支付 100.00,之后又增加了 15.00 手续费。此时有两种处理方式。

策略一:增量开票

先创建商品 invoice:

Invoice I-001
商品费用:100.00
Total:100.00

客户付款后创建 allocation:

Payment P-001: 100.00
P-001 -> I-001: 100.00

增加 fee 后,再创建一张只包含增量金额的 invoice:

Invoice I-002
Additional handling fee:15.00
Total:15.00

随后客户支付:

Payment P-002: 15.00
P-002 -> I-002: 15.00

这种做法最直接。新 invoice 表示新增应收,不需要修改旧 invoice。

策略二:先预授权或收取预付款,最终统一开票

如果未知 fee 本来就是正常业务流程的一部分,也可以先把第一笔资金记为预付款。等费用最终确定后,再生成完整 invoice,并将已有 payment 分配给它。

这种处理方式的账务含义更清楚。不过,支付服务商是否支持延迟捕获、部分捕获,以及预付款需要如何开具凭证,都要根据具体支付渠道和当地规定决定。

不要简单地用“新总额减旧订单总额”

订单变更后计算差额的方向没有问题,但计算基准应该是已经开具的有效应收,而不是当前订单总额或已支付金额。

建议分别计算:

invoice_total = invoice line amounts 的合计
allocated_total = 成功 payment allocations 的合计
invoice_balance = invoice_total - allocated_total - credit_total

订单层面的余额可以汇总其有效 invoices:

order_receivable =
    有效 invoice 金额
    - credit note 金额
    - 已分配付款金额

创建新增 fee invoice 时,应记录明确的收费项目:

Additional handling fee: 15.00

不要只记录难以审计的项目:

Difference: 15.00

否则以后无法判断这笔差额来自运费、税费、手续费还是人工调整。

一笔 Payment 支付多个 Orders

不要在 Payment 中保存多个 order_id,也不要用逗号拼接 ID。金额应通过 PaymentAllocation 分配。

例如,一笔 150.00 的 payment 支付两张 invoice:

Payment P-100: 150.00

Allocation:
P-100 -> Invoice I-101: 100.00
P-100 -> Invoice I-102:  50.00

如果 I-101 属于 Order A,I-102 属于 Order B,这笔 payment 就通过两张 invoice 间接支付了两个 orders。

需要保证:

同一 payment 的 allocated_amount 合计 <= payment.amount

通常还应保证:

同一 invoice 的有效 allocated_amount 合计 <= invoice.total_amount

第二条是否允许暂时超额,取决于系统是否支持客户余额或预存款。如果允许超额,超出的部分不应塞进已经结清的 invoice,而应进入 CustomerCredit、UnappliedPayment 或类似账户。

这些约束涉及多行汇总,通常不能只依靠普通 CHECK 约束。写入 allocation 时,应在数据库事务中锁定相关记录并校验余额,必要时再使用触发器或数据库过程加强约束。

状态不应代替金额计算

系统可以保留便于查询的状态:

Invoice: draft, issued, partially_paid, paid, void
Payment: pending, succeeded, failed, refunded
Order: pending, confirmed, processing, shipped, completed, cancelled

但付款状态最好根据金额推导:

balance = total_amount - allocated_amount - credit_amount

例如:

allocated_amount = 0        -> issued
0 < allocated_amount < total -> partially_paid
balance = 0                 -> paid

状态字段可以缓存计算结果,但金额和 allocation 才是判断依据。尤其要避免支付回调重复到达时,再次累加已付款金额。

退款、作废和负向调整

正式 invoice 签发后,不建议删除记录或直接修改金额:

  • 尚未生效的 draft invoice 可以修改;
  • 已签发但需要完全取消的 invoice 可以标记为 void;
  • 已签发后的减价可以通过 credit note 处理;
  • 已收资金退回客户时,应记录 refund transaction;
  • credit note 和 refund 是两个概念。前者减少应收,后者记录资金实际流出。

如果采用统一账务模型,也可以把 invoice 和 credit note 抽象为应收账户中的 charge 和 credit,但这样会增加复杂度。普通电商系统通常更适合先对这些概念分别建模。

实现时的注意事项

金额与币种

金额应使用 DECIMAL 或最小货币单位整数,不要使用浮点数。每个 invoice、payment 和 allocation 都应有明确币种,并禁止直接跨币种分配:

payment.currency = invoice.currency

需要换汇时,应记录汇率、换汇时间和结算金额,不能只比较两个金额数字。

支付回调幂等

支付服务商可能重复发送成功通知。应为外部交易号建立唯一约束:

UNIQUE (provider, provider_transaction_id)

创建 payment、更新状态和生成 allocation 应放在同一事务中,并使用幂等键避免重复入账。

不要覆盖历史单价

OrderLineItem 和 InvoiceLineItem 都应保存当时的描述、单价、数量和税务信息。product_id 只是来源引用,展示历史单据时不应重新读取商品的当前价格。

区分计算值和快照值

Invoice.total_amount 可以保存为签发时的不可变快照,但需要校验它是否等于 invoice lines、税费和舍入调整后的合计。

Order.estimated_total 可以随业务变化,但不能充当历史财务凭证。

预留未分配付款

一笔 payment 到账后,可能暂时无法确定对应哪张 invoice,因此应允许:

payment.amount > SUM(payment_allocations.allocated_amount)

未分配的部分可以记入客户账户余额,之后再进行分配。

最终建议

对于当前场景,可以采用以下设计:

  1. 保留 Order 和 OrderLineItem,用于记录交易和履约。
  2. 增加不可变的 Invoice 和 InvoiceLineItem,保存正式应收快照。
  3. 后续 fee 使用新的增量 invoice,或在财务语义有要求时使用 debit note,不要修改已签发的 invoice。
  4. 单独保存每次 Payment,不要在 payment 上直接保存单一 order_id。
  5. 使用 PaymentAllocation(payment_id, invoice_id, allocated_amount) 建立多对多的金额分配关系。
  6. 通过 invoice 与 order 的关系,确定 payment 覆盖了哪些 orders。
  7. 根据 invoice 总额、credit 和有效 allocation 计算余额,不要只依赖 status 或订单当前的 total。

解决多次付款问题,不能只增加一个 Invoice 概念。还需要把实际发生的支付交易,以及这笔支付冲抵了哪笔应收,分别建模为 Payment 和 PaymentAllocation。

备注:内容仅供参考。