PAYATHON 2026

PHP:如何避免编辑已开具 invoice 时的支付金额冲突

支付老李

结论

不要让管理员直接修改已经进入支付流程的 invoice 金额。

建议按下面的方式设计:

  1. invoice 开具后,金额和币种不可再变更;
  2. 客户开始付款时,创建独立的 payment attempt,记录并固定本次支付的金额、币种和 invoice 版本;
  3. 如果管理员发现价格有误,先作废原 invoice,再创建新 invoice;
  4. 支付结果以支付网关的服务端回调为准,同时核对回调中的金额、币种、invoice 版本和支付状态;
  5. 使用数据库事务、行锁或乐观锁,避免管理员修改 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,可以提供单独的更正操作:

  1. 在事务中锁定原 invoice;
  2. 把原 invoice 标记为 void;
  3. 尝试取消尚未完成的网关 payment intent;
  4. 创建带有新编号或新版本的新 invoice;
  5. 向客户发送新的支付链接。

取消网关支付订单通常无法保证一定成功,因为客户可能刚好已经完成付款。因此,系统仍需正确处理之后到达的回调。

回调时必须核对金额

不能根据浏览器跳转结果确认支付成功,也不能只看网关返回的 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 记录的是客户实际同意支付的金额。两者都必须可追溯,不能让一个可变的金额字段同时承担开票、支付和对账三种职责。

备注:内容仅供参考。