PAYATHON 2026

如何用 PAYID 关联 manual payments 与 INVOICE

支付小周

结论

付款完成后,系统不能只凭 AMOUNT、METHOD 或 MERCHANT 推断对应的 INVNO。这些字段可能重复,也可能在付款期间发生变化,因此不能作为可靠的关联条件。

在离开 application 前,应先创建一条状态为 PREPARED 的 payment,并保存当时选中的 invoices。银行付款完成后,根据已有的 PAYID 将 payment 更新为 CONFIRMED 即可,无须重新查找或逐张指定 invoice。

如果系统需要支持部分付款、一张 invoice 分多次付款或合并付款,应使用关联表 PAYMENT_INVOICE,不要直接把 PAYID 存入 INVOICE。

推荐的数据结构

CREATE TABLE INVOICE (
    INVNO          BIGINT PRIMARY KEY,
    MERCHANT       VARCHAR(100) NOT NULL,
    AMOUNT         DECIMAL(18, 2) NOT NULL,
    METHOD         VARCHAR(20) NOT NULL,
    STATUS         VARCHAR(20) NOT NULL DEFAULT 'UNPAID'
);

CREATE TABLE PAYMENT (
    PAYID          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    MERCHANT       VARCHAR(100) NOT NULL,
    METHOD         VARCHAR(20) NOT NULL,
    AMOUNT         DECIMAL(18, 2) NOT NULL,
    STATUS         VARCHAR(20) NOT NULL,
    BANK_REFERENCE VARCHAR(100),
    PREPARED_AT    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONFIRMED_AT   TIMESTAMP
);

CREATE TABLE PAYMENT_INVOICE (
    PAYID          BIGINT NOT NULL,
    INVNO          BIGINT NOT NULL,
    APPLIED_AMOUNT DECIMAL(18, 2) NOT NULL,
    PRIMARY KEY (PAYID, INVNO),
    FOREIGN KEY (PAYID) REFERENCES PAYMENT(PAYID),
    FOREIGN KEY (INVNO) REFERENCES INVOICE(INVNO)
);

PAYMENT_INVOICE 记录每笔 payment 支付了哪些 invoices,以及分别支付了多少金额。

这种结构支持:

  • 一笔 payment 支付多张 invoices;
  • 一张 invoice 由多笔 payments 分次支付;
  • payment 只支付 invoice 的部分金额;
  • payment 撤销或失败后,仍可追踪最初选择的 invoices。

如果业务能够绝对保证一张 invoice 永远只对应一笔 payment,也可以直接在 INVOICE 中增加 PAYID 外键。不过,部分付款、退款或付款失败等情况通常会打破这个限制。

第一步:离开 application 前创建 payment

不要等银行操作完成后再生成 PAYID。生成 ID 并不表示钱已经付出,只表示系统创建了一次付款尝试。

例如,准备支付 BT 的三张 cheque invoices:

BEGIN;

INSERT INTO PAYMENT (
    MERCHANT,
    METHOD,
    AMOUNT,
    STATUS
)
VALUES (
    'BT',
    'CHEQUE',
    170.00,
    'PREPARED'
)
RETURNING PAYID;

假设数据库返回:

PAYID = 1

接着在同一个事务中保存本次付款包含的 invoices:

INSERT INTO PAYMENT_INVOICE (
    PAYID,
    INVNO,
    APPLIED_AMOUNT
)
SELECT
    1,
    INVNO,
    AMOUNT
FROM INVOICE
WHERE MERCHANT = 'BT'
  AND METHOD = 'CHEQUE'
  AND STATUS = 'UNPAID';

最后提交事务:

COMMIT;

这条 INSERT ... SELECT 可以一次关联数百张 invoices。用户从银行付款页面返回后,不必逐个输入 INVNO。

实际代码应通过参数传入 PAYID,不能把示例中的 1 写死。RETURNING 的语法取决于数据库。如果当前数据库不支持,应改用对应驱动提供的 generated key API。

第二步:生成银行付款清单

付款清单应读取已经保存的关联关系,不能根据 invoices 的当前状态重新计算:

SELECT
    p.PAYID,
    p.MERCHANT,
    p.METHOD,
    p.AMOUNT,
    pi.INVNO,
    pi.APPLIED_AMOUNT
FROM PAYMENT p
JOIN PAYMENT_INVOICE pi
    ON pi.PAYID = p.PAYID
WHERE p.STATUS = 'PREPARED'
ORDER BY p.PAYID, pi.INVNO;

