feat(pay-v2): P8 Task2 PaidEvent.Kind + SubscriptionProvider + Stripe 订阅 Checkout/取消
This commit is contained in:
@@ -71,6 +71,22 @@ type CallbackInput struct {
|
|||||||
Query map[string]string
|
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})。
|
// PaidEvent — verify_callback / query 的统一产出(设计 §4.1 → {order_ref,status,paid_amount})。
|
||||||
type PaidEvent struct {
|
type PaidEvent struct {
|
||||||
ProviderRef string
|
ProviderRef string
|
||||||
@@ -79,6 +95,14 @@ type PaidEvent struct {
|
|||||||
PaidCurrency string
|
PaidCurrency string
|
||||||
Raw string
|
Raw string
|
||||||
PaidAt *time.Time // 渠道报的支付时间;nil 则 settle 用收到时间,对账时两边时间才对得上
|
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 // 拒付解析出的原单号(可空)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryRequest — Provider.Query 入参:尝试的完整上下文快照,不是裸 provider_ref。
|
// QueryRequest — Provider.Query 入参:尝试的完整上下文快照,不是裸 provider_ref。
|
||||||
@@ -125,6 +149,16 @@ type RecurringProvider interface {
|
|||||||
CancelAgreement(ctx context.Context, agreementRef string) error
|
CancelAgreement(ctx context.Context, agreementRef string) 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 注册表)。启动期注册,运行期只读。
|
// Registry — 方法名 → Provider(设计 §2 Provider adapter 注册表)。启动期注册,运行期只读。
|
||||||
type Registry struct{ providers map[string]Provider }
|
type Registry struct{ providers map[string]Provider }
|
||||||
|
|
||||||
|
|||||||
@@ -41,10 +41,12 @@ func (p *Provider) Method() string { return "stripe" }
|
|||||||
|
|
||||||
func (p *Provider) Capabilities() provider.Capabilities {
|
func (p *Provider) Capabilities() provider.Capabilities {
|
||||||
return provider.Capabilities{
|
return provider.Capabilities{
|
||||||
RenderTypes: []provider.RenderType{provider.RenderRedirect},
|
RenderTypes: []provider.RenderType{provider.RenderRedirect},
|
||||||
SupportsRefund: true, // P4:/v1/refunds
|
SupportsRefund: true, // P4:/v1/refunds
|
||||||
SettleCurrencies: []string{supportedCurrency},
|
SupportsRecurring: true, // P8:Checkout mode=subscription,续费由 Stripe 网关调度
|
||||||
Regions: []string{"global"},
|
RecurringKind: provider.RecurringKindGatewayScheduled,
|
||||||
|
SettleCurrencies: []string{supportedCurrency},
|
||||||
|
Regions: []string{"global"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +113,59 @@ func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (*provi
|
|||||||
}, nil
|
}, 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) {
|
func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) {
|
||||||
sig := in.Headers["Stripe-Signature"]
|
sig := in.Headers["Stripe-Signature"]
|
||||||
// stripe-go 默认 ConstructEvent 会额外校验 event.api_version == SDK 编译期常量
|
// stripe-go 默认 ConstructEvent 会额外校验 event.api_version == SDK 编译期常量
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,11 +27,19 @@ func fakeStripeAPI(t *testing.T) *httptest.Server {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
switch {
|
switch {
|
||||||
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/checkout/sessions"):
|
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/checkout/sessions"):
|
||||||
// 创建 session
|
// 创建 session。一次性(mode=payment)与订阅(mode=subscription)共用此分支,
|
||||||
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"}`)
|
// 靠 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"):
|
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/v1/checkout/sessions/cs_test_123"):
|
||||||
// 查询 session — 已付
|
// 查询 session — 已付
|
||||||
fmt.Fprint(w, `{"id":"cs_test_123","object":"checkout.session","amount_total":2999,"currency":"usd","payment_status":"paid"}`)
|
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"}`)
|
||||||
default:
|
default:
|
||||||
http.Error(w, `{"error":{"message":"not found"}}`, http.StatusNotFound)
|
http.Error(w, `{"error":{"message":"not found"}}`, http.StatusNotFound)
|
||||||
}
|
}
|
||||||
@@ -47,6 +56,41 @@ func newStripe(t *testing.T, ts *httptest.Server) *st.Provider {
|
|||||||
return st.New(sc, whSecret)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestCreateCheckoutRedirect(t *testing.T) {
|
||||||
ts := fakeStripeAPI(t)
|
ts := fakeStripeAPI(t)
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|||||||
Reference in New Issue
Block a user