feat(pay-v2): P8 Task4 续费入账 invoice.paid→renewal order + subscription.renewed

This commit is contained in:
wangjia
2026-07-10 18:06:38 +08:00
parent 4c242607a7
commit c677d64529
5 changed files with 308 additions and 14 deletions
+141
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/wangjia/pay/config"
@@ -187,3 +188,143 @@ func TestSubscriptionActivationOnFirstPayment(t *testing.T) {
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)
}
}
// 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)
}
}