PAYATHON 2026

已有 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 状态与付款流水之间的关系。

备注:内容仅供参考。