package gateway_test import ( "context" "encoding/json" "errors" "strings" "testing" "github.com/wangjia/pay/config" "github.com/wangjia/pay/internal/accounts" "github.com/wangjia/pay/internal/gateway" "github.com/wangjia/pay/internal/model" "github.com/wangjia/pay/internal/provider" "github.com/wangjia/pay/internal/store" ) // fakeSubProvider 实现 provider.SubscriptionProvider:创建订阅 Checkout 返回固定 session。 // VerifyCallback 不经它——测试直接构造 PaidEvent 走 Settle/HandleCallback 的 default 分支 // 时也走它(HandleCallback 仍需先过 VerifyCallback 才能拿到 ev),这里让它原样透传注入的 // JSON 回调体(与 fake.Provider.VerifyCallback 同构,便于测试直接摆事件)。 type fakeSubProvider struct { sessionRef string cancelCalls []string // 记录 CancelSubscription 收到的 providerSubRef,断言调用次数/参数 cancelErr error // 非空时 CancelSubscription 返回该 err(模拟渠道对"已取消订阅"回 400) } func (p *fakeSubProvider) Method() string { return "substripe" } func (p *fakeSubProvider) Capabilities() provider.Capabilities { return provider.Capabilities{ RenderTypes: []provider.RenderType{provider.RenderRedirect}, SupportsRecurring: true, RecurringKind: provider.RecurringKindGatewayScheduled, SettleCurrencies: []string{"USD"}, Regions: []string{"global"}, } } func (p *fakeSubProvider) Create(_ context.Context, _ provider.CreateRequest) (*provider.Session, error) { return nil, errors.New("fakeSubProvider: one-time Create not used") } func (p *fakeSubProvider) CreateSubscriptionCheckout(_ context.Context, req provider.CreateRequest) (*provider.Session, error) { return &provider.Session{ ProviderRef: p.sessionRef, RenderType: provider.RenderRedirect, Payload: map[string]any{"url": "https://checkout.example/" + p.sessionRef, "amount_minor": req.AmountMinor}, }, nil } func (p *fakeSubProvider) CancelSubscription(_ context.Context, providerSubRef string) error { p.cancelCalls = append(p.cancelCalls, providerSubRef) return p.cancelErr } // VerifyCallback 直接把测试构造的 provider.PaidEvent JSON 反序列化透传回放,省去自建协议。 func (p *fakeSubProvider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) { var ev provider.PaidEvent if err := json.Unmarshal(in.Raw, &ev); err != nil { return nil, err } return &ev, nil } func (p *fakeSubProvider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) { return &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending}, nil } func newSubGateway(t *testing.T) (*gateway.Gateway, *fakeSubProvider, *spyEnqueuer, *store.OrderStore, *store.SubscriptionStore) { t.Helper() db := model.OpenTestDB(t) orders := store.NewOrderStore(db) refunds := store.NewRefundStore(db) subs := store.NewSubscriptionStore(db) preg := provider.NewRegistry() fp := &fakeSubProvider{sessionRef: "cs_test_sess1"} preg.Register(fp) areg := accounts.New([]config.AccountConfig{ {AccountID: "sub-a1", Channel: "substripe", Region: "global", Enabled: true, Weight: 1}, }) picker := accounts.NewRouter(areg, nil, nil) spy := &spyEnqueuer{} g := gateway.New(orders, refunds, preg, picker, stubSubResolver{}, spy, "global", subs) return g, fp, spy, orders, subs } type stubSubResolver struct{} func (stubSubResolver) Resolve(sku, currency string) (int64, string, string, error) { if sku != "pro_monthly" { return 0, "", "", gateway.ErrProductNotFound } if currency != "USD" { return 0, "", "", gateway.ErrProductNotFound } return 2999, "Pro 月付", "pro_monthly", nil } func TestCreateSubscriptionPipeline(t *testing.T) { g, fp, _, orders, _ := newSubGateway(t) res, err := g.CreateSubscription(context.Background(), gateway.CreateSubscriptionInput{ SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1", }) if err != nil { t.Fatalf("CreateSubscription: %v", err) } if res.SubID == "" || res.OrderNo == "" { t.Fatalf("result = %+v", res) } if res.Session.RenderType != string(provider.RenderRedirect) { t.Fatalf("session = %+v", res.Session) } o, err := orders.GetOrder(res.OrderNo) if err != nil || o.Status != model.OrderPendingV2 || o.AmountMinor != 2999 || o.Currency != "USD" { t.Fatalf("order = %+v, %v", o, err) } atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10) if len(atts) != 1 || atts[0].ProviderRef != fp.sessionRef { t.Fatalf("attempt = %+v", atts) } } func TestSubscriptionActivationOnFirstPayment(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 event: %v", err) } // 首次:order 翻 paid + 订阅诞生 active + webhook 收到 2 条(payment.succeeded + subscription.created)。 result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}) if err != nil || result != gateway.SettleProcessed { t.Fatalf("HandleCallback = %v, %v", result, err) } o, err := orders.GetOrder(res.OrderNo) if err != nil || o.Status != model.OrderPaidV2 { t.Fatalf("order after settle = %+v, %v", o, err) } sub, err := subs.GetByProviderRef("substripe", "sub_new") if err != nil { t.Fatalf("subscription not created: %v", err) } if sub.Status != model.SubActive || sub.SubID != res.SubID { t.Fatalf("subscription = %+v, want active/%s", sub, res.SubID) } if len(spy.calls) != 2 { t.Fatalf("webhook calls = %d, want 2: %+v", len(spy.calls), spy.calls) } sawPaymentSucceeded, sawSubCreated := false, false for _, c := range spy.calls { switch c["event_type"] { case gateway.EvtPaymentSucceeded: sawPaymentSucceeded = true case gateway.EvtSubscriptionCreated: sawSubCreated = true if c["sub_id"] != res.SubID { t.Fatalf("subscription.created sub_id = %v, want %s", c["sub_id"], res.SubID) } } } if !sawPaymentSucceeded || !sawSubCreated { t.Fatalf("missing expected events: %+v", spy.calls) } // 重投同一 event → 幂等:订单/订阅不重复变动,webhook 不再新增。 result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}) if err != nil { t.Fatalf("HandleCallback replay: %v", err) } if result2 != gateway.SettleDuplicate { t.Fatalf("replay result = %v, want duplicate", result2) } if len(spy.calls) != 2 { t.Fatalf("webhook calls after replay = %d, want still 2: %+v", len(spy.calls), spy.calls) } subAfter, err := subs.GetByProviderRef("substripe", "sub_new") if err != nil { t.Fatalf("subscription after replay: %v", err) } if subAfter.ID != sub.ID { t.Fatalf("subscription duplicated on replay: %+v vs %+v", subAfter, sub) } } // TestSettleRenewal 先经首期激活诞生订阅(provider_sub_ref=sub_new),再喂 // EventSubscriptionRenewal:①新增 renewal OrderV2(paid,out_trade_no 含 -r-) // ②webhook 收到 subscription.renewed ③重投幂等(不双铸/不双发) ④订阅刷新 current_period_end、 // 从 past_due 恢复 active。 func TestSettleRenewal(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) } activateRaw, 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) } if _, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: activateRaw}); err != nil { t.Fatalf("activate: %v", err) } spy.calls = nil // 只看续费产生的 webhook // 续费成功前先把订阅打成 past_due,验证续费成功后能恢复 active(路径复用 Activate)。 if ok, err := subs.MarkPastDue("substripe", "sub_new"); err != nil || !ok { t.Fatalf("MarkPastDue: ok=%v err=%v", ok, err) } renewalRaw, err := json.Marshal(provider.PaidEvent{ Kind: provider.EventSubscriptionRenewal, SubscriptionRef: "sub_new", InvoiceRef: "in_123", PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded, }) if err != nil { t.Fatalf("marshal renewal event: %v", err) } result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw}) if err != nil || result != gateway.SettleProcessed { t.Fatalf("HandleCallback renewal = %v, %v", result, err) } allOrders, err := orders.ListOrders("pangolin", "u-1", 10) if err != nil { t.Fatalf("ListOrders: %v", err) } var renewalOrder *model.OrderV2 for i := range allOrders { if strings.Contains(allOrders[i].OutTradeNo, "-r-") { renewalOrder = &allOrders[i] } } if renewalOrder == nil { t.Fatalf("renewal order not found: %+v", allOrders) } if renewalOrder.Status != model.OrderPaidV2 || renewalOrder.AmountMinor != 2999 || renewalOrder.Currency != "USD" { t.Fatalf("renewal order = %+v", renewalOrder) } if !strings.HasSuffix(renewalOrder.OutTradeNo, "-r-in_123") { t.Fatalf("renewal out_trade_no = %s, want suffix -r-in_123", renewalOrder.OutTradeNo) } if len(spy.calls) != 1 { t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls) } renewedCall := spy.calls[0] if renewedCall["event_type"] != gateway.EvtSubscriptionRenewed { t.Fatalf("event_type = %v, want %s", renewedCall["event_type"], gateway.EvtSubscriptionRenewed) } if renewedCall["sub_id"] != res.SubID { t.Fatalf("sub_id = %v, want %s", renewedCall["sub_id"], res.SubID) } if renewedCall["out_trade_no"] != renewalOrder.OutTradeNo { t.Fatalf("out_trade_no = %v, want %s", renewedCall["out_trade_no"], renewalOrder.OutTradeNo) } if renewedCall["amount_minor"] != int64(2999) { t.Fatalf("amount_minor = %v, want 2999", renewedCall["amount_minor"]) } sub, err := subs.GetByProviderRef("substripe", "sub_new") if err != nil { t.Fatalf("GetByProviderRef: %v", err) } if sub.Status != model.SubActive { t.Fatalf("subscription status after renewal = %v, want active(recovered from past_due)", sub.Status) } if sub.CurrentPeriodEnd == nil { t.Fatalf("current_period_end not refreshed") } // 重投同一 invoice → 不双铸 renewal order、不双发 webhook。 result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw}) if err != nil { t.Fatalf("HandleCallback renewal replay: %v", err) } if result2 != gateway.SettleDuplicate { t.Fatalf("replay result = %v, want duplicate", result2) } ordersAfterReplay, err := orders.ListOrders("pangolin", "u-1", 10) if err != nil { t.Fatalf("ListOrders after replay: %v", err) } renewalCount := 0 for i := range ordersAfterReplay { if strings.Contains(ordersAfterReplay[i].OutTradeNo, "-r-") { renewalCount++ } } if renewalCount != 1 { t.Fatalf("renewal order count after replay = %d, want 1", renewalCount) } if len(spy.calls) != 1 { t.Fatalf("webhook calls after replay = %d, want still 1: %+v", len(spy.calls), spy.calls) } } // TestSubscriptionNotActivatedOnUnpaidSession 复现 CRITICAL 发现:Stripe checkout.session.completed // 在异步支付方式下可能 payment_status=unpaid(映射为 PaidPending)先到达,此时 Settle 返回 // SettleIgnored——订阅诞生钩子必须跟随结算结果关门,不能只看 serr==nil(nil error 但未结算)。 // 断言:无 Subscription 行诞生、无 subscription.created 入队、订单仍 pending。 func TestSubscriptionNotActivatedOnUnpaidSession(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) } // checkout.session.completed(异步支付,payment_status=unpaid)归一化为 PaidPending。 raw, err := json.Marshal(provider.PaidEvent{ Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidPending, PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new", }) if err != nil { t.Fatalf("marshal event: %v", err) } result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}) if err != nil { t.Fatalf("HandleCallback: %v", err) } if result != gateway.SettleIgnored { t.Fatalf("result = %v, want ignored", result) } o, err := orders.GetOrder(res.OrderNo) if err != nil || o.Status != model.OrderPendingV2 { t.Fatalf("order after unpaid callback = %+v, %v, want still pending", o, err) } if _, err := subs.GetByProviderRef("substripe", "sub_new"); !errors.Is(err, store.ErrSubNotFound) { t.Fatalf("subscription should not exist for unpaid session, err = %v", err) } if len(spy.calls) != 0 { t.Fatalf("webhook calls = %d, want 0(no premature subscription.created): %+v", len(spy.calls), spy.calls) } } // TestSettleRenewalUnknownSubscription 未知 provider_sub_ref(订阅未诞生/已清理)→ 忽略,不报错。 func TestSettleRenewalUnknownSubscription(t *testing.T) { g, _, spy, _, _ := newSubGateway(t) ctx := context.Background() raw, err := json.Marshal(provider.PaidEvent{ Kind: provider.EventSubscriptionRenewal, SubscriptionRef: "sub_ghost", InvoiceRef: "in_ghost", PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded, }) if err != nil { t.Fatalf("marshal: %v", err) } result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}) if err != nil { t.Fatalf("HandleCallback: %v", err) } if result != gateway.SettleIgnored { t.Fatalf("result = %v, want ignored", result) } if len(spy.calls) != 0 { t.Fatalf("webhook calls = %d, want 0: %+v", len(spy.calls), spy.calls) } } // activateSub 建订阅 + 喂首期支付事件,把订阅推进到 active,返回 (subID, providerSubRef, orderNo)。 // providerSubRef 固定用 "sub_new"(下面取消/past_due 用例复用同一渠道订阅号反查)。 func activateSub(t *testing.T, g *gateway.Gateway, fp *fakeSubProvider) (subID, providerSubRef, orderNo string) { t.Helper() 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) } if _, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}); err != nil { t.Fatalf("activate: %v", err) } return res.SubID, "sub_new", res.OrderNo } // TestCancelSubscriptionAPI 覆盖主动取消 API 路径:调渠道 CancelSubscription(带正确 // providerSubRef)→ 本地翻 canceled → 入队 subscription.canceled(out_trade_no 用真实首购 // 单号,supporting Notifier 投递门禁天然放行 —— 见 subscription.go finalizeCanceled 注释)。 func TestCancelSubscriptionAPI(t *testing.T) { g, fp, spy, _, subs := newSubGateway(t) subID, providerSubRef, orderNo := activateSub(t, g, fp) spy.calls = nil // 只看取消产生的 webhook 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", 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) } } // TestCancelSubscriptionIdempotentNoOp 重复调用主动取消 API:第二次调用命中 sub.Status // 已 canceled 的早退分支,绝不重新打渠道(渠道对已取消订阅二次 Cancel 会回 400 —— 调用层 // 靠"先查本地终态"归一为幂等 no-op,压根不给渠道二次调用的机会),也不重复入队。 func TestCancelSubscriptionIdempotentNoOp(t *testing.T) { g, fp, spy, _, _ := newSubGateway(t) subID, _, _ := activateSub(t, g, fp) spy.calls = nil if err := g.CancelSubscription(context.Background(), subID); err != nil { t.Fatalf("cancel#1: %v", err) } if err := g.CancelSubscription(context.Background(), subID); err != nil { t.Fatalf("cancel#2(重复调用应幂等 no-op,不应报错): %v", err) } if len(fp.cancelCalls) != 1 { t.Fatalf("cancelCalls = %+v, want exactly 1(第二次不应打渠道)", fp.cancelCalls) } if len(spy.calls) != 1 { t.Fatalf("webhook calls = %d, want 1(不重复入队)", len(spy.calls)) } } // TestInboundSubscriptionDeletedConverges 覆盖入站 customer.subscription.deleted:同订阅 // 幂等标 canceled + 入队一次;重投(渠道 webhook 常见重投)不重复发 webhook。 func TestInboundSubscriptionDeletedConverges(t *testing.T) { g, fp, spy, _, subs := newSubGateway(t) ctx := context.Background() subID, providerSubRef, orderNo := activateSub(t, g, fp) spy.calls = nil 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#1 = %v, %v", result, err) } sub, err := subs.GetBySubID(subID) if err != nil || sub.Status != model.SubCanceled { t.Fatalf("subscription after inbound deleted = %+v, %v", sub, err) } if len(spy.calls) != 1 { t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls) } if spy.calls[0]["out_trade_no"] != orderNo { t.Fatalf("out_trade_no = %v, want %s", spy.calls[0]["out_trade_no"], orderNo) } // 重投同一 customer.subscription.deleted → 幂等,不再新增 webhook。 result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}) if err != nil { t.Fatalf("HandleCallback deleted#2: %v", err) } if result2 != gateway.SettleProcessed { t.Fatalf("replay result = %v, want processed(finalizeCanceled 内部幂等,外层结果按 brief 恒 processed)", result2) } if len(spy.calls) != 1 { t.Fatalf("webhook calls after replay = %d, want still 1: %+v", len(spy.calls), spy.calls) } } // TestCancelAPIThenInboundDeletedConverge 两路收敛同一终态:先走主动取消 API(本地先翻 // canceled + 发 webhook),Stripe 随后异步补投 customer.subscription.deleted → 命中同一 // finalizeCanceled 的幂等分支,不重复发 webhook。 func TestCancelAPIThenInboundDeletedConverge(t *testing.T) { g, fp, spy, _, subs := newSubGateway(t) ctx := context.Background() subID, providerSubRef, _ := activateSub(t, g, fp) spy.calls = nil if err := g.CancelSubscription(ctx, subID); err != nil { t.Fatalf("CancelSubscription: %v", err) } if len(spy.calls) != 1 { t.Fatalf("webhook calls after API cancel = %d, want 1", len(spy.calls)) } 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 after API cancel = %v, %v", result, err) } if len(spy.calls) != 1 { t.Fatalf("webhook calls after inbound deleted = %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) } } // TestSettleSubscriptionCanceledUnknownSubscription 未知 provider_sub_ref → 忽略,不报错 // (与 TestSettleRenewalUnknownSubscription 同惯例)。 func TestSettleSubscriptionCanceledUnknownSubscription(t *testing.T) { g, _, spy, _, _ := newSubGateway(t) raw, err := json.Marshal(provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: "sub_ghost"}) if err != nil { t.Fatalf("marshal: %v", err) } result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw}) if err != nil { t.Fatalf("HandleCallback: %v", err) } if result != gateway.SettleIgnored { t.Fatalf("result = %v, want ignored", result) } if len(spy.calls) != 0 { t.Fatalf("webhook calls = %d, want 0: %+v", len(spy.calls), spy.calls) } } // TestMarkSubscriptionPastDue 覆盖 invoice.payment_failed → active 翻 past_due + 入队 // subscription.past_due(out_trade_no 用真实首购单号)。 func TestMarkSubscriptionPastDue(t *testing.T) { g, fp, spy, _, subs := newSubGateway(t) ctx := context.Background() subID, providerSubRef, orderNo := activateSub(t, g, fp) spy.calls = nil raw, err := json.Marshal(provider.PaidEvent{ Kind: provider.EventSubscriptionPastDue, SubscriptionRef: providerSubRef, InvoiceRef: "in_failed_1", }) 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 past_due = %v, %v", result, err) } sub, err := subs.GetBySubID(subID) if err != nil || sub.Status != model.SubPastDue { t.Fatalf("subscription after past_due = %+v, %v", sub, err) } 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.EvtSubscriptionPastDue || c["sub_id"] != subID || c["out_trade_no"] != orderNo { t.Fatalf("past_due webhook payload = %+v, want event_type=%s sub_id=%s out_trade_no=%s", c, gateway.EvtSubscriptionPastDue, subID, orderNo) } } // TestMarkSubscriptionPastDueUnknownSubscription 未知 provider_sub_ref → 忽略,不报错。 func TestMarkSubscriptionPastDueUnknownSubscription(t *testing.T) { g, _, spy, _, _ := newSubGateway(t) raw, err := json.Marshal(provider.PaidEvent{ Kind: provider.EventSubscriptionPastDue, SubscriptionRef: "sub_ghost", InvoiceRef: "in_ghost", }) if err != nil { t.Fatalf("marshal: %v", err) } result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw}) if err != nil { t.Fatalf("HandleCallback: %v", err) } if result != gateway.SettleIgnored { t.Fatalf("result = %v, want ignored", result) } if len(spy.calls) != 0 { t.Fatalf("webhook calls = %d, want 0: %+v", len(spy.calls), spy.calls) } } // TestPastDueThenRenewalRecovers 端到端串联 past_due 决策记录的完整路径:invoice.payment_failed // → past_due(+ 入队 subscription.past_due);随后 invoice.paid(subscription_cycle)→ settleRenewal // 的 Activate 自动恢复 active,续费锚点刷新。与 T4 的 TestSettleRenewal 不同,这里用真实 // markSubscriptionPastDue 事件路径推进到 past_due(不直接调 subs.MarkPastDue 抄近道)。 func TestPastDueThenRenewalRecovers(t *testing.T) { g, fp, spy, _, subs := newSubGateway(t) ctx := context.Background() subID, providerSubRef, _ := activateSub(t, g, fp) spy.calls = nil failedRaw, err := json.Marshal(provider.PaidEvent{ Kind: provider.EventSubscriptionPastDue, SubscriptionRef: providerSubRef, InvoiceRef: "in_failed_1", }) if err != nil { t.Fatalf("marshal past_due: %v", err) } if _, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: failedRaw}); err != nil { t.Fatalf("HandleCallback past_due: %v", err) } sub, err := subs.GetBySubID(subID) if err != nil || sub.Status != model.SubPastDue { t.Fatalf("subscription after past_due = %+v, %v", sub, err) } renewalRaw, err := json.Marshal(provider.PaidEvent{ Kind: provider.EventSubscriptionRenewal, SubscriptionRef: providerSubRef, InvoiceRef: "in_recover_1", PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded, }) if err != nil { t.Fatalf("marshal renewal: %v", err) } result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw}) if err != nil || result != gateway.SettleProcessed { t.Fatalf("HandleCallback renewal recovery = %v, %v", result, err) } subAfter, err := subs.GetBySubID(subID) if err != nil || subAfter.Status != model.SubActive { t.Fatalf("subscription after renewal recovery = %+v, %v, want active", subAfter, err) } if subAfter.CurrentPeriodEnd == nil { t.Fatalf("current_period_end not refreshed on recovery") } sawPastDue, sawRenewed := false, false for _, c := range spy.calls { switch c["event_type"] { case gateway.EvtSubscriptionPastDue: sawPastDue = true case gateway.EvtSubscriptionRenewed: sawRenewed = true } } if !sawPastDue || !sawRenewed { t.Fatalf("missing expected events: %+v", spy.calls) } } // TestMarkSubscriptionPastDueDuplicateInvoiceNotDoubleEnqueued 同一失败 invoice 重投(渠道 // webhook 常见重投场景)→ MarkPastDue 第二次不再翻转(已 past_due),不重复入队。 func TestMarkSubscriptionPastDueDuplicateInvoiceNotDoubleEnqueued(t *testing.T) { g, fp, spy, _, _ := newSubGateway(t) ctx := context.Background() _, providerSubRef, _ := activateSub(t, g, fp) spy.calls = nil raw, err := json.Marshal(provider.PaidEvent{ Kind: provider.EventSubscriptionPastDue, SubscriptionRef: providerSubRef, InvoiceRef: "in_failed_1", }) if err != nil { t.Fatalf("marshal: %v", err) } if _, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}); err != nil { t.Fatalf("HandleCallback#1: %v", err) } result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}) if err != nil { t.Fatalf("HandleCallback#2: %v", err) } if result2 != gateway.SettleDuplicate { t.Fatalf("replay result = %v, want duplicate", result2) } if len(spy.calls) != 1 { t.Fatalf("webhook calls after replay = %d, want still 1: %+v", len(spy.calls), spy.calls) } }