feat(pay-v2): P8 Task4 续费入账 invoice.paid→renewal order + subscription.renewed
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user