PHP:如何避免编辑已开具 invoice 时的支付金额冲突
结论
不要让管理员直接修改已经进入支付流程的 invoice 金额。
建议按下面的方式设计:
- invoice 开具后,金额和币种不可再变更;
- 客户开始付款时,创建独立的 payment attempt,记录并固定本次支付的金额、币种和 invoice 版本;
- 如果管理员发现价格有误,先作废原 invoice,再创建新 invoice;
- 支付结果以支付网关的服务端回调为准,同时核对回调中的金额、币种、invoice 版本和支付状态;
- 使用数据库事务、行锁或乐观锁,避免管理员修改 invoice 与客户发起支付同时发生。
只在客户付款前重新读取一次数据库金额并不能解决问题。此时客户可能已经进入支付网关页面,按旧金额创建的支付请求也可能仍然有效。
金额冲突是怎么发生的
应用跳转到支付网关时,通常已经把金额提交给网关。之后即使数据库中的 invoice 金额发生变化,网关里的支付金额也不会随之更新。
这时系统中有两份独立的数据:
- 应用数据库中的当前 invoice;
- 支付网关中已经创建的 payment intent、订单或交易。
如果允许直接修改 invoice,可能出现这些问题:
- invoice 显示应付 120 元,客户实际支付的却是 100 元;
- 旧支付链接仍可继续付款;
- 回调处理程序只检查是否“支付成功”,没有核对实际金额;
- 重复回调造成重复入账;
- 管理员修改金额时,客户恰好也在创建支付订单。
所以,单纯“禁止同时操作”并不够。系统需要明确 invoice 和 payment attempt 各自的生命周期,并确保每笔付款都对应一个确定的金额版本。
推荐的数据模型
金额应按最小货币单位保存,例如人民币用分,美元用美分。不要用 float 保存金额。
CREATE TABLE invoices (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NOT NULL,
amount_minor BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL,
version INT UNSIGNED NOT NULL DEFAULT 1,
issued_at DATETIME NULL,
paid_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE payment_attempts (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
invoice_id BIGINT UNSIGNED NOT NULL,
invoice_version INT UNSIGNED NOT NULL,
amount_minor BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL,
gateway_payment_id VARCHAR(191) NULL,
idempotency_key VARCHAR(191) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_idempotency_key (idempotency_key),
UNIQUE KEY uk_gateway_payment_id (gateway_payment_id)
);
invoice 状态可以按业务需要定义:
draft:草稿,可以修改;issued:已开具,不能直接修改金额;payment_pending:已有支付尝试;paid:已支付;void:已作废;expired:已过期。
具体名称可以调整,但服务端必须限制状态只能按合法路径转换。
开始支付时锁定金额
客户点击支付链接后,应用应先在数据库事务中锁定 invoice,再创建 payment attempt。提交给支付网关的金额必须取自这条 payment attempt,不能稍后再去读取 invoice。
下面的示例使用 PDO,具体的异常处理和网关 SDK 调用需要按实际项目调整。
<?php
function createPaymentAttempt(PDO $pdo, int $invoiceId): array
{
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'SELECT id, amount_minor, currency, status, version
FROM invoices
WHERE id = ?
FOR UPDATE'
);
$stmt->execute([$invoiceId]);
$invoice = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$invoice) {
throw new RuntimeException('Invoice not found');
}
if (!in_array($invoice['status'], ['issued', 'payment_pending'], true)) {
throw new RuntimeException('Invoice cannot be paid in its current state');
}
$idempotencyKey = bin2hex(random_bytes(16));
$stmt = $pdo->prepare(
'INSERT INTO payment_attempts (
invoice_id,
invoice_version,
amount_minor,
currency,
status,
idempotency_key,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())'
);
$stmt->execute([
$invoice['id'],
$invoice['version'],
$invoice['amount_minor'],
$invoice['currency'],
'created',
$idempotencyKey,
]);
$attemptId = (int) $pdo->lastInsertId();
$stmt = $pdo->prepare(
"UPDATE invoices
SET status = 'payment_pending', updated_at = NOW()
WHERE id = ?"
);
$stmt->execute([$invoiceId]);
$pdo->commit();
return [
'id' => $attemptId,
'invoice_id' => (int) $invoice['id'],
'invoice_version' => (int) $invoice['version'],
'amount_minor' => (int) $invoice['amount_minor'],
'currency' => $invoice['currency'],
'idempotency_key' => $idempotencyKey,
];
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
事务提交后,使用 payment attempt 中已经固定的金额创建网关订单:
$attempt = createPaymentAttempt($pdo, $invoiceId);
$gatewayPayment = $gateway->createPayment([
'amount' => $attempt['amount_minor'],
'currency' => $attempt['currency'],
'metadata' => [
'invoice_id' => (string) $attempt['invoice_id'],
'invoice_version' => (string) $attempt['invoice_version'],
'payment_attempt_id' => (string) $attempt['id'],
],
'idempotency_key' => $attempt['idempotency_key'],
]);
调用网关前后都不要使用浏览器提交的金额。客户端只负责提交 invoice ID 或支付令牌,最终金额必须由服务端确定。
如果网关支持原生幂等键,也应一并使用,避免网络重试生成多个支付订单。
管理员如何更正价格
最稳妥的做法是只允许修改 draft 状态的金额。invoice 一旦进入 issued 状态,价格错误就应通过“作废并重新开具”来处理。
<?php
function updateDraftInvoiceAmount(
PDO $pdo,
int $invoiceId,
int $newAmountMinor,
int $expectedVersion
): bool {
if ($newAmountMinor < 0) {
throw new InvalidArgumentException('Amount cannot be negative');
}
$stmt = $pdo->prepare(
"UPDATE invoices
SET amount_minor = ?,
version = version + 1,
updated_at = NOW()
WHERE id = ?
AND status = 'draft'
AND version = ?"
);
$stmt->execute([
$newAmountMinor,
$invoiceId,
$expectedVersion,
]);
return $stmt->rowCount() === 1;
}
这里的 version 用作乐观锁。如果管理员打开编辑页面后,invoice 已经被其他操作更新,这条 UPDATE 就不会成功。界面应提示管理员刷新数据,不能直接覆盖最新记录。
对于已经开具的 invoice,可以提供单独的更正操作:
- 在事务中锁定原 invoice;
- 把原 invoice 标记为
void; - 尝试取消尚未完成的网关 payment intent;
- 创建带有新编号或新版本的新 invoice;
- 向客户发送新的支付链接。
取消网关支付订单通常无法保证一定成功,因为客户可能刚好已经完成付款。因此,系统仍需正确处理之后到达的回调。
回调时必须核对金额
不能根据浏览器跳转结果确认支付成功,也不能只看网关返回的 status。支付结果应来自经过验签的服务端 webhook,并与 payment attempt 中的数据进行比较。
<?php
function handleSuccessfulPayment(PDO $pdo, array $event): void
{
// 此处假设 webhook 签名已经按照支付网关文档验证。
$gatewayPaymentId = $event['payment_id'];
$attemptId = (int) $event['metadata']['payment_attempt_id'];
$paidAmountMinor = (int) $event['amount'];
$paidCurrency = strtoupper($event['currency']);
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'SELECT *
FROM payment_attempts
WHERE id = ?
FOR UPDATE'
);
$stmt->execute([$attemptId]);
$attempt = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$attempt) {
throw new RuntimeException('Unknown payment attempt');
}
// Webhook 可能重复发送,已经处理过时直接返回。
if ($attempt['status'] === 'succeeded') {
$pdo->commit();
return;
}
if (
(int) $attempt['amount_minor'] !== $paidAmountMinor ||
strtoupper($attempt['currency']) !== $paidCurrency
) {
$stmt = $pdo->prepare(
"UPDATE payment_attempts
SET status = 'amount_mismatch',
gateway_payment_id = ?,
updated_at = NOW()
WHERE id = ?"
);
$stmt->execute([$gatewayPaymentId, $attemptId]);
$pdo->commit();
return;
}
$stmt = $pdo->prepare(
"UPDATE payment_attempts
SET status = 'succeeded',
gateway_payment_id = ?,
updated_at = NOW()
WHERE id = ?"
);
$stmt->execute([$gatewayPaymentId, $attemptId]);
$stmt = $pdo->prepare(
"UPDATE invoices
SET status = 'paid',
paid_at = NOW(),
updated_at = NOW()
WHERE id = ?
AND version = ?
AND status IN ('issued', 'payment_pending')"
);
$stmt->execute([
$attempt['invoice_id'],
$attempt['invoice_version'],
]);
if ($stmt->rowCount() !== 1) {
/*
* 金额虽然与 payment attempt 一致,但对应的 invoice 已作废、
* 被替换或版本不一致。应进入人工复核或自动退款流程。
*/
$stmt = $pdo->prepare(
"UPDATE payment_attempts
SET status = 'requires_review', updated_at = NOW()
WHERE id = ?"
);
$stmt->execute([$attemptId]);
}
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
在生产环境中,还应检查:
- webhook 签名和时间戳;
- 网关支付 ID 是否已经处理过;
- 商户账号是否正确;
- payment attempt 是否属于对应的 invoice;
- 实际支付金额和币种是否一致;
- 网关返回的支付状态;
- invoice 是否已经付款、作废或被替换。
客户已经进入旧支付页面怎么办
旧金额一旦提交给支付网关,只修改数据库并不能阻止客户继续付款。具体可以根据网关提供的能力处理:
- 如果网关支持取消 payment intent,在作废 invoice 时立即尝试取消;
- 给支付订单设置合理的过期时间;
- 收到 webhook 后检查 invoice 的版本和状态;
- 如果旧 invoice 已作废,把该笔付款标记为
requires_review,不要自动将新 invoice 设为已支付; - 按业务规则退款、补收差价、退还差额或转入人工对账。
假设原价是 100 元,管理员将其更正为 120 元,而客户已经支付了 100 元。系统不能直接把金额为 120 元的新 invoice 标记为已支付。它可以先保留“旧 invoice 已收款 100 元”这一事实,再按业务规则退款或生成一张 20 元的补款单。
需要注意的细节
- 不要相信前端传入的金额;
- 不要用
float计算货币; - 不要依赖浏览器付款后的返回页面确认收款;
- 不要用新数据覆盖 invoice 的金额变更记录,应保留审计轨迹;
- webhook 必须允许重复投递,处理过程也必须保持幂等;
- 数据库事务不要包含调用外部支付网关的整个网络请求,否则锁可能被长时间占用;
- invoice 编号、税务要求和更正方式可能受当地法规约束。如果 invoice 是正式税务凭证,应按当地规则采用红冲、贷项通知单或重新开票,不能直接修改历史数据。
invoice 记录的是已经向客户开具的账单,payment attempt 记录的是客户实际同意支付的金额。两者都必须可追溯,不能让一个可变的金额字段同时承担开票、支付和对账三种职责。
备注:内容仅供参考。
