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自建/支付宝周期扣/微信papaycron 到期主动 charge
gateway_scheduled网关Stripe Subscriptions / PayPal监听 invoice.paid webhook
store_managed平台Apple IAP / Google Play被动接 Server Notification/RTDN
token_offsession我方Stripe自建/支付宝周期扣/微信papaycron 到期主动 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_failedsubscription.past_due;取消(业务方 API 或渠道侧)→ subscription.canceled;charge.dispute.createdChargeback 记录 + 原单打 disputed 标 + chargeback.receivedtoken_offsession/store_managed/支付宝周期扣仍只是设计进契约的能力位,避免第三次 breaking,但本轮不实现。

+ +

5.2 接入方显式声明支持事件集

+

BizSystemConfig.SupportedEvents(config.yamlbiz.<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_typepayload 关键字段
subscription.createdout_trade_no(首购单号)/sub_id/biz_system/biz_ref/product_biz_code/amount_minor/currency/channel/created_at
subscription.renewedout_trade_no(续费单号,与首购单号不同)/sub_id/biz_system/biz_ref/product_biz_code/amount_minor/currency/channel/paid_at
subscription.past_dueout_trade_no(首购单号)/sub_id/biz_system/biz_ref/product_biz_code/failed_at
subscription.canceledout_trade_no(首购单号)/sub_id/biz_system/biz_ref/product_biz_code/canceled_at
chargeback.receivedout_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. 待确认 / 后续

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) }