251 lines
10 KiB
Go
251 lines
10 KiB
Go
// 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"
|
|
"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 {
|
|
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),
|
|
},
|
|
},
|
|
}},
|
|
}
|
|
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,与本地主动标一致收敛。
|
|
func (p *Provider) CancelSubscription(_ context.Context, providerSubRef string) error {
|
|
if _, err := p.sc.Subscriptions.Cancel(providerSubRef, nil); err != nil {
|
|
return fmt.Errorf("stripe: 取消订阅失败: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
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
|
|
}
|
|
return &provider.PaidEvent{
|
|
ProviderRef: sess.ID,
|
|
Status: status,
|
|
PaidAmountMinor: sess.AmountTotal, // cent
|
|
PaidCurrency: strings.ToUpper(string(sess.Currency)),
|
|
Raw: string(raw),
|
|
}
|
|
}
|