From 62abdc1d65612e1b246b7c42894c936bb70b6b66 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 18:18:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(pay-v2):=20P8=20Task5=20=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E5=8F=96=E6=B6=88(API+=E5=85=A5=E7=AB=99)+=20past=5Fdue=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/settle.go | 13 +- internal/gateway/subscription.go | 115 +++++++++ internal/gateway/subscription_test.go | 318 +++++++++++++++++++++++- internal/handler/gateway.go | 25 ++ internal/handler/gateway_test.go | 143 +++++++++++ internal/provider/stripe/stripe.go | 16 ++ internal/provider/stripe/stripe_test.go | 50 ++++ internal/router/router.go | 2 + 8 files changed, 669 insertions(+), 13 deletions(-) diff --git a/internal/gateway/settle.go b/internal/gateway/settle.go index 906851f..e5e6ba6 100644 --- a/internal/gateway/settle.go +++ b/internal/gateway/settle.go @@ -115,7 +115,7 @@ func (g *Gateway) HandleCallback(ctx context.Context, method string, in provider case provider.EventSubscriptionPastDue: return g.markSubscriptionPastDue(ctx, method, ev) // Task 5 case provider.EventSubscriptionCanceled: - return g.settleSubscriptionCanceled(ctx, ev) // Task 5 + return g.settleSubscriptionCanceled(ctx, method, ev) // Task 5 case provider.EventChargeback: return g.recordChargeback(ctx, method, ev) // Task 6 default: // EventPayment:一次性 / 订阅首期 @@ -133,8 +133,7 @@ func (g *Gateway) HandleCallback(ctx context.Context, method string, in provider } } -// --- Task 4/5/6 处理器占位(本 Task 只需 default 分支可用 + onSubscriptionActivated)。 -// 保持本 Task 独立可编译;后续 Task 各自替换实现 + 补测试。 +// --- Task 6 处理器占位(Task 4 续费/Task 5 past_due+取消已替换实现,见 subscription.go)。 // settleRenewal 处理续费 invoice.paid(设计 §5/§4 决策记录):每期铸独立 renewal OrderV2 // (out_trade_no = 首购单号 + "-r-" + invoice id),建即 paid(续费不经收银台,无 pending 中间态)。 @@ -189,14 +188,6 @@ func (g *Gateway) settleRenewal(ctx context.Context, method string, ev *provider return SettleProcessed, nil } -func (g *Gateway) markSubscriptionPastDue(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) { - return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind) -} - -func (g *Gateway) settleSubscriptionCanceled(ctx context.Context, ev *provider.PaidEvent) (SettleResult, error) { - return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind) -} - func (g *Gateway) recordChargeback(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) { return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind) } diff --git a/internal/gateway/subscription.go b/internal/gateway/subscription.go index dd58d52..e9c9a6f 100644 --- a/internal/gateway/subscription.go +++ b/internal/gateway/subscription.go @@ -9,6 +9,7 @@ import ( "github.com/wangjia/pay/internal/model" "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/store" "github.com/wangjia/pay/internal/util" "github.com/wangjia/pay/internal/accounts" @@ -133,3 +134,117 @@ func (g *Gateway) onSubscriptionActivated(ctx context.Context, ev *provider.Paid "created_at": time.Now().Format(time.RFC3339), }) } + +// SubscriptionView — GET /api/v2/subscriptions/:sub_id 返回视图(状态 + 续费锚点)。 +type SubscriptionView struct { + SubID string `json:"sub_id"` + Status string `json:"status"` + CurrentPeriodEnd *time.Time `json:"current_period_end,omitempty"` + CanceledAt *time.Time `json:"canceled_at,omitempty"` +} + +func (g *Gateway) GetSubscription(subID string) (*SubscriptionView, error) { + sub, err := g.subs.GetBySubID(subID) + if err != nil { + return nil, err // store.ErrSubNotFound + } + return &SubscriptionView{ + SubID: sub.SubID, Status: string(sub.Status), + CurrentPeriodEnd: sub.CurrentPeriodEnd, CanceledAt: sub.CanceledAt, + }, nil +} + +// CancelSubscription 主动取消(POST /api/v2/subscriptions/:sub_id/cancel):查订阅 → 已终态则 +// 幂等 no-op(不再打渠道,避免重复取消命中渠道 400)→ 渠道侧取消 → 本地翻 canceled + 入队 +// subscription.canceled。Stripe 随后异步发 customer.subscription.deleted,入站处理器 +// (settleSubscriptionCanceled)再次调用同一 finalizeCanceled,两路收敛同一终态、天然幂等。 +func (g *Gateway) CancelSubscription(ctx context.Context, subID string) error { + sub, err := g.subs.GetBySubID(subID) + if err != nil { + return err // store.ErrSubNotFound + } + if sub.Status == model.SubCanceled { + return nil // 已取消(重复调用/webhook 已先到):幂等 no-op,不再打渠道 + } + prov, err := g.providers.Get(sub.Channel) + if err != nil { + return err + } + if sp, ok := prov.(provider.SubscriptionProvider); ok { + if err := sp.CancelSubscription(ctx, sub.ProviderSubRef); err != nil { + return err + } + } + return g.finalizeCanceled(sub) +} + +// settleSubscriptionCanceled 处理入站 customer.subscription.deleted:反查订阅 → finalizeCanceled。 +// 未知 provider_sub_ref(订阅未诞生/已清理)忽略,不报错(与 settleRenewal 同惯例)。 +func (g *Gateway) settleSubscriptionCanceled(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) { + sub, err := g.subs.GetByProviderRef(method, ev.SubscriptionRef) + if err != nil { + if errors.Is(err, store.ErrSubNotFound) { + return SettleIgnored, nil + } + return SettleFailed, err + } + if err := g.finalizeCanceled(sub); err != nil { + return SettleFailed, err + } + return SettleProcessed, nil +} + +// finalizeCanceled 是取消的唯一落地点(主动取消 API + 入站 webhook 两路收敛于此): +// MarkCanceled 幂等翻转 canceled;仅首次真正翻转(flipped)且有业务方(BizSystem 非空) +// 才入队 subscription.canceled——重投/独立收款不重复发。 +// +// outbox 键取真实首购单号 sub.OutTradeNo(不用合成后缀键):该单已 paid,Notifier 投递门禁 +// (orderPaid)天然放行;event_type=subscription.canceled 与该单已有的 payment.succeeded / +// subscription.created 行不同 event_type,唯一键 (out_trade_no,event_type,refund_id) 不撞。 +// canceled 每订阅只发生一次,不存在"同订阅多次 canceled 被吞"的问题(不同于 past_due)。 +func (g *Gateway) finalizeCanceled(sub *model.Subscription) error { + flipped, err := g.subs.MarkCanceled(sub.SubID) + if err != nil { + return err + } + if !flipped || sub.BizSystem == "" { + return nil // 已取消(重投)/ 独立收款无业务方回调 + } + return g.webhook.Enqueue(sub.OutTradeNo, sub.BizSystem, EvtSubscriptionCanceled, "", map[string]any{ + "event_type": EvtSubscriptionCanceled, "out_trade_no": sub.OutTradeNo, "sub_id": sub.SubID, + "biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode, + "canceled_at": time.Now().Format(time.RFC3339), + }) +} + +// markSubscriptionPastDue 处理入站 invoice.payment_failed:active→past_due(MarkPastDue 只在 +// 当前 active 时翻转,已 past_due/已 canceled 不动),入队 subscription.past_due 供业务方提醒 +// 用户换卡。下期 invoice.paid 成功由 settleRenewal 的 Activate 自动恢复 active。 +// +// outbox 键取真实首购单号 sub.OutTradeNo(理由同 finalizeCanceled):Task5 最小可行接受 +// "同订阅多次 past_due 只报首次"(第二次失败的行撞 (out_trade_no,event_type) 唯一键、 +// EnqueueDelivery 幂等 no-op),Task 7 视需要再引入合成键 + Notifier 门禁旁路。 +func (g *Gateway) markSubscriptionPastDue(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) { + sub, err := g.subs.GetByProviderRef(method, ev.SubscriptionRef) + if err != nil { + if errors.Is(err, store.ErrSubNotFound) { + return SettleIgnored, nil + } + return SettleFailed, err + } + flipped, err := g.subs.MarkPastDue(sub.Channel, sub.ProviderSubRef) + if err != nil { + return SettleFailed, err + } + if !flipped || sub.BizSystem == "" { + return SettleDuplicate, nil + } + if err := g.webhook.Enqueue(sub.OutTradeNo, sub.BizSystem, EvtSubscriptionPastDue, "", map[string]any{ + "event_type": EvtSubscriptionPastDue, "out_trade_no": sub.OutTradeNo, "sub_id": sub.SubID, + "biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode, + "failed_at": time.Now().Format(time.RFC3339), + }); err != nil { + return SettleFailed, err + } + return SettleProcessed, nil +} diff --git a/internal/gateway/subscription_test.go b/internal/gateway/subscription_test.go index adc2145..5f016ba 100644 --- a/internal/gateway/subscription_test.go +++ b/internal/gateway/subscription_test.go @@ -20,7 +20,9 @@ import ( // 时也走它(HandleCallback 仍需先过 VerifyCallback 才能拿到 ev),这里让它原样透传注入的 // JSON 回调体(与 fake.Provider.VerifyCallback 同构,便于测试直接摆事件)。 type fakeSubProvider struct { - sessionRef string + sessionRef string + cancelCalls []string // 记录 CancelSubscription 收到的 providerSubRef,断言调用次数/参数 + cancelErr error // 非空时 CancelSubscription 返回该 err(模拟渠道对"已取消订阅"回 400) } func (p *fakeSubProvider) Method() string { return "substripe" } @@ -47,7 +49,10 @@ func (p *fakeSubProvider) CreateSubscriptionCheckout(_ context.Context, req prov }, nil } -func (p *fakeSubProvider) CancelSubscription(_ context.Context, _ string) error { return 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) { @@ -371,3 +376,312 @@ func TestSettleRenewalUnknownSubscription(t *testing.T) { 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) + } +} diff --git a/internal/handler/gateway.go b/internal/handler/gateway.go index adeb483..21a2345 100644 --- a/internal/handler/gateway.go +++ b/internal/handler/gateway.go @@ -105,6 +105,31 @@ func (h *GatewayHandler) GetStatus(c *gin.Context) { util.RespondSuccess(c, v) } +// GetSubscription GET /api/v2/subscriptions/:sub_id —— 查订阅状态 + 续费锚点。 +func (h *GatewayHandler) GetSubscription(c *gin.Context) { + v, err := h.g.GetSubscription(c.Param("sub_id")) + if err != nil { + util.RespondError(c, http.StatusNotFound, "subscription_not_found", "订阅不存在") + return + } + util.RespondSuccess(c, v) +} + +// CancelSubscription POST /api/v2/subscriptions/:sub_id/cancel —— 主动取消:查订阅 → 渠道 +// 取消 → 本地翻 canceled + 入队 subscription.canceled(幂等,重复调用 no-op)。 +func (h *GatewayHandler) CancelSubscription(c *gin.Context) { + if err := h.g.CancelSubscription(c.Request.Context(), c.Param("sub_id")); err != nil { + if errors.Is(err, store.ErrSubNotFound) { + util.RespondError(c, http.StatusNotFound, "subscription_not_found", "订阅不存在") + return + } + log.Printf("[v2 subscription] 取消失败 sub_id=%s: %v", c.Param("sub_id"), err) + util.RespondError(c, http.StatusInternalServerError, "cancel_failed", "取消失败,请稍后重试") + return + } + util.RespondSuccess(c, gin.H{"canceled": true}) +} + type retryRequest struct { Method string `json:"method"` } diff --git a/internal/handler/gateway_test.go b/internal/handler/gateway_test.go index 90c5d3c..3e4ff84 100644 --- a/internal/handler/gateway_test.go +++ b/internal/handler/gateway_test.go @@ -248,3 +248,146 @@ func (p *eurFakeProvider) VerifyCallback(_ context.Context, in provider.Callback func (p *eurFakeProvider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) { return nil, errors.New("not implemented") } + +// subFakeProvider 实现 provider.SubscriptionProvider,供 handler 层 CancelSubscription/ +// GetSubscription 端到端测试用(独立于 internal/gateway 包的 fakeSubProvider,同构但不能跨 +// 测试包复用未导出类型)。VerifyCallback 原样透传测试构造的 provider.PaidEvent JSON。 +type subFakeProvider struct { + sessionRef string +} + +func (p *subFakeProvider) Method() string { return "subfake" } + +func (p *subFakeProvider) Capabilities() provider.Capabilities { + return provider.Capabilities{ + RenderTypes: []provider.RenderType{provider.RenderRedirect}, + SupportsRecurring: true, + RecurringKind: provider.RecurringKindGatewayScheduled, + SettleCurrencies: []string{"USD"}, + Regions: []string{"global"}, + } +} + +func (p *subFakeProvider) Create(_ context.Context, _ provider.CreateRequest) (*provider.Session, error) { + return nil, errors.New("subFakeProvider: one-time Create not used") +} + +func (p *subFakeProvider) 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}, + }, nil +} + +func (p *subFakeProvider) CancelSubscription(_ context.Context, _ string) error { return nil } + +func (p *subFakeProvider) 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 *subFakeProvider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) { + return &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending}, nil +} + +type subResolver struct{} + +func (subResolver) Resolve(sku, currency string) (int64, string, string, error) { + return 2999, "Pro 月付", "pro_monthly", nil +} + +// buildSubEngine 装配一套支持订阅的路由(subfake 渠道),供取消/查询端点测试用。 +func buildSubEngine(t *testing.T) (*gin.Engine, *subFakeProvider) { + t.Helper() + gin.SetMode(gin.TestMode) + db := model.OpenTestDB(t) + orders := store.NewOrderStore(db) + refunds := store.NewRefundStore(db) + subs := store.NewSubscriptionStore(db) + preg := provider.NewRegistry() + fp := &subFakeProvider{sessionRef: "cs_sub_1"} + preg.Register(fp) + areg := accounts.New([]config.AccountConfig{ + {AccountID: "subfake-a1", Channel: "subfake", Region: "global", Enabled: true, Weight: 1}, + }) + picker := accounts.NewRouter(areg, nil, nil) + g := gateway.New(orders, refunds, preg, picker, subResolver{}, nopEnqueuer{}, "global", subs) + r := gin.New() + router.SetupV2(r, g) + return r, fp +} + +// TestV2SubscriptionNotFound404 —— 未知 sub_id 的取消/查询都应 404,而不是 500。 +func TestV2SubscriptionNotFound404(t *testing.T) { + r, _ := buildSubEngine(t) + + wGet, _ := do(t, r, http.MethodGet, "/api/v2/subscriptions/SUB-GHOST", nil) + if wGet.Code != http.StatusNotFound { + t.Fatalf("get unknown sub code=%d, want 404", wGet.Code) + } + wCancel, _ := do(t, r, http.MethodPost, "/api/v2/subscriptions/SUB-GHOST/cancel", nil) + if wCancel.Code != http.StatusNotFound { + t.Fatalf("cancel unknown sub code=%d, want 404", wCancel.Code) + } +} + +// TestV2SubscriptionCancelLifecycle 端到端:建订阅 → 首期支付回调激活 → 查询 active → +// 取消 → 查询 canceled;取消端点重复调用幂等(仍 200,canceled=true)。 +func TestV2SubscriptionCancelLifecycle(t *testing.T) { + r, fp := buildSubEngine(t) + + w, out := do(t, r, http.MethodPost, "/api/v2/subscriptions", map[string]any{ + "sku": "pro_monthly", "method": "subfake", "biz_system": "", "biz_ref": "u-1", + }) + if w.Code != http.StatusOK { + t.Fatalf("create subscription code=%d body=%v", w.Code, out) + } + data := out["data"].(map[string]any) + subID := data["sub_id"].(string) + + // 激活前:GetSubscription 404(Subscription 行在首期支付回调时才诞生)。 + wPre, _ := do(t, r, http.MethodGet, "/api/v2/subscriptions/"+subID, nil) + if wPre.Code != http.StatusNotFound { + t.Fatalf("get before activation code=%d, want 404", wPre.Code) + } + + // 首期支付回调激活。 + raw, err := json.Marshal(provider.PaidEvent{ + Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded, + PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_h1", + }) + if err != nil { + t.Fatalf("marshal event: %v", err) + } + wc, _ := do(t, r, http.MethodPost, "/api/v2/callback/subfake", json.RawMessage(raw)) + if wc.Code != http.StatusOK { + t.Fatalf("activate callback code=%d", wc.Code) + } + + wActive, outActive := do(t, r, http.MethodGet, "/api/v2/subscriptions/"+subID, nil) + if wActive.Code != http.StatusOK { + t.Fatalf("get after activation code=%d body=%v", wActive.Code, outActive) + } + if outActive["data"].(map[string]any)["status"] != "active" { + t.Fatalf("status = %v, want active", outActive["data"]) + } + + // 取消。 + wCancel, outCancel := do(t, r, http.MethodPost, "/api/v2/subscriptions/"+subID+"/cancel", nil) + if wCancel.Code != http.StatusOK || outCancel["data"].(map[string]any)["canceled"] != true { + t.Fatalf("cancel = %d %v", wCancel.Code, outCancel) + } + wAfter, outAfter := do(t, r, http.MethodGet, "/api/v2/subscriptions/"+subID, nil) + if wAfter.Code != http.StatusOK || outAfter["data"].(map[string]any)["status"] != "canceled" { + t.Fatalf("status after cancel = %d %v", wAfter.Code, outAfter) + } + + // 重复取消:幂等 200,不报错。 + wCancel2, outCancel2 := do(t, r, http.MethodPost, "/api/v2/subscriptions/"+subID+"/cancel", nil) + if wCancel2.Code != http.StatusOK || outCancel2["data"].(map[string]any)["canceled"] != true { + t.Fatalf("repeat cancel = %d %v", wCancel2.Code, outCancel2) + } +} diff --git a/internal/provider/stripe/stripe.go b/internal/provider/stripe/stripe.go index aec98f3..6a40208 100644 --- a/internal/provider/stripe/stripe.go +++ b/internal/provider/stripe/stripe.go @@ -214,6 +214,22 @@ func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput) ev.PaidAt = &t } return ev, nil + case "customer.subscription.deleted": + var sub gostripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + return nil, fmt.Errorf("stripe: 解析 subscription 失败: %w", err) + } + return &provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: sub.ID, Raw: string(in.Raw)}, nil + case "invoice.payment_failed": + var inv gostripe.Invoice + if err := json.Unmarshal(event.Data.Raw, &inv); err != nil { + return nil, fmt.Errorf("stripe: 解析 invoice 失败: %w", err) + } + ev := &provider.PaidEvent{Kind: provider.EventSubscriptionPastDue, InvoiceRef: inv.ID, Raw: string(in.Raw)} + if inv.Subscription != nil { + ev.SubscriptionRef = inv.Subscription.ID + } + return ev, nil default: // 其它事件此阶段不处理:归一化 pending(管线 Settle 视为 ignored)。 return &provider.PaidEvent{Status: provider.PaidPending, Raw: string(in.Raw)}, nil diff --git a/internal/provider/stripe/stripe_test.go b/internal/provider/stripe/stripe_test.go index e6113b2..0a05d24 100644 --- a/internal/provider/stripe/stripe_test.go +++ b/internal/provider/stripe/stripe_test.go @@ -217,6 +217,56 @@ func TestVerifyInvoicePaidFirstPeriodSkipped(t *testing.T) { } } +// customer.subscription.deleted(Stripe 侧取消,主动取消/欠费催收耗尽后网关删除)→ +// 归一化为 EventSubscriptionCanceled,携带渠道订阅号供 gateway 反查 Subscription。 +func TestVerifyCustomerSubscriptionDeleted(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + + payload := `{"id":"evt_del","object":"event","type":"customer.subscription.deleted","data":{"object":{"id":"sub_new","object":"subscription","status":"canceled"}}}` + sig := signStripe(payload, whSecret, time.Now().Unix()) + + ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{ + Raw: []byte(payload), + Headers: map[string]string{"Stripe-Signature": sig}, + }) + if err != nil { + t.Fatalf("verify: %v", err) + } + if ev.Kind != provider.EventSubscriptionCanceled { + t.Fatalf("kind = %v, want EventSubscriptionCanceled", ev.Kind) + } + if ev.SubscriptionRef != "sub_new" { + t.Fatalf("subscription_ref = %s, want sub_new", ev.SubscriptionRef) + } +} + +// invoice.payment_failed(某期扣款失败)→ 归一化为 EventSubscriptionPastDue,携带失败 +// 发票号(供 gateway 铸 outbox 幂等键)+ 渠道订阅号。 +func TestVerifyInvoicePaymentFailed(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + + payload := `{"id":"evt_fail","object":"event","type":"invoice.payment_failed","data":{"object":{"id":"in_failed","object":"invoice","subscription":{"id":"sub_new","object":"subscription"}}}}` + sig := signStripe(payload, whSecret, time.Now().Unix()) + + ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{ + Raw: []byte(payload), + Headers: map[string]string{"Stripe-Signature": sig}, + }) + if err != nil { + t.Fatalf("verify: %v", err) + } + if ev.Kind != provider.EventSubscriptionPastDue { + t.Fatalf("kind = %v, want EventSubscriptionPastDue", ev.Kind) + } + if ev.InvoiceRef != "in_failed" || ev.SubscriptionRef != "sub_new" { + t.Fatalf("event = %+v", ev) + } +} + // signStripe 复刻 Stripe webhook 签名头: t=,v1=hex(HMAC-SHA256(secret, ".")) func signStripe(payload, secret string, ts int64) string { mac := hmac.New(sha256.New, []byte(secret)) diff --git a/internal/router/router.go b/internal/router/router.go index 0a09cf5..dbb59e2 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -46,6 +46,8 @@ func SetupV2(r *gin.Engine, g *gateway.Gateway) { { v2.POST("/orders", h.CreateOrder) v2.POST("/subscriptions", h.CreateSubscription) + v2.GET("/subscriptions/:sub_id", h.GetSubscription) + v2.POST("/subscriptions/:sub_id/cancel", h.CancelSubscription) v2.GET("/orders/:order_no", h.GetStatus) v2.POST("/orders/:order_no/retry", h.Retry) v2.POST("/orders/:order_no/cancel", h.Cancel)