如何用 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。
备注:内容仅供参考。
