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:
@@ -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