merge: P8 订阅/recurring + 拒付 chargeback 并入(订阅生命周期/续费/取消/past_due/chargeback/事件集收口)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u # Conflicts: # internal/model/testdb.go # internal/provider/provider.go # internal/router/router.go # internal/store/order_query_test.go # main.go
This commit is contained in:
@@ -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 回调用)。未声明或无密钥则视为未接入。
|
||||
|
||||
@@ -276,24 +276,44 @@
|
||||
<tbody>
|
||||
<tr><td><code>payment.succeeded</code></td><td><span class="tag ok">先做</span></td><td>收款成功 → 接入方开通</td></tr>
|
||||
<tr><td><code>refund.succeeded</code> / <code>refund.failed</code></td><td><span class="tag ok">卡类要真做</span></td><td>退款结果 → 接入方冲正权益</td></tr>
|
||||
<tr><td><code>chargeback.created</code></td><td><span class="tag info">设计进·later</span></td><td>信用卡拒付(平台/发卡行发起)</td></tr>
|
||||
<tr><td><code>subscription.renewed|failed|canceled</code></td><td><span class="tag info">设计进·later</span></td><td>订阅事件(§见 recurring)</td></tr>
|
||||
<tr><td><code>subscription.created</code></td><td><span class="tag ok">✅ P8</span></td><td>订阅诞生(gateway_scheduled 首购成功,Stripe Subscriptions)</td></tr>
|
||||
<tr><td><code>subscription.renewed</code></td><td><span class="tag ok">✅ P8</span></td><td>续费成功(invoice.paid,下一期扣款到账)</td></tr>
|
||||
<tr><td><code>subscription.past_due</code></td><td><span class="tag ok">✅ P8</span></td><td>续费失败(invoice.payment_failed,订阅转 past_due,提醒用户换卡)</td></tr>
|
||||
<tr><td><code>subscription.canceled</code></td><td><span class="tag ok">✅ P8</span></td><td>订阅取消(业务方发起或渠道侧取消)</td></tr>
|
||||
<tr><td><code>chargeback.received</code></td><td><span class="tag ok">✅ P8</span></td><td>信用卡拒付(发卡行 dispute,仅 Stripe;crypto/支付宝/微信本轮无此流)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>签名沿用双向 HMAC(<code>system\ntimestamp\nnonce\nrawBody</code>,±5min,nonce 防重放);payload 幂等键 <code>out_trade_no</code>。接入方收 <code>payment.succeeded</code> → 验签 → 幂等 → 金额核对 → <code>max(现到期,now)+时长</code> 叠加(<b>此算法抽成共享工具</b>);非 200/不含 SUCCESS → 60s 重试 24h。</p>
|
||||
|
||||
<h3>5.1 自动续订(recurring):设计进模型、暂不实现</h3>
|
||||
<h3>5.1 自动续订(recurring):<code>gateway_scheduled</code>(Stripe)P8 已实现,其余仍设计进模型</h3>
|
||||
<p>本质分歧在"谁驱动下一期扣款",归 4 类,统一为"pay 维护 subscription 状态机,不同 kind 不同推进方式":</p>
|
||||
<table>
|
||||
<thead><tr><th>recurring_kind</th><th>谁驱动</th><th>代表</th><th>pay 如何推进</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>token_offsession</code></td><td>我方</td><td>Stripe自建/支付宝周期扣/微信papay</td><td>cron 到期主动 charge</td></tr>
|
||||
<tr><td><code>gateway_scheduled</code></td><td>网关</td><td>Stripe Subscriptions / PayPal</td><td>监听 invoice.paid webhook</td></tr>
|
||||
<tr><td><code>store_managed</code></td><td>平台</td><td>Apple IAP / Google Play</td><td>被动接 Server Notification/RTDN</td></tr>
|
||||
<tr><td><code>token_offsession</code></td><td>我方</td><td>Stripe自建/支付宝周期扣/微信papay</td><td>cron 到期主动 charge(能力位,<b>未实现</b>)</td></tr>
|
||||
<tr><td><code>gateway_scheduled</code></td><td>网关</td><td>Stripe Subscriptions / PayPal</td><td>监听 invoice.paid webhook(<span class="tag ok">✅ P8 已实现,Stripe</span>;PayPal 未接)</td></tr>
|
||||
<tr><td><code>store_managed</code></td><td>平台</td><td>Apple IAP / Google Play</td><td>被动接 Server Notification/RTDN(能力位,<b>未实现</b>)</td></tr>
|
||||
<tr><td><code>none</code></td><td>无</td><td>所有 crypto / 单笔</td><td>到期提醒用户手动再买(伪续订)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="small">统一 subscription 实体 + 状态机(active→grace→expired);单笔层与订阅层解耦。crypto 天生无 recurring、store_managed 完全平台掌控——跨渠道订阅是"同一 entitlement 的不同实现"。本轮把 event_type/subscription 实体设计进契约、避免第三次 breaking,但只建一次性付款。</p>
|
||||
<p class="small">统一 subscription 实体 + 状态机(active→past_due→canceled,<code>internal/model/subscription.go</code>);单笔层与订阅层解耦。crypto 天生无 recurring(<code>SupportsRecurring=false</code>,也无 chargeback)、store_managed 完全平台掌控——跨渠道订阅是"同一 entitlement 的不同实现"。P8 落地范围:Stripe Checkout(subscription mode)首购 → <code>subscription.created</code>;<code>invoice.paid</code> → 续费单 + <code>subscription.renewed</code>;<code>invoice.payment_failed</code> → <code>subscription.past_due</code>;取消(业务方 API 或渠道侧)→ <code>subscription.canceled</code>;<code>charge.dispute.created</code> → <code>Chargeback</code> 记录 + 原单打 disputed 标 + <code>chargeback.received</code>。<code>token_offsession</code>/<code>store_managed</code>/支付宝周期扣仍只是设计进契约的能力位,避免第三次 breaking,但本轮不实现。</p>
|
||||
|
||||
<h3>5.2 接入方显式声明支持事件集</h3>
|
||||
<p><code>BizSystemConfig.SupportedEvents</code>(<code>config.yaml</code> 的 <code>biz.<name>.supported_events</code>):接入方在配置里显式声明自己能处理哪些 <code>event_type</code>。<b>空 = 只收 <code>payment.succeeded</code></b>(v1/P2 老接入方向后兼容,不会突然收到 <code>subscription.*</code>/<code>chargeback.received</code> 等新事件把它们搞崩);非空则按声明集精确匹配。Notifier 投递前过滤:<code>event_type</code> 不在声明集 → 不 POST、直接标 <code>delivered</code>(视为已受理,不占重试队列)——避免给没准备好订阅/拒付处理逻辑的业务方硬推未知事件。</p>
|
||||
<p class="small">门禁旁路:<code>subscription.past_due</code>/<code>subscription.canceled</code> 挂在已 paid 的首购单(<code>out_trade_no</code>)上,既有的 <code>orderPaid</code> 投递门禁天然放行,无需额外旁路。</p>
|
||||
|
||||
<h3>5.3 webhook payload 契约(P8 新增事件)</h3>
|
||||
<table>
|
||||
<thead><tr><th>event_type</th><th>payload 关键字段</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>subscription.created</code></td><td><code>out_trade_no</code>(首购单号)/<code>sub_id</code>/<code>biz_system</code>/<code>biz_ref</code>/<code>product_biz_code</code>/<code>amount_minor</code>/<code>currency</code>/<code>channel</code>/<code>created_at</code></td></tr>
|
||||
<tr><td><code>subscription.renewed</code></td><td><code>out_trade_no</code>(<b>续费单号</b>,与首购单号不同)/<code>sub_id</code>/<code>biz_system</code>/<code>biz_ref</code>/<code>product_biz_code</code>/<code>amount_minor</code>/<code>currency</code>/<code>channel</code>/<code>paid_at</code></td></tr>
|
||||
<tr><td><code>subscription.past_due</code></td><td><code>out_trade_no</code>(首购单号)/<code>sub_id</code>/<code>biz_system</code>/<code>biz_ref</code>/<code>product_biz_code</code>/<code>failed_at</code></td></tr>
|
||||
<tr><td><code>subscription.canceled</code></td><td><code>out_trade_no</code>(首购单号)/<code>sub_id</code>/<code>biz_system</code>/<code>biz_ref</code>/<code>product_biz_code</code>/<code>canceled_at</code></td></tr>
|
||||
<tr><td><code>chargeback.received</code></td><td><code>out_trade_no</code>/<code>dispute_ref</code>/<code>biz_system</code>/<code>biz_ref</code>/<code>product_biz_code</code>/<code>amount_minor</code>/<code>currency</code>/<code>reason</code>/<code>received_at</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="small">所有事件的 <code>event_type</code> 字段本身也回显在 payload 顶层(与 <code>X-Pay-Event</code> 头一致),便于业务方单点分发。<code>subscription.renewed</code> 的幂等键是续费单号(每期不同),其余订阅/拒付事件幂等键是首购单号 + <code>event_type</code>(同订阅只会 created 一次、canceled 一次;<code>past_due</code> 当前实现下同订阅多次失败只保证首次必达,见 <code>internal/gateway/subscription.go::markSubscriptionPastDue</code> 注释)。</p>
|
||||
|
||||
<h2>6. 退款设计</h2>
|
||||
<p><b>退款政策归业务,退款机制归 pay</b>(与促销同一原则)。能不能退/退多少/时限 = 业务侧;pay 提供机制 + 记账 + 事件,不判断该不该退。</p>
|
||||
@@ -408,7 +428,7 @@
|
||||
<h2>13. 待确认 / 后续</h2>
|
||||
<ul>
|
||||
<li>本设计聚焦 <b>pay 深设计 + codes 定架构</b>;entitlement 各产品自留(不在本设计)。</li>
|
||||
<li>实现分期(留给 writing-plans):数据模型 + 配置/账户注册表 → Provider 抽象 + 一次性收款管线 + webhook v2 → 首批渠道(crypto 自托管 + 支付宝 + Stripe)→ 退款 → 多账户路由 → 对账 job → codes 共享内核 →(later)订阅/拒付。</li>
|
||||
<li>实现分期(留给 writing-plans):数据模型 + 配置/账户注册表 → Provider 抽象 + 一次性收款管线 + webhook v2 → 首批渠道(crypto 自托管 + 支付宝 + Stripe)→ 退款 → 多账户路由 → 对账 job → codes 共享内核 →(later)订阅/拒付 —— <b>P8 已落地</b>:<code>gateway_scheduled</code>(Stripe)订阅状态机 + <code>chargeback</code> 拒付记录 + 业务方事件集声明/过滤;<code>token_offsession</code>/<code>store_managed</code> 仍留能力位、未实现。</li>
|
||||
<li>补测试(现 pay 核心资金链路几乎无单测,托多产品钱必须补)。</li>
|
||||
<li>pangolin / jiu 收口改造:<b>pay 定稿后</b>再动(见 brain todo)。</li>
|
||||
</ul>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+154
-2
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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+"失败,请稍后重试")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/...)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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=<ts>,v1=hex(HMAC-SHA256(secret, "<ts>.<payload>"))
|
||||
func signStripe(payload, secret string, ts int64) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user