fix(v2): 取消失配收敛——渠道已取消(哨兵)→本地补齐+事件,不再500
CancelSubscription 本地 active/past_due 但渠道已先行取消(dashboard 手工 / 竞态未消费的 deleted webhook)时,stripe adapter 原样透传渠道拒绝,handler 映 成 500 cancel_failed——渠道取消这一事实明明已成立。新增 provider.ErrSubAlready Canceled 哨兵,stripe adapter 识别 resource_missing / "already been canceled" 两种真实 Stripe 错误形态并 wrap;gateway.CancelSubscription 命中哨兵后走与入 站 webhook 相同的 finalizeCanceled 本地收敛,两路对同一终态天然幂等。 同 re-review 顺手核掉同型缺口:onSubscriptionActivated 的 `!created→return nil` 在入队 subscription.created 之前短路,首次入队失败后 Stripe 重投会因 created=false 永久跳过入队——通知永久丢失。改为无论 created 与否都无条件入队,outbox 唯一键 ON CONFLICT DO NOTHING 天然幂等自愈,与 finalizeCanceled/markSubscriptionPastDue/settleRenewal 同一写法。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -35,8 +35,12 @@ func (stubResolver) Resolve(sku, currency string) (int64, string, string, error)
|
||||
|
||||
type spyEnqueuer struct {
|
||||
calls []map[string]any
|
||||
failNext bool // 置 true 模拟 outbox 入队失败(settle 崩溃窗口测试用)
|
||||
seen map[string]bool // 镜像真实 WebhookStore.EnqueueDelivery 的 (out_trade_no,event_type,
|
||||
failNext bool // 置 true 模拟 outbox 入队失败(settle 崩溃窗口测试用),命中后消费一次即清零。
|
||||
// failOnEventType 非空时,下一次遇到该 event_type 的入队调用失败一次(消费后清空)——用于
|
||||
// 测试"同一 HandleCallback 内先后两次 Enqueue,只让第二次(如 subscription.created)失败,
|
||||
// 第一次(如 payment.succeeded)先成功"这类场景,failNext 做不到按 event_type 区分。
|
||||
failOnEventType string
|
||||
seen map[string]bool // 镜像真实 WebhookStore.EnqueueDelivery 的 (out_trade_no,event_type,
|
||||
// refund_id) 唯一键 ON CONFLICT DO NOTHING:重复 key 静默 no-op(不追加 calls,也不算失败)。
|
||||
// 修复"重投补入队自愈"后,业务代码会在 duplicate/重投分支也调用 Enqueue,若 spy 仍是无脑
|
||||
// 计数器就会把 outbox 天然幂等的重复行误判成"多发了一次 webhook",这里镜像真实幂等语义。
|
||||
@@ -47,6 +51,10 @@ func (s *spyEnqueuer) Enqueue(outTradeNo, bizSystem, eventType, refundID string,
|
||||
s.failNext = false
|
||||
return errors.New("outbox down")
|
||||
}
|
||||
if s.failOnEventType != "" && eventType == s.failOnEventType {
|
||||
s.failOnEventType = ""
|
||||
return errors.New("outbox down")
|
||||
}
|
||||
key := outTradeNo + "|" + eventType + "|" + refundID
|
||||
if s.seen == nil {
|
||||
s.seen = make(map[string]bool)
|
||||
|
||||
@@ -100,6 +100,8 @@ func (g *Gateway) CreateSubscription(ctx context.Context, in CreateSubscriptionI
|
||||
|
||||
// onSubscriptionActivated 幂等诞生订阅 + 入队 subscription.created。首期支付回调触发。
|
||||
// created 事件走首购 order 的 out_trade_no + event_type=subscription.created(唯一键天然不撞 payment.succeeded)。
|
||||
// 入队失败原样返回 err,调用方(HandleCallback)映射 SettleFailed,驱动 Stripe 侧重投自愈
|
||||
// (见下方入队处注释)。
|
||||
func (g *Gateway) onSubscriptionActivated(ctx context.Context, ev *provider.PaidEvent) error {
|
||||
att, err := g.orders.AttemptByProviderRef(ev.ProviderRef)
|
||||
if err != nil {
|
||||
@@ -116,7 +118,8 @@ func (g *Gateway) onSubscriptionActivated(ctx context.Context, ev *provider.Paid
|
||||
return nil
|
||||
}
|
||||
subID := "SUB-" + att.OutTradeNo // 与 CreateSubscription 同式派生 → 重投算出同一 SubID,Create 幂等
|
||||
created, err := g.subs.Create(&model.Subscription{
|
||||
// created(首次诞生 true / 重投幂等 false)不再驱动入队分叉,见下方入队处注释——不必单独取用。
|
||||
_, err = g.subs.Create(&model.Subscription{
|
||||
SubID: subID, OutTradeNo: o.OutTradeNo, BizSystem: o.BizSystem, BizRef: o.BizRef, BizCode: o.BizCode,
|
||||
Channel: att.Channel, ProviderSubRef: ev.SubscriptionRef, RecurringKind: provider.RecurringKindGatewayScheduled,
|
||||
AmountMinor: o.AmountMinor, Currency: o.Currency, Status: model.SubActive,
|
||||
@@ -124,9 +127,15 @@ func (g *Gateway) onSubscriptionActivated(ctx context.Context, ev *provider.Paid
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !created || o.BizSystem == "" {
|
||||
return nil // 已诞生过(重投)/ 独立收款无业务方回调
|
||||
if o.BizSystem == "" {
|
||||
return nil // 独立收款无业务方回调
|
||||
}
|
||||
// 入队不按 created 分叉(P8 Task4/5 审计发现的同型反纪律,同 commit 一并修——settleRenewal/
|
||||
// finalizeCanceled/markSubscriptionPastDue 已是这个写法):若首次 Create 成功但入队瞬时失败,
|
||||
// 调用方(HandleCallback)拿到 err 后 Stripe 会重投同一 checkout.session.completed;此时
|
||||
// subs.Create 已幂等、created=false,若像旧实现那样 `!created→return nil` 直接跳过,outbox
|
||||
// 永远补不上这一行,subscription.created 通知永久丢失。改为无论 created 与否都尝试入队,
|
||||
// outbox 唯一键 ON CONFLICT DO NOTHING 天然幂等:行已存在则 no-op,行曾丢失则本次补建。
|
||||
return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, EvtSubscriptionCreated, "", map[string]any{
|
||||
"event_type": EvtSubscriptionCreated, "out_trade_no": o.OutTradeNo, "sub_id": subID,
|
||||
"biz_system": o.BizSystem, "biz_ref": o.BizRef, "product_biz_code": o.BizCode,
|
||||
@@ -158,6 +167,13 @@ func (g *Gateway) GetSubscription(subID string) (*SubscriptionView, error) {
|
||||
// 幂等 no-op(不再打渠道,避免重复取消命中渠道 400)→ 渠道侧取消 → 本地翻 canceled + 入队
|
||||
// subscription.canceled。Stripe 随后异步发 customer.subscription.deleted,入站处理器
|
||||
// (settleSubscriptionCanceled)再次调用同一 finalizeCanceled,两路收敛同一终态、天然幂等。
|
||||
//
|
||||
// 本地 active/past_due 但渠道已先行取消(dashboard 手工取消 / 竞态下未消费的 deleted webhook
|
||||
// 抢先落地)时,渠道会拒绝二次 Cancel;adapter 将其识别并 wrap 成 provider.ErrSubAlreadyCanceled
|
||||
// (errors.Is 可判)。这不是"取消失败"而是"取消事实已在渠道成立"——按此哨兵走与入站 webhook
|
||||
// 相同的本地收敛(finalizeCanceled),而非把渠道拒绝原样透传成 500。与随后可能补投的
|
||||
// customer.subscription.deleted 双路收敛于同一 finalizeCanceled:MarkCanceled 是状态条件
|
||||
// UPDATE(只在非 canceled 时翻转)+ outbox 唯一键 ON CONFLICT DO NOTHING,谁先谁后都幂等。
|
||||
func (g *Gateway) CancelSubscription(ctx context.Context, subID string) error {
|
||||
sub, err := g.subs.GetBySubID(subID)
|
||||
if err != nil {
|
||||
@@ -171,7 +187,7 @@ func (g *Gateway) CancelSubscription(ctx context.Context, subID string) error {
|
||||
return err
|
||||
}
|
||||
if sp, ok := prov.(provider.SubscriptionProvider); ok {
|
||||
if err := sp.CancelSubscription(ctx, sub.ProviderSubRef); err != nil {
|
||||
if err := sp.CancelSubscription(ctx, sub.ProviderSubRef); err != nil && !errors.Is(err, provider.ErrSubAlreadyCanceled) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -454,6 +455,79 @@ func TestCancelSubscriptionIdempotentNoOp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelSubscriptionChannelAlreadyCanceledConverges 覆盖渠道已先行取消(dashboard 手工 /
|
||||
// 竞态下未消费的 deleted webhook 抢先落地)的失配场景:本地仍 active,adapter 打渠道拿到
|
||||
// provider.ErrSubAlreadyCanceled(此处用死脚手架 fp.cancelErr 模拟 stripe adapter 已 wrap 好的
|
||||
// 哨兵错误)——CancelSubscription 不应再报错(修复前会把这个 err 原样透传,handler 会映成 500
|
||||
// cancel_failed;修复后按哨兵走本地收敛),本地翻 canceled 且恰好入队一次 subscription.canceled。
|
||||
func TestCancelSubscriptionChannelAlreadyCanceledConverges(t *testing.T) {
|
||||
g, fp, spy, _, subs := newSubGateway(t)
|
||||
subID, providerSubRef, orderNo := activateSub(t, g, fp)
|
||||
spy.calls = nil
|
||||
fp.cancelErr = fmt.Errorf("%w: stripe simulated already-canceled", provider.ErrSubAlreadyCanceled)
|
||||
|
||||
if err := g.CancelSubscription(context.Background(), subID); err != nil {
|
||||
t.Fatalf("CancelSubscription(渠道已取消场景应本地收敛,不应报错): %v", err)
|
||||
}
|
||||
if len(fp.cancelCalls) != 1 || fp.cancelCalls[0] != providerSubRef {
|
||||
t.Fatalf("cancelCalls = %+v, want [%s](渠道确实被打过一次,只是回了'已取消'错误)", fp.cancelCalls, providerSubRef)
|
||||
}
|
||||
sub, err := subs.GetBySubID(subID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBySubID: %v", err)
|
||||
}
|
||||
if sub.Status != model.SubCanceled || sub.CanceledAt == nil {
|
||||
t.Fatalf("subscription after cancel(渠道失配场景) = %+v, want locally canceled", sub)
|
||||
}
|
||||
if len(spy.calls) != 1 {
|
||||
t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls)
|
||||
}
|
||||
c := spy.calls[0]
|
||||
if c["event_type"] != gateway.EvtSubscriptionCanceled || c["sub_id"] != subID || c["out_trade_no"] != orderNo {
|
||||
t.Fatalf("cancel webhook payload = %+v, want event_type=%s sub_id=%s out_trade_no=%s",
|
||||
c, gateway.EvtSubscriptionCanceled, subID, orderNo)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelSubscriptionWebhookThenAPIConverge 覆盖反向竞态:入站 customer.subscription.deleted
|
||||
// 先到(本地先翻 canceled + 发一次 webhook),随后业务方/用户侧发起的主动取消 API 才姗姗来迟。
|
||||
// 此时本地已是终态,CancelSubscription 应在打渠道之前就早退(sub.Status==canceled 分支),
|
||||
// 绝不二次调用渠道、也绝不重复入队——即便 fp.cancelErr 被设成"渠道已取消"哨兵(模拟万一实现
|
||||
// 顺序有误、真打了渠道也不该出错),结果仍应是幂等 no-op。
|
||||
func TestCancelSubscriptionWebhookThenAPIConverge(t *testing.T) {
|
||||
g, fp, spy, _, subs := newSubGateway(t)
|
||||
ctx := context.Background()
|
||||
subID, providerSubRef, _ := activateSub(t, g, fp)
|
||||
spy.calls = nil
|
||||
fp.cancelErr = fmt.Errorf("%w: stripe simulated already-canceled", provider.ErrSubAlreadyCanceled)
|
||||
|
||||
raw, err := json.Marshal(provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: providerSubRef})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
|
||||
if err != nil || result != gateway.SettleProcessed {
|
||||
t.Fatalf("HandleCallback deleted(webhook 先到) = %v, %v", result, err)
|
||||
}
|
||||
if len(spy.calls) != 1 {
|
||||
t.Fatalf("webhook calls after inbound deleted = %d, want 1", len(spy.calls))
|
||||
}
|
||||
|
||||
if err := g.CancelSubscription(ctx, subID); err != nil {
|
||||
t.Fatalf("CancelSubscription(webhook 已先到,应幂等 no-op 不报错): %v", err)
|
||||
}
|
||||
if len(fp.cancelCalls) != 0 {
|
||||
t.Fatalf("cancelCalls = %+v, want 0(本地已终态,不应再打渠道)", fp.cancelCalls)
|
||||
}
|
||||
if len(spy.calls) != 1 {
|
||||
t.Fatalf("webhook calls after API cancel(webhook 先到之后) = %d, want still 1(不重复入队)", len(spy.calls))
|
||||
}
|
||||
sub, err := subs.GetBySubID(subID)
|
||||
if err != nil || sub.Status != model.SubCanceled {
|
||||
t.Fatalf("subscription = %+v, %v", sub, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundSubscriptionDeletedConverges 覆盖入站 customer.subscription.deleted:同订阅
|
||||
// 幂等标 canceled + 入队一次;重投(渠道 webhook 常见重投)不重复发 webhook。
|
||||
func TestInboundSubscriptionDeletedConverges(t *testing.T) {
|
||||
@@ -760,6 +834,74 @@ func TestSettleRenewalEnqueueFailureThenRetryRecovers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubscriptionActivatedEnqueueFailureThenRetryRecovers 镜像
|
||||
// TestSettleRenewalEnqueueFailureThenRetryRecovers,覆盖同型缺口(T5 re-review 发现):
|
||||
// onSubscriptionActivated 先 subs.Create(先于入队幂等诞生订阅行)后 Enqueue subscription.created
|
||||
// ——首次入队失败(瞬时)后,checkout.session.completed 重投走 created=false 的分支必须仍尝试
|
||||
// 入队(outbox 唯一键幂等,行不存在则补建),否则 subscription.created 永久丢失(旧实现
|
||||
// `!created→return nil` 直接跳过,行永远补不上)。
|
||||
//
|
||||
// 用 spy.failOnEventType 只让 subscription.created 这一次入队失败(而非 Settle 内更早的
|
||||
// payment.succeeded)——同一 HandleCallback 里先后两次 Enqueue,只想复现"第二次失败"这个窗口。
|
||||
//
|
||||
// 断言:①首次:payment.succeeded 已入队(Settle 已 processed,订单已 paid),订阅行已幂等
|
||||
// 诞生 active(Create 先于 Enqueue),但 subscription.created 入队失败 → HandleCallback 整体
|
||||
// SettleFailed+err ②同一事件重投 → subscription.created 最终恰入队一次(payment.succeeded
|
||||
// 不因重投重发,outbox 唯一键幂等)③重投不双铸订阅行(SubID 幂等派生)。
|
||||
func TestSubscriptionActivatedEnqueueFailureThenRetryRecovers(t *testing.T) {
|
||||
g, fp, spy, orders, subs := newSubGateway(t)
|
||||
ctx := context.Background()
|
||||
res, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
|
||||
SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSubscription: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(provider.PaidEvent{
|
||||
Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
|
||||
PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal activate event: %v", err)
|
||||
}
|
||||
|
||||
spy.failOnEventType = gateway.EvtSubscriptionCreated
|
||||
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
|
||||
if err == nil || result != gateway.SettleFailed {
|
||||
t.Fatalf("subscription.created 入队失败应 SettleFailed+err, got %v, %v", result, err)
|
||||
}
|
||||
o, err := orders.GetOrder(res.OrderNo)
|
||||
if err != nil || o.Status != model.OrderPaidV2 {
|
||||
t.Fatalf("order after first(失败) attempt = %+v, %v, want paid(payment.succeeded 已先成功入队+翻转)", o, err)
|
||||
}
|
||||
subID := "SUB-" + res.OrderNo
|
||||
sub, err := subs.GetBySubID(subID)
|
||||
if err != nil || sub.Status != model.SubActive {
|
||||
t.Fatalf("subscription after first(失败) attempt = %+v, %v, want already active(Create 先于 Enqueue,幂等诞生)", sub, err)
|
||||
}
|
||||
if len(spy.calls) != 1 || spy.calls[0]["event_type"] != "payment.succeeded" {
|
||||
t.Fatalf("首次 calls = %+v, want 仅 payment.succeeded 一条(subscription.created 那次入队失败,未记入)", spy.calls)
|
||||
}
|
||||
|
||||
// Stripe 拿不到 200 会重投同一 checkout.session.completed。
|
||||
result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleCallback retry: %v", err)
|
||||
}
|
||||
_ = result2 // duplicate(created=false,订单/订阅已在)——本用例只关心自愈,不断言具体 result 值
|
||||
if len(spy.calls) != 2 {
|
||||
t.Fatalf("重投后 calls = %d, want 2(payment.succeeded 幂等 no-op 不重发 + subscription.created 补建恰一次): %+v", len(spy.calls), spy.calls)
|
||||
}
|
||||
if spy.calls[1]["event_type"] != gateway.EvtSubscriptionCreated || spy.calls[1]["sub_id"] != subID {
|
||||
t.Fatalf("payload = %+v, want event_type=%s sub_id=%s", spy.calls[1], gateway.EvtSubscriptionCreated, subID)
|
||||
}
|
||||
|
||||
subAfter, err := subs.GetBySubID(subID)
|
||||
if err != nil || subAfter.Status != model.SubActive {
|
||||
t.Fatalf("subscription after retry = %+v, %v", subAfter, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettleRenewalNoBizSystemStillProcessed 独立收款(无业务方回调,BizSystem=="")的续费首过
|
||||
// 应与 enqueuePaymentSucceeded 的"无业务方=跳过入队但仍 processed"语义对齐,不能误判 duplicate
|
||||
// (created=true 是真正的首次成交,只是没有下游 webhook 可发)。
|
||||
|
||||
@@ -122,6 +122,13 @@ type QueryRequest struct {
|
||||
var (
|
||||
ErrUnknownMethod = errors.New("provider: unknown method")
|
||||
ErrNotSupported = errors.New("provider: capability not supported")
|
||||
|
||||
// ErrSubAlreadyCanceled — SubscriptionProvider.CancelSubscription 的哨兵:渠道侧订阅
|
||||
// 已处于取消终态(dashboard 手工取消 / 竞态下未消费的 deleted webhook 抢先落地),本地
|
||||
// 发起的主动取消打到渠道时渠道拒绝(如 Stripe "already been canceled" / resource_missing)。
|
||||
// 调用方(gateway.CancelSubscription)须将其视为"取消事实已成立",走本地收敛而非报错——
|
||||
// 具体渠道 adapter 负责把渠道原生错误 wrap 成本哨兵(参见 stripe.isAlreadyCanceledErr)。
|
||||
ErrSubAlreadyCanceled = errors.New("provider: subscription already canceled at channel")
|
||||
)
|
||||
|
||||
// Provider — 每个支付渠道实现的统一接口(设计 §4.1 PaymentProvider)。
|
||||
|
||||
@@ -7,6 +7,7 @@ package stripe
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -159,13 +160,41 @@ func (p *Provider) CreateSubscriptionCheckout(_ context.Context, req provider.Cr
|
||||
|
||||
// CancelSubscription 立即取消 Stripe 订阅(不等本期末)。Stripe 随后发 customer.subscription.deleted,
|
||||
// 入站处理器幂等标 canceled,与本地主动标一致收敛。
|
||||
//
|
||||
// 渠道已先行取消(dashboard 手工 / 竞态下未消费的 deleted webhook 抢先落地)时,Stripe 会拒绝
|
||||
// 二次 Cancel:识别出这类错误后 wrap 成 provider.ErrSubAlreadyCanceled(errors.Is 可判),不是
|
||||
// "取消失败"而是"取消已成立"——调用方(gateway.CancelSubscription)据此走本地收敛而非报错。
|
||||
func (p *Provider) CancelSubscription(_ context.Context, providerSubRef string) error {
|
||||
if _, err := p.sc.Subscriptions.Cancel(providerSubRef, nil); err != nil {
|
||||
if isAlreadyCanceledErr(err) {
|
||||
return fmt.Errorf("%w: %v", provider.ErrSubAlreadyCanceled, err)
|
||||
}
|
||||
return fmt.Errorf("stripe: 取消订阅失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAlreadyCanceledErr 判定"渠道侧订阅已处于取消终态"这一场景,对应 vendored v79
|
||||
// (github.com/stripe/stripe-go/v79 error.go)实测/文档记录的两种 *stripe.Error 形态:
|
||||
//
|
||||
// - 订阅对象已被彻底删除(引用旧 id 查不到):Type=invalid_request_error,
|
||||
// Code=resource_missing(有明确机器可读 Code,见 error.go ErrorCodeResourceMissing)。
|
||||
// - 订阅对象仍在但 status=canceled(二次 Cancel 同一仍存在的订阅):Type=invalid_request_error,
|
||||
// **无 Code**(Stripe 对这种校验类拒绝不下发机器可读 code,仅给 Msg 文案
|
||||
// "This subscription has already been canceled."),只能按已知文案兜底、大小写不敏感匹配,
|
||||
// 避免因标点/大小写细节波动误判。
|
||||
func isAlreadyCanceledErr(err error) bool {
|
||||
var stripeErr *gostripe.Error
|
||||
if !errors.As(err, &stripeErr) {
|
||||
return false
|
||||
}
|
||||
if stripeErr.Code == gostripe.ErrorCodeResourceMissing {
|
||||
return true
|
||||
}
|
||||
return stripeErr.Type == gostripe.ErrorTypeInvalidRequest &&
|
||||
strings.Contains(strings.ToLower(stripeErr.Msg), "already been canceled")
|
||||
}
|
||||
|
||||
func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) {
|
||||
sig := in.Headers["Stripe-Signature"]
|
||||
// stripe-go 默认 ConstructEvent 会额外校验 event.api_version == SDK 编译期常量
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -40,6 +41,20 @@ func fakeStripeAPI(t *testing.T) *httptest.Server {
|
||||
fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","amount_total":2999,"currency":"usd","payment_status":"paid"}`)
|
||||
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_cancel_ok"):
|
||||
fmt.Fprint(w, `{"id":"sub_cancel_ok","object":"subscription","status":"canceled"}`)
|
||||
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_resource_missing"):
|
||||
// 渠道已彻底删除该订阅对象:invalid_request_error + code=resource_missing(有明确
|
||||
// 机器可读 Code)。
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprint(w, `{"error":{"type":"invalid_request_error","code":"resource_missing","message":"No such subscription: 'sub_resource_missing'"}}`)
|
||||
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_already_canceled"):
|
||||
// 订阅对象仍在但 status=canceled,二次 Cancel:invalid_request_error,**无 code**,
|
||||
// 只有 Stripe 实测的固定文案。
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `{"error":{"type":"invalid_request_error","message":"This subscription has already been canceled."}}`)
|
||||
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_cancel_other_error"):
|
||||
// 与"已取消"无关的普通渠道拒绝(如权限/网络类),不应被误判成哨兵。
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `{"error":{"type":"invalid_request_error","message":"Something else went wrong."}}`)
|
||||
default:
|
||||
http.Error(w, `{"error":{"message":"not found"}}`, http.StatusNotFound)
|
||||
}
|
||||
@@ -82,6 +97,53 @@ func TestCancelSubscription(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelSubscriptionResourceMissingWrapsSentinel 覆盖"渠道已彻底删除该订阅对象"这一
|
||||
// 已取消形态:*stripe.Error{Type:invalid_request_error, Code:resource_missing} → wrap 成
|
||||
// provider.ErrSubAlreadyCanceled(errors.Is 可判),不是不可判别的裸字符串错误。
|
||||
func TestCancelSubscriptionResourceMissingWrapsSentinel(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
p := newStripe(t, ts)
|
||||
err := p.CancelSubscription(context.Background(), "sub_resource_missing")
|
||||
if err == nil {
|
||||
t.Fatalf("cancel resource_missing: want error, got nil")
|
||||
}
|
||||
if !errors.Is(err, provider.ErrSubAlreadyCanceled) {
|
||||
t.Fatalf("cancel resource_missing err = %v, want wraps provider.ErrSubAlreadyCanceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelSubscriptionAlreadyCanceledMessageWrapsSentinel 覆盖"订阅对象仍在但 status=canceled
|
||||
// 二次 Cancel"这一形态:Stripe 对此场景**不下发机器可读 Code**,只有 invalid_request_error 类型
|
||||
// + 固定文案"already been canceled"——同样应 wrap 成哨兵。
|
||||
func TestCancelSubscriptionAlreadyCanceledMessageWrapsSentinel(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
p := newStripe(t, ts)
|
||||
err := p.CancelSubscription(context.Background(), "sub_already_canceled")
|
||||
if err == nil {
|
||||
t.Fatalf("cancel already_canceled: want error, got nil")
|
||||
}
|
||||
if !errors.Is(err, provider.ErrSubAlreadyCanceled) {
|
||||
t.Fatalf("cancel already_canceled err = %v, want wraps provider.ErrSubAlreadyCanceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelSubscriptionOtherErrorNotWrapped 反例:与"已取消"无关的渠道拒绝不应被误判成
|
||||
// 哨兵,原样透传成普通错误(不能 errors.Is 命中)。
|
||||
func TestCancelSubscriptionOtherErrorNotWrapped(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
p := newStripe(t, ts)
|
||||
err := p.CancelSubscription(context.Background(), "sub_cancel_other_error")
|
||||
if err == nil {
|
||||
t.Fatalf("cancel other error: want error, got nil")
|
||||
}
|
||||
if errors.Is(err, provider.ErrSubAlreadyCanceled) {
|
||||
t.Fatalf("cancel other error err = %v, 不应误判成 ErrSubAlreadyCanceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitiesRecurring(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
|
||||
Reference in New Issue
Block a user