PAYATHON 2026

如何通过 ePay/Bambora PHP API 完成单笔支付?

支付小周

结论

capture() 不能创建新支付,只能对已经完成授权并取得真实 transactionid 的交易执行扣款。传入 -1 指向的是一笔不存在的交易,因此会返回 -1008。

标准流程如下:

  1. 服务端创建本地订单。
  2. 用户通过 ePay/Bambora 托管支付窗口输入银行卡信息并完成验证。
  3. 授权成功后,ePay/Bambora 创建交易并返回 transactionid。
  4. 服务端核验交易信息。
  5. 使用真实的 transactionid 调用 capture(),或者在发起支付时启用即时扣款。

这个流程与系统是否属于 webshop 无关。只要需要持卡人付款,就必须先授权,再扣款。

为什么不能直接调用 capture()

银行卡支付通常分为两个阶段:

  • Authorize(授权):验证银行卡,完成必要的身份验证,并冻结或保留相应金额。授权成功后会生成 transactionid。
  • Capture(扣款):使用已有的 transactionid,正式扣取已授权的金额。

当前代码跳过了授权,直接尝试捕获交易:

$result = $client->capture($epay_params);

transactionid = -1 并不表示“创建新交易”,它只是一个不存在的交易编号,所以接口返回:

{
  "captureResult": false,
  "pbsResponse": -1,
  "epayresponse": -1008
}

这说明 SOAP 请求已经到达接口,但接口找不到要捕获的交易。

MerchantID 不需要保密

merchantnumber 或 MerchantID 通常只是商户的公开标识,不是密码。使用托管支付窗口时,这项信息不可避免地会出现在浏览器请求或前端代码中。

需要保密的是:

  • API 密钥或 SOAP 访问凭据;
  • MD5/签名密钥;
  • 后台管理密码;
  • 其他能够授权敏感操作的凭据。

这些信息只能保存在服务端,不能写进 JavaScript、HTML 或公开仓库。

推荐的支付流程

1. 在服务端创建订单

先在自己的数据库中创建待支付订单,并生成一个不可重复的 orderid。金额必须由服务端确定,不能直接采用浏览器提交的值。

例如:

<?php

$orderId = 'CAL-' . bin2hex(random_bytes(8));
$amount = 9995; // 最小货币单位,例如 99.95 DKK
$currency = 'DKK';

// 将订单写入数据库:
// order_id = $orderId
// amount = $amount
// currency = $currency
// status = pending

2. 打开托管支付窗口

如果使用的是问题中 SOAP 地址对应的旧版 ePay Payment Window,前端调用通常类似下面这样:

<script src="https://ssl.ditonlinebetalingssystem.dk/integration/ewindow/paymentwindow.js"></script>

<button type="button" id="pay-button">支付</button>

<script>
const paymentWindow = new PaymentWindow({
    merchantnumber: "1234567",
    amount: "9995",
    currency: "DKK",
    orderid: "CAL-EXAMPLE-001",
    accepturl: "https://example.com/payment/accept.php",
    cancelurl: "https://example.com/payment/cancel.php",
    windowstate: "3"
});

document.getElementById("pay-button").addEventListener("click", function () {
    paymentWindow.open();
});
</script>

具体参数名称可能因 ePay/Bambora 接入版本而异,应以测试账户对应的 Payment Window 文档为准。不要将签名密钥放进这段 JavaScript。

用户在支付窗口中输入银行卡信息并完成授权后,支付平台才会生成真正的交易编号。

接收并核验交易编号

授权成功后,平台通常会将用户跳转到 accepturl,同时附带交易编号、订单号等参数。在旧版集成中,交易编号可能通过 txnid 传递,实际名称应以对应版本的回调参数说明为准。

<?php

$transactionId = filter_input(
    INPUT_GET,
    'txnid',
    FILTER_VALIDATE_INT
);

if (!$transactionId) {
    http_response_code(400);
    exit('Missing or invalid transaction ID');
}

$orderId = $_GET['orderid'] ?? '';

if ($orderId === '') {
    http_response_code(400);
    exit('Missing order ID');
}

