fix(v2): stripe sessionToEvent 回填 SubscriptionRef(订阅首期激活主链路生产可用)

checkout.session.completed 的 sessionToEvent 从不读 sess.Subscription,导致真实
Stripe 路径下 PaidEvent.SubscriptionRef 恒为空,settle.go 的激活门(SubscriptionRef
!= "")恒 false,订阅创建→首付→激活链路在生产环境整体不工作。fake provider 测试
直塞 SubscriptionRef,掩盖了这个接缝。

补 sess.Subscription != nil 时回填 ID(与既有 inv.Subscription 同套路);Kind 保持
零值 EventPayment——分派门只看 SubscriptionRef,不看 Kind。新增 adapter 测试先红
后绿,验证 VerifyCallback 真实路径产出正确 SubscriptionRef。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
wangjia
2026-07-10 19:12:33 +08:00
parent 2a4be0117d
commit 4a1a431f62
2 changed files with 38 additions and 1 deletions
+10 -1
View File
@@ -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
}
+28
View File
@@ -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) {