diff --git a/internal/provider/stripe/stripe.go b/internal/provider/stripe/stripe.go index 7fe02dd..0d4ab33 100644 --- a/internal/provider/stripe/stripe.go +++ b/internal/provider/stripe/stripe.go @@ -315,11 +315,20 @@ func sessionToEvent(sess *gostripe.CheckoutSession, raw []byte) *provider.PaidEv case gostripe.CheckoutSessionPaymentStatusUnpaid: status = provider.PaidPending } - return &provider.PaidEvent{ + ev := &provider.PaidEvent{ ProviderRef: sess.ID, Status: status, PaidAmountMinor: sess.AmountTotal, // cent PaidCurrency: strings.ToUpper(string(sess.Currency)), Raw: string(raw), } + // mode=subscription 的 Checkout 完成时,sess.Subscription 必被 Stripe 填充 + // (*Subscription;字符串 id 会 unmarshal 成 &Subscription{ID:...})。一次性单 + // (mode=payment)天然为 nil,不影响既有语义。Kind 保持零值 EventPayment——本仓 + // 分派门(settle.go)靠 SubscriptionRef != "" 而非 Kind 判断是否触发订阅激活, + // 见 internal/gateway/settle.go 的 onSubscriptionActivated 调用点。 + if sess.Subscription != nil { + ev.SubscriptionRef = sess.Subscription.ID + } + return ev } diff --git a/internal/provider/stripe/stripe_test.go b/internal/provider/stripe/stripe_test.go index f889253..e40a16e 100644 --- a/internal/provider/stripe/stripe_test.go +++ b/internal/provider/stripe/stripe_test.go @@ -213,6 +213,34 @@ func TestVerifyWebhook(t *testing.T) { } } +// 订阅 Checkout(mode=subscription)完成时,session payload 携带 subscription 字段 +// (stripe-go v79 CheckoutSession.Subscription 为 *Subscription,字符串 id 会 unmarshal +// 成 &Subscription{ID:...})。sessionToEvent 必须回填 PaidEvent.SubscriptionRef——否则 +// gateway.settleRenewal 的激活门(ev.SubscriptionRef != "")恒 false,订阅首期激活主链路 +// 在生产环境(真实 Stripe webhook)整体不工作,只是被 fake provider 的测试掩盖了。 +func TestVerifyWebhookSubscriptionSessionBackfillsSubscriptionRef(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + + payload := `{"id":"evt_sub_1","object":"event","type":"checkout.session.completed","data":{"object":{"id":"cs_sub_123","object":"checkout.session","mode":"subscription","amount_total":2999,"currency":"usd","payment_status":"paid","subscription":"sub_new"}}}` + 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.SubscriptionRef != "sub_new" { + t.Fatalf("SubscriptionRef = %q, want %q (event = %+v)", ev.SubscriptionRef, "sub_new", ev) + } + if ev.ProviderRef != "cs_sub_123" || ev.Status != provider.PaidSucceeded { + t.Fatalf("event = %+v", ev) + } +} + // 用错误的签名密钥(冒充攻击者伪造 webhook)→ ConstructEventWithOptions 内部 HMAC 校验 // 必失败,VerifyCallback 必须返回 error,绝不能返回 PaidEvent(哪怕 payload 里状态是 paid)。 func TestVerifyWebhookWrongSecretFails(t *testing.T) {