这样,即使用户离开 application 后又录入了新的 invoice,新记录也不会混入已经准备好的 payment。

对于示例数据,可以在准备阶段分别创建三条 payment:

PAYID  MERCHANT  METHOD  AMOUNT
1      BT        CHEQUE  170.00
2      VIRGIN    CHAPS   300.00
3      ORANGE    BACS     10.00

关联关系保存在关联表中:

PAYID  INVNO  APPLIED_AMOUNT
1      1      100.00
1      3       50.00
1      6       20.00
2      2      200.00
2      5      100.00
3      4       10.00

PAYID 是贯穿离开 application 和返回确认两个阶段的稳定标识。

第三步:返回后确认 payment

银行付款成功后,使用页面保存的 PAYID 或付款清单上的 PAYID 确认付款:

BEGIN;

UPDATE PAYMENT
SET STATUS = 'CONFIRMED',
    BANK_REFERENCE = :bank_reference,
    CONFIRMED_AT = CURRENT_TIMESTAMP
WHERE PAYID = :payid
  AND STATUS = 'PREPARED';

如果业务只允许全额支付,可以把关联的 invoices 更新为 PAID:

UPDATE INVOICE
SET STATUS = 'PAID'
WHERE INVNO IN (
    SELECT INVNO
    FROM PAYMENT_INVOICE
    WHERE PAYID = :payid
);

然后提交:

COMMIT;

如果付款失败,不要删除关联关系,只需将状态改为 FAILED:

UPDATE PAYMENT
SET STATUS = 'FAILED'
WHERE PAYID = :payid
  AND STATUS = 'PREPARED';

保留失败记录便于审计,也可以解释这些 invoices 为什么曾出现在某次付款清单中。

部分付款时如何判断 invoice 是否已付清

如果允许部分付款,不能在任意 payment 确认后直接把 invoice 标记为 PAID。系统需要汇总所有已确认 payment 的付款金额:

SELECT
    i.INVNO,
    i.AMOUNT AS INVOICE_AMOUNT,
    COALESCE(SUM(pi.APPLIED_AMOUNT), 0) AS PAID_AMOUNT
FROM INVOICE i
LEFT JOIN PAYMENT_INVOICE pi
    ON pi.INVNO = i.INVNO
LEFT JOIN PAYMENT p
    ON p.PAYID = pi.PAYID
   AND p.STATUS = 'CONFIRMED'
WHERE i.INVNO = :invno
GROUP BY i.INVNO, i.AMOUNT;

再根据余额确定状态:

PAID_AMOUNT = 0               -> UNPAID
0 < PAID_AMOUNT < AMOUNT      -> PARTIALLY_PAID
PAID_AMOUNT = AMOUNT          -> PAID

除非业务明确允许预付款或溢付,否则应禁止 PAID_AMOUNT 超过 invoice 金额。

防止重复支付和并发问题

准备 payment 时,两个用户可能同时选中同一张 invoice。只查询 STATUS = 'UNPAID' 不能完全避免这种竞争,需要增加明确的占用状态,例如:

UNPAID -> RESERVED -> PAID
                  -> UNPAID

创建 payment 时,应在同一个事务中锁定并占用 invoices:

BEGIN;

SELECT INVNO
FROM INVOICE
WHERE MERCHANT = :merchant
  AND METHOD = :method
  AND STATUS = 'UNPAID'
FOR UPDATE;

创建 payment 和关联记录后:

UPDATE INVOICE
SET STATUS = 'RESERVED'
WHERE INVNO IN (
    SELECT INVNO
    FROM PAYMENT_INVOICE
    WHERE PAYID = :payid
);

付款确认成功后,将状态改为 PAID;付款取消或失败时,则恢复为 UNPAID。具体的锁定语法取决于所用数据库。

还需要注意以下几点:

  • payment 金额应等于关联记录中 APPLIED_AMOUNT 的合计;
  • 确认操作必须具有幂等性,重复点击不能造成重复记账;
  • 页面应提交 PAYID,不能重新提交查询条件;
  • 最好为 BANK_REFERENCE 设置适当的唯一约束,防止同一笔银行交易被重复录入;
  • 不要通过 MERCHANT + METHOD + AMOUNT 查找 payment,因为这些字段的组合并不唯一;
  • 金额应使用 DECIMAL,不能使用浮点类型;
  • payment 的修改、失败和撤销都应保留审计记录。

核心原则是先确定并保存本次准备支付的 invoices,再到银行完成付款。返回 application 后,只需确认已经存在的 payment,不必重新推断它对应哪些 invoices。

备注:内容仅供参考。