已有 Invoice Table 时是否还需要 Payment Table?
明确结论
通常应该单独建立 payment 表。
invoice 表描述应收金额、到期时间和结清状态,payment 表记录实际发生的付款。它们属于两类不同的数据。一张 invoice 可能有多次付款、部分付款、失败重试或退款,也可能通过不同渠道支付。只在 invoice 中保存一个 payment_date,无法完整记录这些情况。
如果业务能严格保证一张 invoice 最多付款一次,而且不需要保留失败记录、退款信息或支付渠道数据,可以暂时不建 payment 表。
为什么需要拆分
单独建立 payment 表后,可以处理以下常见情况:
- 用户分两次付清同一张 invoice。
- 第一次支付失败,第二次支付成功。
- 支付成功后发生部分退款或全额退款。
- 保存支付平台的交易编号,方便对账和排查问题。
- 支付回调被重复发送,需要做幂等处理。
- invoice 已经付款,但仍需查询到账时间、支付方式和支付金额。
如果只在 invoice 中保存 payment_date,这个字段的含义很难确定:它表示第一次支付、最后一次支付,还是完全付清的时间?
推荐的数据结构
可以继续用 invoice 保存账单汇总信息,每次付款则作为一条独立记录写入 payment:
CREATE TABLE invoice (
invoice_id BIGINT PRIMARY KEY,
creation_date DATETIME NOT NULL,
due_date DATETIME NOT NULL,
paid_at DATETIME NULL,
status VARCHAR(20) NOT NULL,
user_id BIGINT NOT NULL,
total_price DECIMAL(12, 2) NOT NULL,
currency CHAR(3) NOT NULL
);
CREATE TABLE payment (
payment_id BIGINT PRIMARY KEY,
invoice_id BIGINT NOT NULL,
payment_date DATETIME NOT NULL,
amount DECIMAL(12, 2) NOT NULL,
status VARCHAR(20) NOT NULL,
provider VARCHAR(30) NULL,
transaction_id VARCHAR(100) NULL,
created_at DATETIME NOT NULL,
CONSTRAINT fk_payment_invoice
FOREIGN KEY (invoice_id) REFERENCES invoice(invoice_id),
CONSTRAINT uq_payment_transaction
UNIQUE (provider, transaction_id)
);
各字段的含义如下:
invoice.total_price是应付总额。payment.amount是某次实际付款的金额。invoice.paid_at只记录整张 invoice 完全付清的时间,可以为空。transaction_id保存支付平台返回的交易编号。currency不应依赖系统默认值,否则以后支持多币种时容易产生歧义。- 金额应使用
DECIMAL,不要使用FLOAT或DOUBLE。
status 可以按业务需要定义为:
invoice: pending, partially_paid, paid, overdue, cancelled
payment: pending, succeeded, failed, refunded
是否在数据库中使用 ENUM,取决于系统的迁移方式和数据库类型。支付状态往往会随着业务扩展,使用 VARCHAR 配合约束或状态表,修改时更灵活。
如何计算 invoice 的付款状态
已支付金额应从状态为成功的付款记录中汇总:
SELECT
i.invoice_id,
i.total_price,
COALESCE(SUM(
CASE
WHEN p.status = 'succeeded' THEN p.amount
ELSE 0
END
), 0) AS amount_paid
FROM invoice AS i
LEFT JOIN payment AS p
ON p.invoice_id = i.invoice_id
WHERE i.invoice_id = ?
GROUP BY i.invoice_id, i.total_price;
再根据汇总结果判断状态:
amount_paid = 0 -> pending
0 < amount_paid < total_price -> partially_paid
amount_paid >= total_price -> paid
overdue 更适合表示超过 due_date 但尚未付清的状态,不必将其设计为不可逆的最终状态。原设计中的 expired 容易被理解成 invoice 已经失效。如果实际含义是逾期,使用 overdue 更准确。
写入付款时保持一致性
确认支付成功后,应在同一个数据库事务中新增付款记录并更新 invoice 状态:
START TRANSACTION;
INSERT INTO payment (
payment_id,
invoice_id,
payment_date,
amount,
status,
provider,
transaction_id,
created_at
) VALUES (
?, ?, CURRENT_TIMESTAMP, ?, 'succeeded', ?, ?, CURRENT_TIMESTAMP
);
UPDATE invoice
SET
status = 'paid',
paid_at = CURRENT_TIMESTAMP
WHERE invoice_id = ?;
COMMIT;
这段示例的前提是本次付款能够让 invoice 完全结清。如果允许部分付款,更新状态前应重新汇总成功支付的金额,再决定写入 partially_paid 还是 paid。遇到并发支付时,还要使用行锁、条件更新或其他并发控制方式,避免重复结算。
需要注意的问题
是否把付款失败记录放入 payment 表,取决于这张表的定义。如果 payment 表示支付尝试,保存 failed 状态是合理的。如果它只记录实际资金流水,失败的尝试应放在单独的 payment_attempt 表中。
还需要考虑以下问题:
- 使用
transaction_id或幂等键,避免重复支付回调生成两条付款记录。 - 退款最好作为独立流水保存,不要直接覆盖原付款金额。
- 财务对账不能只依赖 invoice 上的缓存状态,付款流水是更可靠的明细依据。
- 如果保留
invoice.status和paid_at,它们就是汇总字段,必须与付款记录同步更新。 price_paid更适合命名为amount,因为每一行已经代表一笔付款。- 时间字段应有明确的时区规则,例如统一保存 UTC。
因此,增加 payment 表是合理的,也便于追踪支付记录,处理部分付款、失败重试、退款和对账。原始设计可以作为起点,但建议补充交易编号、币种、支付渠道和幂等约束,同时明确 invoice 状态与付款流水之间的关系。
备注:内容仅供参考。
