Files
pay/internal/gateway/subscription_test.go
T
wangjia b8bdb2c193 fix(v2): 订阅激活门改按结算结果(unpaid session 不再无付款激活)
HandleCallback 原门 serr==nil && SubscriptionRef!="" 挡不住 Settle() 对非
PaidSucceeded 事件返回 (SettleIgnored, nil) 的情况(如 Stripe
checkout.session.completed 异步支付 payment_status=unpaid),导致
onSubscriptionActivated 无确认付款先激活订阅。改为按结算结果开门
(SettleProcessed/SettleDuplicate),并在 onSubscriptionActivated 内加
o.Status.Settled() 纵深防御。
2026-07-10 18:09:56 +08:00

374 lines
14 KiB
Go

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
}
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, _ string) error { return nil }
// 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)
}
}