diff --git a/config/config.go b/config/config.go
index 81e8abd..479c975 100644
--- a/config/config.go
+++ b/config/config.go
@@ -92,6 +92,11 @@ type WechatConfig struct {
type BizSystemConfig struct {
CallbackURL string `mapstructure:"callback_url"` // 支付成功回调业务方接收器地址
Secret string `mapstructure:"secret"` // HMAC 共享密钥
+ // SupportedEvents 接入方显式声明支持的 webhook event_type 集合(设计 §「接入方显式
+ // 声明支持事件集」)。空 = 只收 payment.succeeded(向后兼容 v1/P2 接入方,不会突然
+ // 收到 subscription.*/chargeback.received 等新事件把它们搞崩);非空则按声明集精确
+ // 匹配,Notifier 投递前过滤(见 internal/webhook/notifier.go::eventSupported)。
+ SupportedEvents []string `mapstructure:"supported_events"`
}
// BizByName 按业务系统名取配置(供下单鉴权 / webhook 回调用)。未声明或无密钥则视为未接入。
diff --git a/docs/pay-v2-unified-gateway-design.html b/docs/pay-v2-unified-gateway-design.html
index 2ebb620..dc68dcf 100644
--- a/docs/pay-v2-unified-gateway-design.html
+++ b/docs/pay-v2-unified-gateway-design.html
@@ -276,24 +276,44 @@
payment.succeeded | 先做 | 收款成功 → 接入方开通 |
refund.succeeded / refund.failed | 卡类要真做 | 退款结果 → 接入方冲正权益 |
-chargeback.created | 设计进·later | 信用卡拒付(平台/发卡行发起) |
-subscription.renewed|failed|canceled | 设计进·later | 订阅事件(§见 recurring) |
+subscription.created | ✅ P8 | 订阅诞生(gateway_scheduled 首购成功,Stripe Subscriptions) |
+subscription.renewed | ✅ P8 | 续费成功(invoice.paid,下一期扣款到账) |
+subscription.past_due | ✅ P8 | 续费失败(invoice.payment_failed,订阅转 past_due,提醒用户换卡) |
+subscription.canceled | ✅ P8 | 订阅取消(业务方发起或渠道侧取消) |
+chargeback.received | ✅ P8 | 信用卡拒付(发卡行 dispute,仅 Stripe;crypto/支付宝/微信本轮无此流) |
签名沿用双向 HMAC(system\ntimestamp\nnonce\nrawBody,±5min,nonce 防重放);payload 幂等键 out_trade_no。接入方收 payment.succeeded → 验签 → 幂等 → 金额核对 → max(现到期,now)+时长 叠加(此算法抽成共享工具);非 200/不含 SUCCESS → 60s 重试 24h。
-5.1 自动续订(recurring):设计进模型、暂不实现
+5.1 自动续订(recurring):gateway_scheduled(Stripe)P8 已实现,其余仍设计进模型
本质分歧在"谁驱动下一期扣款",归 4 类,统一为"pay 维护 subscription 状态机,不同 kind 不同推进方式":
| recurring_kind | 谁驱动 | 代表 | pay 如何推进 |
-token_offsession | 我方 | Stripe自建/支付宝周期扣/微信papay | cron 到期主动 charge |
-gateway_scheduled | 网关 | Stripe Subscriptions / PayPal | 监听 invoice.paid webhook |
-store_managed | 平台 | Apple IAP / Google Play | 被动接 Server Notification/RTDN |
+token_offsession | 我方 | Stripe自建/支付宝周期扣/微信papay | cron 到期主动 charge(能力位,未实现) |
+gateway_scheduled | 网关 | Stripe Subscriptions / PayPal | 监听 invoice.paid webhook(✅ P8 已实现,Stripe;PayPal 未接) |
+store_managed | 平台 | Apple IAP / Google Play | 被动接 Server Notification/RTDN(能力位,未实现) |
none | 无 | 所有 crypto / 单笔 | 到期提醒用户手动再买(伪续订) |
-统一 subscription 实体 + 状态机(active→grace→expired);单笔层与订阅层解耦。crypto 天生无 recurring、store_managed 完全平台掌控——跨渠道订阅是"同一 entitlement 的不同实现"。本轮把 event_type/subscription 实体设计进契约、避免第三次 breaking,但只建一次性付款。
+统一 subscription 实体 + 状态机(active→past_due→canceled,internal/model/subscription.go);单笔层与订阅层解耦。crypto 天生无 recurring(SupportsRecurring=false,也无 chargeback)、store_managed 完全平台掌控——跨渠道订阅是"同一 entitlement 的不同实现"。P8 落地范围:Stripe Checkout(subscription mode)首购 → subscription.created;invoice.paid → 续费单 + subscription.renewed;invoice.payment_failed → subscription.past_due;取消(业务方 API 或渠道侧)→ subscription.canceled;charge.dispute.created → Chargeback 记录 + 原单打 disputed 标 + chargeback.received。token_offsession/store_managed/支付宝周期扣仍只是设计进契约的能力位,避免第三次 breaking,但本轮不实现。
+
+5.2 接入方显式声明支持事件集
+BizSystemConfig.SupportedEvents(config.yaml 的 biz.<name>.supported_events):接入方在配置里显式声明自己能处理哪些 event_type。空 = 只收 payment.succeeded(v1/P2 老接入方向后兼容,不会突然收到 subscription.*/chargeback.received 等新事件把它们搞崩);非空则按声明集精确匹配。Notifier 投递前过滤:event_type 不在声明集 → 不 POST、直接标 delivered(视为已受理,不占重试队列)——避免给没准备好订阅/拒付处理逻辑的业务方硬推未知事件。
+门禁旁路:subscription.past_due/subscription.canceled 挂在已 paid 的首购单(out_trade_no)上,既有的 orderPaid 投递门禁天然放行,无需额外旁路。
+
+5.3 webhook payload 契约(P8 新增事件)
+
+| event_type | payload 关键字段 |
+
+subscription.created | out_trade_no(首购单号)/sub_id/biz_system/biz_ref/product_biz_code/amount_minor/currency/channel/created_at |
+subscription.renewed | out_trade_no(续费单号,与首购单号不同)/sub_id/biz_system/biz_ref/product_biz_code/amount_minor/currency/channel/paid_at |
+subscription.past_due | out_trade_no(首购单号)/sub_id/biz_system/biz_ref/product_biz_code/failed_at |
+subscription.canceled | out_trade_no(首购单号)/sub_id/biz_system/biz_ref/product_biz_code/canceled_at |
+chargeback.received | out_trade_no/dispute_ref/biz_system/biz_ref/product_biz_code/amount_minor/currency/reason/received_at |
+
+
+所有事件的 event_type 字段本身也回显在 payload 顶层(与 X-Pay-Event 头一致),便于业务方单点分发。subscription.renewed 的幂等键是续费单号(每期不同),其余订阅/拒付事件幂等键是首购单号 + event_type(同订阅只会 created 一次、canceled 一次;past_due 当前实现下同订阅多次失败只保证首次必达,见 internal/gateway/subscription.go::markSubscriptionPastDue 注释)。
6. 退款设计
退款政策归业务,退款机制归 pay(与促销同一原则)。能不能退/退多少/时限 = 业务侧;pay 提供机制 + 记账 + 事件,不判断该不该退。
@@ -408,7 +428,7 @@
13. 待确认 / 后续
- 本设计聚焦 pay 深设计 + codes 定架构;entitlement 各产品自留(不在本设计)。
-- 实现分期(留给 writing-plans):数据模型 + 配置/账户注册表 → Provider 抽象 + 一次性收款管线 + webhook v2 → 首批渠道(crypto 自托管 + 支付宝 + Stripe)→ 退款 → 多账户路由 → 对账 job → codes 共享内核 →(later)订阅/拒付。
+- 实现分期(留给 writing-plans):数据模型 + 配置/账户注册表 → Provider 抽象 + 一次性收款管线 + webhook v2 → 首批渠道(crypto 自托管 + 支付宝 + Stripe)→ 退款 → 多账户路由 → 对账 job → codes 共享内核 →(later)订阅/拒付 —— P8 已落地:
gateway_scheduled(Stripe)订阅状态机 + chargeback 拒付记录 + 业务方事件集声明/过滤;token_offsession/store_managed 仍留能力位、未实现。
- 补测试(现 pay 核心资金链路几乎无单测,托多产品钱必须补)。
- pangolin / jiu 收口改造:pay 定稿后再动(见 brain todo)。
diff --git a/internal/gateway/chargeback_test.go b/internal/gateway/chargeback_test.go
new file mode 100644
index 0000000..457c0ba
--- /dev/null
+++ b/internal/gateway/chargeback_test.go
@@ -0,0 +1,171 @@
+package gateway_test
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/wangjia/pay/config"
+ "github.com/wangjia/pay/internal/accounts"
+ "github.com/wangjia/pay/internal/gateway"
+ "github.com/wangjia/pay/internal/model"
+ "github.com/wangjia/pay/internal/provider"
+ "github.com/wangjia/pay/internal/store"
+)
+
+// newChargebackGateway 装配一套独立的 gateway(渠道 "substripe",复用 subscription_test.go
+// 的 fakeSubProvider——它把测试注入的 JSON 原样反序列化成 provider.PaidEvent,包括
+// Kind/DisputeRef/OutTradeNo 等 P8 新增字段,fake.Provider 的精简版协议做不到这点),额外
+// 暴露 *store.ChargebackStore 供断言落库情况(P8 Task6 专用,不复用 newGateway/newSubGateway
+// 避免改动其多处既有调用签名)。
+func newChargebackGateway(t *testing.T) (*gateway.Gateway, *store.OrderStore, *store.ChargebackStore, *spyEnqueuer) {
+ t.Helper()
+ db := model.OpenTestDB(t)
+ orders := store.NewOrderStore(db)
+ refunds := store.NewRefundStore(db)
+ subs := store.NewSubscriptionStore(db)
+ chargebacks := store.NewChargebackStore(db)
+ preg := provider.NewRegistry()
+ preg.Register(&fakeSubProvider{sessionRef: "cs_cb_1"})
+ areg := accounts.New([]config.AccountConfig{
+ {AccountID: "cb-a1", Channel: "substripe", Region: "global", Enabled: true, Weight: 1},
+ })
+ picker := accounts.NewRouter(areg, nil, nil)
+ spy := &spyEnqueuer{}
+ g := gateway.New(orders, refunds, preg, picker, stubSubResolver{}, spy, "global", subs, chargebacks)
+ return g, orders, chargebacks, spy
+}
+
+func seedPaidOrder(t *testing.T, orders *store.OrderStore, no string) {
+ t.Helper()
+ if err := orders.CreateOrder(&model.OrderV2{
+ OutTradeNo: no, BizSystem: "pangolin", BizRef: "u-1", BizCode: "pro_month",
+ AmountMinor: 2999, Currency: "USD", Status: model.OrderPaidV2,
+ }); err != nil {
+ t.Fatalf("seed paid order: %v", err)
+ }
+}
+
+// TestRecordChargebackHappyPath 覆盖 brief Step1 ①②:charge.dispute.created → 落
+// Chargeback 一行 + 原 order Disputed=true + webhook spy 收 chargeback.received,且不
+// 自动改订单状态机(仍是 paid,只是 Disputed 打标)。
+func TestRecordChargebackHappyPath(t *testing.T) {
+ g, orders, chargebacks, spy := newChargebackGateway(t)
+ seedPaidOrder(t, orders, "PAY-1")
+
+ raw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventChargeback, DisputeRef: "dp_1", OutTradeNo: "PAY-1",
+ ProviderPaymentRef: "pi_1", PaidAmountMinor: 2999, PaidCurrency: "USD",
+ Reason: "fraudulent", Status: provider.PaidFailed,
+ })
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil || result != gateway.SettleProcessed {
+ t.Fatalf("HandleCallback = %v, %v, want SettleProcessed", result, err)
+ }
+
+ o, err := orders.GetOrder("PAY-1")
+ if err != nil {
+ t.Fatalf("GetOrder: %v", err)
+ }
+ if !o.Disputed {
+ t.Fatalf("order.Disputed = false, want true")
+ }
+ if o.Status != model.OrderPaidV2 {
+ t.Fatalf("order.Status = %s, want unchanged paid(打标不改状态机)", o.Status)
+ }
+
+ _ = chargebacks // 幂等落库由下面的重投用例断言(created=false)
+
+ if len(spy.calls) != 1 {
+ t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls)
+ }
+ c := spy.calls[0]
+ if c["event_type"] != gateway.EvtChargebackReceived || c["out_trade_no"] != "PAY-1" || c["dispute_ref"] != "dp_1" {
+ t.Fatalf("webhook payload = %+v", c)
+ }
+}
+
+// TestRecordChargebackDuplicateNotDoubleRecordedOrEnqueued 拒付重投(Stripe 常见重投场景)→
+// Chargeback 表不双记(ON CONFLICT dispute_ref)、webhook 不双发(outbox 唯一键)。
+func TestRecordChargebackDuplicateNotDoubleRecordedOrEnqueued(t *testing.T) {
+ g, orders, _, spy := newChargebackGateway(t)
+ seedPaidOrder(t, orders, "PAY-2")
+
+ raw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventChargeback, DisputeRef: "dp_2", OutTradeNo: "PAY-2",
+ ProviderPaymentRef: "pi_2", PaidAmountMinor: 1999, PaidCurrency: "USD",
+ Reason: "duplicate", Status: provider.PaidFailed,
+ })
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if _, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw}); err != nil {
+ t.Fatalf("HandleCallback#1: %v", err)
+ }
+ result2, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil {
+ t.Fatalf("HandleCallback#2: %v", err)
+ }
+ if result2 != gateway.SettleDuplicate {
+ t.Fatalf("replay result = %v, want duplicate", result2)
+ }
+ if len(spy.calls) != 1 {
+ t.Fatalf("webhook calls after replay = %d, want still 1: %+v", len(spy.calls), spy.calls)
+ }
+}
+
+// TestRecordChargebackEmptyOutTradeNoNotEnqueued 覆盖 brief Step1 ③:out_trade_no 空
+// (订阅拒付,PI 无 metadata)→ 仍落 Chargeback 记录,但不入队业务 webhook(无法定位业务单)。
+func TestRecordChargebackEmptyOutTradeNoNotEnqueued(t *testing.T) {
+ g, _, chargebacks, spy := newChargebackGateway(t)
+
+ raw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventChargeback, DisputeRef: "dp_sub_1", OutTradeNo: "",
+ ProviderPaymentRef: "pi_sub_1", PaidAmountMinor: 999, PaidCurrency: "USD",
+ Reason: "fraudulent", Status: provider.PaidFailed,
+ })
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil || result != gateway.SettleProcessed {
+ t.Fatalf("HandleCallback = %v, %v, want SettleProcessed(已记录未转发)", result, err)
+ }
+ if len(spy.calls) != 0 {
+ t.Fatalf("webhook calls = %d, want 0(无法定位业务单不转发): %+v", len(spy.calls), spy.calls)
+ }
+ // 重投同一空 out_trade_no dispute → Chargeback 仍不双记(created 幂等),同样不入队。
+ again, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil || again != gateway.SettleDuplicate {
+ t.Fatalf("replay = %v, %v, want duplicate", again, err)
+ }
+ if len(spy.calls) != 0 {
+ t.Fatalf("webhook calls after replay = %d, want still 0", len(spy.calls))
+ }
+ _ = chargebacks
+}
+
+// TestRecordChargebackUnknownOrderNotBlocking out_trade_no 非空但查单失败(极端场景,如脏
+// 数据/竞态)→ 已记录 Chargeback,定位失败不阻断、不 panic,同样不转发。
+func TestRecordChargebackUnknownOrderNotBlocking(t *testing.T) {
+ g, _, _, spy := newChargebackGateway(t)
+
+ raw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventChargeback, DisputeRef: "dp_unknown", OutTradeNo: "NOPE",
+ ProviderPaymentRef: "pi_unknown", PaidAmountMinor: 500, PaidCurrency: "USD",
+ Reason: "fraudulent", Status: provider.PaidFailed,
+ })
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil || result != gateway.SettleProcessed {
+ t.Fatalf("HandleCallback = %v, %v, want SettleProcessed(已记录,查单失败不阻断)", result, err)
+ }
+ if len(spy.calls) != 0 {
+ t.Fatalf("webhook calls = %d, want 0", len(spy.calls))
+ }
+}
diff --git a/internal/gateway/e2e_crypto_test.go b/internal/gateway/e2e_crypto_test.go
index 93b2898..16a9bcf 100644
--- a/internal/gateway/e2e_crypto_test.go
+++ b/internal/gateway/e2e_crypto_test.go
@@ -45,6 +45,8 @@ func TestE2ECryptoQuerySettles(t *testing.T) {
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
+ subs := store.NewSubscriptionStore(db)
+ chargebacks := store.NewChargebackStore(db)
acctReg := accounts.New([]config.AccountConfig{
{AccountID: "e2e-1", Channel: "crypto", Enabled: true, Region: "global", CredentialEnvPrefix: "e2e"},
})
@@ -56,7 +58,7 @@ func TestE2ECryptoQuerySettles(t *testing.T) {
picker := accounts.NewRouter(acctReg, nil, nil)
spy := &spyEnqueuer{}
- g := gateway.New(orders, refunds, preg, picker, cryptoResolver{}, spy, "global")
+ g := gateway.New(orders, refunds, preg, picker, cryptoResolver{}, spy, "global", subs, chargebacks)
// 下单 → 从 session payload 拿到期望链上金额(base+唯一尾数),喂给假 TronGrid。
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{
diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go
index f03ba1e..d579dce 100644
--- a/internal/gateway/gateway.go
+++ b/internal/gateway/gateway.go
@@ -39,19 +39,22 @@ type WebhookEnqueuer interface {
}
type Gateway struct {
- orders *store.OrderStore
- refunds *store.RefundStore
- providers *provider.Registry
- picker accounts.Picker
- products ProductResolver
- webhook WebhookEnqueuer
- region string
+ orders *store.OrderStore
+ refunds *store.RefundStore
+ providers *provider.Registry
+ picker accounts.Picker
+ products ProductResolver
+ webhook WebhookEnqueuer
+ region string
+ subs *store.SubscriptionStore
+ chargebacks *store.ChargebackStore
}
func New(orders *store.OrderStore, refunds *store.RefundStore, providers *provider.Registry, picker accounts.Picker,
- products ProductResolver, webhook WebhookEnqueuer, region string) *Gateway {
+ products ProductResolver, webhook WebhookEnqueuer, region string, subs *store.SubscriptionStore,
+ chargebacks *store.ChargebackStore) *Gateway {
return &Gateway{orders: orders, refunds: refunds, providers: providers, picker: picker,
- products: products, webhook: webhook, region: region}
+ products: products, webhook: webhook, region: region, subs: subs, chargebacks: chargebacks}
}
type CreateOrderInput struct {
diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go
index b2a43a4..e608aad 100644
--- a/internal/gateway/gateway_test.go
+++ b/internal/gateway/gateway_test.go
@@ -35,7 +35,15 @@ func (stubResolver) Resolve(sku, currency string) (int64, string, string, error)
type spyEnqueuer struct {
calls []map[string]any
- failNext bool // 置 true 模拟 outbox 入队失败(settle 崩溃窗口测试用)
+ failNext bool // 置 true 模拟 outbox 入队失败(settle 崩溃窗口测试用),命中后消费一次即清零。
+ // failOnEventType 非空时,下一次遇到该 event_type 的入队调用失败一次(消费后清空)——用于
+ // 测试"同一 HandleCallback 内先后两次 Enqueue,只让第二次(如 subscription.created)失败,
+ // 第一次(如 payment.succeeded)先成功"这类场景,failNext 做不到按 event_type 区分。
+ failOnEventType string
+ seen map[string]bool // 镜像真实 WebhookStore.EnqueueDelivery 的 (out_trade_no,event_type,
+ // refund_id) 唯一键 ON CONFLICT DO NOTHING:重复 key 静默 no-op(不追加 calls,也不算失败)。
+ // 修复"重投补入队自愈"后,业务代码会在 duplicate/重投分支也调用 Enqueue,若 spy 仍是无脑
+ // 计数器就会把 outbox 天然幂等的重复行误判成"多发了一次 webhook",这里镜像真实幂等语义。
}
func (s *spyEnqueuer) Enqueue(outTradeNo, bizSystem, eventType, refundID string, data map[string]any) error {
@@ -43,6 +51,18 @@ func (s *spyEnqueuer) Enqueue(outTradeNo, bizSystem, eventType, refundID string,
s.failNext = false
return errors.New("outbox down")
}
+ if s.failOnEventType != "" && eventType == s.failOnEventType {
+ s.failOnEventType = ""
+ return errors.New("outbox down")
+ }
+ key := outTradeNo + "|" + eventType + "|" + refundID
+ if s.seen == nil {
+ s.seen = make(map[string]bool)
+ }
+ if s.seen[key] {
+ return nil // 幂等 no-op,行已存在
+ }
+ s.seen[key] = true
s.calls = append(s.calls, data)
return nil
}
@@ -52,6 +72,8 @@ func newGateway(t *testing.T) (*gateway.Gateway, *fake.Provider, *spyEnqueuer, *
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
+ subs := store.NewSubscriptionStore(db)
+ chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
fp := fake.New()
preg.Register(fp)
@@ -62,7 +84,7 @@ func newGateway(t *testing.T) (*gateway.Gateway, *fake.Provider, *spyEnqueuer, *
})
picker := accounts.NewRouter(areg, nil, nil) // 默认 round_robin
spy := &spyEnqueuer{}
- g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global")
+ g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global", subs, chargebacks)
return g, fp, spy, orders
}
diff --git a/internal/gateway/settle.go b/internal/gateway/settle.go
index 2526af5..6f594b4 100644
--- a/internal/gateway/settle.go
+++ b/internal/gateway/settle.go
@@ -96,7 +96,9 @@ func (g *Gateway) enqueuePaymentSucceeded(att *model.Attempt, paidAt time.Time)
}
// HandleCallback runs a channel's raw callback through its Provider.VerifyCallback
-// (验签/解析封死在渠道内) then settles the normalized event.
+// (验签/解析封死在渠道内) then dispatches the normalized event by Kind(设计 §5):
+// 一次性/首期支付走既有 Settle(零 fork);订阅续费/催收/取消/拒付各有专属处理器
+// (Task 4/5/6,本 Task 先占 stub 保证独立可编译)。
func (g *Gateway) HandleCallback(ctx context.Context, method string, in provider.CallbackInput) (SettleResult, error) {
prov, err := g.providers.Get(method)
if err != nil {
@@ -106,7 +108,157 @@ func (g *Gateway) HandleCallback(ctx context.Context, method string, in provider
if err != nil {
return SettleNotFound, err
}
- return g.Settle(ctx, ev)
+ switch ev.Kind {
+ case provider.EventSubscriptionRenewal:
+ return g.settleRenewal(ctx, method, ev) // Task 4
+ case provider.EventSubscriptionPastDue:
+ return g.markSubscriptionPastDue(ctx, method, ev) // Task 5
+ case provider.EventSubscriptionCanceled:
+ return g.settleSubscriptionCanceled(ctx, method, ev) // Task 5
+ case provider.EventChargeback:
+ return g.recordChargeback(ctx, method, ev) // Task 6
+ default: // EventPayment:一次性 / 订阅首期
+ res, serr := g.Settle(ctx, ev)
+ // 门必须按结算结果开,不能只看 serr==nil:Settle 对非成功状态(如 Stripe
+ // checkout.session.completed 的 payment_status=unpaid 异步支付)返回
+ // (SettleIgnored, nil)——serr 为 nil 但订单并未结算,不能诞生订阅。
+ // Duplicate = 成功结算的重放(已 paid/已终态),安全且保证重投幂等路径仍能补建订阅行。
+ if (res == SettleProcessed || res == SettleDuplicate) && ev.SubscriptionRef != "" {
+ if aerr := g.onSubscriptionActivated(ctx, ev); aerr != nil {
+ return SettleFailed, aerr // 诞生订阅失败可重试(Stripe 重投)
+ }
+ }
+ return res, serr
+ }
+}
+
+// --- Task 6 处理器占位(Task 4 续费/Task 5 past_due+取消已替换实现,见 subscription.go)。
+
+// settleRenewal 处理续费 invoice.paid(设计 §5/§4 决策记录):每期铸独立 renewal OrderV2
+// (out_trade_no = 首购单号 + "-r-" + invoice id),建即 paid(续费不经收银台,无 pending 中间态)。
+// 幂等靠 renewal attempt 的 (channel,provider_ref=invoice.ID) 唯一索引 + renewal order 的
+// out_trade_no 唯一索引双保险。
+//
+// 入队纪律(与文件头 Settle 的顺序不变量同根同源,但形态不同——续费建单是"单步已 paid",
+// 没有 Settle 那种"翻转严格发生在成功入队之后"的两段式可依赖):**duplicate 分支(created=
+// false)也必须尝试入队**,不能像旧实现那样直接 return。理由:首次入队若瞬时失败,订单/attempt
+// 已在同一事务内落为 paid(不回滚,幂等键护着),而 outbox 从未有行;Stripe 拿不到 200 会重投
+// 同一 invoice.paid,此时 created 必为 false——若 duplicate 分支不入队,该续费通知永久丢失
+// (SyncPendingAttempts 也救不了:续费 attempt 生来就是 AttemptPaid,不在 pending 轮询范围)。
+// outbox 唯一键 (out_trade_no,event_type,refund_id) 上 ON CONFLICT DO NOTHING 天然幂等:行已
+// 存在则本次 no-op,行曾丢失则本次补建——重投即自愈,任何一分支入队失败都仍返回 SettleFailed
+// 交渠道再重投。
+func (g *Gateway) settleRenewal(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
+ // channel = 触发本次回调的 method(与 markSubscriptionPastDue/recordChargeback 同式);
+ // 订阅诞生时 Subscription.Channel 落的正是 att.Channel=method,查询须对齐,不能硬编码字面量
+ // "stripe"(测试固定用 "substripe" 注册子供应商,生产 Stripe 适配器 Method()="stripe")。
+ sub, err := g.subs.GetByProviderRef(method, ev.SubscriptionRef)
+ if err != nil {
+ if errors.Is(err, store.ErrSubNotFound) {
+ log.Printf("[renewal] 未知订阅 provider_ref=%s(未诞生/已清理),忽略", ev.SubscriptionRef)
+ return SettleIgnored, nil
+ }
+ return SettleFailed, err
+ }
+ paidAt := time.Now()
+ if ev.PaidAt != nil {
+ paidAt = *ev.PaidAt
+ }
+ renewalNo := sub.OutTradeNo + "-r-" + ev.InvoiceRef
+ created, err := g.orders.CreateRenewalPaid(
+ &model.OrderV2{
+ OutTradeNo: renewalNo, BizSystem: sub.BizSystem, BizRef: sub.BizRef, BizCode: sub.BizCode,
+ Subject: "续费", AmountMinor: ev.PaidAmountMinor, Currency: ev.PaidCurrency,
+ Status: model.OrderPaidV2, PaidAt: &paidAt,
+ },
+ &model.Attempt{
+ OutTradeNo: renewalNo, Channel: sub.Channel, Provider: sub.Channel, ProviderRef: ev.InvoiceRef,
+ AmountMinor: ev.PaidAmountMinor, Currency: ev.PaidCurrency, Status: model.AttemptPaid, PaidAt: &paidAt,
+ })
+ if err != nil {
+ return SettleFailed, err
+ }
+ // 续费成功即恢复/维持 active,刷新续费锚点(period_end 最小可行取 paidAt+30d;精确值后续从 invoice.period_end 下发)。
+ nextEnd := paidAt.Add(30 * 24 * time.Hour)
+ if _, err := g.subs.Activate(sub.SubID, &nextEnd); err != nil {
+ return SettleFailed, err
+ }
+ // 无论 created 与否都尝试入队(见函数注释的入队纪律);独立收款(BizSystem=="")无下游可发,跳过。
+ if sub.BizSystem != "" {
+ if err := g.enqueueRenewed(sub, renewalNo, ev, paidAt); err != nil {
+ return SettleFailed, err // 已建单的事实不回滚(幂等键护着);渠道重投时补入队
+ }
+ }
+ if !created {
+ return SettleDuplicate, nil // 重投:订单/attempt 已在(幂等 no-op),outbox 已补齐
+ }
+ return SettleProcessed, nil // 首过(含无业务方的独立收款,对齐 enqueuePaymentSucceeded 语义)
+}
+
+// enqueueRenewed 组 subscription.renewed 领域 payload 并幂等入队(抽出供 settleRenewal 的
+// created/duplicate 两分支共用,避免复制两份 payload 构造)。
+func (g *Gateway) enqueueRenewed(sub *model.Subscription, renewalNo string, ev *provider.PaidEvent, paidAt time.Time) error {
+ return g.webhook.Enqueue(renewalNo, sub.BizSystem, EvtSubscriptionRenewed, "", map[string]any{
+ "event_type": EvtSubscriptionRenewed, "out_trade_no": renewalNo, "sub_id": sub.SubID,
+ "biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode,
+ "amount_minor": ev.PaidAmountMinor, "currency": ev.PaidCurrency, "channel": sub.Channel,
+ "paid_at": paidAt.Format(time.RFC3339),
+ })
+}
+
+// recordChargeback 处理入站 charge.dispute.created(P8 Task6,设计 §6「钱到账不可逆」的
+// 例外形态)。只 Stripe(卡)有此语义;alipay/微信本轮无拒付流,crypto 收款永无
+// chargeback——三者均不产出 EventChargeback,本函数只会被 stripe adapter 触发。
+//
+// 决策记录:不自动回收权益(§6,业务方裁量),只做三件事——①落 Chargeback(幂等 by
+// dispute_ref,DisputeRef 重投 no-op,不双记)②给原订单打 Disputed 标(不改状态机)
+// ③入队 chargeback.received 给业务方自行冲正。out_trade_no 解析失败(订阅拒付/查单失败)
+// 时仍落 Chargeback 留痕 + log 告警,但不阻断、不转发(无法定位业务单)。
+//
+// 入队不按 created 分叉(与 settleRenewal/finalizeCanceled/markSubscriptionPastDue 同型的
+// P8 反纪律修法):Chargeback.Create 与 Enqueue 是两次独立写,不在同一事务——若首次 Create
+// 成功但 Enqueue 瞬时失败,调用方(HandleCallback)拿到 err 后 Stripe 会重投同一
+// charge.dispute.created,此时 created 必为 false;若像"created=false→直接 return
+// SettleDuplicate"那样跳过下面的打标+入队,chargeback.received 通知永久丢失、Disputed
+// 标也永远打不上。改为无论 created 与否都走完打标+入队,outbox 唯一键 ON CONFLICT DO
+// NOTHING + MarkDisputed 条件 UPDATE 天然双重幂等——重投即自愈,不会双记/双发。
+func (g *Gateway) recordChargeback(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
+ created, err := g.chargebacks.Create(&model.Chargeback{
+ DisputeRef: ev.DisputeRef, OutTradeNo: ev.OutTradeNo, Channel: method,
+ ProviderPaymentRef: ev.ProviderPaymentRef, AmountMinor: ev.PaidAmountMinor,
+ Currency: ev.PaidCurrency, Reason: ev.Reason, Status: "received",
+ })
+ if err != nil {
+ return SettleFailed, err
+ }
+ result := SettleProcessed
+ if !created {
+ result = SettleDuplicate // 拒付重投:Chargeback 已记录过,但仍需补齐下面的打标/入队(见函数注释)
+ }
+ if ev.OutTradeNo == "" {
+ log.Printf("[chargeback] dispute=%s 无法定位业务单(订阅/无 metadata),已记录未转发", ev.DisputeRef)
+ return result, nil
+ }
+ o, err := g.orders.GetOrder(ev.OutTradeNo)
+ if err != nil {
+ log.Printf("[chargeback] dispute=%s out_trade_no=%s 查单失败: %v", ev.DisputeRef, ev.OutTradeNo, err)
+ return result, nil // 已记录 chargeback;定位失败不阻断
+ }
+ if _, err := g.orders.MarkDisputed(o.OutTradeNo); err != nil { // 打标不改状态机;条件 UPDATE 幂等
+ return SettleFailed, err
+ }
+ if o.BizSystem == "" {
+ return result, nil
+ }
+ if err := g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, EvtChargebackReceived, "", map[string]any{
+ "event_type": EvtChargebackReceived, "out_trade_no": o.OutTradeNo, "dispute_ref": ev.DisputeRef,
+ "biz_system": o.BizSystem, "biz_ref": o.BizRef, "product_biz_code": o.BizCode,
+ "amount_minor": ev.PaidAmountMinor, "currency": ev.PaidCurrency, "reason": ev.Reason,
+ "received_at": time.Now().Format(time.RFC3339),
+ }); err != nil {
+ return SettleFailed, err
+ }
+ return result, nil
}
// SyncPendingAttempts polls every pending attempt via its Provider.Query and
diff --git a/internal/gateway/settle_test.go b/internal/gateway/settle_test.go
index 5faf227..04713c5 100644
--- a/internal/gateway/settle_test.go
+++ b/internal/gateway/settle_test.go
@@ -138,6 +138,8 @@ func TestSettleTransientReadErrorIsFailed(t *testing.T) {
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
+ subs := store.NewSubscriptionStore(db)
+ chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
fp := fake.New()
preg.Register(fp)
@@ -146,7 +148,7 @@ func TestSettleTransientReadErrorIsFailed(t *testing.T) {
})
picker := accounts.NewRouter(areg, nil, nil)
spy := &spyEnqueuer{}
- g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global")
+ g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global", subs, chargebacks)
ctx := context.Background()
g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
diff --git a/internal/gateway/subscription.go b/internal/gateway/subscription.go
new file mode 100644
index 0000000..930ee07
--- /dev/null
+++ b/internal/gateway/subscription.go
@@ -0,0 +1,278 @@
+package gateway
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/wangjia/pay/internal/model"
+ "github.com/wangjia/pay/internal/provider"
+ "github.com/wangjia/pay/internal/store"
+ "github.com/wangjia/pay/internal/util"
+
+ "github.com/wangjia/pay/internal/accounts"
+)
+
+// webhook event 常量集中在 internal/gateway(Task 7 收敛业务方声明)。
+const (
+ EvtPaymentSucceeded = "payment.succeeded"
+ EvtSubscriptionCreated = "subscription.created"
+ EvtSubscriptionRenewed = "subscription.renewed"
+ EvtSubscriptionPastDue = "subscription.past_due"
+ EvtSubscriptionCanceled = "subscription.canceled"
+ EvtChargebackReceived = "chargeback.received"
+)
+
+type CreateSubscriptionInput struct {
+ SKU string
+ Method string
+ BizSystem string
+ BizRef string
+ ReturnURL string
+}
+
+type SubscriptionResult struct {
+ SubID string `json:"sub_id"`
+ OrderNo string `json:"order_no"`
+ Session SessionView `json:"session"`
+}
+
+// CreateSubscription 解析套餐权威金额 → 选 stripe 账户(须实现 SubscriptionProvider 且
+// SupportsRecurring)→ 落 pending 首购 OrderV2 + Attempt → 返回订阅 Checkout redirect。
+// 订阅在首期支付回调时诞生(见 onSubscriptionActivated)。
+func (g *Gateway) CreateSubscription(ctx context.Context, in CreateSubscriptionInput) (*SubscriptionResult, error) {
+ prov, err := g.providers.Get(in.Method)
+ if err != nil {
+ return nil, err
+ }
+ caps := prov.Capabilities()
+ subProv, ok := prov.(provider.SubscriptionProvider)
+ if !ok || !caps.SupportsRecurring {
+ return nil, provider.ErrNotSupported
+ }
+ if len(caps.SettleCurrencies) == 0 {
+ return nil, ErrNoSettleCurrency
+ }
+ currency := caps.SettleCurrencies[0]
+ amount, subject, bizCode, err := g.products.Resolve(in.SKU, currency)
+ if err != nil {
+ return nil, err // ErrProductNotFound(含"该币种无价")
+ }
+ outTradeNo := util.NewOutTradeNo("pay")
+ acct, err := g.picker.Pick(in.Method, g.region, accounts.PickHint{OutTradeNo: outTradeNo, AmountMinor: amount})
+ if err != nil {
+ if errors.Is(err, accounts.ErrNoAccount) {
+ return nil, ErrNoAccount
+ }
+ return nil, err
+ }
+ // SubID 确定性派生自 out_trade_no(与 onSubscriptionActivated 一致,消除双号)。
+ subID := "SUB-" + outTradeNo
+ sess, err := subProv.CreateSubscriptionCheckout(ctx, provider.CreateRequest{
+ OutTradeNo: outTradeNo, Subject: subject, AmountMinor: amount, Currency: currency,
+ Account: acct, ReturnURL: in.ReturnURL, Metadata: map[string]string{"pay_sub_id": subID},
+ })
+ if err != nil {
+ return nil, fmt.Errorf("gateway.CreateSubscription: %w", err)
+ }
+ order := &model.OrderV2{
+ OutTradeNo: outTradeNo, BizSystem: in.BizSystem, BizRef: in.BizRef, BizCode: bizCode,
+ Subject: subject, AmountMinor: amount, Currency: currency, Status: model.OrderPendingV2,
+ }
+ if err := g.orders.CreateOrder(order); err != nil {
+ return nil, err
+ }
+ att := &model.Attempt{
+ OutTradeNo: outTradeNo, Channel: in.Method, AccountID: acct.AccountID, Provider: in.Method,
+ ProviderRef: sess.ProviderRef, RenderType: string(sess.RenderType),
+ AmountMinor: amount, Currency: currency, Status: model.AttemptPending, ExpiresAt: sess.ExpiresAt,
+ }
+ if err := g.orders.CreateAttempt(att); err != nil {
+ return nil, err
+ }
+ return &SubscriptionResult{
+ SubID: subID, OrderNo: outTradeNo,
+ Session: SessionView{RenderType: string(sess.RenderType), Payload: sess.Payload, ExpiresAt: sess.ExpiresAt},
+ }, nil
+}
+
+// onSubscriptionActivated 幂等诞生订阅 + 入队 subscription.created。首期支付回调触发。
+// created 事件走首购 order 的 out_trade_no + event_type=subscription.created(唯一键天然不撞 payment.succeeded)。
+// 入队失败原样返回 err,调用方(HandleCallback)映射 SettleFailed,驱动 Stripe 侧重投自愈
+// (见下方入队处注释)。
+func (g *Gateway) onSubscriptionActivated(ctx context.Context, ev *provider.PaidEvent) error {
+ att, err := g.orders.AttemptByProviderRef(ev.ProviderRef)
+ if err != nil {
+ return nil // 首期会话未落库(不该发生);交由 Settle 侧日志,订阅侧静默
+ }
+ o, err := g.orders.GetOrder(att.OutTradeNo)
+ if err != nil {
+ return err
+ }
+ if !o.Status.Settled() {
+ // 纵深防御:HandleCallback 的门(res==Processed/Duplicate)已挡住未结算订单,
+ // 这里再核一遍订单状态,防止未来调用点绕过门直接调用本函数。
+ log.Printf("[subscription] onSubscriptionActivated 订单未结算 out_trade_no=%s status=%s,拒绝激活", o.OutTradeNo, o.Status)
+ return nil
+ }
+ subID := "SUB-" + att.OutTradeNo // 与 CreateSubscription 同式派生 → 重投算出同一 SubID,Create 幂等
+ // created(首次诞生 true / 重投幂等 false)不再驱动入队分叉,见下方入队处注释——不必单独取用。
+ _, err = g.subs.Create(&model.Subscription{
+ SubID: subID, OutTradeNo: o.OutTradeNo, BizSystem: o.BizSystem, BizRef: o.BizRef, BizCode: o.BizCode,
+ Channel: att.Channel, ProviderSubRef: ev.SubscriptionRef, RecurringKind: provider.RecurringKindGatewayScheduled,
+ AmountMinor: o.AmountMinor, Currency: o.Currency, Status: model.SubActive,
+ })
+ if err != nil {
+ return err
+ }
+ if o.BizSystem == "" {
+ return nil // 独立收款无业务方回调
+ }
+ // 入队不按 created 分叉(P8 Task4/5 审计发现的同型反纪律,同 commit 一并修——settleRenewal/
+ // finalizeCanceled/markSubscriptionPastDue 已是这个写法):若首次 Create 成功但入队瞬时失败,
+ // 调用方(HandleCallback)拿到 err 后 Stripe 会重投同一 checkout.session.completed;此时
+ // subs.Create 已幂等、created=false,若像旧实现那样 `!created→return nil` 直接跳过,outbox
+ // 永远补不上这一行,subscription.created 通知永久丢失。改为无论 created 与否都尝试入队,
+ // outbox 唯一键 ON CONFLICT DO NOTHING 天然幂等:行已存在则 no-op,行曾丢失则本次补建。
+ return g.webhook.Enqueue(o.OutTradeNo, o.BizSystem, EvtSubscriptionCreated, "", map[string]any{
+ "event_type": EvtSubscriptionCreated, "out_trade_no": o.OutTradeNo, "sub_id": subID,
+ "biz_system": o.BizSystem, "biz_ref": o.BizRef, "product_biz_code": o.BizCode,
+ "amount_minor": o.AmountMinor, "currency": o.Currency, "channel": att.Channel,
+ "created_at": time.Now().Format(time.RFC3339),
+ })
+}
+
+// SubscriptionView — GET /api/v2/subscriptions/:sub_id 返回视图(状态 + 续费锚点)。
+type SubscriptionView struct {
+ SubID string `json:"sub_id"`
+ Status string `json:"status"`
+ CurrentPeriodEnd *time.Time `json:"current_period_end,omitempty"`
+ CanceledAt *time.Time `json:"canceled_at,omitempty"`
+}
+
+func (g *Gateway) GetSubscription(subID string) (*SubscriptionView, error) {
+ sub, err := g.subs.GetBySubID(subID)
+ if err != nil {
+ return nil, err // store.ErrSubNotFound
+ }
+ return &SubscriptionView{
+ SubID: sub.SubID, Status: string(sub.Status),
+ CurrentPeriodEnd: sub.CurrentPeriodEnd, CanceledAt: sub.CanceledAt,
+ }, nil
+}
+
+// CancelSubscription 主动取消(POST /api/v2/subscriptions/:sub_id/cancel):查订阅 → 已终态则
+// 幂等 no-op(不再打渠道,避免重复取消命中渠道 400)→ 渠道侧取消 → 本地翻 canceled + 入队
+// subscription.canceled。Stripe 随后异步发 customer.subscription.deleted,入站处理器
+// (settleSubscriptionCanceled)再次调用同一 finalizeCanceled,两路收敛同一终态、天然幂等。
+//
+// 本地 active/past_due 但渠道已先行取消(dashboard 手工取消 / 竞态下未消费的 deleted webhook
+// 抢先落地)时,渠道会拒绝二次 Cancel;adapter 将其识别并 wrap 成 provider.ErrSubAlreadyCanceled
+// (errors.Is 可判)。这不是"取消失败"而是"取消事实已在渠道成立"——按此哨兵走与入站 webhook
+// 相同的本地收敛(finalizeCanceled),而非把渠道拒绝原样透传成 500。与随后可能补投的
+// customer.subscription.deleted 双路收敛于同一 finalizeCanceled:MarkCanceled 是状态条件
+// UPDATE(只在非 canceled 时翻转)+ outbox 唯一键 ON CONFLICT DO NOTHING,谁先谁后都幂等。
+func (g *Gateway) CancelSubscription(ctx context.Context, subID string) error {
+ sub, err := g.subs.GetBySubID(subID)
+ if err != nil {
+ return err // store.ErrSubNotFound
+ }
+ if sub.Status == model.SubCanceled {
+ return nil // 已取消(重复调用/webhook 已先到):幂等 no-op,不再打渠道
+ }
+ prov, err := g.providers.Get(sub.Channel)
+ if err != nil {
+ return err
+ }
+ if sp, ok := prov.(provider.SubscriptionProvider); ok {
+ if err := sp.CancelSubscription(ctx, sub.ProviderSubRef); err != nil && !errors.Is(err, provider.ErrSubAlreadyCanceled) {
+ return err
+ }
+ }
+ return g.finalizeCanceled(sub)
+}
+
+// settleSubscriptionCanceled 处理入站 customer.subscription.deleted:反查订阅 → finalizeCanceled。
+// 未知 provider_sub_ref(订阅未诞生/已清理)忽略,不报错(与 settleRenewal 同惯例)。
+func (g *Gateway) settleSubscriptionCanceled(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
+ sub, err := g.subs.GetByProviderRef(method, ev.SubscriptionRef)
+ if err != nil {
+ if errors.Is(err, store.ErrSubNotFound) {
+ return SettleIgnored, nil
+ }
+ return SettleFailed, err
+ }
+ if err := g.finalizeCanceled(sub); err != nil {
+ return SettleFailed, err
+ }
+ return SettleProcessed, nil
+}
+
+// finalizeCanceled 是取消的唯一落地点(主动取消 API + 入站 webhook 两路收敛于此):
+// MarkCanceled 幂等翻转 canceled,随后(有业务方时)入队 subscription.canceled。
+//
+// 入队不按 flipped 分叉(P8 Task4 审计发现的同型反纪律,同 commit 一并修):若第一次翻转
+// 成功但入队瞬时失败,调用方拿到 err 后重投——此时 MarkCanceled 已是"已取消"故 flipped=
+// false,若像旧实现那样 `!flipped→return nil` 直接跳过,outbox 永远补不上这一行,通知永久
+// 丢失。改为无论 flipped 与否都尝试入队,outbox 唯一键 ON CONFLICT DO NOTHING 天然幂等:
+// 行已存在则 no-op,行曾丢失则本次补建。canceled 每订阅只发生一次,不存在"同订阅多次 canceled
+// 真被吞"的歧义(不同于 past_due 可能有多次不同 invoice 失败),无条件入队是安全的。
+//
+// outbox 键取真实首购单号 sub.OutTradeNo(不用合成后缀键):该单已 paid,Notifier 投递门禁
+// (orderPaid)天然放行;event_type=subscription.canceled 与该单已有的 payment.succeeded /
+// subscription.created 行不同 event_type,唯一键 (out_trade_no,event_type,refund_id) 不撞。
+func (g *Gateway) finalizeCanceled(sub *model.Subscription) error {
+ if _, err := g.subs.MarkCanceled(sub.SubID); err != nil {
+ return err
+ }
+ if sub.BizSystem == "" {
+ return nil // 独立收款无业务方回调
+ }
+ return g.webhook.Enqueue(sub.OutTradeNo, sub.BizSystem, EvtSubscriptionCanceled, "", map[string]any{
+ "event_type": EvtSubscriptionCanceled, "out_trade_no": sub.OutTradeNo, "sub_id": sub.SubID,
+ "biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode,
+ "canceled_at": time.Now().Format(time.RFC3339),
+ })
+}
+
+// markSubscriptionPastDue 处理入站 invoice.payment_failed:active→past_due(MarkPastDue 只在
+// 当前 active 时翻转,已 past_due/已 canceled 不动),入队 subscription.past_due 供业务方提醒
+// 用户换卡。下期 invoice.paid 成功由 settleRenewal 的 Activate 自动恢复 active。
+//
+// 入队不按 flipped 分叉(P8 Task4 审计发现的同型反纪律,同 commit 一并修):首次翻转成功但
+// 入队瞬时失败时,重投这个同一事件会命中 flipped=false(已是 past_due),旧实现直接 return
+// 会让这条 past_due 通知永久丢失。改为无论 flipped 与否都尝试入队,outbox 唯一键 ON CONFLICT
+// DO NOTHING 天然幂等——重投即自愈。
+//
+// outbox 键取真实首购单号 sub.OutTradeNo(理由同 finalizeCanceled):Task5 最小可行接受
+// "同订阅多次 past_due(不同 invoice 各自失败)只报首次"这一语义限制不变——第二次真实失败的
+// 行同样撞 (out_trade_no,event_type) 唯一键、幂等 no-op,Task 7 视需要再引入合成键 + Notifier
+// 门禁旁路;这里修的只是"同一事件重投也补不回入队"这一段自愈缺口,不扩大既有限制的范围。
+func (g *Gateway) markSubscriptionPastDue(ctx context.Context, method string, ev *provider.PaidEvent) (SettleResult, error) {
+ sub, err := g.subs.GetByProviderRef(method, ev.SubscriptionRef)
+ if err != nil {
+ if errors.Is(err, store.ErrSubNotFound) {
+ return SettleIgnored, nil
+ }
+ return SettleFailed, err
+ }
+ flipped, err := g.subs.MarkPastDue(sub.Channel, sub.ProviderSubRef)
+ if err != nil {
+ return SettleFailed, err
+ }
+ if sub.BizSystem != "" {
+ if err := g.webhook.Enqueue(sub.OutTradeNo, sub.BizSystem, EvtSubscriptionPastDue, "", map[string]any{
+ "event_type": EvtSubscriptionPastDue, "out_trade_no": sub.OutTradeNo, "sub_id": sub.SubID,
+ "biz_system": sub.BizSystem, "biz_ref": sub.BizRef, "product_biz_code": sub.BizCode,
+ "failed_at": time.Now().Format(time.RFC3339),
+ }); err != nil {
+ return SettleFailed, err
+ }
+ }
+ if !flipped {
+ return SettleDuplicate, nil
+ }
+ return SettleProcessed, nil
+}
diff --git a/internal/gateway/subscription_test.go b/internal/gateway/subscription_test.go
new file mode 100644
index 0000000..9eba499
--- /dev/null
+++ b/internal/gateway/subscription_test.go
@@ -0,0 +1,947 @@
+package gateway_test
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/wangjia/pay/config"
+ "github.com/wangjia/pay/internal/accounts"
+ "github.com/wangjia/pay/internal/gateway"
+ "github.com/wangjia/pay/internal/model"
+ "github.com/wangjia/pay/internal/provider"
+ "github.com/wangjia/pay/internal/store"
+)
+
+// fakeSubProvider 实现 provider.SubscriptionProvider:创建订阅 Checkout 返回固定 session。
+// VerifyCallback 不经它——测试直接构造 PaidEvent 走 Settle/HandleCallback 的 default 分支
+// 时也走它(HandleCallback 仍需先过 VerifyCallback 才能拿到 ev),这里让它原样透传注入的
+// JSON 回调体(与 fake.Provider.VerifyCallback 同构,便于测试直接摆事件)。
+type fakeSubProvider struct {
+ sessionRef string
+ cancelCalls []string // 记录 CancelSubscription 收到的 providerSubRef,断言调用次数/参数
+ cancelErr error // 非空时 CancelSubscription 返回该 err(模拟渠道对"已取消订阅"回 400)
+}
+
+func (p *fakeSubProvider) Method() string { return "substripe" }
+
+func (p *fakeSubProvider) Capabilities() provider.Capabilities {
+ return provider.Capabilities{
+ RenderTypes: []provider.RenderType{provider.RenderRedirect},
+ SupportsRecurring: true,
+ RecurringKind: provider.RecurringKindGatewayScheduled,
+ SettleCurrencies: []string{"USD"},
+ Regions: []string{"global"},
+ }
+}
+
+func (p *fakeSubProvider) Create(_ context.Context, _ provider.CreateRequest) (*provider.Session, error) {
+ return nil, errors.New("fakeSubProvider: one-time Create not used")
+}
+
+func (p *fakeSubProvider) CreateSubscriptionCheckout(_ context.Context, req provider.CreateRequest) (*provider.Session, error) {
+ return &provider.Session{
+ ProviderRef: p.sessionRef,
+ RenderType: provider.RenderRedirect,
+ Payload: map[string]any{"url": "https://checkout.example/" + p.sessionRef, "amount_minor": req.AmountMinor},
+ }, nil
+}
+
+func (p *fakeSubProvider) CancelSubscription(_ context.Context, providerSubRef string) error {
+ p.cancelCalls = append(p.cancelCalls, providerSubRef)
+ return p.cancelErr
+}
+
+// VerifyCallback 直接把测试构造的 provider.PaidEvent JSON 反序列化透传回放,省去自建协议。
+func (p *fakeSubProvider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) {
+ var ev provider.PaidEvent
+ if err := json.Unmarshal(in.Raw, &ev); err != nil {
+ return nil, err
+ }
+ return &ev, nil
+}
+
+func (p *fakeSubProvider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
+ return &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending}, nil
+}
+
+func newSubGateway(t *testing.T) (*gateway.Gateway, *fakeSubProvider, *spyEnqueuer, *store.OrderStore, *store.SubscriptionStore) {
+ t.Helper()
+ db := model.OpenTestDB(t)
+ orders := store.NewOrderStore(db)
+ refunds := store.NewRefundStore(db)
+ subs := store.NewSubscriptionStore(db)
+ chargebacks := store.NewChargebackStore(db)
+ preg := provider.NewRegistry()
+ fp := &fakeSubProvider{sessionRef: "cs_test_sess1"}
+ preg.Register(fp)
+ areg := accounts.New([]config.AccountConfig{
+ {AccountID: "sub-a1", Channel: "substripe", Region: "global", Enabled: true, Weight: 1},
+ })
+ picker := accounts.NewRouter(areg, nil, nil)
+ spy := &spyEnqueuer{}
+ g := gateway.New(orders, refunds, preg, picker, stubSubResolver{}, spy, "global", subs, chargebacks)
+ return g, fp, spy, orders, subs
+}
+
+type stubSubResolver struct{}
+
+func (stubSubResolver) Resolve(sku, currency string) (int64, string, string, error) {
+ if sku != "pro_monthly" {
+ return 0, "", "", gateway.ErrProductNotFound
+ }
+ if currency != "USD" {
+ return 0, "", "", gateway.ErrProductNotFound
+ }
+ return 2999, "Pro 月付", "pro_monthly", nil
+}
+
+func TestCreateSubscriptionPipeline(t *testing.T) {
+ g, fp, _, orders, _ := newSubGateway(t)
+ res, err := g.CreateSubscription(context.Background(), gateway.CreateSubscriptionInput{
+ SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
+ })
+ if err != nil {
+ t.Fatalf("CreateSubscription: %v", err)
+ }
+ if res.SubID == "" || res.OrderNo == "" {
+ t.Fatalf("result = %+v", res)
+ }
+ if res.Session.RenderType != string(provider.RenderRedirect) {
+ t.Fatalf("session = %+v", res.Session)
+ }
+ o, err := orders.GetOrder(res.OrderNo)
+ if err != nil || o.Status != model.OrderPendingV2 || o.AmountMinor != 2999 || o.Currency != "USD" {
+ t.Fatalf("order = %+v, %v", o, err)
+ }
+ atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
+ if len(atts) != 1 || atts[0].ProviderRef != fp.sessionRef {
+ t.Fatalf("attempt = %+v", atts)
+ }
+}
+
+func TestSubscriptionActivationOnFirstPayment(t *testing.T) {
+ g, fp, spy, orders, subs := newSubGateway(t)
+ ctx := context.Background()
+ res, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
+ SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
+ })
+ if err != nil {
+ t.Fatalf("CreateSubscription: %v", err)
+ }
+
+ raw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
+ PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new",
+ })
+ if err != nil {
+ t.Fatalf("marshal event: %v", err)
+ }
+
+ // 首次:order 翻 paid + 订阅诞生 active + webhook 收到 2 条(payment.succeeded + subscription.created)。
+ result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil || result != gateway.SettleProcessed {
+ t.Fatalf("HandleCallback = %v, %v", result, err)
+ }
+ o, err := orders.GetOrder(res.OrderNo)
+ if err != nil || o.Status != model.OrderPaidV2 {
+ t.Fatalf("order after settle = %+v, %v", o, err)
+ }
+ sub, err := subs.GetByProviderRef("substripe", "sub_new")
+ if err != nil {
+ t.Fatalf("subscription not created: %v", err)
+ }
+ if sub.Status != model.SubActive || sub.SubID != res.SubID {
+ t.Fatalf("subscription = %+v, want active/%s", sub, res.SubID)
+ }
+ if len(spy.calls) != 2 {
+ t.Fatalf("webhook calls = %d, want 2: %+v", len(spy.calls), spy.calls)
+ }
+ sawPaymentSucceeded, sawSubCreated := false, false
+ for _, c := range spy.calls {
+ switch c["event_type"] {
+ case gateway.EvtPaymentSucceeded:
+ sawPaymentSucceeded = true
+ case gateway.EvtSubscriptionCreated:
+ sawSubCreated = true
+ if c["sub_id"] != res.SubID {
+ t.Fatalf("subscription.created sub_id = %v, want %s", c["sub_id"], res.SubID)
+ }
+ }
+ }
+ if !sawPaymentSucceeded || !sawSubCreated {
+ t.Fatalf("missing expected events: %+v", spy.calls)
+ }
+
+ // 重投同一 event → 幂等:订单/订阅不重复变动,webhook 不再新增。
+ result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil {
+ t.Fatalf("HandleCallback replay: %v", err)
+ }
+ if result2 != gateway.SettleDuplicate {
+ t.Fatalf("replay result = %v, want duplicate", result2)
+ }
+ if len(spy.calls) != 2 {
+ t.Fatalf("webhook calls after replay = %d, want still 2: %+v", len(spy.calls), spy.calls)
+ }
+ subAfter, err := subs.GetByProviderRef("substripe", "sub_new")
+ if err != nil {
+ t.Fatalf("subscription after replay: %v", err)
+ }
+ if subAfter.ID != sub.ID {
+ t.Fatalf("subscription duplicated on replay: %+v vs %+v", subAfter, sub)
+ }
+}
+
+// TestSettleRenewal 先经首期激活诞生订阅(provider_sub_ref=sub_new),再喂
+// EventSubscriptionRenewal:①新增 renewal OrderV2(paid,out_trade_no 含 -r-)
+// ②webhook 收到 subscription.renewed ③重投幂等(不双铸/不双发) ④订阅刷新 current_period_end、
+// 从 past_due 恢复 active。
+func TestSettleRenewal(t *testing.T) {
+ g, fp, spy, orders, subs := newSubGateway(t)
+ ctx := context.Background()
+ res, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
+ SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
+ })
+ if err != nil {
+ t.Fatalf("CreateSubscription: %v", err)
+ }
+ activateRaw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
+ PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new",
+ })
+ if err != nil {
+ t.Fatalf("marshal activate event: %v", err)
+ }
+ if _, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: activateRaw}); err != nil {
+ t.Fatalf("activate: %v", err)
+ }
+ spy.calls = nil // 只看续费产生的 webhook
+
+ // 续费成功前先把订阅打成 past_due,验证续费成功后能恢复 active(路径复用 Activate)。
+ if ok, err := subs.MarkPastDue("substripe", "sub_new"); err != nil || !ok {
+ t.Fatalf("MarkPastDue: ok=%v err=%v", ok, err)
+ }
+
+ renewalRaw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventSubscriptionRenewal, SubscriptionRef: "sub_new", InvoiceRef: "in_123",
+ PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded,
+ })
+ if err != nil {
+ t.Fatalf("marshal renewal event: %v", err)
+ }
+
+ result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
+ if err != nil || result != gateway.SettleProcessed {
+ t.Fatalf("HandleCallback renewal = %v, %v", result, err)
+ }
+
+ allOrders, err := orders.ListOrders("pangolin", "u-1", 10)
+ if err != nil {
+ t.Fatalf("ListOrders: %v", err)
+ }
+ var renewalOrder *model.OrderV2
+ for i := range allOrders {
+ if strings.Contains(allOrders[i].OutTradeNo, "-r-") {
+ renewalOrder = &allOrders[i]
+ }
+ }
+ if renewalOrder == nil {
+ t.Fatalf("renewal order not found: %+v", allOrders)
+ }
+ if renewalOrder.Status != model.OrderPaidV2 || renewalOrder.AmountMinor != 2999 || renewalOrder.Currency != "USD" {
+ t.Fatalf("renewal order = %+v", renewalOrder)
+ }
+ if !strings.HasSuffix(renewalOrder.OutTradeNo, "-r-in_123") {
+ t.Fatalf("renewal out_trade_no = %s, want suffix -r-in_123", renewalOrder.OutTradeNo)
+ }
+
+ if len(spy.calls) != 1 {
+ t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls)
+ }
+ renewedCall := spy.calls[0]
+ if renewedCall["event_type"] != gateway.EvtSubscriptionRenewed {
+ t.Fatalf("event_type = %v, want %s", renewedCall["event_type"], gateway.EvtSubscriptionRenewed)
+ }
+ if renewedCall["sub_id"] != res.SubID {
+ t.Fatalf("sub_id = %v, want %s", renewedCall["sub_id"], res.SubID)
+ }
+ if renewedCall["out_trade_no"] != renewalOrder.OutTradeNo {
+ t.Fatalf("out_trade_no = %v, want %s", renewedCall["out_trade_no"], renewalOrder.OutTradeNo)
+ }
+ if renewedCall["amount_minor"] != int64(2999) {
+ t.Fatalf("amount_minor = %v, want 2999", renewedCall["amount_minor"])
+ }
+
+ sub, err := subs.GetByProviderRef("substripe", "sub_new")
+ if err != nil {
+ t.Fatalf("GetByProviderRef: %v", err)
+ }
+ if sub.Status != model.SubActive {
+ t.Fatalf("subscription status after renewal = %v, want active(recovered from past_due)", sub.Status)
+ }
+ if sub.CurrentPeriodEnd == nil {
+ t.Fatalf("current_period_end not refreshed")
+ }
+
+ // 重投同一 invoice → 不双铸 renewal order、不双发 webhook。
+ result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
+ if err != nil {
+ t.Fatalf("HandleCallback renewal replay: %v", err)
+ }
+ if result2 != gateway.SettleDuplicate {
+ t.Fatalf("replay result = %v, want duplicate", result2)
+ }
+ ordersAfterReplay, err := orders.ListOrders("pangolin", "u-1", 10)
+ if err != nil {
+ t.Fatalf("ListOrders after replay: %v", err)
+ }
+ renewalCount := 0
+ for i := range ordersAfterReplay {
+ if strings.Contains(ordersAfterReplay[i].OutTradeNo, "-r-") {
+ renewalCount++
+ }
+ }
+ if renewalCount != 1 {
+ t.Fatalf("renewal order count after replay = %d, want 1", renewalCount)
+ }
+ if len(spy.calls) != 1 {
+ t.Fatalf("webhook calls after replay = %d, want still 1: %+v", len(spy.calls), spy.calls)
+ }
+}
+
+// TestSubscriptionNotActivatedOnUnpaidSession 复现 CRITICAL 发现:Stripe checkout.session.completed
+// 在异步支付方式下可能 payment_status=unpaid(映射为 PaidPending)先到达,此时 Settle 返回
+// SettleIgnored——订阅诞生钩子必须跟随结算结果关门,不能只看 serr==nil(nil error 但未结算)。
+// 断言:无 Subscription 行诞生、无 subscription.created 入队、订单仍 pending。
+func TestSubscriptionNotActivatedOnUnpaidSession(t *testing.T) {
+ g, fp, spy, orders, subs := newSubGateway(t)
+ ctx := context.Background()
+ res, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
+ SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
+ })
+ if err != nil {
+ t.Fatalf("CreateSubscription: %v", err)
+ }
+
+ // checkout.session.completed(异步支付,payment_status=unpaid)归一化为 PaidPending。
+ raw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidPending,
+ PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new",
+ })
+ if err != nil {
+ t.Fatalf("marshal event: %v", err)
+ }
+
+ result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil {
+ t.Fatalf("HandleCallback: %v", err)
+ }
+ if result != gateway.SettleIgnored {
+ t.Fatalf("result = %v, want ignored", result)
+ }
+
+ o, err := orders.GetOrder(res.OrderNo)
+ if err != nil || o.Status != model.OrderPendingV2 {
+ t.Fatalf("order after unpaid callback = %+v, %v, want still pending", o, err)
+ }
+ if _, err := subs.GetByProviderRef("substripe", "sub_new"); !errors.Is(err, store.ErrSubNotFound) {
+ t.Fatalf("subscription should not exist for unpaid session, err = %v", err)
+ }
+ if len(spy.calls) != 0 {
+ t.Fatalf("webhook calls = %d, want 0(no premature subscription.created): %+v", len(spy.calls), spy.calls)
+ }
+}
+
+// TestSettleRenewalUnknownSubscription 未知 provider_sub_ref(订阅未诞生/已清理)→ 忽略,不报错。
+func TestSettleRenewalUnknownSubscription(t *testing.T) {
+ g, _, spy, _, _ := newSubGateway(t)
+ ctx := context.Background()
+ raw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventSubscriptionRenewal, SubscriptionRef: "sub_ghost", InvoiceRef: "in_ghost",
+ PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded,
+ })
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil {
+ t.Fatalf("HandleCallback: %v", err)
+ }
+ if result != gateway.SettleIgnored {
+ t.Fatalf("result = %v, want ignored", result)
+ }
+ if len(spy.calls) != 0 {
+ t.Fatalf("webhook calls = %d, want 0: %+v", len(spy.calls), spy.calls)
+ }
+}
+
+// activateSub 建订阅 + 喂首期支付事件,把订阅推进到 active,返回 (subID, providerSubRef, orderNo)。
+// providerSubRef 固定用 "sub_new"(下面取消/past_due 用例复用同一渠道订阅号反查)。
+func activateSub(t *testing.T, g *gateway.Gateway, fp *fakeSubProvider) (subID, providerSubRef, orderNo string) {
+ t.Helper()
+ ctx := context.Background()
+ res, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
+ SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
+ })
+ if err != nil {
+ t.Fatalf("CreateSubscription: %v", err)
+ }
+ raw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
+ PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new",
+ })
+ if err != nil {
+ t.Fatalf("marshal activate event: %v", err)
+ }
+ if _, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}); err != nil {
+ t.Fatalf("activate: %v", err)
+ }
+ return res.SubID, "sub_new", res.OrderNo
+}
+
+// TestCancelSubscriptionAPI 覆盖主动取消 API 路径:调渠道 CancelSubscription(带正确
+// providerSubRef)→ 本地翻 canceled → 入队 subscription.canceled(out_trade_no 用真实首购
+// 单号,supporting Notifier 投递门禁天然放行 —— 见 subscription.go finalizeCanceled 注释)。
+func TestCancelSubscriptionAPI(t *testing.T) {
+ g, fp, spy, _, subs := newSubGateway(t)
+ subID, providerSubRef, orderNo := activateSub(t, g, fp)
+ spy.calls = nil // 只看取消产生的 webhook
+
+ if err := g.CancelSubscription(context.Background(), subID); err != nil {
+ t.Fatalf("CancelSubscription: %v", err)
+ }
+ if len(fp.cancelCalls) != 1 || fp.cancelCalls[0] != providerSubRef {
+ t.Fatalf("cancelCalls = %+v, want [%s]", fp.cancelCalls, providerSubRef)
+ }
+ sub, err := subs.GetBySubID(subID)
+ if err != nil {
+ t.Fatalf("GetBySubID: %v", err)
+ }
+ if sub.Status != model.SubCanceled || sub.CanceledAt == nil {
+ t.Fatalf("subscription after cancel = %+v", sub)
+ }
+ if len(spy.calls) != 1 {
+ t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls)
+ }
+ c := spy.calls[0]
+ if c["event_type"] != gateway.EvtSubscriptionCanceled || c["sub_id"] != subID || c["out_trade_no"] != orderNo {
+ t.Fatalf("cancel webhook payload = %+v, want event_type=%s sub_id=%s out_trade_no=%s",
+ c, gateway.EvtSubscriptionCanceled, subID, orderNo)
+ }
+}
+
+// TestCancelSubscriptionIdempotentNoOp 重复调用主动取消 API:第二次调用命中 sub.Status
+// 已 canceled 的早退分支,绝不重新打渠道(渠道对已取消订阅二次 Cancel 会回 400 —— 调用层
+// 靠"先查本地终态"归一为幂等 no-op,压根不给渠道二次调用的机会),也不重复入队。
+func TestCancelSubscriptionIdempotentNoOp(t *testing.T) {
+ g, fp, spy, _, _ := newSubGateway(t)
+ subID, _, _ := activateSub(t, g, fp)
+ spy.calls = nil
+
+ if err := g.CancelSubscription(context.Background(), subID); err != nil {
+ t.Fatalf("cancel#1: %v", err)
+ }
+ if err := g.CancelSubscription(context.Background(), subID); err != nil {
+ t.Fatalf("cancel#2(重复调用应幂等 no-op,不应报错): %v", err)
+ }
+ if len(fp.cancelCalls) != 1 {
+ t.Fatalf("cancelCalls = %+v, want exactly 1(第二次不应打渠道)", fp.cancelCalls)
+ }
+ if len(spy.calls) != 1 {
+ t.Fatalf("webhook calls = %d, want 1(不重复入队)", len(spy.calls))
+ }
+}
+
+// TestCancelSubscriptionChannelAlreadyCanceledConverges 覆盖渠道已先行取消(dashboard 手工 /
+// 竞态下未消费的 deleted webhook 抢先落地)的失配场景:本地仍 active,adapter 打渠道拿到
+// provider.ErrSubAlreadyCanceled(此处用死脚手架 fp.cancelErr 模拟 stripe adapter 已 wrap 好的
+// 哨兵错误)——CancelSubscription 不应再报错(修复前会把这个 err 原样透传,handler 会映成 500
+// cancel_failed;修复后按哨兵走本地收敛),本地翻 canceled 且恰好入队一次 subscription.canceled。
+func TestCancelSubscriptionChannelAlreadyCanceledConverges(t *testing.T) {
+ g, fp, spy, _, subs := newSubGateway(t)
+ subID, providerSubRef, orderNo := activateSub(t, g, fp)
+ spy.calls = nil
+ fp.cancelErr = fmt.Errorf("%w: stripe simulated already-canceled", provider.ErrSubAlreadyCanceled)
+
+ if err := g.CancelSubscription(context.Background(), subID); err != nil {
+ t.Fatalf("CancelSubscription(渠道已取消场景应本地收敛,不应报错): %v", err)
+ }
+ if len(fp.cancelCalls) != 1 || fp.cancelCalls[0] != providerSubRef {
+ t.Fatalf("cancelCalls = %+v, want [%s](渠道确实被打过一次,只是回了'已取消'错误)", fp.cancelCalls, providerSubRef)
+ }
+ sub, err := subs.GetBySubID(subID)
+ if err != nil {
+ t.Fatalf("GetBySubID: %v", err)
+ }
+ if sub.Status != model.SubCanceled || sub.CanceledAt == nil {
+ t.Fatalf("subscription after cancel(渠道失配场景) = %+v, want locally canceled", sub)
+ }
+ if len(spy.calls) != 1 {
+ t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls)
+ }
+ c := spy.calls[0]
+ if c["event_type"] != gateway.EvtSubscriptionCanceled || c["sub_id"] != subID || c["out_trade_no"] != orderNo {
+ t.Fatalf("cancel webhook payload = %+v, want event_type=%s sub_id=%s out_trade_no=%s",
+ c, gateway.EvtSubscriptionCanceled, subID, orderNo)
+ }
+}
+
+// TestCancelSubscriptionWebhookThenAPIConverge 覆盖反向竞态:入站 customer.subscription.deleted
+// 先到(本地先翻 canceled + 发一次 webhook),随后业务方/用户侧发起的主动取消 API 才姗姗来迟。
+// 此时本地已是终态,CancelSubscription 应在打渠道之前就早退(sub.Status==canceled 分支),
+// 绝不二次调用渠道、也绝不重复入队——即便 fp.cancelErr 被设成"渠道已取消"哨兵(模拟万一实现
+// 顺序有误、真打了渠道也不该出错),结果仍应是幂等 no-op。
+func TestCancelSubscriptionWebhookThenAPIConverge(t *testing.T) {
+ g, fp, spy, _, subs := newSubGateway(t)
+ ctx := context.Background()
+ subID, providerSubRef, _ := activateSub(t, g, fp)
+ spy.calls = nil
+ fp.cancelErr = fmt.Errorf("%w: stripe simulated already-canceled", provider.ErrSubAlreadyCanceled)
+
+ raw, err := json.Marshal(provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: providerSubRef})
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil || result != gateway.SettleProcessed {
+ t.Fatalf("HandleCallback deleted(webhook 先到) = %v, %v", result, err)
+ }
+ if len(spy.calls) != 1 {
+ t.Fatalf("webhook calls after inbound deleted = %d, want 1", len(spy.calls))
+ }
+
+ if err := g.CancelSubscription(ctx, subID); err != nil {
+ t.Fatalf("CancelSubscription(webhook 已先到,应幂等 no-op 不报错): %v", err)
+ }
+ if len(fp.cancelCalls) != 0 {
+ t.Fatalf("cancelCalls = %+v, want 0(本地已终态,不应再打渠道)", fp.cancelCalls)
+ }
+ if len(spy.calls) != 1 {
+ t.Fatalf("webhook calls after API cancel(webhook 先到之后) = %d, want still 1(不重复入队)", len(spy.calls))
+ }
+ sub, err := subs.GetBySubID(subID)
+ if err != nil || sub.Status != model.SubCanceled {
+ t.Fatalf("subscription = %+v, %v", sub, err)
+ }
+}
+
+// TestInboundSubscriptionDeletedConverges 覆盖入站 customer.subscription.deleted:同订阅
+// 幂等标 canceled + 入队一次;重投(渠道 webhook 常见重投)不重复发 webhook。
+func TestInboundSubscriptionDeletedConverges(t *testing.T) {
+ g, fp, spy, _, subs := newSubGateway(t)
+ ctx := context.Background()
+ subID, providerSubRef, orderNo := activateSub(t, g, fp)
+ spy.calls = nil
+
+ raw, err := json.Marshal(provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: providerSubRef})
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil || result != gateway.SettleProcessed {
+ t.Fatalf("HandleCallback deleted#1 = %v, %v", result, err)
+ }
+ sub, err := subs.GetBySubID(subID)
+ if err != nil || sub.Status != model.SubCanceled {
+ t.Fatalf("subscription after inbound deleted = %+v, %v", sub, err)
+ }
+ if len(spy.calls) != 1 {
+ t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls)
+ }
+ if spy.calls[0]["out_trade_no"] != orderNo {
+ t.Fatalf("out_trade_no = %v, want %s", spy.calls[0]["out_trade_no"], orderNo)
+ }
+
+ // 重投同一 customer.subscription.deleted → 幂等,不再新增 webhook。
+ result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil {
+ t.Fatalf("HandleCallback deleted#2: %v", err)
+ }
+ if result2 != gateway.SettleProcessed {
+ t.Fatalf("replay result = %v, want processed(finalizeCanceled 内部幂等,外层结果按 brief 恒 processed)", result2)
+ }
+ if len(spy.calls) != 1 {
+ t.Fatalf("webhook calls after replay = %d, want still 1: %+v", len(spy.calls), spy.calls)
+ }
+}
+
+// TestCancelAPIThenInboundDeletedConverge 两路收敛同一终态:先走主动取消 API(本地先翻
+// canceled + 发 webhook),Stripe 随后异步补投 customer.subscription.deleted → 命中同一
+// finalizeCanceled 的幂等分支,不重复发 webhook。
+func TestCancelAPIThenInboundDeletedConverge(t *testing.T) {
+ g, fp, spy, _, subs := newSubGateway(t)
+ ctx := context.Background()
+ subID, providerSubRef, _ := activateSub(t, g, fp)
+ spy.calls = nil
+
+ if err := g.CancelSubscription(ctx, subID); err != nil {
+ t.Fatalf("CancelSubscription: %v", err)
+ }
+ if len(spy.calls) != 1 {
+ t.Fatalf("webhook calls after API cancel = %d, want 1", len(spy.calls))
+ }
+
+ raw, err := json.Marshal(provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: providerSubRef})
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil || result != gateway.SettleProcessed {
+ t.Fatalf("HandleCallback deleted after API cancel = %v, %v", result, err)
+ }
+ if len(spy.calls) != 1 {
+ t.Fatalf("webhook calls after inbound deleted = %d, want still 1(两路收敛,不重复发)", len(spy.calls))
+ }
+ sub, err := subs.GetBySubID(subID)
+ if err != nil || sub.Status != model.SubCanceled {
+ t.Fatalf("subscription = %+v, %v", sub, err)
+ }
+}
+
+// TestSettleSubscriptionCanceledUnknownSubscription 未知 provider_sub_ref → 忽略,不报错
+// (与 TestSettleRenewalUnknownSubscription 同惯例)。
+func TestSettleSubscriptionCanceledUnknownSubscription(t *testing.T) {
+ g, _, spy, _, _ := newSubGateway(t)
+ raw, err := json.Marshal(provider.PaidEvent{Kind: provider.EventSubscriptionCanceled, SubscriptionRef: "sub_ghost"})
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil {
+ t.Fatalf("HandleCallback: %v", err)
+ }
+ if result != gateway.SettleIgnored {
+ t.Fatalf("result = %v, want ignored", result)
+ }
+ if len(spy.calls) != 0 {
+ t.Fatalf("webhook calls = %d, want 0: %+v", len(spy.calls), spy.calls)
+ }
+}
+
+// TestMarkSubscriptionPastDue 覆盖 invoice.payment_failed → active 翻 past_due + 入队
+// subscription.past_due(out_trade_no 用真实首购单号)。
+func TestMarkSubscriptionPastDue(t *testing.T) {
+ g, fp, spy, _, subs := newSubGateway(t)
+ ctx := context.Background()
+ subID, providerSubRef, orderNo := activateSub(t, g, fp)
+ spy.calls = nil
+
+ raw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventSubscriptionPastDue, SubscriptionRef: providerSubRef, InvoiceRef: "in_failed_1",
+ })
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil || result != gateway.SettleProcessed {
+ t.Fatalf("HandleCallback past_due = %v, %v", result, err)
+ }
+ sub, err := subs.GetBySubID(subID)
+ if err != nil || sub.Status != model.SubPastDue {
+ t.Fatalf("subscription after past_due = %+v, %v", sub, err)
+ }
+ if len(spy.calls) != 1 {
+ t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls)
+ }
+ c := spy.calls[0]
+ if c["event_type"] != gateway.EvtSubscriptionPastDue || c["sub_id"] != subID || c["out_trade_no"] != orderNo {
+ t.Fatalf("past_due webhook payload = %+v, want event_type=%s sub_id=%s out_trade_no=%s",
+ c, gateway.EvtSubscriptionPastDue, subID, orderNo)
+ }
+}
+
+// TestMarkSubscriptionPastDueUnknownSubscription 未知 provider_sub_ref → 忽略,不报错。
+func TestMarkSubscriptionPastDueUnknownSubscription(t *testing.T) {
+ g, _, spy, _, _ := newSubGateway(t)
+ raw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventSubscriptionPastDue, SubscriptionRef: "sub_ghost", InvoiceRef: "in_ghost",
+ })
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil {
+ t.Fatalf("HandleCallback: %v", err)
+ }
+ if result != gateway.SettleIgnored {
+ t.Fatalf("result = %v, want ignored", result)
+ }
+ if len(spy.calls) != 0 {
+ t.Fatalf("webhook calls = %d, want 0: %+v", len(spy.calls), spy.calls)
+ }
+}
+
+// TestPastDueThenRenewalRecovers 端到端串联 past_due 决策记录的完整路径:invoice.payment_failed
+// → past_due(+ 入队 subscription.past_due);随后 invoice.paid(subscription_cycle)→ settleRenewal
+// 的 Activate 自动恢复 active,续费锚点刷新。与 T4 的 TestSettleRenewal 不同,这里用真实
+// markSubscriptionPastDue 事件路径推进到 past_due(不直接调 subs.MarkPastDue 抄近道)。
+func TestPastDueThenRenewalRecovers(t *testing.T) {
+ g, fp, spy, _, subs := newSubGateway(t)
+ ctx := context.Background()
+ subID, providerSubRef, _ := activateSub(t, g, fp)
+ spy.calls = nil
+
+ failedRaw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventSubscriptionPastDue, SubscriptionRef: providerSubRef, InvoiceRef: "in_failed_1",
+ })
+ if err != nil {
+ t.Fatalf("marshal past_due: %v", err)
+ }
+ if _, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: failedRaw}); err != nil {
+ t.Fatalf("HandleCallback past_due: %v", err)
+ }
+ sub, err := subs.GetBySubID(subID)
+ if err != nil || sub.Status != model.SubPastDue {
+ t.Fatalf("subscription after past_due = %+v, %v", sub, err)
+ }
+
+ renewalRaw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventSubscriptionRenewal, SubscriptionRef: providerSubRef, InvoiceRef: "in_recover_1",
+ PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded,
+ })
+ if err != nil {
+ t.Fatalf("marshal renewal: %v", err)
+ }
+ result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
+ if err != nil || result != gateway.SettleProcessed {
+ t.Fatalf("HandleCallback renewal recovery = %v, %v", result, err)
+ }
+ subAfter, err := subs.GetBySubID(subID)
+ if err != nil || subAfter.Status != model.SubActive {
+ t.Fatalf("subscription after renewal recovery = %+v, %v, want active", subAfter, err)
+ }
+ if subAfter.CurrentPeriodEnd == nil {
+ t.Fatalf("current_period_end not refreshed on recovery")
+ }
+
+ sawPastDue, sawRenewed := false, false
+ for _, c := range spy.calls {
+ switch c["event_type"] {
+ case gateway.EvtSubscriptionPastDue:
+ sawPastDue = true
+ case gateway.EvtSubscriptionRenewed:
+ sawRenewed = true
+ }
+ }
+ if !sawPastDue || !sawRenewed {
+ t.Fatalf("missing expected events: %+v", spy.calls)
+ }
+}
+
+// TestMarkSubscriptionPastDueDuplicateInvoiceNotDoubleEnqueued 同一失败 invoice 重投(渠道
+// webhook 常见重投场景)→ MarkPastDue 第二次不再翻转(已 past_due),不重复入队。
+func TestMarkSubscriptionPastDueDuplicateInvoiceNotDoubleEnqueued(t *testing.T) {
+ g, fp, spy, _, _ := newSubGateway(t)
+ ctx := context.Background()
+ _, providerSubRef, _ := activateSub(t, g, fp)
+ spy.calls = nil
+
+ raw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventSubscriptionPastDue, SubscriptionRef: providerSubRef, InvoiceRef: "in_failed_1",
+ })
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if _, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw}); err != nil {
+ t.Fatalf("HandleCallback#1: %v", err)
+ }
+ result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil {
+ t.Fatalf("HandleCallback#2: %v", err)
+ }
+ if result2 != gateway.SettleDuplicate {
+ t.Fatalf("replay result = %v, want duplicate", result2)
+ }
+ if len(spy.calls) != 1 {
+ t.Fatalf("webhook calls after replay = %d, want still 1: %+v", len(spy.calls), spy.calls)
+ }
+}
+
+// TestSettleRenewalEnqueueFailureThenRetryRecovers 复现 Important 发现:settleRenewal 先
+// CreateRenewalPaid(建单已 paid,单步无 pending 中间态)后 Enqueue——首次入队失败(瞬时)后,
+// invoice.paid 重投走 created=false 的 duplicate 分支必须仍尝试入队(outbox 唯一键幂等,
+// 行不存在则补建),否则 subscription.renewed 永久丢失(SyncPendingAttempts 救不了:续费
+// attempt 生来就是 AttemptPaid,不在 pending 轮询范围)。
+// 断言:①首次入队失败 → SettleFailed+err,订单已建为 paid(幂等键护着,不回滚)②同一
+// invoice.paid 重投 → subscription.renewed 最终恰入队一次 ③renewal order 不因重投双铸。
+func TestSettleRenewalEnqueueFailureThenRetryRecovers(t *testing.T) {
+ g, fp, spy, orders, _ := newSubGateway(t)
+ ctx := context.Background()
+ activateSub(t, g, fp)
+ spy.calls = nil
+
+ renewalRaw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventSubscriptionRenewal, SubscriptionRef: "sub_new", InvoiceRef: "in_flaky",
+ PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded,
+ })
+ if err != nil {
+ t.Fatalf("marshal renewal event: %v", err)
+ }
+
+ spy.failNext = true
+ result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
+ if err == nil || result != gateway.SettleFailed {
+ t.Fatalf("首次入队失败应 SettleFailed+err, got %v, %v", result, err)
+ }
+ allOrders, err := orders.ListOrders("pangolin", "u-1", 10)
+ if err != nil {
+ t.Fatalf("ListOrders: %v", err)
+ }
+ renewalCount := 0
+ for i := range allOrders {
+ if strings.Contains(allOrders[i].OutTradeNo, "-r-") {
+ renewalCount++
+ if allOrders[i].Status != model.OrderPaidV2 {
+ t.Fatalf("renewal order 应已建为 paid(幂等键护着,不因入队失败回滚), got %+v", allOrders[i])
+ }
+ }
+ }
+ if renewalCount != 1 {
+ t.Fatalf("renewal order count after first(失败) attempt = %d, want 1", renewalCount)
+ }
+ if len(spy.calls) != 0 {
+ t.Fatalf("入队失败不应留下 webhook 记录, got %+v", spy.calls)
+ }
+
+ // 渠道重投同一 invoice.paid(Stripe 拿不到 200 会重投):outbox 补建自愈。
+ result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
+ if err != nil {
+ t.Fatalf("HandleCallback retry: %v", err)
+ }
+ _ = result2 // duplicate(created=false,订单已在)——本用例只关心自愈,不断言具体 result 值
+ if len(spy.calls) != 1 {
+ t.Fatalf("重投后 subscription.renewed 应恰入队一次(自愈), got %d: %+v", len(spy.calls), spy.calls)
+ }
+ if spy.calls[0]["event_type"] != gateway.EvtSubscriptionRenewed {
+ t.Fatalf("event_type = %v, want %s", spy.calls[0]["event_type"], gateway.EvtSubscriptionRenewed)
+ }
+
+ allOrdersAfter, err := orders.ListOrders("pangolin", "u-1", 10)
+ if err != nil {
+ t.Fatalf("ListOrders after retry: %v", err)
+ }
+ renewalCountAfter := 0
+ for i := range allOrdersAfter {
+ if strings.Contains(allOrdersAfter[i].OutTradeNo, "-r-") {
+ renewalCountAfter++
+ }
+ }
+ if renewalCountAfter != 1 {
+ t.Fatalf("renewal order count after retry = %d, want still 1(不双铸)", renewalCountAfter)
+ }
+}
+
+// TestSubscriptionActivatedEnqueueFailureThenRetryRecovers 镜像
+// TestSettleRenewalEnqueueFailureThenRetryRecovers,覆盖同型缺口(T5 re-review 发现):
+// onSubscriptionActivated 先 subs.Create(先于入队幂等诞生订阅行)后 Enqueue subscription.created
+// ——首次入队失败(瞬时)后,checkout.session.completed 重投走 created=false 的分支必须仍尝试
+// 入队(outbox 唯一键幂等,行不存在则补建),否则 subscription.created 永久丢失(旧实现
+// `!created→return nil` 直接跳过,行永远补不上)。
+//
+// 用 spy.failOnEventType 只让 subscription.created 这一次入队失败(而非 Settle 内更早的
+// payment.succeeded)——同一 HandleCallback 里先后两次 Enqueue,只想复现"第二次失败"这个窗口。
+//
+// 断言:①首次:payment.succeeded 已入队(Settle 已 processed,订单已 paid),订阅行已幂等
+// 诞生 active(Create 先于 Enqueue),但 subscription.created 入队失败 → HandleCallback 整体
+// SettleFailed+err ②同一事件重投 → subscription.created 最终恰入队一次(payment.succeeded
+// 不因重投重发,outbox 唯一键幂等)③重投不双铸订阅行(SubID 幂等派生)。
+func TestSubscriptionActivatedEnqueueFailureThenRetryRecovers(t *testing.T) {
+ g, fp, spy, orders, subs := newSubGateway(t)
+ ctx := context.Background()
+ res, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
+ SKU: "pro_monthly", Method: "substripe", BizSystem: "pangolin", BizRef: "u-1",
+ })
+ if err != nil {
+ t.Fatalf("CreateSubscription: %v", err)
+ }
+ raw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
+ PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new",
+ })
+ if err != nil {
+ t.Fatalf("marshal activate event: %v", err)
+ }
+
+ spy.failOnEventType = gateway.EvtSubscriptionCreated
+ result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
+ if err == nil || result != gateway.SettleFailed {
+ t.Fatalf("subscription.created 入队失败应 SettleFailed+err, got %v, %v", result, err)
+ }
+ o, err := orders.GetOrder(res.OrderNo)
+ if err != nil || o.Status != model.OrderPaidV2 {
+ t.Fatalf("order after first(失败) attempt = %+v, %v, want paid(payment.succeeded 已先成功入队+翻转)", o, err)
+ }
+ subID := "SUB-" + res.OrderNo
+ sub, err := subs.GetBySubID(subID)
+ if err != nil || sub.Status != model.SubActive {
+ t.Fatalf("subscription after first(失败) attempt = %+v, %v, want already active(Create 先于 Enqueue,幂等诞生)", sub, err)
+ }
+ if len(spy.calls) != 1 || spy.calls[0]["event_type"] != "payment.succeeded" {
+ t.Fatalf("首次 calls = %+v, want 仅 payment.succeeded 一条(subscription.created 那次入队失败,未记入)", spy.calls)
+ }
+
+ // Stripe 拿不到 200 会重投同一 checkout.session.completed。
+ result2, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: raw})
+ if err != nil {
+ t.Fatalf("HandleCallback retry: %v", err)
+ }
+ _ = result2 // duplicate(created=false,订单/订阅已在)——本用例只关心自愈,不断言具体 result 值
+ if len(spy.calls) != 2 {
+ t.Fatalf("重投后 calls = %d, want 2(payment.succeeded 幂等 no-op 不重发 + subscription.created 补建恰一次): %+v", len(spy.calls), spy.calls)
+ }
+ if spy.calls[1]["event_type"] != gateway.EvtSubscriptionCreated || spy.calls[1]["sub_id"] != subID {
+ t.Fatalf("payload = %+v, want event_type=%s sub_id=%s", spy.calls[1], gateway.EvtSubscriptionCreated, subID)
+ }
+
+ subAfter, err := subs.GetBySubID(subID)
+ if err != nil || subAfter.Status != model.SubActive {
+ t.Fatalf("subscription after retry = %+v, %v", subAfter, err)
+ }
+}
+
+// TestSettleRenewalNoBizSystemStillProcessed 独立收款(无业务方回调,BizSystem=="")的续费首过
+// 应与 enqueuePaymentSucceeded 的"无业务方=跳过入队但仍 processed"语义对齐,不能误判 duplicate
+// (created=true 是真正的首次成交,只是没有下游 webhook 可发)。
+func TestSettleRenewalNoBizSystemStillProcessed(t *testing.T) {
+ g, fp, spy, _, subs := newSubGateway(t)
+ ctx := context.Background()
+ _, err := g.CreateSubscription(ctx, gateway.CreateSubscriptionInput{
+ SKU: "pro_monthly", Method: "substripe", // BizSystem/BizRef 留空 = 独立收款
+ })
+ if err != nil {
+ t.Fatalf("CreateSubscription: %v", err)
+ }
+ activateRaw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
+ PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_new",
+ })
+ if err != nil {
+ t.Fatalf("marshal activate event: %v", err)
+ }
+ if _, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: activateRaw}); err != nil {
+ t.Fatalf("activate: %v", err)
+ }
+ sub, err := subs.GetByProviderRef("substripe", "sub_new")
+ if err != nil || sub.BizSystem != "" {
+ t.Fatalf("subscription = %+v, %v, want BizSystem empty(独立收款)", sub, err)
+ }
+
+ renewalRaw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventSubscriptionRenewal, SubscriptionRef: "sub_new", InvoiceRef: "in_indep_1",
+ PaidAmountMinor: 2999, PaidCurrency: "USD", Status: provider.PaidSucceeded,
+ })
+ if err != nil {
+ t.Fatalf("marshal renewal event: %v", err)
+ }
+ result, err := g.HandleCallback(ctx, "substripe", provider.CallbackInput{Raw: renewalRaw})
+ if err != nil || result != gateway.SettleProcessed {
+ t.Fatalf("独立收款续费首过应 processed(非 duplicate), got %v, %v", result, err)
+ }
+ if len(spy.calls) != 0 {
+ t.Fatalf("独立收款不应入队 webhook, got %+v", spy.calls)
+ }
+}
diff --git a/internal/handler/gateway.go b/internal/handler/gateway.go
index 977a94e..590400f 100644
--- a/internal/handler/gateway.go
+++ b/internal/handler/gateway.go
@@ -92,6 +92,39 @@ func (h *GatewayHandler) CreateOrder(c *gin.Context) {
util.RespondSuccess(c, res)
}
+// CreateSubscription POST /api/v2/subscriptions —— 建订阅,返回 {sub_id, order_no, session:{render_type, payload}}。
+// biz_system 非空 → 校验 HMAC 签名(复用 v1 verifyBizSign,与 CreateOrder 同惯例)。
+func (h *GatewayHandler) CreateSubscription(c *gin.Context) {
+ raw, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, maxOrderBodyBytes))
+ if err != nil {
+ util.RespondError(c, http.StatusBadRequest, "bad_request", "请求体过大或读取失败")
+ return
+ }
+ var req createV2Request
+ if err := json.Unmarshal(raw, &req); err != nil {
+ util.RespondError(c, http.StatusBadRequest, "bad_request", "参数格式错误")
+ return
+ }
+ if req.SKU == "" || req.Method == "" {
+ util.RespondError(c, http.StatusBadRequest, "bad_request", "缺少 sku 或 method")
+ return
+ }
+ if req.BizSystem != "" {
+ if err := verifyBizSign(c, req.BizSystem, raw); err != nil {
+ util.RespondError(c, http.StatusUnauthorized, "unauthorized", err.Error())
+ return
+ }
+ }
+ res, err := h.g.CreateSubscription(c.Request.Context(), gateway.CreateSubscriptionInput{
+ SKU: req.SKU, Method: req.Method, BizSystem: req.BizSystem, BizRef: req.BizRef, ReturnURL: req.ReturnURL,
+ })
+ if err != nil {
+ h.writeCreateErr(c, "建订阅", req.Method, err)
+ return
+ }
+ util.RespondSuccess(c, res)
+}
+
// GetStatus GET /api/v2/orders/:order_no
func (h *GatewayHandler) GetStatus(c *gin.Context) {
v, err := h.g.GetOrder(c.Param("order_no"))
@@ -102,6 +135,31 @@ func (h *GatewayHandler) GetStatus(c *gin.Context) {
util.RespondSuccess(c, v)
}
+// GetSubscription GET /api/v2/subscriptions/:sub_id —— 查订阅状态 + 续费锚点。
+func (h *GatewayHandler) GetSubscription(c *gin.Context) {
+ v, err := h.g.GetSubscription(c.Param("sub_id"))
+ if err != nil {
+ util.RespondError(c, http.StatusNotFound, "subscription_not_found", "订阅不存在")
+ return
+ }
+ util.RespondSuccess(c, v)
+}
+
+// CancelSubscription POST /api/v2/subscriptions/:sub_id/cancel —— 主动取消:查订阅 → 渠道
+// 取消 → 本地翻 canceled + 入队 subscription.canceled(幂等,重复调用 no-op)。
+func (h *GatewayHandler) CancelSubscription(c *gin.Context) {
+ if err := h.g.CancelSubscription(c.Request.Context(), c.Param("sub_id")); err != nil {
+ if errors.Is(err, store.ErrSubNotFound) {
+ util.RespondError(c, http.StatusNotFound, "subscription_not_found", "订阅不存在")
+ return
+ }
+ log.Printf("[v2 subscription] 取消失败 sub_id=%s: %v", c.Param("sub_id"), err)
+ util.RespondError(c, http.StatusInternalServerError, "cancel_failed", "取消失败,请稍后重试")
+ return
+ }
+ util.RespondSuccess(c, gin.H{"canceled": true})
+}
+
type retryRequest struct {
Method string `json:"method"`
Metadata map[string]string `json:"metadata,omitempty"` // 同 createV2Request.Metadata,过同一白名单
@@ -189,6 +247,8 @@ func (h *GatewayHandler) writeCreateErr(c *gin.Context, action, method string, e
util.RespondError(c, http.StatusConflict, "currency_mismatch", "该支付方式结算币种与订单不符,请换一种支付方式")
case errors.Is(err, gateway.ErrNoSettleCurrency):
util.RespondError(c, http.StatusServiceUnavailable, "no_settle_currency", "该支付方式配置不完整,暂不可用")
+ case errors.Is(err, provider.ErrNotSupported):
+ util.RespondError(c, http.StatusBadRequest, "method_not_recurring", "该支付方式不支持订阅")
default:
log.Printf("[v2 order] %s失败 method=%s: %v", action, method, err)
util.RespondError(c, http.StatusInternalServerError, "create_failed", action+"失败,请稍后重试")
diff --git a/internal/handler/gateway_test.go b/internal/handler/gateway_test.go
index 713a000..059d714 100644
--- a/internal/handler/gateway_test.go
+++ b/internal/handler/gateway_test.go
@@ -44,13 +44,15 @@ func buildEngineWithStore(t *testing.T) (*gin.Engine, *store.OrderStore) {
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
+ subs := store.NewSubscriptionStore(db)
+ chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
preg.Register(fake.New())
areg := accounts.New([]config.AccountConfig{
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
- g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global")
+ g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
r := gin.New()
router.SetupV2(r, g)
return r, orders
@@ -177,6 +179,8 @@ func TestV2RetryCurrencyMismatch409(t *testing.T) {
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
+ subs := store.NewSubscriptionStore(db)
+ chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
preg.Register(fake.New()) // method="fake", settles in "USDT"
@@ -189,7 +193,7 @@ func TestV2RetryCurrencyMismatch409(t *testing.T) {
{AccountID: "fake-a2", Channel: "fake_eur", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
- g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global")
+ g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
r := gin.New()
router.SetupV2(r, g)
@@ -246,3 +250,147 @@ func (p *eurFakeProvider) VerifyCallback(_ context.Context, in provider.Callback
func (p *eurFakeProvider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
return nil, errors.New("not implemented")
}
+
+// subFakeProvider 实现 provider.SubscriptionProvider,供 handler 层 CancelSubscription/
+// GetSubscription 端到端测试用(独立于 internal/gateway 包的 fakeSubProvider,同构但不能跨
+// 测试包复用未导出类型)。VerifyCallback 原样透传测试构造的 provider.PaidEvent JSON。
+type subFakeProvider struct {
+ sessionRef string
+}
+
+func (p *subFakeProvider) Method() string { return "subfake" }
+
+func (p *subFakeProvider) Capabilities() provider.Capabilities {
+ return provider.Capabilities{
+ RenderTypes: []provider.RenderType{provider.RenderRedirect},
+ SupportsRecurring: true,
+ RecurringKind: provider.RecurringKindGatewayScheduled,
+ SettleCurrencies: []string{"USD"},
+ Regions: []string{"global"},
+ }
+}
+
+func (p *subFakeProvider) Create(_ context.Context, _ provider.CreateRequest) (*provider.Session, error) {
+ return nil, errors.New("subFakeProvider: one-time Create not used")
+}
+
+func (p *subFakeProvider) CreateSubscriptionCheckout(_ context.Context, req provider.CreateRequest) (*provider.Session, error) {
+ return &provider.Session{
+ ProviderRef: p.sessionRef, RenderType: provider.RenderRedirect,
+ Payload: map[string]any{"url": "https://checkout.example/" + p.sessionRef},
+ }, nil
+}
+
+func (p *subFakeProvider) CancelSubscription(_ context.Context, _ string) error { return nil }
+
+func (p *subFakeProvider) VerifyCallback(_ context.Context, in provider.CallbackInput) (*provider.PaidEvent, error) {
+ var ev provider.PaidEvent
+ if err := json.Unmarshal(in.Raw, &ev); err != nil {
+ return nil, err
+ }
+ return &ev, nil
+}
+
+func (p *subFakeProvider) Query(_ context.Context, req provider.QueryRequest) (*provider.PaidEvent, error) {
+ return &provider.PaidEvent{ProviderRef: req.ProviderRef, Status: provider.PaidPending}, nil
+}
+
+type subResolver struct{}
+
+func (subResolver) Resolve(sku, currency string) (int64, string, string, error) {
+ return 2999, "Pro 月付", "pro_monthly", nil
+}
+
+// buildSubEngine 装配一套支持订阅的路由(subfake 渠道),供取消/查询端点测试用。
+func buildSubEngine(t *testing.T) (*gin.Engine, *subFakeProvider) {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ db := model.OpenTestDB(t)
+ orders := store.NewOrderStore(db)
+ refunds := store.NewRefundStore(db)
+ subs := store.NewSubscriptionStore(db)
+ chargebacks := store.NewChargebackStore(db)
+ preg := provider.NewRegistry()
+ fp := &subFakeProvider{sessionRef: "cs_sub_1"}
+ preg.Register(fp)
+ areg := accounts.New([]config.AccountConfig{
+ {AccountID: "subfake-a1", Channel: "subfake", Region: "global", Enabled: true, Weight: 1},
+ })
+ picker := accounts.NewRouter(areg, nil, nil)
+ g := gateway.New(orders, refunds, preg, picker, subResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
+ r := gin.New()
+ router.SetupV2(r, g)
+ return r, fp
+}
+
+// TestV2SubscriptionNotFound404 —— 未知 sub_id 的取消/查询都应 404,而不是 500。
+func TestV2SubscriptionNotFound404(t *testing.T) {
+ r, _ := buildSubEngine(t)
+
+ wGet, _ := do(t, r, http.MethodGet, "/api/v2/subscriptions/SUB-GHOST", nil)
+ if wGet.Code != http.StatusNotFound {
+ t.Fatalf("get unknown sub code=%d, want 404", wGet.Code)
+ }
+ wCancel, _ := do(t, r, http.MethodPost, "/api/v2/subscriptions/SUB-GHOST/cancel", nil)
+ if wCancel.Code != http.StatusNotFound {
+ t.Fatalf("cancel unknown sub code=%d, want 404", wCancel.Code)
+ }
+}
+
+// TestV2SubscriptionCancelLifecycle 端到端:建订阅 → 首期支付回调激活 → 查询 active →
+// 取消 → 查询 canceled;取消端点重复调用幂等(仍 200,canceled=true)。
+func TestV2SubscriptionCancelLifecycle(t *testing.T) {
+ r, fp := buildSubEngine(t)
+
+ w, out := do(t, r, http.MethodPost, "/api/v2/subscriptions", map[string]any{
+ "sku": "pro_monthly", "method": "subfake", "biz_system": "", "biz_ref": "u-1",
+ })
+ if w.Code != http.StatusOK {
+ t.Fatalf("create subscription code=%d body=%v", w.Code, out)
+ }
+ data := out["data"].(map[string]any)
+ subID := data["sub_id"].(string)
+
+ // 激活前:GetSubscription 404(Subscription 行在首期支付回调时才诞生)。
+ wPre, _ := do(t, r, http.MethodGet, "/api/v2/subscriptions/"+subID, nil)
+ if wPre.Code != http.StatusNotFound {
+ t.Fatalf("get before activation code=%d, want 404", wPre.Code)
+ }
+
+ // 首期支付回调激活。
+ raw, err := json.Marshal(provider.PaidEvent{
+ Kind: provider.EventPayment, ProviderRef: fp.sessionRef, Status: provider.PaidSucceeded,
+ PaidAmountMinor: 2999, PaidCurrency: "USD", SubscriptionRef: "sub_h1",
+ })
+ if err != nil {
+ t.Fatalf("marshal event: %v", err)
+ }
+ wc, _ := do(t, r, http.MethodPost, "/api/v2/callback/subfake", json.RawMessage(raw))
+ if wc.Code != http.StatusOK {
+ t.Fatalf("activate callback code=%d", wc.Code)
+ }
+
+ wActive, outActive := do(t, r, http.MethodGet, "/api/v2/subscriptions/"+subID, nil)
+ if wActive.Code != http.StatusOK {
+ t.Fatalf("get after activation code=%d body=%v", wActive.Code, outActive)
+ }
+ if outActive["data"].(map[string]any)["status"] != "active" {
+ t.Fatalf("status = %v, want active", outActive["data"])
+ }
+
+ // 取消。
+ wCancel, outCancel := do(t, r, http.MethodPost, "/api/v2/subscriptions/"+subID+"/cancel", nil)
+ if wCancel.Code != http.StatusOK || outCancel["data"].(map[string]any)["canceled"] != true {
+ t.Fatalf("cancel = %d %v", wCancel.Code, outCancel)
+ }
+ wAfter, outAfter := do(t, r, http.MethodGet, "/api/v2/subscriptions/"+subID, nil)
+ if wAfter.Code != http.StatusOK || outAfter["data"].(map[string]any)["status"] != "canceled" {
+ t.Fatalf("status after cancel = %d %v", wAfter.Code, outAfter)
+ }
+
+ // 重复取消:幂等 200,不报错。
+ wCancel2, outCancel2 := do(t, r, http.MethodPost, "/api/v2/subscriptions/"+subID+"/cancel", nil)
+ if wCancel2.Code != http.StatusOK || outCancel2["data"].(map[string]any)["canceled"] != true {
+ t.Fatalf("repeat cancel = %d %v", wCancel2.Code, outCancel2)
+ }
+}
diff --git a/internal/handler/metadata_test.go b/internal/handler/metadata_test.go
index a38de08..07980d5 100644
--- a/internal/handler/metadata_test.go
+++ b/internal/handler/metadata_test.go
@@ -30,7 +30,7 @@ func TestV2CreateOrderMetadataWhitelist(t *testing.T) {
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
- g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global")
+ g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", store.NewSubscriptionStore(db), store.NewChargebackStore(db))
r := gin.New()
router.SetupV2(r, g)
diff --git a/internal/handler/ratelimit_test.go b/internal/handler/ratelimit_test.go
index ba80d27..e237255 100644
--- a/internal/handler/ratelimit_test.go
+++ b/internal/handler/ratelimit_test.go
@@ -34,7 +34,7 @@ func TestV2CreateOrderRateLimited(t *testing.T) {
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
- g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global")
+ g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", store.NewSubscriptionStore(db), store.NewChargebackStore(db))
r := gin.New()
router.SetupV2(r, g)
@@ -87,7 +87,7 @@ func TestV2RateLimitDisabled(t *testing.T) {
{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1},
})
picker := accounts.NewRouter(areg, nil, nil)
- g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global")
+ g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", store.NewSubscriptionStore(db), store.NewChargebackStore(db))
r := gin.New()
router.SetupV2(r, g)
diff --git a/internal/handler/refund_test.go b/internal/handler/refund_test.go
index b9c677a..54c08e9 100644
--- a/internal/handler/refund_test.go
+++ b/internal/handler/refund_test.go
@@ -45,11 +45,13 @@ func buildRefundEngine(t *testing.T) (*gin.Engine, *gateway.Gateway, *store.Orde
db := model.OpenTestDB(t)
orders := store.NewOrderStore(db)
refunds := store.NewRefundStore(db)
+ subs := store.NewSubscriptionStore(db)
+ chargebacks := store.NewChargebackStore(db)
preg := provider.NewRegistry()
fp := fake.New()
preg.Register(fp)
areg := accounts.New([]config.AccountConfig{{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}})
- g := gateway.New(orders, refunds, preg, accounts.NewRouter(areg, nil, nil), oneResolver{}, nopEnqueuer{}, "global")
+ g := gateway.New(orders, refunds, preg, accounts.NewRouter(areg, nil, nil), oneResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
r := gin.New()
router.SetupV2(r, g)
return r, g, orders, fp
diff --git a/internal/model/testdb.go b/internal/model/testdb.go
index 5a95e71..afa66ea 100644
--- a/internal/model/testdb.go
+++ b/internal/model/testdb.go
@@ -32,7 +32,7 @@ func OpenTestDB(t *testing.T) *gorm.DB {
t.Fatalf("open test db: %v", err)
}
if err := db.AutoMigrate(&OrderV2{}, &Attempt{}, &Account{}, &Refund{}, &WebhookDelivery{},
- &Product{}, &ProductPrice{}, &OrphanPayment{}); err != nil {
+ &Product{}, &ProductPrice{}, &OrphanPayment{}, &Subscription{}, &Chargeback{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := UpgradeWebhookDeliveryIndex(db); err != nil {
diff --git a/internal/model/v2.go b/internal/model/v2.go
index 94665b0..ecad990 100644
--- a/internal/model/v2.go
+++ b/internal/model/v2.go
@@ -72,6 +72,9 @@ type OrderV2 struct {
DiscountReason string `gorm:"size:128"`
PaidAt *time.Time
ExpiresAt *time.Time // 整体购买窗口
+ // Disputed 打标位(P8 Task6):渠道拒付(charge.dispute.created)命中该单时置 true,
+ // 仅留痕、不改状态机(§6 决策:不自动回收权益,业务方按 chargeback.received 自行冲正)。
+ Disputed bool `gorm:"default:false"`
}
// ---- 支付尝试(收款账本)----
@@ -121,3 +124,53 @@ type Refund struct {
InitiatedBy string `gorm:"size:16"` // business/platform
CompletedAt *time.Time
}
+
+// ---- 订阅(recurring)----
+
+type SubStatus string
+
+const (
+ SubActive SubStatus = "active" // 已激活,正常续费
+ SubPastDue SubStatus = "past_due" // 某期扣款失败,待恢复(invoice.payment_failed)
+ SubCanceled SubStatus = "canceled" // 已取消(主动/网关删除),终态
+)
+
+// Subscription 是"同一 entitlement 的跨期账本"(设计 §5.1)。单笔层(OrderV2/Attempt)
+// 与订阅层解耦:每期扣款仍落一张 renewal OrderV2,Subscription 只维护状态机 + 续费锚点。
+type Subscription struct {
+ Base
+ SubID string `gorm:"uniqueIndex;size:64;not null"` // pay 生成的逻辑订阅号
+ OutTradeNo string `gorm:"index;size:64;not null"` // 首购 order(诞生订阅那笔)
+ MerchantID uint64 `gorm:"index"`
+ BizSystem string `gorm:"index;size:32"`
+ BizRef string `gorm:"size:128"`
+ BizCode string `gorm:"index;size:64"` // 套餐码副本,续费事件带回
+ Channel string `gorm:"index;size:32;not null;uniqueIndex:uq_sub_provider"`
+ ProviderSubRef string `gorm:"size:128;not null;uniqueIndex:uq_sub_provider"` // 渠道订阅号(stripe sub id)
+ RecurringKind string `gorm:"size:24"` // gateway_scheduled/token_offsession/...
+ AmountMinor int64 `gorm:"not null"`
+ Currency string `gorm:"size:16;not null"`
+ Status SubStatus `gorm:"index;size:16;not null"`
+ CurrentPeriodEnd *time.Time
+ CanceledAt *time.Time
+}
+
+// ---- 拒付(chargeback,P8 Task6)----
+
+// Chargeback 记录渠道拒付(设计 §6「钱到账不可逆」的例外形态:卡组织裁定退单)。只有
+// Stripe(卡)有此语义;alipay/微信本轮无拒付流,crypto 收款不可逆、永无 chargeback——
+// 三者均不实现 Capabilities 里任何 chargeback 相关声明(P8 未新增该 capability 位,
+// 靠 EventKind 是否产出 EventChargeback 天然区分)。
+// 每次拒付(含重投)按 DisputeRef 幂等落一行留痕;是否成功定位/转发给业务方是另一回事
+// (见 gateway.recordChargeback),Chargeback 表本身对"能否定位业务单"保持中立、全记录。
+type Chargeback struct {
+ Base
+ DisputeRef string `gorm:"uniqueIndex;size:128;not null"` // 渠道拒付号(stripe dp_...)
+ OutTradeNo string `gorm:"index;size:64"` // 解析出的原单号(订阅拒付可能为空)
+ Channel string `gorm:"index;size:32;not null"`
+ ProviderPaymentRef string `gorm:"size:128"` // 关联 PaymentIntent id
+ AmountMinor int64 `gorm:"not null"`
+ Currency string `gorm:"size:16;not null"`
+ Reason string `gorm:"size:64"`
+ Status string `gorm:"size:24"` // 渠道拒付状态快照(needs_response/...)
+}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 907ccca..c353cc9 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -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 }
diff --git a/internal/provider/stripe/stripe.go b/internal/provider/stripe/stripe.go
index b22de53..2ba6b52 100644
--- a/internal/provider/stripe/stripe.go
+++ b/internal/provider/stripe/stripe.go
@@ -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
}
diff --git a/internal/provider/stripe/stripe_test.go b/internal/provider/stripe/stripe_test.go
index 09510db..e40a16e 100644
--- a/internal/provider/stripe/stripe_test.go
+++ b/internal/provider/stripe/stripe_test.go
@@ -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=,v1=hex(HMAC-SHA256(secret, "."))
func signStripe(payload, secret string, ts int64) string {
mac := hmac.New(sha256.New, []byte(secret))
diff --git a/internal/reconcile/sync_test.go b/internal/reconcile/sync_test.go
index db01a38..5cd6c6e 100644
--- a/internal/reconcile/sync_test.go
+++ b/internal/reconcile/sync_test.go
@@ -35,7 +35,7 @@ func TestSyncPendingTaskSettlesViaQuery(t *testing.T) {
fp := fake.New()
preg.Register(fp)
areg := accounts.New([]config.AccountConfig{{AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true}})
- gw := gateway.New(orders, refunds, preg, accounts.NewRouter(areg, nil, nil), stubResolver{}, nopEnq{}, "global")
+ gw := gateway.New(orders, refunds, preg, accounts.NewRouter(areg, nil, nil), stubResolver{}, nopEnq{}, "global", store.NewSubscriptionStore(db), store.NewChargebackStore(db))
res, _ := gw.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1"})
atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
diff --git a/internal/router/router.go b/internal/router/router.go
index c8e7fba..e380892 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -45,13 +45,16 @@ func SetupV2(r *gin.Engine, g *gateway.Gateway) {
h := handler.NewGatewayHandler(g)
v2 := r.Group("/api/v2")
{
- // per-IP 限流(jiu 反馈波,P3 限流纵深):只挂改状态端点(下单/重试/取消)——
+ // per-IP 限流(jiu 反馈波,P3 限流纵深):只挂改状态端点(下单/重试/取消/订阅创建/订阅取消)——
// - callback 不限:来源是渠道服务器,不是终端用户,限了只会误伤渠道重投。
- // - GET 查单/查退款不限:只读、幂等,是结果页轮询的正常用法,风险远低于
+ // - GET 查单/查退款/查订阅不限:只读、幂等,是结果页轮询的正常用法,风险远低于
// 下单类写操作;真要限也该给比写操作更宽松的档,这里选择直接不限。
// - /refunds(创建退款)/admin 组不在本次改动范围内(见 config.RateLimit 注释)。
stateLimit := stateChangeRateLimit()
v2.POST("/orders", stateLimit, h.CreateOrder)
+ v2.POST("/subscriptions", stateLimit, h.CreateSubscription)
+ v2.GET("/subscriptions/:sub_id", h.GetSubscription)
+ v2.POST("/subscriptions/:sub_id/cancel", stateLimit, h.CancelSubscription)
v2.GET("/orders/:order_no", h.GetStatus)
v2.POST("/orders/:order_no/retry", stateLimit, h.Retry)
v2.POST("/orders/:order_no/cancel", stateLimit, h.Cancel)
diff --git a/internal/store/chargeback.go b/internal/store/chargeback.go
new file mode 100644
index 0000000..4d3f4f3
--- /dev/null
+++ b/internal/store/chargeback.go
@@ -0,0 +1,29 @@
+package store
+
+import (
+ "fmt"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+
+ "github.com/wangjia/pay/internal/model"
+)
+
+// ChargebackStore 持久化拒付留痕(P8 Task6)。与 SubscriptionStore/OrderStore 同惯例:
+// 幂等靠 DB 唯一约束 + ON CONFLICT DO NOTHING,不在应用层加锁。
+type ChargebackStore struct{ db *gorm.DB }
+
+func NewChargebackStore(db *gorm.DB) *ChargebackStore { return &ChargebackStore{db: db} }
+
+// Create 幂等插入:重复 dispute_ref(渠道重投同一拒付)→ no-op(created=false)。
+// Chargeback 表对"能否定位业务单"保持中立——OutTradeNo 为空也照常落一行留痕。
+func (s *ChargebackStore) Create(cb *model.Chargeback) (bool, error) {
+ res := s.db.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "dispute_ref"}},
+ DoNothing: true,
+ }).Create(cb)
+ if res.Error != nil {
+ return false, fmt.Errorf("store.ChargebackStore.Create: %w", res.Error)
+ }
+ return res.RowsAffected > 0, nil
+}
diff --git a/internal/store/chargeback_test.go b/internal/store/chargeback_test.go
new file mode 100644
index 0000000..72fd07f
--- /dev/null
+++ b/internal/store/chargeback_test.go
@@ -0,0 +1,44 @@
+package store_test
+
+import (
+ "testing"
+
+ "github.com/wangjia/pay/internal/model"
+ "github.com/wangjia/pay/internal/store"
+)
+
+func TestChargebackCreateIdempotent(t *testing.T) {
+ s := store.NewChargebackStore(model.OpenTestDB(t))
+ cb := &model.Chargeback{
+ DisputeRef: "dp_1", OutTradeNo: "PAY-1", Channel: "stripe",
+ ProviderPaymentRef: "pi_1", AmountMinor: 2999, Currency: "USD",
+ Reason: "fraudulent", Status: "received",
+ }
+ created, err := s.Create(cb)
+ if err != nil || !created {
+ t.Fatalf("first create: created=%v err=%v", created, err)
+ }
+ // 同 dispute_ref 重投(Stripe 重投 charge.dispute.created)→ 幂等 no-op,不双记。
+ again, err := s.Create(&model.Chargeback{
+ DisputeRef: "dp_1", OutTradeNo: "PAY-1", Channel: "stripe",
+ ProviderPaymentRef: "pi_1", AmountMinor: 2999, Currency: "USD",
+ Reason: "fraudulent", Status: "received",
+ })
+ if err != nil || again {
+ t.Fatalf("dup create: again=%v err=%v", again, err)
+ }
+}
+
+// 订阅拒付常无法解析出业务单号(PI 不带 out_trade_no metadata)——Chargeback 表对此
+// 保持中立,OutTradeNo 空值也照常落一行留痕,不因空值而失败或跳过。
+func TestChargebackCreateWithEmptyOutTradeNo(t *testing.T) {
+ s := store.NewChargebackStore(model.OpenTestDB(t))
+ created, err := s.Create(&model.Chargeback{
+ DisputeRef: "dp_sub_1", OutTradeNo: "", Channel: "stripe",
+ ProviderPaymentRef: "pi_sub_1", AmountMinor: 999, Currency: "USD",
+ Reason: "fraudulent", Status: "received",
+ })
+ if err != nil || !created {
+ t.Fatalf("create with empty out_trade_no: created=%v err=%v", created, err)
+ }
+}
diff --git a/internal/store/order.go b/internal/store/order.go
index 58d783f..d10d761 100644
--- a/internal/store/order.go
+++ b/internal/store/order.go
@@ -8,6 +8,7 @@ import (
"time"
"gorm.io/gorm"
+ "gorm.io/gorm/clause"
"github.com/wangjia/pay/internal/model"
)
@@ -65,6 +66,35 @@ func (s *OrderStore) MarkAttemptPaid(outTradeNo, channel, providerRef string, at
return flipped, nil
}
+// CreateRenewalPaid 幂等建一张已付 renewal order + attempt(续费不经收银台,建即 paid)。
+// 重复(invoice 重投)→ created=false。renewal order/attempt 均带唯一约束,ON CONFLICT DO NOTHING。
+func (s *OrderStore) CreateRenewalPaid(order *model.OrderV2, att *model.Attempt) (bool, error) {
+ var created bool
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ ores := tx.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "out_trade_no"}}, DoNothing: true,
+ }).Create(order)
+ if ores.Error != nil {
+ return ores.Error
+ }
+ if ores.RowsAffected == 0 {
+ return nil // 已建过 → 幂等 no-op
+ }
+ ares := tx.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "channel"}, {Name: "provider_ref"}}, DoNothing: true,
+ }).Create(att)
+ if ares.Error != nil {
+ return ares.Error
+ }
+ created = ares.RowsAffected > 0
+ return nil
+ })
+ if err != nil {
+ return false, fmt.Errorf("store.CreateRenewalPaid: %w", err)
+ }
+ return created, nil
+}
+
func (s *OrderStore) CancelOrder(outTradeNo string) (bool, error) {
res := s.db.Model(&model.OrderV2{}).
Where("out_trade_no = ? AND status = ?", outTradeNo, model.OrderPendingV2).
diff --git a/internal/store/order_query.go b/internal/store/order_query.go
index 22bd89b..72c673b 100644
--- a/internal/store/order_query.go
+++ b/internal/store/order_query.go
@@ -27,6 +27,19 @@ func (s *OrderStore) GetOrder(outTradeNo string) (*model.OrderV2, error) {
return &o, nil
}
+// MarkDisputed 打拒付标(P8 Task6):条件 UPDATE 仅在 disputed=false 时翻转,幂等——
+// 重投同一 dispute 命中 rows_affected=0,不报错,调用方(recordChargeback)不看返回值
+// 也安全(打标不改状态机,不存在"取消标记"这一操作,单向翻转足够)。
+func (s *OrderStore) MarkDisputed(outTradeNo string) (bool, error) {
+ res := s.db.Model(&model.OrderV2{}).
+ Where("out_trade_no = ? AND disputed = ?", outTradeNo, false).
+ Update("disputed", true)
+ if res.Error != nil {
+ return false, fmt.Errorf("store.MarkDisputed: %w", res.Error)
+ }
+ return res.RowsAffected > 0, nil
+}
+
// AttemptByProviderRef resolves an attempt from a bare provider_ref, so settlement
// can recover out_trade_no + channel from a callback/query that only carries the ref.
func (s *OrderStore) AttemptByProviderRef(providerRef string) (*model.Attempt, error) {
diff --git a/internal/store/order_query_test.go b/internal/store/order_query_test.go
index 82ffbd5..7e723ae 100644
--- a/internal/store/order_query_test.go
+++ b/internal/store/order_query_test.go
@@ -232,3 +232,25 @@ func TestListAttemptsByChannelSince(t *testing.T) {
t.Fatalf("只应含近期 crypto, got %+v", got)
}
}
+
+// TestMarkDisputed 覆盖 P8 Task6 打标:条件 UPDATE 只在 disputed=false 时翻转,重投同一
+// dispute(第二次调用)幂等 no-op(rows_affected=0,不报错)。
+func TestMarkDisputed(t *testing.T) {
+ s := store.NewOrderStore(model.OpenTestDB(t))
+ seedOrder(t, s, "PAY-Q3")
+
+ flipped, err := s.MarkDisputed("PAY-Q3")
+ if err != nil || !flipped {
+ t.Fatalf("first MarkDisputed: flipped=%v err=%v", flipped, err)
+ }
+ o, err := s.GetOrder("PAY-Q3")
+ if err != nil || !o.Disputed {
+ t.Fatalf("order after MarkDisputed = %+v, %v", o, err)
+ }
+
+ // 重投同一 dispute → 幂等 no-op,不报错。
+ again, err := s.MarkDisputed("PAY-Q3")
+ if err != nil || again {
+ t.Fatalf("dup MarkDisputed: again=%v err=%v", again, err)
+ }
+}
diff --git a/internal/store/subscription.go b/internal/store/subscription.go
new file mode 100644
index 0000000..0ba7822
--- /dev/null
+++ b/internal/store/subscription.go
@@ -0,0 +1,87 @@
+package store
+
+import (
+ "errors"
+ "fmt"
+ "time"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+
+ "github.com/wangjia/pay/internal/model"
+)
+
+var ErrSubNotFound = errors.New("store: subscription not found")
+
+type SubscriptionStore struct{ db *gorm.DB }
+
+func NewSubscriptionStore(db *gorm.DB) *SubscriptionStore { return &SubscriptionStore{db: db} }
+
+// Create 幂等插入:重复 (channel,provider_sub_ref) → no-op(created=false)。
+// Stripe 会重投 checkout.session.completed;诞生订阅必须幂等。
+func (s *SubscriptionStore) Create(sub *model.Subscription) (bool, error) {
+ res := s.db.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "channel"}, {Name: "provider_sub_ref"}},
+ DoNothing: true,
+ }).Create(sub)
+ if res.Error != nil {
+ return false, fmt.Errorf("store.SubscriptionStore.Create: %w", res.Error)
+ }
+ return res.RowsAffected > 0, nil
+}
+
+func (s *SubscriptionStore) GetBySubID(subID string) (*model.Subscription, error) {
+ var sub model.Subscription
+ if err := s.db.Where("sub_id = ?", subID).First(&sub).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, ErrSubNotFound
+ }
+ return nil, fmt.Errorf("store.GetBySubID: %w", err)
+ }
+ return &sub, nil
+}
+
+func (s *SubscriptionStore) GetByProviderRef(channel, ref string) (*model.Subscription, error) {
+ var sub model.Subscription
+ if err := s.db.Where("channel = ? AND provider_sub_ref = ?", channel, ref).First(&sub).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, ErrSubNotFound
+ }
+ return nil, fmt.Errorf("store.GetByProviderRef: %w", err)
+ }
+ return &sub, nil
+}
+
+// Activate 置 active + 刷新续费锚点(past_due 续费成功恢复也走它);canceled 终态不复活。
+func (s *SubscriptionStore) Activate(subID string, periodEnd *time.Time) (bool, error) {
+ upd := map[string]any{"status": model.SubActive}
+ if periodEnd != nil {
+ upd["current_period_end"] = periodEnd
+ }
+ res := s.db.Model(&model.Subscription{}).
+ Where("sub_id = ? AND status <> ?", subID, model.SubCanceled).Updates(upd)
+ if res.Error != nil {
+ return false, fmt.Errorf("store.Activate: %w", res.Error)
+ }
+ return res.RowsAffected > 0, nil
+}
+
+func (s *SubscriptionStore) MarkPastDue(channel, providerRef string) (bool, error) {
+ res := s.db.Model(&model.Subscription{}).
+ Where("channel = ? AND provider_sub_ref = ? AND status = ?", channel, providerRef, model.SubActive).
+ Update("status", model.SubPastDue)
+ if res.Error != nil {
+ return false, fmt.Errorf("store.MarkPastDue: %w", res.Error)
+ }
+ return res.RowsAffected > 0, nil
+}
+
+func (s *SubscriptionStore) MarkCanceled(subID string) (bool, error) {
+ res := s.db.Model(&model.Subscription{}).
+ Where("sub_id = ? AND status <> ?", subID, model.SubCanceled).
+ Updates(map[string]any{"status": model.SubCanceled, "canceled_at": time.Now()})
+ if res.Error != nil {
+ return false, fmt.Errorf("store.MarkCanceled: %w", res.Error)
+ }
+ return res.RowsAffected > 0, nil
+}
diff --git a/internal/store/subscription_test.go b/internal/store/subscription_test.go
new file mode 100644
index 0000000..22dc9c1
--- /dev/null
+++ b/internal/store/subscription_test.go
@@ -0,0 +1,65 @@
+package store_test
+
+import (
+ "testing"
+ "time"
+
+ "github.com/wangjia/pay/internal/model"
+ "github.com/wangjia/pay/internal/store"
+)
+
+func newSubStore(t *testing.T) *store.SubscriptionStore {
+ return store.NewSubscriptionStore(model.OpenTestDB(t))
+}
+
+func TestSubscriptionCreateIdempotent(t *testing.T) {
+ s := newSubStore(t)
+ sub := &model.Subscription{SubID: "SUB-1", Channel: "stripe", ProviderSubRef: "sub_x",
+ OutTradeNo: "PAY-1", BizSystem: "pangolin", BizRef: "u-1", BizCode: "pro_month",
+ AmountMinor: 2999, Currency: "USD", Status: model.SubActive}
+ created, err := s.Create(sub)
+ if err != nil || !created {
+ t.Fatalf("first create: created=%v err=%v", created, err)
+ }
+ // 同 provider_sub_ref 再建 → 幂等 no-op(Stripe 重投 checkout.completed)
+ again, err := s.Create(&model.Subscription{SubID: "SUB-2", Channel: "stripe", ProviderSubRef: "sub_x",
+ OutTradeNo: "PAY-1", AmountMinor: 2999, Currency: "USD", Status: model.SubActive})
+ if err != nil || again {
+ t.Fatalf("dup create: again=%v err=%v", again, err)
+ }
+}
+
+func TestSubscriptionStateMachine(t *testing.T) {
+ s := newSubStore(t)
+ end := time.Now().Add(30 * 24 * time.Hour)
+ if _, err := s.Create(&model.Subscription{SubID: "SUB-9", Channel: "stripe", ProviderSubRef: "sub_9",
+ OutTradeNo: "PAY-9", AmountMinor: 2999, Currency: "USD", Status: model.SubActive}); err != nil {
+ t.Fatal(err)
+ }
+ // active → past_due
+ ok, _ := s.MarkPastDue("stripe", "sub_9")
+ if !ok {
+ t.Fatal("MarkPastDue should flip active→past_due")
+ }
+ // past_due → active(续费恢复)
+ ok, _ = s.Activate("SUB-9", &end)
+ if !ok {
+ t.Fatal("Activate should recover past_due→active")
+ }
+ // → canceled(终态)
+ ok, _ = s.MarkCanceled("SUB-9")
+ if !ok {
+ t.Fatal("MarkCanceled should flip →canceled")
+ }
+ // canceled 后不可复活
+ if ok, _ := s.Activate("SUB-9", &end); ok {
+ t.Fatal("canceled sub must not be re-activated")
+ }
+ if ok, _ := s.MarkPastDue("stripe", "sub_9"); ok {
+ t.Fatal("canceled sub must not go past_due")
+ }
+ got, err := s.GetBySubID("SUB-9")
+ if err != nil || got.Status != model.SubCanceled {
+ t.Fatalf("final status = %v err=%v", got.Status, err)
+ }
+}
diff --git a/internal/webhook/notifier.go b/internal/webhook/notifier.go
index 11b1b15..8fed5d0 100644
--- a/internal/webhook/notifier.go
+++ b/internal/webhook/notifier.go
@@ -154,6 +154,10 @@ func (n *Notifier) deliverOne(d *store.WebhookDeliveryRow) bool {
n.fail(d, "biz system not configured")
return false
}
+ if !eventSupported(cfg.SupportedEvents, d.EventType) {
+ _ = n.deliveries.MarkDelivered(d.ID) // 业务方未订阅该事件:视为已受理,不投递、不重试
+ return true
+ }
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := uuid.NewString()
sign := util.HMACSign(cfg.Secret, d.BizSystem, ts, nonce, d.Payload)
@@ -186,6 +190,21 @@ func (n *Notifier) deliverOne(d *store.WebhookDeliveryRow) bool {
return false
}
+// eventSupported 判断业务方是否声明支持该 event_type(设计 §「接入方显式声明支持事件
+// 集」):list 为空 → 仅 payment.succeeded 为真(v1/P2 接入方向后兼容,不会突然收到
+// subscription.*/chargeback.received 等新事件把它们搞崩);非空则按声明集精确匹配。
+func eventSupported(list []string, ev string) bool {
+ if len(list) == 0 {
+ return ev == "payment.succeeded"
+ }
+ for _, e := range list {
+ if e == ev {
+ return true
+ }
+ }
+ return false
+}
+
// truncate safely truncates a string to n bytes without splitting UTF-8 runes.
func truncate(s string, n int) string {
if len(s) <= n {
diff --git a/internal/webhook/notifier_test.go b/internal/webhook/notifier_test.go
index fb5ebaf..2dc36f7 100644
--- a/internal/webhook/notifier_test.go
+++ b/internal/webhook/notifier_test.go
@@ -2,6 +2,7 @@ package webhook_test
import (
"encoding/json"
+ "fmt"
"io"
"net/http"
"net/http/httptest"
@@ -188,3 +189,72 @@ func TestNotifierGateSkipsUnpaidOrder(t *testing.T) {
t.Fatalf("翻转后应投递, sent=%d hits=%d", sent, hits)
}
}
+
+// 事件集过滤(P8 Task7):业务方未声明支持的事件不投递、直接标 delivered(视为已受理,
+// 不占重试),已声明的事件正常投。SupportedEvents 空 → 只收 payment.succeeded(向后兼容
+// v1/P2 接入方,不会突然收到新事件把它们搞崩)。
+func TestNotifierFiltersUnsupportedEvents(t *testing.T) {
+ var hits []string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits = append(hits, r.Header.Get("X-Pay-Event"))
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("SUCCESS"))
+ }))
+ defer srv.Close()
+
+ ws := store.NewWebhookStore(model.OpenTestDB(t))
+ n := webhook.NewNotifier(ws, func(string) (config.BizSystemConfig, bool) {
+ // 只声明支持 payment.succeeded(空集等价语义的显式形式,顺带覆盖非空单元素集)。
+ return config.BizSystemConfig{CallbackURL: srv.URL, Secret: "x", SupportedEvents: []string{"payment.succeeded"}}, true
+ }, func(string) (bool, error) { return true, nil })
+
+ _ = n.Enqueue("PAY-5", "pangolin", "subscription.renewed", "", map[string]any{"event_type": "subscription.renewed"})
+ _ = n.Enqueue("PAY-6", "pangolin", "payment.succeeded", "", map[string]any{"event_type": "payment.succeeded"})
+
+ // deliverOne 对"未订阅事件"也返回 true(视为已受理、直接标 delivered,见 notifier.go
+ // eventSupported 分支的注释),所以 sent 计两条;真正发出的 POST 只应有 1 次。
+ sent, err := n.DeliverPending(10)
+ if err != nil || sent != 2 {
+ t.Fatalf("DeliverPending = %d, %v, want 2(1 条真投 + 1 条未订阅直接标 delivered)", sent, err)
+ }
+ if len(hits) != 1 || hits[0] != "payment.succeeded" {
+ t.Fatalf("只应收到 payment.succeeded 一次 POST, got %v", hits)
+ }
+ // 未订阅的事件已标 delivered(视为已受理),不占重试队列。
+ pend, _ := ws.ListUndelivered(10)
+ if len(pend) != 0 {
+ t.Fatalf("未订阅事件应标 delivered、不留队, got %+v", pend)
+ }
+}
+
+// 声明了全部 P8 事件集的业务方:所有事件都应正常投递(不被过滤)。
+func TestNotifierDeliversAllDeclaredEvents(t *testing.T) {
+ var hits []string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits = append(hits, r.Header.Get("X-Pay-Event"))
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("SUCCESS"))
+ }))
+ defer srv.Close()
+
+ all := []string{
+ "payment.succeeded", "subscription.renewed", "subscription.created",
+ "subscription.canceled", "subscription.past_due", "chargeback.received",
+ }
+ ws := store.NewWebhookStore(model.OpenTestDB(t))
+ n := webhook.NewNotifier(ws, func(string) (config.BizSystemConfig, bool) {
+ return config.BizSystemConfig{CallbackURL: srv.URL, Secret: "x", SupportedEvents: all}, true
+ }, func(string) (bool, error) { return true, nil })
+
+ for i, ev := range all {
+ _ = n.Enqueue(fmt.Sprintf("PAY-ALL-%d", i), "pangolin", ev, "", map[string]any{"event_type": ev})
+ }
+
+ sent, err := n.DeliverPending(10)
+ if err != nil || sent != len(all) {
+ t.Fatalf("DeliverPending = %d, %v, want %d(声明集内全投)", sent, err, len(all))
+ }
+ if len(hits) != len(all) {
+ t.Fatalf("应收到 %d 次 POST, got %v", len(all), hits)
+ }
+}
diff --git a/main.go b/main.go
index a655f44..309bb88 100644
--- a/main.go
+++ b/main.go
@@ -55,6 +55,8 @@ func main() {
// v2 统一网关装配(P2 骨架,P3 配置驱动注册接线):provider 注册表 + gateway + webhook notifier。
orderStore := store.NewOrderStore(db)
refundStore := store.NewRefundStore(db)
+ subStore := store.NewSubscriptionStore(db)
+ chargebackStore := store.NewChargebackStore(db)
webhookStore := store.NewWebhookStore(db)
notifier := webhook.NewNotifier(webhookStore, config.C.BizByName, func(no string) (bool, error) {
o, err := orderStore.GetOrder(no) // 投递门禁:订单已结算(paid 及退款态)才放行
@@ -71,7 +73,7 @@ func main() {
// limit_aware 用量数据源:P6 对账 Runner 周期 Refresh 的真实用量源(替 NopUsage)。
usage := reconcile.NewUsageSource(orderStore, time.Now)
acctPicker := accounts.NewRouter(acctReg, config.C.Routing, usage)
- gw := gateway.New(orderStore, refundStore, pReg, acctPicker, productResolver, notifier, "cn")
+ gw := gateway.New(orderStore, refundStore, pReg, acctPicker, productResolver, notifier, "cn", subStore, chargebackStore)
router.SetupV2(r, gw)
// P6 后台守护 / 对账:订单过期清理 + 用量刷新 + 查单对账收敛 + 已付抽查 +
@@ -166,6 +168,8 @@ func autoMigrate(db *gorm.DB) {
&model.NotifyLog{},
&model.BizNotifyLog{},
&model.OrderV2{}, &model.Attempt{}, &model.Account{}, &model.Refund{}, &model.WebhookDelivery{}, &model.OrphanPayment{}, // v2
+ &model.Subscription{}, // v2 recurring
+ &model.Chargeback{}, // v2 recurring — P8 Task6 拒付记录
); err != nil {
log.Fatalf("自动迁移失败: %v", err)
}