PAYATHON 2026

支持信用卡退款与 pre-auth 的 Event RSVP 支付服务

支付老李

结论

可以用 Stripe、Adyen 或 Braintree 处理托管信用卡表单、预授权、扣款、撤销授权和退款。对于 PHP + CodeIgniter 项目,Stripe Checkout + PaymentIntents 通常更容易集成。

不过,信用卡预授权不能无限期保留。授权有效期取决于卡组织、发卡行、商户类型和支付服务商,通常只有数天,无法可靠地覆盖从 RSVP 到数周甚至数月后活动开始的整个周期。

更稳妥的流程是:

  1. 用户 RSVP 时,通过 hosted checkout page 收集并保存支付方式,暂不扣款。
  2. 在活动开始前 24 小时创建 off-session payment 并完成扣款。
  3. 如果主办方提前取消活动,只需取消 RSVP 和付款计划,无须释放授权。
  4. 如果 RSVP 距离活动开始时间很近,而且授权有效期足够,可以使用 manual capture,先授权,再于活动开始前 24 小时捕获。
  5. 如果已经扣款,取消活动时应执行 refund,而不是取消 authorization。

保存支付方式并不等于锁定资金。活动开始前,用户可能换卡、冻结卡片或出现余额不足,因此最终扣款仍有失败的可能。

推荐的支付流程

RSVP 时保存支付方式

使用 Stripe Checkout 的 setup 模式。用户在 Stripe 托管页面填写卡片信息,你的服务器只接收 Customer、SetupIntent 和 PaymentMethod 等标识,不会接触原始卡号或 CVC。

业务记录可保存以下字段:

event_id
user_id
rsvp_status
stripe_customer_id
stripe_payment_method_id
payment_status
charge_at
payment_intent_id

创建 Checkout Session 的 PHP 示例:

<?php

require_once APPPATH . '../vendor/autoload.php';

$stripe = new \Stripe\StripeClient($_ENV['STRIPE_SECRET_KEY']);

$session = $stripe->checkout->sessions->create([
    'mode' => 'setup',
    'customer' => $stripeCustomerId,
    'success_url' => site_url(
        'payments/setup-success?session_id={CHECKOUT_SESSION_ID}'
    ),
    'cancel_url' => site_url('events/' . $eventId),
    'metadata' => [
        'event_id' => (string) $eventId,
        'user_id' => (string) $userId,
    ],
]);

redirect($session->url);

实际项目中,应通过 Composer 安装并加载支付服务商的官方 PHP SDK。密钥应存放在环境变量或密钥管理系统中,不要写入控制器,也不要提交到代码仓库。

通过 webhook 确认保存结果

不能只依赖 success_url。用户完成操作后可能直接关闭浏览器,重定向请求也可能被伪造。支付状态应以通过签名验证的 webhook 为准。

处理 checkout.session.completed 时,可以取得对应的 SetupIntent:

<?php

$payload = file_get_contents('php://input');
$signature = $this->input->get_request_header('Stripe-Signature', true);

try {
    $event = \Stripe\Webhook::constructEvent(
        $payload,
        $signature,
        $_ENV['STRIPE_WEBHOOK_SECRET']
    );
} catch (\UnexpectedValueException $e) {
    show_error('Invalid payload', 400);
    return;
} catch (\Stripe\Exception\SignatureVerificationException $e) {
    show_error('Invalid signature', 400);
    return;
}

if ($event->type === 'checkout.session.completed') {
    $session = $event->data->object;

    if ($session->mode === 'setup') {
        $setupIntent = $stripe->setupIntents->retrieve(
            $session->setup_intent,
            []
        );

        $eventId = $session->metadata->event_id;
        $userId = $session->metadata->user_id;
        $paymentMethodId = $setupIntent->payment_method;

        // 将 paymentMethodId 与当前 RSVP 记录关联。
        // 同时保存 stripe_customer_id,并将支付准备状态标记为 ready。
    }
}

http_response_code(200);

还要核对 webhook 中的 event_id、user_id、customer 是否与本地记录一致,不能直接信任 metadata。

活动开始前 24 小时扣款

定时任务可以每隔几分钟查询符合以下条件的 RSVP:

event_start_at <= now + 24 hours
payment_status = scheduled
event_status = active

找到记录后,创建并确认 off-session PaymentIntent:

<?php

$idempotencyKey = sprintf(
    'event-%d-rsvp-%d-charge-v1',
    $eventId,
    $rsvpId
);

try {
    $paymentIntent = $stripe->paymentIntents->create([
        'amount' => $amountInMinorUnits,
        'currency' => 'usd',
        'customer' => $stripeCustomerId,
        'payment_method' => $stripePaymentMethodId,
        'off_session' => true,
        'confirm' => true,
        'description' => 'Event RSVP payment',
        'metadata' => [
            'event_id' => (string) $eventId,
            'rsvp_id' => (string) $rsvpId,
        ],
    ], [
        'idempotency_key' => $idempotencyKey,
    ]);

    // 保存 $paymentIntent->id。
    // 最终状态仍应由 webhook 确认。
} catch (\Stripe\Exception\CardException $e) {
    $paymentIntent = $e->getError()->payment_intent ?? null;

    // 记录失败状态,并通知用户更新支付方式。
    // 某些交易可能要求用户重新认证。
}

