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)
未分配的部分可以记入客户账户余额,之后再进行分配。
最终建议
对于当前场景,可以采用以下设计:
- 保留
Order和OrderLineItem,用于记录交易和履约。 - 增加不可变的
Invoice和InvoiceLineItem,保存正式应收快照。 - 后续 fee 使用新的增量 invoice,或在财务语义有要求时使用 debit note,不要修改已签发的 invoice。
- 单独保存每次
Payment,不要在 payment 上直接保存单一order_id。 - 使用
PaymentAllocation(payment_id, invoice_id, allocated_amount)建立多对多的金额分配关系。 - 通过 invoice 与 order 的关系,确定 payment 覆盖了哪些 orders。
- 根据 invoice 总额、credit 和有效 allocation 计算余额,不要只依赖
status或订单当前的total。
解决多次付款问题,不能只增加一个 Invoice 概念。还需要把实际发生的支付交易,以及这笔支付冲抵了哪笔应收,分别建模为 Payment 和 PaymentAllocation。
备注:内容仅供参考。
