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
+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()