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:
wangjia
2026-07-10 18:42:20 +08:00
parent ff65a06bf2
commit 02b2fcfa41
6 changed files with 270 additions and 6 deletions
+10 -2
View File
@@ -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)
+20 -4
View File
@@ -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
}
}
+142
View File
@@ -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 可发)。
+7
View File
@@ -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)。
+29
View File
@@ -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 编译期常量
+62
View File
@@ -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()