948 lines
39 KiB
Go
948 lines
39 KiB
Go
package gateway_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"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)
|
|
chargebacks := store.NewChargebackStore(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, chargebacks)
|
|
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))
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestSettleRenewalEnqueueFailureThenRetryRecovers 复现 Important 发现:settleRenewal 先
|
|
// CreateRenewalPaid(建单已 paid,单步无 pending 中间态)后 Enqueue——首次入队失败(瞬时)后,
|
|
// invoice.paid 重投走 created=false 的 duplicate 分支必须仍尝试入队(outbox 唯一键幂等,
|
|
// 行不存在则补建),否则 subscription.renewed 永久丢失(SyncPendingAttempts 救不了:续费
|
|
// attempt 生来就是 AttemptPaid,不在 pending 轮询范围)。
|
|
// 断言:①首次入队失败 → SettleFailed+err,订单已建为 paid(幂等键护着,不回滚)②同一
|
|
// invoice.paid 重投 → subscription.renewed 最终恰入队一次 ③renewal order 不因重投双铸。
|
|
func TestSettleRenewalEnqueueFailureThenRetryRecovers(t *testing.T) {
|
|
g, fp, spy, orders, _ := newSubGateway(t)
|
|
ctx := context.Background()
|
|
activateSub(t, g, fp)
|
|
spy.calls = nil
|
|
|
|
renewalRaw, err := json.Marshal(provider.PaidEvent{
|
|
Kind: provider.EventSubscriptionRenewal, SubscriptionRef: "sub_new", InvoiceRef: "in_flaky",
|
|
PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal renewal event: %v", err)
|
|
}
|
|
|
|
spy.failNext = true
|
|
result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
|
|
if err == nil || result != gateway.SettleFailed {
|
|
t.Fatalf("首次入队失败应 SettleFailed+err, got %v, %v", result, err)
|
|
}
|
|
allOrders, err := orders.ListOrders("pangolin", "u-1", 10)
|
|
if err != nil {
|
|
t.Fatalf("ListOrders: %v", err)
|
|
}
|
|
renewalCount := 0
|
|
for i := range allOrders {
|
|
if strings.Contains(allOrders[i].OutTradeNo, "-r-") {
|
|
renewalCount++
|
|
if allOrders[i].Status != model.OrderPaidV2 {
|
|
t.Fatalf("renewal order 应已建为 paid(幂等键护着,不因入队失败回滚), got %+v", allOrders[i])
|
|
}
|
|
}
|
|
}
|
|
if renewalCount != 1 {
|
|
t.Fatalf("renewal order count after first(失败) attempt = %d, want 1", renewalCount)
|
|
}
|
|
if len(spy.calls) != 0 {
|
|
t.Fatalf("入队失败不应留下 webhook 记录, got %+v", spy.calls)
|
|
}
|
|
|
|
// 渠道重投同一 invoice.paid(Stripe 拿不到 200 会重投):outbox 补建自愈。
|
|
result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
|
|
if err != nil {
|
|
t.Fatalf("HandleCallback retry: %v", err)
|
|
}
|
|
_ = result2 // duplicate(created=false,订单已在)——本用例只关心自愈,不断言具体 result 值
|
|
if len(spy.calls) != 1 {
|
|
t.Fatalf("重投后 subscription.renewed 应恰入队一次(自愈), got %d: %+v", len(spy.calls), spy.calls)
|
|
}
|
|
if spy.calls[0]["event_type"] != gateway.EvtSubscriptionRenewed {
|
|
t.Fatalf("event_type = %v, want %s", spy.calls[0]["event_type"], gateway.EvtSubscriptionRenewed)
|
|
}
|
|
|
|
allOrdersAfter, err := orders.ListOrders("pangolin", "u-1", 10)
|
|
if err != nil {
|
|
t.Fatalf("ListOrders after retry: %v", err)
|
|
}
|
|
renewalCountAfter := 0
|
|
for i := range allOrdersAfter {
|
|
if strings.Contains(allOrdersAfter[i].OutTradeNo, "-r-") {
|
|
renewalCountAfter++
|
|
}
|
|
}
|
|
if renewalCountAfter != 1 {
|
|
t.Fatalf("renewal order count after retry = %d, want still 1(不双铸)", renewalCountAfter)
|
|
}
|
|
}
|
|
|
|
// 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 可发)。
|
|
func TestSettleRenewalNoBizSystemStillProcessed(t *testing.T) {
|
|
g, fp, spy, _, subs := newSubGateway(t)
|
|
ctx := context.Background()
|
|
_, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
|
|
SKU: "pro_monthly", Method: "substripe", // BizSystem/BizRef 留空 = 独立收款
|
|
})
|
|
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)
|
|
}
|
|
sub, err := subs.GetByProviderRef("substripe", "sub_new")
|
|
if err != nil || sub.BizSystem != "" {
|
|
t.Fatalf("subscription = %+v, %v, want BizSystem empty(独立收款)", sub, err)
|
|
}
|
|
|
|
renewalRaw, err := json.Marshal(provider.PaidEvent{
|
|
Kind: provider.EventSubscriptionRenewal, SubscriptionRef: "sub_new", InvoiceRef: "in_indep_1",
|
|
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("独立收款续费首过应 processed(非 duplicate), got %v, %v", result, err)
|
|
}
|
|
if len(spy.calls) != 0 {
|
|
t.Fatalf("独立收款不应入队 webhook, got %+v", spy.calls)
|
|
}
|
|
}
|