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