Stripe 沙箱退款永远成功:异步退款、拒付与卡级状态污染怎么复现
上线第一周对账,退款表里一条 status=succeeded 的记录,在 Stripe 后台已经是 refund.failed。本地没有任何 webhook handler 接这个事件,订单状态停在“已退款”,账不平。这类问题很难在沙箱里提前发现:用普通测试卡发起的退款会立即成功,之后状态不再变化。
先把测试前提摆清楚。测试卡只在 sandbox(测试模式、test API key)里用,不动真实资金;不要用真实卡号,也不要在 live 模式用真实卡测试。服务端建议用 PaymentMethod(如 pm_card_visa),不要硬编码卡号,否则上线时可能不符合 PCI 要求。测试环境不要做压测,会撞 rate limit。测试交互式表单用 4242 4242 4242 4242,有效期填未来日期(如 12/34),CVC 任意 3 位(Amex 4 位)。
一、异步退款:沙箱里退款没有终态变化
现象
本地退款接口返回 succeeded,代码直接把订单标成退款完成。线上出现两种没处理过的情况:退款先 pending,过一会儿才 succeeded;或者先 succeeded,几分钟后变成 failed 并发出 refund.failed。后一种直接导致账不平。
官方行为
线上退款是异步的,可能先显示成功后来失败,也可能先 pending 后成功。沙箱默认用普通测试卡发起的退款立即成功,且之后状态不再变化,所以永远测不到失败的退款。卡退款的取消只能在 Dashboard 操作;线上可取消窗口很短且不确定,测试环境用 30 分钟模拟这个窗口。
用哪张测试卡复现
- 先
pending后succeeded:4000 0000 0000 7726。退款初始status=pending,稍后转succeeded并发refund.updated事件。 - 先
succeeded后failed:4000 0000 0000 5126。退款初始succeeded,几分钟后转failed并发refund.failed事件。
代码要处理什么
不要只依赖退款接口的同步返回。监听 refund.updated 和 refund.failed,允许状态从 succeeded 回退到 failed:
switch (refund.status) {
case 'pending':
order.refundState = 'processing'
break
case 'succeeded':
order.refundState = 'refunded'
break
case 'failed':
order.refundState = 'refund_failed'
// 进入人工对账队列,不要静默吞掉
break
}
refund.failed 要有独立的告警和对账任务;把退款完成当成不可逆的终态,线上就会漏账。
二、拒付与争议:支付成功几小时后才扣回
现象
支付接口返回成功,订单发货。几小时或几天后,一笔 fraudulent 争议创建,余额被扣回,代码里只处理了 charge.succeeded,没有任何争议处理逻辑。
官方行为
争议是异步创建的。默认账户设置下,支付成功,稍后才创建 fraudulent 争议,这更贴近线上。fraudulent 争议在通过 3DS 认证后是受保护的;product not received 争议在 3DS 认证后不受保护。所以“上不上 3DS”不能一概而论。3D Secure 测试卡的具体卡号见官方 Testing 页 3D Secure 小节,本文未逐一列出。
用哪张测试卡复现
- 异步 fraudulent 争议:
4061 2199 4639 3810,支付成功,稍后创建争议,3DS 后受保护。 - 同步 fraudulent 争议:
4000 0000 0000 0259,支付请求期间就创建,3DS 后受保护。 - product not received 争议:
4000 0000 0000 2685,3DS 认证后不受保护。 - 询证(inquiry):
4000 0000 0000 1976。 - 早期欺诈预警:
4000 0000 0000 5423。 - 多次争议:
4000 0004 0400 0079。 - 部分退款后异步产生争议:
4000 0084 0000 0795,10 元退 3 元,剩 7 元产生争议;全额退款不触发。
代码要处理什么
监听 charge.dispute.created、charge.dispute.updated、charge.dispute.closed,争议创建时先冻结对应订单的结算或打风险标记。回应争议时,API 里用 uncategorized_text 传 winning_evidence / losing_evidence 控制胜负;inquiry 可以用 escalate_inquiry_evidence 升级为 chargeback。Dashboard 里填在 Additional information,再 Submit evidence。
不要把 3DS 当成所有争议的挡箭牌:fraudulent 争议受保护,product not received 不受保护,两条路径要分开处理。
三、Radar 与风控拦截:fraud 是 card error,不是争议
现象
上线后一批 PaymentIntent 失败,错误码是 fraud。代码把 fraud 和拒付(dispute)混成一类,触发了争议处理流程,实际这是一次被 Radar 拦截的支付,钱根本没扣。
官方行为
被 Radar 拦截的结果是 card error,error code 为 fraud。风险等级分 highest、elevated 等;动态风险阈值只有开启 Dynamic risk thresholds 才拦;Radar 自适应 3DS 在开启 Adaptive 3DS 时会要求 3DS。CVC、邮编校验失败也会以 card error 返回。只传卡号不传 CVC/邮编时,对应校验会被跳过,所以“校验不会失败”;要测失败必须带上这些字段。
用哪张测试卡复现
- 永远被拦,risk level highest:
4100 0000 0000 0019。 - highest 但取决于设置:
4000 0000 0000 4954。 - elevated,可能进人工审核队列:
4000 0000 0000 9235。 - 拒付分高:
4000 0084 0000 0407;早期欺诈预警分高:4000 0084 0000 0159。 - 动态风险阈值:
4000 0084 0000 1017,开启 Dynamic risk thresholds 才拦。 - Radar 自适应 3DS:
4000 0084 0560 0003,开启 Adaptive 3DS 时会要求 3DS。 - CVC 校验失败:
4000 0000 0000 0101,要传 CVC;邮编校验失败:4000 0000 0000 0036,要传邮编;两者都失败:4000 0000 0000 0010。
代码要处理什么
在支付失败分支里单独识别 fraud:
if (err.code === 'fraud') {
// Radar 拦截:提示换支付方式或联系人工,不走争议流程
} else if (err.code === 'card_declined') {
// 发卡行拒付,按 decline_code 给文案
}
测试 CVC、邮编校验时,请求里必须带上 cvc 和 postal_code,否则校验被跳过,用例等于没测。
四、卡级状态污染:同一张卡不能跑两个场景
现象
本地单个用例跑通,CI 里按顺序跑完整套件时失败。前面某个用例用同一张卡触发过争议或退款,后面的用例拿到的卡级状态已经不一样了。
官方行为
一个测试用例产生的卡级状态会影响后续用同一张卡的测试。当测试依赖卡级状态时,每个独立的端到端场景应换一张测试卡。
用哪张测试卡复现
- 模拟发卡行拒付的卡不能 attach 到 Customer 对象。
- 要测“已绑定卡扣款失败”,用
4000 0000 0000 0341:它能成功 attach 到 Customer,但后续扣款失败。
代码要处理什么
测试编排里按场景分配卡号,不要并行复用同一张卡;涉及 Customer 绑定、争议、退款的用例各自独立。CI 失败时先检查卡号是否被前面的用例污染,再查业务逻辑。
五、常见错误卡与 decline code 速查
| 测试卡 | 返回 |
|---|---|
4000 0000 0000 0002 | card_declined / generic_decline |
4000 0000 0000 9995 | card_declined / insufficient_funds |
4000 0000 0000 9987 | card_declined / lost_card |
4000 0000 0000 9979 | card_declined / stolen_card |
4000 0000 0000 0069 | expired_card,无 decline code |
4000 0000 0000 0127 | incorrect_cvc |
4000 0000 0000 0119 | processing_error |
4242 4242 4242 4241 | incorrect_number,卡号 Luhn 不过 |
4000 0000 0000 6975 | card_declined / card_velocity_exceeded |
无效数据不需要特殊卡:月份填 13 返回 invalid_expiry_month;年份用 50 年前的(如 95)返回 invalid_expiry_year;两位 CVC(如 99)返回 invalid_cvc。
另外两个容易忽略的点:用位置邮箱模拟客户所在国家,Checkout Session / Payment Link 里用 test+location_US@example.com 形式(+location_XX,XX 是两位国家码),页面会按该国家的币种与可用支付方式渲染;部分卡种跨币种、跨境会有 cross-border fee,测试环境也一样,比如 JCB、UnionPay 这类非美国发卡。
参考地址
- Testing 总览与全部测试卡:
- 测试用例:
- 沙箱:
- 3D Secure 测试卡:(本文未逐一列出卡号)