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
+35 -11
View File
@@ -180,20 +180,44 @@ func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput)
if err != nil {
return nil, fmt.Errorf("stripe: webhook 验签失败: %w", err)
}
if event.Type != "checkout.session.completed" {
switch event.Type {
case "checkout.session.completed":
var sess gostripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
return nil, fmt.Errorf("stripe: 解析 session 失败: %w", err)
}
ev := sessionToEvent(&sess, in.Raw)
if event.Created > 0 {
paidAt := unixToTime(event.Created)
ev.PaidAt = &paidAt
}
return ev, nil
case "invoice.paid":
var inv gostripe.Invoice
if err := json.Unmarshal(event.Data.Raw, &inv); err != nil {
return nil, fmt.Errorf("stripe: 解析 invoice 失败: %w", err)
}
if inv.BillingReason != gostripe.InvoiceBillingReasonSubscriptionCycle {
// 首期(subscription_create)由 checkout.session.completed 入账;其余非续费忽略。
return &provider.PaidEvent{Kind: provider.EventPayment, Status: provider.PaidPending, Raw: string(in.Raw)}, nil
}
ev := &provider.PaidEvent{
Kind: provider.EventSubscriptionRenewal, Status: provider.PaidSucceeded,
InvoiceRef: inv.ID, PaidAmountMinor: inv.Total, PaidCurrency: strings.ToUpper(string(inv.Currency)),
Raw: string(in.Raw),
}
if inv.Subscription != nil {
ev.SubscriptionRef = inv.Subscription.ID
}
if event.Created > 0 {
t := unixToTime(event.Created)
ev.PaidAt = &t
}
return ev, nil
default:
// 其它事件此阶段不处理:归一化 pending(管线 Settle 视为 ignored)。
return &provider.PaidEvent{Status: provider.PaidPending, Raw: string(in.Raw)}, nil
}
var sess gostripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
return nil, fmt.Errorf("stripe: 解析 session 失败: %w", err)
}
ev := sessionToEvent(&sess, in.Raw)
if event.Created > 0 {
paidAt := unixToTime(event.Created)
ev.PaidAt = &paidAt
}
return ev, nil
}
func (p *Provider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
+50
View File
@@ -167,6 +167,56 @@ func TestVerifyWebhookWrongSecretFails(t *testing.T) {
}
}
// invoice.paid + billing_reason=subscription_cycle → 归一化为 EventSubscriptionRenewal,
// 携带 invoice/subscription/金额,供 gateway.settleRenewal 铸 renewal order。
func TestVerifyInvoicePaidRenewal(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_r","object":"event","type":"invoice.paid","created":1700000000,"data":{"object":{"id":"in_123","object":"invoice","billing_reason":"subscription_cycle","total":2999,"currency":"usd","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.EventSubscriptionRenewal {
t.Fatalf("kind = %v, want EventSubscriptionRenewal", ev.Kind)
}
if ev.InvoiceRef != "in_123" || ev.SubscriptionRef != "sub_new" || ev.PaidAmountMinor != 2999 || ev.Status != provider.PaidSucceeded {
t.Fatalf("event = %+v", ev)
}
if ev.PaidCurrency != "USD" {
t.Fatalf("currency = %s, want USD", ev.PaidCurrency)
}
}
// invoice.paid 但 billing_reason=subscription_create 是首期发票,与 checkout.session.completed
// 是同一笔钱——由后者入账,这里必须跳过(归一化为 EventPayment,不触发续费铸单)。
func TestVerifyInvoicePaidFirstPeriodSkipped(t *testing.T) {
ts := fakeStripeAPI(t)
defer ts.Close()
p := newStripe(t, ts)
payload := `{"id":"evt_r2","object":"event","type":"invoice.paid","created":1700000000,"data":{"object":{"id":"in_first","object":"invoice","billing_reason":"subscription_create","total":2999,"currency":"usd","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.EventPayment {
t.Fatalf("kind = %v, want EventPayment(skip)", ev.Kind)
}
}
// signStripe 复刻 Stripe webhook 签名头: t=<ts>,v1=hex(HMAC-SHA256(secret, "<ts>.<payload>"))
func signStripe(payload, secret string, ts int64) string {
mac := hmac.New(sha256.New, []byte(secret))