da8bbefe2e
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
232 lines
9.4 KiB
Go
232 lines
9.4 KiB
Go
// Package provider abstracts a payment channel behind a uniform interface:
|
|
// create → {render_type, payload}, verify_callback / query → normalized PaidEvent.
|
|
// Channel-specific quirks (RSA/cert/on-chain confirmations/receipt checks) are
|
|
// sealed inside each Provider's VerifyCallback; the pipeline stays channel-neutral.
|
|
package provider
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/wangjia/pay/config"
|
|
)
|
|
|
|
// RenderType — 客户端只认的 6 种付款意图形态(设计 §4.2),不含任何 UI。
|
|
type RenderType string
|
|
|
|
const (
|
|
RenderRedirect RenderType = "redirect"
|
|
RenderQR RenderType = "qr"
|
|
RenderCryptoAddress RenderType = "crypto_address"
|
|
RenderNativePay RenderType = "native_pay"
|
|
RenderSDKHandoff RenderType = "sdk_handoff"
|
|
RenderIAPReceipt RenderType = "iap_receipt"
|
|
)
|
|
|
|
// PaidStatus — verify_callback / query 归一化后的支付状态。
|
|
type PaidStatus string
|
|
|
|
const (
|
|
PaidPending PaidStatus = "pending"
|
|
PaidSucceeded PaidStatus = "succeeded"
|
|
PaidFailed PaidStatus = "failed"
|
|
)
|
|
|
|
// Capabilities — 渠道能力自述(设计 §4.1 capabilities())。
|
|
type Capabilities struct {
|
|
RenderTypes []RenderType
|
|
SupportsRefund bool
|
|
SupportsRecurring bool
|
|
RecurringKind string // token_offsession/gateway_scheduled/store_managed/none
|
|
SettleCurrencies []string
|
|
Regions []string
|
|
}
|
|
|
|
// CreateRequest — Provider.Create 入参:pay 已定金额/币种/账户,Provider 拿去渠道建单。
|
|
type CreateRequest struct {
|
|
OutTradeNo string
|
|
Subject string
|
|
AmountMinor int64
|
|
Currency string
|
|
Account config.AccountConfig // 选中的收款账户(含 CredentialEnvPrefix)
|
|
ReturnURL string
|
|
Metadata map[string]string
|
|
}
|
|
|
|
// Session — Provider.Create 产出:渠道单号 + 付款意图数据(render_type + payload)。
|
|
type Session struct {
|
|
ProviderRef string
|
|
RenderType RenderType
|
|
Payload map[string]any
|
|
ExpiresAt *time.Time
|
|
}
|
|
|
|
// CallbackInput — 渠道原始回调(HTTP body/头/query),由各 Provider 自行解析验签。
|
|
type CallbackInput struct {
|
|
Raw []byte
|
|
Headers 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})。
|
|
type PaidEvent struct {
|
|
ProviderRef string
|
|
Status PaidStatus
|
|
PaidAmountMinor int64
|
|
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。
|
|
// crypto 自托管的"查单"= 按地址+期望金额+时间窗扫链核对;裸 ref 会逼渠道 adapter
|
|
// 自建 ref→(地址/金额/窗口) 映射表,重复 pay 已持有的数据。管线侧(SyncPendingAttempts)
|
|
// 本就拿着整个 attempt,填这个结构零成本。
|
|
type QueryRequest struct {
|
|
ProviderRef string
|
|
OutTradeNo string
|
|
AccountID string
|
|
AmountMinor int64
|
|
Currency string
|
|
CreatedAt time.Time
|
|
ExpiresAt *time.Time
|
|
}
|
|
|
|
var (
|
|
ErrUnknownMethod = errors.New("provider: unknown method")
|
|
ErrNotSupported = errors.New("provider: capability not supported")
|
|
|
|
// ErrRefundRejected — 渠道对退款请求的一个"确定性"拒绝(如金额超出可退余额、
|
|
// 交易已完结等业务规则拒绝),而非超时/网络错误/5xx 这类结果不确定的失败。
|
|
// RefundingProvider.Refund 的实现须在能确认渠道明确拒绝时,让返回的 error 满足
|
|
// errors.Is(err, ErrRefundRejected)(errors.Join 或 %w 包裹均可);网关据此区分
|
|
// "钱确定没退成可以标 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)。
|
|
type Provider interface {
|
|
Method() string
|
|
Capabilities() Capabilities
|
|
Create(ctx context.Context, req CreateRequest) (*Session, error)
|
|
VerifyCallback(ctx context.Context, in CallbackInput) (*PaidEvent, error)
|
|
Query(ctx context.Context, req QueryRequest) (*PaidEvent, error)
|
|
}
|
|
|
|
// RefundingProvider — 可选:支持渠道退款的 Provider 额外实现(P4)。refundID 为 pay 侧
|
|
// 退款单号,作渠道幂等键(alipay out_request_no / stripe Idempotency-Key)——同单多次
|
|
// 部分退款靠它去重,重试不重复退。不支持退款的渠道不实现本接口(capabilities=false)。
|
|
type RefundingProvider interface {
|
|
Provider
|
|
Refund(ctx context.Context, providerRef, refundID string, amountMinor int64, reason string) (refundRef string, status PaidStatus, err error)
|
|
}
|
|
|
|
// RecurringProvider — 可选:支持自动续订(P8,设计 §5.1 4 类 kind)。
|
|
type RecurringProvider interface {
|
|
Provider
|
|
CreateAgreement(ctx context.Context, req CreateRequest) (agreementRef string, err error)
|
|
Charge(ctx context.Context, agreementRef string, amountMinor int64, currency string) (*PaidEvent, error)
|
|
CancelAgreement(ctx context.Context, agreementRef string) error
|
|
}
|
|
|
|
// ---- 对账:孤儿到账扫描(P6,可选接口)----
|
|
|
|
// KnownAttempt 是 pay 合法签发过的一笔尝试的对账维度:期望金额 = base(AmountMinor)+ 渠道尾数
|
|
// (尾数封在 provider_ref,由渠道自解,pay 不算)。渠道据此判断一笔到账是否"有主"。
|
|
type KnownAttempt struct {
|
|
AmountMinor int64
|
|
ProviderRef string
|
|
}
|
|
|
|
// OrphanScanRequest 扫描某账户 Since 以来、不匹配任何 Known 的到账。
|
|
type OrphanScanRequest struct {
|
|
AccountID string
|
|
Since time.Time
|
|
Known []KnownAttempt
|
|
}
|
|
|
|
// OrphanTransfer 一笔"有钱到账但无主"的转账(付错金额/手动转/超窗迟到旧款)。
|
|
type OrphanTransfer struct {
|
|
TxID string
|
|
AmountMinor int64
|
|
Currency string
|
|
At time.Time
|
|
}
|
|
|
|
// OrphanScanner 自托管渠道(crypto)可选实现:发现到账但不匹配任何 attempt 的转账。
|
|
// 网关侧渠道(alipay/stripe)以对账单核对,不实现此接口。
|
|
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 }
|
|
|
|
func NewRegistry() *Registry { return &Registry{providers: map[string]Provider{}} }
|
|
|
|
func (r *Registry) Register(p Provider) { r.providers[p.Method()] = p }
|
|
|
|
func (r *Registry) Get(method string) (Provider, error) {
|
|
p, ok := r.providers[method]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, method)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// Methods 返回已注册方法名(字典序,供 GET /methods 下发已启用渠道)。
|
|
func (r *Registry) Methods() []string {
|
|
out := make([]string, 0, len(r.providers))
|
|
for m := range r.providers {
|
|
out = append(out, m)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|