// Package stripe adapts Stripe Checkout to provider.Provider: Create → hosted // Checkout Session (redirect), VerifyCallback → webhook signature verify, Query → // session lookup. The *client.API + webhook secret are injected at assembly (default // backend in prod; httptest backend in tests) so nothing hits the real network in CI. package stripe import ( "context" "encoding/json" "errors" "fmt" "strings" "time" gostripe "github.com/stripe/stripe-go/v79" "github.com/stripe/stripe-go/v79/client" "github.com/stripe/stripe-go/v79/webhook" "github.com/wangjia/pay/internal/provider" ) // USD 是 Stripe 最小单位(cent)= money 包的 USD minor(均为 1e-2),两边天然对齐, // Create/Query/VerifyCallback 全程直传 int64 分,不经 money.Parse/Format。 // // 零小数币种注意(如 JPY/KRW):Stripe 对这类币种的"最小单位"就是整数主单位本身(无 cent // 概念),若未来扩展这类币种,不能再假设 AmountMinor 与 Stripe 金额 1:1——当前仅支持 // USD,不涉及该分支,留此注释供后续扩展参考。 const supportedCurrency = "USD" type Provider struct { sc *client.API webhookSecret string } // New 装配期注入已配置好 backend(生产走默认;测试注入指向 httptest 的 backend)的 // *client.API,以及来自 env(CredentialEnvPrefix)的 webhook 签名密钥。 func New(sc *client.API, webhookSecret string) *Provider { return &Provider{sc: sc, webhookSecret: webhookSecret} } 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 SupportsRecurring: true, // P8:Checkout mode=subscription,续费由 Stripe 网关调度 RecurringKind: provider.RecurringKindGatewayScheduled, SettleCurrencies: []string{supportedCurrency}, Regions: []string{"global"}, } } // Refund 经 Checkout Session 取 PaymentIntent 再退款。refundID 作 Idempotency-Key。 // 不传 Stripe Reason(仅收枚举);业务文案只落 pay 本地。 func (p *Provider) Refund(_ context.Context, providerRef, refundID string, amountMinor int64, _ string) (string, provider.PaidStatus, error) { sess, err := p.sc.CheckoutSessions.Get(providerRef, nil) if err != nil { return "", provider.PaidFailed, fmt.Errorf("stripe: 取 session 失败: %w", err) } if sess.PaymentIntent == nil || sess.PaymentIntent.ID == "" { return "", provider.PaidFailed, fmt.Errorf("stripe: session %s 无 payment_intent,无法退款", providerRef) } params := &gostripe.RefundParams{ PaymentIntent: gostripe.String(sess.PaymentIntent.ID), Amount: gostripe.Int64(amountMinor), // cent = USD minor,直传 } params.SetIdempotencyKey(refundID) rf, err := p.sc.Refunds.New(params) if err != nil { // *stripe.Error + HTTPStatusCode 4xx = 渠道对这次请求的确定性拒绝(参数/状态 // 类错误,如金额超出可退余额、PaymentIntent 已全额退过);5xx/网络错误(err 不是 // *stripe.Error,或是但状态码 5xx)结果不确定,不 wrap ErrRefundRejected —— // 与 alipay 同一判定原则(见 provider.ErrRefundRejected 注释 / item 1)。 var serr *gostripe.Error if errors.As(err, &serr) && serr.HTTPStatusCode >= 400 && serr.HTTPStatusCode < 500 { return "", provider.PaidFailed, fmt.Errorf("stripe: 退款被拒: %w", errors.Join(provider.ErrRefundRejected, err)) } return "", provider.PaidFailed, fmt.Errorf("stripe: 退款请求失败: %w", err) } return rf.ID, mapRefundStatus(rf.Status), nil } func mapRefundStatus(s gostripe.RefundStatus) provider.PaidStatus { switch s { case gostripe.RefundStatusSucceeded: return provider.PaidSucceeded case gostripe.RefundStatusFailed, gostripe.RefundStatusCanceled: return provider.PaidFailed default: // pending / requires_action → 异步,交上层保持 processing return provider.PaidPending } } func (p *Provider) Create(_ 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.CheckoutSessionModePayment)), 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), // cent = USD minor,直传 ProductData: &gostripe.CheckoutSessionLineItemPriceDataProductDataParams{ Name: gostripe.String(req.Subject), }, }, }}, // 一次性(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 { return nil, fmt.Errorf("stripe: 创建 Checkout Session 失败: %w", err) } return &provider.Session{ ProviderRef: sess.ID, RenderType: provider.RenderRedirect, Payload: map[string]any{"url": sess.URL}, }, 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 编译期常量 // stripe.APIVersion,但 webhook 端点的 API 版本是在 Stripe Dashboard 独立配置的, // 与所拉取的 SDK 版本不必一致——我们又不依赖 SDK 按版本反序列化(下面对 // event.Data.Raw 自己 json.Unmarshal 成 CheckoutSession,不吃 SDK 的类型化解码), // 所以显式 IgnoreAPIVersionMismatch:true,避免把"版本不同"误判成"验签失败"。 // 时间容差不受影响:Tolerance 留零值,constructEvent 内部仍回退到 DefaultTolerance // (5 分钟),签名 HMAC 校验本身完全不受此 flag 影响。 event, err := webhook.ConstructEventWithOptions(in.Raw, sig, p.webhookSecret, webhook.ConstructEventOptions{IgnoreAPIVersionMismatch: true}) if err != nil { return nil, fmt.Errorf("stripe: webhook 验签失败: %w", err) } 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 } } func (p *Provider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) { sess, err := p.sc.CheckoutSessions.Get(req.ProviderRef, nil) if err != nil { return nil, fmt.Errorf("stripe: 查询 session 失败: %w", err) } ev := sessionToEvent(sess, nil) ev.ProviderRef = req.ProviderRef return ev, nil } func unixToTime(sec int64) time.Time { return time.Unix(sec, 0).UTC() } func sessionToEvent(sess *gostripe.CheckoutSession, raw []byte) *provider.PaidEvent { status := provider.PaidPending switch sess.PaymentStatus { case gostripe.CheckoutSessionPaymentStatusPaid, gostripe.CheckoutSessionPaymentStatusNoPaymentRequired: status = provider.PaidSucceeded case gostripe.CheckoutSessionPaymentStatusUnpaid: status = provider.PaidPending } 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 }