merge: P8 订阅/recurring + 拒付 chargeback 并入(订阅生命周期/续费/取消/past_due/chargeback/事件集收口)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u

# Conflicts:
#	internal/model/testdb.go
#	internal/provider/provider.go
#	internal/router/router.go
#	internal/store/order_query_test.go
#	main.go
This commit is contained in:
wangjia
2026-07-10 19:43:32 +08:00
32 changed files with 2826 additions and 52 deletions
+178 -16
View File
@@ -42,10 +42,12 @@ func (p *Provider) Method() string { return "stripe" }
func (p *Provider) Capabilities() provider.Capabilities {
return provider.Capabilities{
RenderTypes: []provider.RenderType{provider.RenderRedirect},
SupportsRefund: true, // P4:/v1/refunds
SettleCurrencies: []string{supportedCurrency},
Regions: []string{"global"},
RenderTypes: []provider.RenderType{provider.RenderRedirect},
SupportsRefund: true, // P4:/v1/refunds
SupportsRecurring: true, // P8:Checkout mode=subscription,续费由 Stripe 网关调度
RecurringKind: provider.RecurringKindGatewayScheduled,
SettleCurrencies: []string{supportedCurrency},
Regions: []string{"global"},
}
}
@@ -108,6 +110,14 @@ func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provi
},
},
}},
// 一次性(mode=payment)Checkout 生成的 PaymentIntent 打 out_trade_no metadata(P8
// Task6):dispute webhook(charge.dispute.created)只带 payment_intent id,须靠此
// metadata 才能反查回原订单——订阅首期/续费的 charge 由 invoice 生成,Stripe 不透传
// SubscriptionData.Metadata 到 PI,那类拒付的 out_trade_no 天然解析为空(honest scope,
// 见 VerifyCallback 的 charge.dispute.created 分支注释)。
PaymentIntentData: &gostripe.CheckoutSessionPaymentIntentDataParams{
Metadata: map[string]string{"out_trade_no": req.OutTradeNo},
},
}
sess, err := p.sc.CheckoutSessions.New(params)
if err != nil {
@@ -120,6 +130,87 @@ func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provi
}, nil
}
// CreateSubscriptionCheckout 建 Stripe 订阅(Checkout mode=subscription):返回 redirect 收银台。
// 订阅号(sub_...)在用户完成支付后才生成 → 经 checkout.session.completed webhook 诞生 pay 订阅。
// 续费由 Stripe 网关驱动(invoice.paid),pay 不主动 Charge(区别于 token_offsession)。
func (p *Provider) CreateSubscriptionCheckout(_ context.Context, req provider.CreateRequest) (*provider.Session, error) {
if req.Currency != supportedCurrency {
return nil, fmt.Errorf("stripe: 仅支持 %s, got %s", supportedCurrency, req.Currency)
}
params := &gostripe.CheckoutSessionParams{
Mode: gostripe.String(string(gostripe.CheckoutSessionModeSubscription)),
SuccessURL: gostripe.String(req.ReturnURL),
ClientReferenceID: gostripe.String(req.OutTradeNo),
LineItems: []*gostripe.CheckoutSessionLineItemParams{{
Quantity: gostripe.Int64(1),
PriceData: &gostripe.CheckoutSessionLineItemPriceDataParams{
Currency: gostripe.String(strings.ToLower(supportedCurrency)),
UnitAmount: gostripe.Int64(req.AmountMinor),
// 最小可行:固定月付。周期(month/year)后续由 product 定价档下发,此处留 month 默认。
Recurring: &gostripe.CheckoutSessionLineItemPriceDataRecurringParams{
Interval: gostripe.String("month"),
},
ProductData: &gostripe.CheckoutSessionLineItemPriceDataProductDataParams{
Name: gostripe.String(req.Subject),
},
},
}},
// 订阅 metadata 带 out_trade_no,便于人工对账;续费/取消映射实际走 sub id 反查,不依赖它。
SubscriptionData: &gostripe.CheckoutSessionSubscriptionDataParams{
Metadata: map[string]string{"out_trade_no": req.OutTradeNo},
},
}
if v := req.Metadata["pay_sub_id"]; v != "" {
params.SubscriptionData.Metadata["pay_sub_id"] = v
}
sess, err := p.sc.CheckoutSessions.New(params)
if err != nil {
return nil, fmt.Errorf("stripe: 创建订阅 Checkout 失败: %w", err)
}
return &provider.Session{
ProviderRef: sess.ID,
RenderType: provider.RenderRedirect,
Payload: map[string]any{"url": sess.URL},
}, nil
}
// CancelSubscription 立即取消 Stripe 订阅(不等本期末)。Stripe 随后发 customer.subscription.deleted,
// 入站处理器幂等标 canceled,与本地主动标一致收敛。
//
// 渠道已先行取消(dashboard 手工 / 竞态下未消费的 deleted webhook 抢先落地)时,Stripe 会拒绝
// 二次 Cancel:识别出这类错误后 wrap 成 provider.ErrSubAlreadyCanceled(errors.Is 可判),不是
// "取消失败"而是"取消已成立"——调用方(gateway.CancelSubscription)据此走本地收敛而非报错。
func (p *Provider) CancelSubscription(_ context.Context, providerSubRef string) error {
if _, err := p.sc.Subscriptions.Cancel(providerSubRef, nil); err != nil {
if isAlreadyCanceledErr(err) {
return fmt.Errorf("%w: %v", provider.ErrSubAlreadyCanceled, err)
}
return fmt.Errorf("stripe: 取消订阅失败: %w", err)
}
return nil
}
// isAlreadyCanceledErr 判定"渠道侧订阅已处于取消终态"这一场景,对应 vendored v79
// (github.com/stripe/stripe-go/v79 error.go)实测/文档记录的两种 *stripe.Error 形态:
//
// - 订阅对象已被彻底删除(引用旧 id 查不到):Type=invalid_request_error,
// Code=resource_missing(有明确机器可读 Code,见 error.go ErrorCodeResourceMissing)。
// - 订阅对象仍在但 status=canceled(二次 Cancel 同一仍存在的订阅):Type=invalid_request_error,
// **无 Code**(Stripe 对这种校验类拒绝不下发机器可读 code,仅给 Msg 文案
// "This subscription has already been canceled."),只能按已知文案兜底、大小写不敏感匹配,
// 避免因标点/大小写细节波动误判。
func isAlreadyCanceledErr(err error) bool {
var stripeErr *gostripe.Error
if !errors.As(err, &stripeErr) {
return false
}
if stripeErr.Code == gostripe.ErrorCodeResourceMissing {
return true
}
return stripeErr.Type == gostripe.ErrorTypeInvalidRequest &&
strings.Contains(strings.ToLower(stripeErr.Msg), "already been canceled")
}
func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) {
sig := in.Headers["Stripe-Signature"]
// stripe-go 默认 ConstructEvent 会额外校验 event.api_version == SDK 编译期常量
@@ -134,20 +225,82 @@ 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
case "customer.subscription.deleted":
var sub gostripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return nil, fmt.Errorf("stripe: 解析 subscription 失败: %w", err)
}
return &provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: sub.ID, Raw: string(in.Raw)}, nil
case "invoice.payment_failed":
var inv gostripe.Invoice
if err := json.Unmarshal(event.Data.Raw, &inv); err != nil {
return nil, fmt.Errorf("stripe: 解析 invoice 失败: %w", err)
}
ev := &provider.PaidEvent{Kind: provider.EventSubscriptionPastDue, InvoiceRef: inv.ID, Raw: string(in.Raw)}
if inv.Subscription != nil {
ev.SubscriptionRef = inv.Subscription.ID
}
return ev, nil
case "charge.dispute.created":
// 拒付(设计 §6 决策记录):dispute payload 里 payment_intent 常只是 id 未展开,须
// 反查 PaymentIntents.Get 取其 metadata["out_trade_no"](Create 时已 stamp,见上方
// Create 的 PaymentIntentData 注释)。订阅首期/续费 charge 的 PI 不带该 metadata
// (Stripe 不透传 SubscriptionData.Metadata 到 PI)→ OutTradeNo 天然为空,交
// gateway.recordChargeback 记录但不转发(honest scope,精确定位留后续轮次)。
var d gostripe.Dispute
if err := json.Unmarshal(event.Data.Raw, &d); err != nil {
return nil, fmt.Errorf("stripe: 解析 dispute 失败: %w", err)
}
ev := &provider.PaidEvent{
Kind: provider.EventChargeback, DisputeRef: d.ID, Status: provider.PaidFailed,
PaidAmountMinor: d.Amount, PaidCurrency: strings.ToUpper(string(d.Currency)),
Reason: string(d.Reason), Raw: string(in.Raw),
}
if d.PaymentIntent != nil && d.PaymentIntent.ID != "" {
ev.ProviderPaymentRef = d.PaymentIntent.ID
if pi, err := p.sc.PaymentIntents.Get(d.PaymentIntent.ID, nil); err == nil && pi.Metadata != nil {
ev.OutTradeNo = pi.Metadata["out_trade_no"] // 一次性单带;订阅 charge 为空
}
}
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) {
@@ -170,11 +323,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
}