amount 一般使用货币的最小单位,例如美元的分。零小数货币和其他币种的规则并不完全相同,应按支付服务商的货币文档处理。

同一个 RSVP 必须使用稳定的 idempotency key,防止定时任务重试、网络超时或并发执行导致重复扣款。数据库也应使用唯一约束或事务锁,确保一条 RSVP 只能进入一次扣款流程。

如果必须在 RSVP 时预授权

如果活动会在几天内举行,可以创建 capture_method=manual 的 PaymentIntent:

<?php

$paymentIntent = $stripe->paymentIntents->create([
    'amount' => $amountInMinorUnits,
    'currency' => 'usd',
    'customer' => $stripeCustomerId,
    'payment_method' => $stripePaymentMethodId,
    'confirm' => true,
    'capture_method' => 'manual',
    'metadata' => [
        'event_id' => (string) $eventId,
        'rsvp_id' => (string) $rsvpId,
    ],
], [
    'idempotency_key' => 'event-' . $eventId . '-rsvp-' . $rsvpId . '-auth-v1',
]);

授权成功后,PaymentIntent 通常会进入 requires_capture 状态。在活动开始前 24 小时执行捕获:

<?php

$captured = $stripe->paymentIntents->capture(
    $paymentIntentId,
    [],
    [
        'idempotency_key' => 'capture-' . $paymentIntentId . '-v1',
    ]
);

如果主办方在扣款前取消活动,则取消 PaymentIntent:

<?php

$canceled = $stripe->paymentIntents->cancel(
    $paymentIntentId,
    [],
    [
        'idempotency_key' => 'cancel-' . $paymentIntentId . '-v1',
    ]
);

预授权取消后,支付服务商会通知发卡行释放授权。不过,用户账单上的待处理金额何时消失由发卡行决定,不一定会立即完成。

采用这一流程前,必须根据账户所在地、卡种、商户类型和支付服务商规则确认授权有效期。不要把”常见有效期”硬编码成业务保证。如果捕获时授权已经过期,只能重新发起交易。

已扣款后的退款

如果 PaymentIntent 已经成功捕获,就不能再通过取消授权退回款项,而应创建 Refund:

<?php

$refund = $stripe->refunds->create([
    'payment_intent' => $paymentIntentId,
    // 部分退款时增加:
    // 'amount' => $refundAmountInMinorUnits,
], [
    'idempotency_key' => 'refund-' . $paymentIntentId . '-v1',
]);

退款成功不代表资金会立即回到用户账户,具体到账时间由卡组织和发卡行决定。退款结果也应通过 webhook 更新,不能只依赖 API 请求同步返回的结果。

SaaS 服务如何选择

如果团队希望快速接入 hosted page、支付方式保存、off-session payment、manual capture 和 refund,可以选择 Stripe。它的 PHP SDK 和 PaymentIntents 流程也比较完整。

Adyen 更适合覆盖多个地区、支持多种支付方式或交易规模较大的业务,但接入、风控和商务配置通常更复杂。Braintree 也提供托管式支付组件、支付方式保存及授权与结算功能,是否适用取决于业务所在国家、币种和商户账户条件。

“托管表单”通常包括以下几种形式:

  • 完全跳转到支付服务商页面,例如 Stripe Checkout。
  • 在本站嵌入由支付服务商托管的字段或组件。
  • 由自己的后端接收原始卡片数据。

如果希望尽量缩小 PCI DSS 范围,应优先考虑前两种方式,尤其是完整 hosted checkout。即使系统不接触卡号,仍须妥善保护 webhook、API key、Customer ID、PaymentMethod ID 和订单数据,也不能因此声称完全不承担 PCI DSS 责任。

定价与风险方面的注意事项

各服务的费率会随国家、币种、卡类型、跨境交易、退款、拒付、货币转换和交易量而变化,不能只比较公开的单笔费率。正式选择前,应查看当前地区的官方报价,并按以下方式计算:

总成本 =
交易手续费
+ 跨境及换汇费用
+ 退款相关成本
+ 拒付成本
+ 失败重试成本
+ 开发和运维成本

还需要处理这些情况:

  • 保留用户同意未来扣款的证据,包括金额、时间和取消政策。
  • 满足 3-D Secure 或其他强客户认证要求。
  • off-session 扣款时,系统可能要求用户重新认证。
  • 卡片可能过期、挂失、额度不足或被银行拒绝。
  • 活动改期后,需要重新计算 charge_at。
  • 防止定时任务重复运行和并发扣款。
  • webhook 可能重复、乱序或延迟到达。
  • 处理部分捕获、部分退款和多人 RSVP。
  • 正确处理时区和夏令时。数据库中的活动时间和扣款时间宜统一保存为 UTC。
  • 日志中不得记录卡号、CVC、完整 webhook 密钥或 API secret。

长期预约活动不适合从 RSVP 开始一直占用用户的信用额度。更合适的做法是通过 hosted setup 保存支付方式,再于活动开始前 24 小时执行 off-session 扣款。这样更容易兼顾维护成本、用户体验和支付规则。manual capture 只适用于短周期活动,并且必须确认授权在捕获时仍然有效。

备注:内容仅供参考。