fix(v2): settleRenewal 重投补入队(通知不再永久丢)+ 无业务方首过返回 Processed(+同型路径核修)
settleRenewal 先 CreateRenewalPaid(建单已 paid)后 Enqueue,首次入队失败后重投走 created=false 的 duplicate 分支旧实现直接 return、不再入队,subscription.renewed 永久丢失(SyncPendingAttempts 救不了)。改为 duplicate 分支也无条件尝试入队,outbox 唯一键 ON CONFLICT DO NOTHING 幂等自愈;顺带对齐无业务方首过应返回 Processed。核查 发现 finalizeCanceled/markSubscriptionPastDue 有同型反纪律,同修。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -685,3 +685,120 @@ func TestMarkSubscriptionPastDueDuplicateInvoiceNotDoubleEnqueued(t *testing.T)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user