支持信用卡退款与 pre-auth 的 Event RSVP 支付服务
结论
可以用 Stripe、Adyen 或 Braintree 处理托管信用卡表单、预授权、扣款、撤销授权和退款。对于 PHP + CodeIgniter 项目,Stripe Checkout + PaymentIntents 通常更容易集成。
不过,信用卡预授权不能无限期保留。授权有效期取决于卡组织、发卡行、商户类型和支付服务商,通常只有数天,无法可靠地覆盖从 RSVP 到数周甚至数月后活动开始的整个周期。
更稳妥的流程是:
- 用户 RSVP 时,通过 hosted checkout page 收集并保存支付方式,暂不扣款。
- 在活动开始前 24 小时创建 off-session payment 并完成扣款。
- 如果主办方提前取消活动,只需取消 RSVP 和付款计划,无须释放授权。
- 如果 RSVP 距离活动开始时间很近,而且授权有效期足够,可以使用 manual capture,先授权,再于活动开始前 24 小时捕获。
- 如果已经扣款,取消活动时应执行 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 只适用于短周期活动,并且必须确认授权在捕获时仍然有效。
备注:内容仅供参考。
