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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user