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