feat(pay-v2): P8 Task5 订阅取消(API+入站)+ past_due 状态
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user