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:
@@ -71,6 +71,22 @@ type CallbackInput struct {
|
||||
Query map[string]string
|
||||
}
|
||||
|
||||
// EventKind — PaidEvent 的语义判别器(P8,设计 §5)。零值 EventPayment 是既有一次性/首期
|
||||
// 支付语义(P3 三渠道全落此),新增枚举全为 additive,不改变任何既有调用点的行为。
|
||||
type EventKind string
|
||||
|
||||
const (
|
||||
EventPayment EventKind = "" // 默认:一次性/首期支付(向后兼容)
|
||||
EventSubscriptionRenewal EventKind = "subscription_renewal"
|
||||
EventSubscriptionPastDue EventKind = "subscription_past_due"
|
||||
EventSubscriptionCanceled EventKind = "subscription_canceled"
|
||||
EventChargeback EventKind = "chargeback"
|
||||
)
|
||||
|
||||
// RecurringKindGatewayScheduled — Capabilities.RecurringKind 取值之一:续费由渠道网关自身
|
||||
// 调度驱动(如 Stripe invoice.paid),pay 不主动发起 Charge(区别于 token_offsession)。
|
||||
const RecurringKindGatewayScheduled = "gateway_scheduled"
|
||||
|
||||
// PaidEvent — verify_callback / query 的统一产出(设计 §4.1 → {order_ref,status,paid_amount})。
|
||||
type PaidEvent struct {
|
||||
ProviderRef string
|
||||
@@ -79,6 +95,15 @@ type PaidEvent struct {
|
||||
PaidCurrency string
|
||||
Raw string
|
||||
PaidAt *time.Time // 渠道报的支付时间;nil 则 settle 用收到时间,对账时两边时间才对得上
|
||||
|
||||
// 以下均可选(P8,零值=旧行为):
|
||||
Kind EventKind // 事件语义判别器,零值=既有一次性支付
|
||||
SubscriptionRef string // 渠道订阅号(checkout.completed 诞生 / invoice / deleted 反查)
|
||||
InvoiceRef string // 续费期次唯一号(renewal attempt 的 provider_ref)
|
||||
DisputeRef string // 拒付号
|
||||
ProviderPaymentRef string // 拒付关联的 PaymentIntent id
|
||||
OutTradeNo string // 拒付解析出的原单号(可空)
|
||||
Reason string // 拒付原因(渠道枚举,如 stripe fraudulent/product_not_received)
|
||||
}
|
||||
|
||||
// QueryRequest — Provider.Query 入参:尝试的完整上下文快照,不是裸 provider_ref。
|
||||
@@ -106,6 +131,13 @@ var (
|
||||
// "钱确定没退成可以标 failed 释放额度" vs "退没退不确定,必须留 processing 占额度
|
||||
// 交人工/对账收敛"(P6 RefundStuckAlertTask)。
|
||||
ErrRefundRejected = errors.New("provider: refund rejected by channel")
|
||||
|
||||
// ErrSubAlreadyCanceled — SubscriptionProvider.CancelSubscription 的哨兵:渠道侧订阅
|
||||
// 已处于取消终态(dashboard 手工取消 / 竞态下未消费的 deleted webhook 抢先落地),本地
|
||||
// 发起的主动取消打到渠道时渠道拒绝(如 Stripe "already been canceled" / resource_missing)。
|
||||
// 调用方(gateway.CancelSubscription)须将其视为"取消事实已成立",走本地收敛而非报错——
|
||||
// 具体渠道 adapter 负责把渠道原生错误 wrap 成本哨兵(参见 stripe.isAlreadyCanceledErr)。
|
||||
ErrSubAlreadyCanceled = errors.New("provider: subscription already canceled at channel")
|
||||
)
|
||||
|
||||
// Provider — 每个支付渠道实现的统一接口(设计 §4.1 PaymentProvider)。
|
||||
@@ -163,6 +195,16 @@ type OrphanScanner interface {
|
||||
ScanOrphans(ctx context.Context, req OrphanScanRequest) ([]OrphanTransfer, error)
|
||||
}
|
||||
|
||||
// SubscriptionProvider — 可选:渠道网关自身调度续费(RecurringKindGatewayScheduled,如
|
||||
// Stripe Checkout mode=subscription)的 Provider 额外实现。与 RecurringProvider(pay 主动
|
||||
// Charge 的 token_offsession 类)不同:订阅号在用户完成收银台支付后才诞生,续费由渠道
|
||||
// webhook(invoice.paid)驱动,pay 只负责建单与取消。
|
||||
type SubscriptionProvider interface {
|
||||
Provider
|
||||
CreateSubscriptionCheckout(ctx context.Context, req CreateRequest) (*Session, error)
|
||||
CancelSubscription(ctx context.Context, providerSubRef string) error
|
||||
}
|
||||
|
||||
// Registry — 方法名 → Provider(设计 §2 Provider adapter 注册表)。启动期注册,运行期只读。
|
||||
type Registry struct{ providers map[string]Provider }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -26,11 +28,39 @@ func fakeStripeAPI(t *testing.T) *httptest.Server {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/checkout/sessions"):
|
||||
// 创建 session
|
||||
fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_test_123","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`)
|
||||
// 创建 session。一次性(mode=payment)与订阅(mode=subscription)共用此分支,
|
||||
// 靠 form body 是否带 "subscription" 区分,回不同 id 供各自用例断言。
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if strings.Contains(string(b), "subscription") {
|
||||
fmt.Fprint(w, `{"id":"cs_sub_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_sub_123","mode":"subscription","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`)
|
||||
} else {
|
||||
fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_test_123","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`)
|
||||
}
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/checkout/sessions/cs_test_123"):
|
||||
// 查询 session — 已付
|
||||
fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","amount_total":2999,"currency":"usd","payment_status":"paid"}`)
|
||||
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_cancel_ok"):
|
||||
fmt.Fprint(w, `{"id":"sub_cancel_ok","object":"subscription","status":"canceled"}`)
|
||||
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_resource_missing"):
|
||||
// 渠道已彻底删除该订阅对象:invalid_request_error + code=resource_missing(有明确
|
||||
// 机器可读 Code)。
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprint(w, `{"error":{"type":"invalid_request_error","code":"resource_missing","message":"No such subscription: 'sub_resource_missing'"}}`)
|
||||
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_already_canceled"):
|
||||
// 订阅对象仍在但 status=canceled,二次 Cancel:invalid_request_error,**无 code**,
|
||||
// 只有 Stripe 实测的固定文案。
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `{"error":{"type":"invalid_request_error","message":"This subscription has already been canceled."}}`)
|
||||
case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "/v1/subscriptions/sub_cancel_other_error"):
|
||||
// 与"已取消"无关的普通渠道拒绝(如权限/网络类),不应被误判成哨兵。
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `{"error":{"type":"invalid_request_error","message":"Something else went wrong."}}`)
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/payment_intents/pi_1"):
|
||||
// 一次性单的 PI:携带 Create 时 stamp 的 out_trade_no metadata。
|
||||
fmt.Fprint(w, `{"id":"pi_1","object":"payment_intent","metadata":{"out_trade_no":"PAY-1"}}`)
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/payment_intents/pi_sub_1"):
|
||||
// 订阅续费 charge 的 PI:不带 out_trade_no metadata(Stripe 不透传订阅 metadata 到 PI)。
|
||||
fmt.Fprint(w, `{"id":"pi_sub_1","object":"payment_intent","metadata":{}}`)
|
||||
default:
|
||||
http.Error(w, `{"error":{"message":"not found"}}`, http.StatusNotFound)
|
||||
}
|
||||
@@ -47,6 +77,88 @@ func newStripe(t *testing.T, ts *httptest.Server) *st.Provider {
|
||||
return st.New(sc, whSecret)
|
||||
}
|
||||
|
||||
func TestCreateSubscriptionCheckout(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
p := newStripe(t, ts)
|
||||
|
||||
sess, err := p.CreateSubscriptionCheckout(context.Background(), provider.CreateRequest{
|
||||
OutTradeNo: "PAY-S1", Subject: "Pro Monthly", AmountMinor: 2999, Currency: "USD",
|
||||
ReturnURL: "https://x/return", Metadata: map[string]string{"pay_sub_id": "SUB-1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create sub checkout: %v", err)
|
||||
}
|
||||
if sess.RenderType != provider.RenderRedirect || sess.ProviderRef != "cs_sub_123" {
|
||||
t.Fatalf("session = %+v", sess)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelSubscription(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
p := newStripe(t, ts)
|
||||
if err := p.CancelSubscription(context.Background(), "sub_cancel_ok"); err != nil {
|
||||
t.Fatalf("cancel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelSubscriptionResourceMissingWrapsSentinel 覆盖"渠道已彻底删除该订阅对象"这一
|
||||
// 已取消形态:*stripe.Error{Type:invalid_request_error, Code:resource_missing} → wrap 成
|
||||
// provider.ErrSubAlreadyCanceled(errors.Is 可判),不是不可判别的裸字符串错误。
|
||||
func TestCancelSubscriptionResourceMissingWrapsSentinel(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
p := newStripe(t, ts)
|
||||
err := p.CancelSubscription(context.Background(), "sub_resource_missing")
|
||||
if err == nil {
|
||||
t.Fatalf("cancel resource_missing: want error, got nil")
|
||||
}
|
||||
if !errors.Is(err, provider.ErrSubAlreadyCanceled) {
|
||||
t.Fatalf("cancel resource_missing err = %v, want wraps provider.ErrSubAlreadyCanceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelSubscriptionAlreadyCanceledMessageWrapsSentinel 覆盖"订阅对象仍在但 status=canceled
|
||||
// 二次 Cancel"这一形态:Stripe 对此场景**不下发机器可读 Code**,只有 invalid_request_error 类型
|
||||
// + 固定文案"already been canceled"——同样应 wrap 成哨兵。
|
||||
func TestCancelSubscriptionAlreadyCanceledMessageWrapsSentinel(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
p := newStripe(t, ts)
|
||||
err := p.CancelSubscription(context.Background(), "sub_already_canceled")
|
||||
if err == nil {
|
||||
t.Fatalf("cancel already_canceled: want error, got nil")
|
||||
}
|
||||
if !errors.Is(err, provider.ErrSubAlreadyCanceled) {
|
||||
t.Fatalf("cancel already_canceled err = %v, want wraps provider.ErrSubAlreadyCanceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCancelSubscriptionOtherErrorNotWrapped 反例:与"已取消"无关的渠道拒绝不应被误判成
|
||||
// 哨兵,原样透传成普通错误(不能 errors.Is 命中)。
|
||||
func TestCancelSubscriptionOtherErrorNotWrapped(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
p := newStripe(t, ts)
|
||||
err := p.CancelSubscription(context.Background(), "sub_cancel_other_error")
|
||||
if err == nil {
|
||||
t.Fatalf("cancel other error: want error, got nil")
|
||||
}
|
||||
if errors.Is(err, provider.ErrSubAlreadyCanceled) {
|
||||
t.Fatalf("cancel other error err = %v, 不应误判成 ErrSubAlreadyCanceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitiesRecurring(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
c := newStripe(t, ts).Capabilities()
|
||||
if !c.SupportsRecurring || c.RecurringKind != provider.RecurringKindGatewayScheduled {
|
||||
t.Fatalf("caps = %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCheckoutRedirect(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
@@ -101,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) {
|
||||
@@ -123,6 +263,185 @@ func TestVerifyWebhookWrongSecretFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// invoice.paid + billing_reason=subscription_cycle → 归一化为 EventSubscriptionRenewal,
|
||||
// 携带 invoice/subscription/金额,供 gateway.settleRenewal 铸 renewal order。
|
||||
func TestVerifyInvoicePaidRenewal(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
p := newStripe(t, ts)
|
||||
|
||||
payload := `{"id":"evt_r","object":"event","type":"invoice.paid","created":1700000000,"data":{"object":{"id":"in_123","object":"invoice","billing_reason":"subscription_cycle","total":2999,"currency":"usd","subscription":{"id":"sub_new","object":"subscription"}}}}`
|
||||
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.Kind != provider.EventSubscriptionRenewal {
|
||||
t.Fatalf("kind = %v, want EventSubscriptionRenewal", ev.Kind)
|
||||
}
|
||||
if ev.InvoiceRef != "in_123" || ev.SubscriptionRef != "sub_new" || ev.PaidAmountMinor != 2999 || ev.Status != provider.PaidSucceeded {
|
||||
t.Fatalf("event = %+v", ev)
|
||||
}
|
||||
if ev.PaidCurrency != "USD" {
|
||||
t.Fatalf("currency = %s, want USD", ev.PaidCurrency)
|
||||
}
|
||||
}
|
||||
|
||||
// invoice.paid 但 billing_reason=subscription_create 是首期发票,与 checkout.session.completed
|
||||
// 是同一笔钱——由后者入账,这里必须跳过(归一化为 EventPayment,不触发续费铸单)。
|
||||
func TestVerifyInvoicePaidFirstPeriodSkipped(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
p := newStripe(t, ts)
|
||||
|
||||
payload := `{"id":"evt_r2","object":"event","type":"invoice.paid","created":1700000000,"data":{"object":{"id":"in_first","object":"invoice","billing_reason":"subscription_create","total":2999,"currency":"usd","subscription":{"id":"sub_new","object":"subscription"}}}}`
|
||||
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.Kind != provider.EventPayment {
|
||||
t.Fatalf("kind = %v, want EventPayment(skip)", ev.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// customer.subscription.deleted(Stripe 侧取消,主动取消/欠费催收耗尽后网关删除)→
|
||||
// 归一化为 EventSubscriptionCanceled,携带渠道订阅号供 gateway 反查 Subscription。
|
||||
func TestVerifyCustomerSubscriptionDeleted(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
p := newStripe(t, ts)
|
||||
|
||||
payload := `{"id":"evt_del","object":"event","type":"customer.subscription.deleted","data":{"object":{"id":"sub_new","object":"subscription","status":"canceled"}}}`
|
||||
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.Kind != provider.EventSubscriptionCanceled {
|
||||
t.Fatalf("kind = %v, want EventSubscriptionCanceled", ev.Kind)
|
||||
}
|
||||
if ev.SubscriptionRef != "sub_new" {
|
||||
t.Fatalf("subscription_ref = %s, want sub_new", ev.SubscriptionRef)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateSendsPaymentIntentMetadata 覆盖 P8 Task6:一次性(mode=payment)Checkout 的
|
||||
// Create 必须给 PaymentIntentData 打 out_trade_no metadata,dispute webhook 反查 PI 才能
|
||||
// 定位原订单(见 VerifyCallback 的 charge.dispute.created 分支)。
|
||||
func TestCreateSendsPaymentIntentMetadata(t *testing.T) {
|
||||
var gotBody string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","url":"https://checkout.stripe.com/c/pay/cs_test_123","amount_total":2999,"currency":"usd","payment_status":"unpaid"}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
p := newStripe(t, ts)
|
||||
|
||||
if _, err := p.Create(context.Background(), provider.CreateRequest{
|
||||
OutTradeNo: "PAY-1", Subject: "Pro Year", AmountMinor: 2999, Currency: "USD",
|
||||
ReturnURL: "https://x/return",
|
||||
}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if !strings.Contains(gotBody, "payment_intent_data") || !strings.Contains(gotBody, "out_trade_no") || !strings.Contains(gotBody, "PAY-1") {
|
||||
t.Fatalf("create request body missing payment_intent_data out_trade_no metadata: %s", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// charge.dispute.created(拒付)→ 归一化为 EventChargeback:dispute payload 的
|
||||
// payment_intent 只是 id("pi_1")未展开,须反查 PaymentIntents.Get 取 metadata
|
||||
// out_trade_no(一次性单 Create 已 stamp)。
|
||||
func TestVerifyChargeDisputeCreated(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
p := newStripe(t, ts)
|
||||
|
||||
payload := `{"id":"evt_dp1","object":"event","type":"charge.dispute.created","data":{"object":{"id":"dp_1","object":"dispute","amount":2999,"currency":"usd","reason":"fraudulent","status":"needs_response","payment_intent":"pi_1"}}}`
|
||||
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.Kind != provider.EventChargeback {
|
||||
t.Fatalf("kind = %v, want EventChargeback", ev.Kind)
|
||||
}
|
||||
if ev.DisputeRef != "dp_1" || ev.OutTradeNo != "PAY-1" || ev.PaidAmountMinor != 2999 || ev.PaidCurrency != "USD" || ev.Reason != "fraudulent" {
|
||||
t.Fatalf("event = %+v", ev)
|
||||
}
|
||||
if ev.ProviderPaymentRef != "pi_1" {
|
||||
t.Fatalf("provider_payment_ref = %s, want pi_1", ev.ProviderPaymentRef)
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅续费 charge 的 PI 不带 out_trade_no metadata(Stripe 不透传订阅 metadata 到 PI)→
|
||||
// OutTradeNo 归一化为空,gateway.recordChargeback 据此记录但不转发(honest scope)。
|
||||
func TestVerifyChargeDisputeCreatedNoOutTradeNo(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
p := newStripe(t, ts)
|
||||
|
||||
payload := `{"id":"evt_dp2","object":"event","type":"charge.dispute.created","data":{"object":{"id":"dp_sub_1","object":"dispute","amount":999,"currency":"usd","reason":"fraudulent","status":"needs_response","payment_intent":"pi_sub_1"}}}`
|
||||
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.Kind != provider.EventChargeback || ev.DisputeRef != "dp_sub_1" {
|
||||
t.Fatalf("event = %+v", ev)
|
||||
}
|
||||
if ev.OutTradeNo != "" {
|
||||
t.Fatalf("out_trade_no = %q, want empty(订阅拒付无法定位)", ev.OutTradeNo)
|
||||
}
|
||||
}
|
||||
|
||||
// invoice.payment_failed(某期扣款失败)→ 归一化为 EventSubscriptionPastDue,携带失败
|
||||
// 发票号(供 gateway 铸 outbox 幂等键)+ 渠道订阅号。
|
||||
func TestVerifyInvoicePaymentFailed(t *testing.T) {
|
||||
ts := fakeStripeAPI(t)
|
||||
defer ts.Close()
|
||||
p := newStripe(t, ts)
|
||||
|
||||
payload := `{"id":"evt_fail","object":"event","type":"invoice.payment_failed","data":{"object":{"id":"in_failed","object":"invoice","subscription":{"id":"sub_new","object":"subscription"}}}}`
|
||||
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.Kind != provider.EventSubscriptionPastDue {
|
||||
t.Fatalf("kind = %v, want EventSubscriptionPastDue", ev.Kind)
|
||||
}
|
||||
if ev.InvoiceRef != "in_failed" || ev.SubscriptionRef != "sub_new" {
|
||||
t.Fatalf("event = %+v", ev)
|
||||
}
|
||||
}
|
||||
|
||||
// signStripe 复刻 Stripe webhook 签名头: t=<ts>,v1=hex(HMAC-SHA256(secret, "<ts>.<payload>"))
|
||||
func signStripe(payload, secret string, ts int64) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
|
||||
Reference in New Issue
Block a user