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:
wangjia
2026-07-10 19:43:32 +08:00
32 changed files with 2826 additions and 52 deletions
+42
View File
@@ -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 }