Files
pay/internal/provider/stripe/stripe.go
T

139 lines
5.5 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: false, // P4
SettleCurrencies: []string{supportedCurrency},
Regions: []string{"global"},
}
}
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
}
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)
}
if event.Type != "checkout.session.completed" {
// 其它事件此阶段不处理:归一化 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) {
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),
}
}