浏览器访问了 accepturl,不代表订单已经支付。查询字符串可以被修改,用户也可能重复访问该地址。

服务端至少需要检查:

  • 回调签名是否正确;
  • transactionid 是否真实存在;
  • 交易是否属于当前商户;
  • orderid 是否与本地订单一致;
  • 金额和币种是否一致;
  • 交易是否已经授权;
  • 订单是否已经处理,以免重复扣款。

不同历史版本使用的签名字段和算法可能不同,不要凭猜测自行拼接。应按照账户对应版本的签名说明实现,或通过官方提供的交易查询接口再次确认。

使用真实 transactionid 执行 capture

确认授权成功后,才能调用 capture():

<?php

$transactionId = 123456789; // 授权成功后由 ePay/Bambora 返回
$orderId = 'CAL-EXAMPLE-001';
$amount = 9995;

$params = [
    'merchantnumber'    => '1234567',
    'transactionid'     => $transactionId,
    'amount'            => $amount,
    'group'             => '',
    'paymentcollection' => 1,
    'orderid'           => $orderId,
    'pbsResponse'       => -1,
    'epayresponse'      => -1,
];

try {
    $client = new SoapClient(
        'https://ssl.ditonlinebetalingssystem.dk/remote/payment.asmx?WSDL',
        [
            'exceptions' => true,
            'trace'      => false,
        ]
    );

    $result = $client->capture($params);

    if (!empty($result->captureResult)) {
        // 使用原子更新,避免同一订单被重复处理。
        // updateOrderStatus($orderId, 'paid', $transactionId);

        echo 'Payment captured';
    } else {
        $pbsResponse = $result->pbsResponse ?? null;
        $epayResponse = $result->epayresponse ?? null;

        error_log(sprintf(
            'Capture failed: order=%s transaction=%d pbs=%s epay=%s',
            $orderId,
            $transactionId,
            var_export($pbsResponse, true),
            var_export($epayResponse, true)
        ));

        http_response_code(502);
        echo 'Payment could not be captured';
    }
} catch (SoapFault $exception) {
    error_log('ePay SOAP error: ' . $exception->getMessage());

    http_response_code(502);
    echo 'Payment service unavailable';
}

传给 capture() 的金额不能超过授权金额。账户配置、收单协议和接口版本会决定是否允许部分扣款或多次扣款。

即时扣款

如果业务要求付款完成后立即扣款,可以在创建支付窗口时启用类似 instantcapture 的选项:

const paymentWindow = new PaymentWindow({
    merchantnumber: "1234567",
    amount: "9995",
    currency: "DKK",
    orderid: "CAL-EXAMPLE-001",
    instantcapture: "1",
    accepturl: "https://example.com/payment/accept.php",
    cancelurl: "https://example.com/payment/cancel.php"
});

授权成功后,平台会尝试立即完成 capture。PHP 后端通常只需核验结果并更新订单状态。

即时扣款是否可用、参数值采用什么格式,以及允许在什么时间扣款,仍取决于商户协议和账户设置。某些业务只能在服务已经提供后扣款,不能在创建预约时立即扣款。

能否完全通过 PHP 收集银行卡并创建交易

只有 ePay/Bambora 为账户开通了专门的服务器端授权接口,并且系统满足相应的 PCI DSS、安全认证和收单要求,自己的 PHP 服务才可能直接接收银行卡信息并发起授权。

普通的 remote/payment.asmx 管理接口通常用于处理已有交易,例如:

  • 查询交易;
  • 捕获交易;
  • 退款;
  • 删除或取消授权。

它不能把虚构的 transactionid 转换成一笔新支付。

对于一般的 calendar webapp,托管支付窗口更合适。银行卡信息会直接提交给支付平台,服务器只保存订单号、金额、交易编号和支付状态,承担的 PCI 风险也小得多。

所以,需要解决的不是寻找另一个 capture() 参数,而是先通过支付窗口完成授权,取得真实的 transactionid,再由 PHP 核验并捕获这笔交易。

备注:内容仅供参考。