feat(pay-v2): P8 Task6 拒付 chargeback 记录 + chargeback.received

This commit is contained in:
wangjia
2026-07-10 18:52:13 +08:00
parent 02b2fcfa41
commit b0714ca758
19 changed files with 502 additions and 22 deletions
+171
View File
@@ -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))
}
}
+2 -1
View File
@@ -46,6 +46,7 @@ func TestE2ECryptoQuerySettles(t *testing.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"},
})
@@ -57,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", subs)
g := gateway.New(orders, refunds, preg, picker, cryptoResolver{}, spy, "global", subs, chargebacks)
// 下单 → 从 session payload 拿到期望链上金额(base+唯一尾数),喂给假 TronGrid。
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{
+12 -10
View File
@@ -39,20 +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
subs *store.SubscriptionStore
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, subs *store.SubscriptionStore) *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, subs: subs}
products: products, webhook: webhook, region: region, subs: subs, chargebacks: chargebacks}
}
type CreateOrderInput struct {
+2 -1
View File
@@ -73,6 +73,7 @@ func newGateway(t *testing.T) (*gateway.Gateway, *fake.Provider, *spyEnqueuer, *
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)
@@ -83,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", subs)
g := gateway.New(orders, refunds, preg, picker, stubResolver{}, spy, "global", subs, chargebacks)
return g, fp, spy, orders
}
+52 -2
View File
@@ -3,7 +3,6 @@ package gateway
import (
"context"
"errors"
"fmt"
"log"
"time"
@@ -207,8 +206,59 @@ func (g *Gateway) enqueueRenewed(sub *model.Subscription, renewalNo string, ev *
})
}
// 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) {
return SettleFailed, fmt.Errorf("not implemented: %s", ev.Kind)
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
+2 -1
View File
@@ -139,6 +139,7 @@ func TestSettleTransientReadErrorIsFailed(t *testing.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)
@@ -147,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", subs)
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"})
+2 -1
View File
@@ -74,6 +74,7 @@ func newSubGateway(t *testing.T) (*gateway.Gateway, *fakeSubProvider, *spyEnqueu
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)
@@ -82,7 +83,7 @@ func newSubGateway(t *testing.T) (*gateway.Gateway, *fakeSubProvider, *spyEnqueu
})
picker := accounts.NewRouter(areg, nil, nil)
spy := &spyEnqueuer{}
g := gateway.New(orders, refunds, preg, picker, stubSubResolver{}, spy, "global", subs)
g := gateway.New(orders, refunds, preg, picker, stubSubResolver{}, spy, "global", subs, chargebacks)
return g, fp, spy, orders, subs
}
+6 -3
View File
@@ -45,13 +45,14 @@ func buildEngineWithStore(t *testing.T) (*gin.Engine, *store.OrderStore) {
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", subs)
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
r := gin.New()
router.SetupV2(r, g)
return r, orders
@@ -179,6 +180,7 @@ func TestV2RetryCurrencyMismatch409(t *testing.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"
@@ -191,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", subs)
g := gateway.New(orders, refunds, preg, picker, oneResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
r := gin.New()
router.SetupV2(r, g)
@@ -307,6 +309,7 @@ func buildSubEngine(t *testing.T) (*gin.Engine, *subFakeProvider) {
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)
@@ -314,7 +317,7 @@ func buildSubEngine(t *testing.T) (*gin.Engine, *subFakeProvider) {
{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)
g := gateway.New(orders, refunds, preg, picker, subResolver{}, nopEnqueuer{}, "global", subs, chargebacks)
r := gin.New()
router.SetupV2(r, g)
return r, fp
+2 -1
View File
@@ -45,11 +45,12 @@ func buildRefundEngine(t *testing.T) (*gin.Engine, *gateway.Gateway, *store.Orde
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", subs)
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
+1 -1
View File
@@ -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{}, &Subscription{}); err != nil {
&Product{}, &ProductPrice{}, &Subscription{}, &Chargeback{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := UpgradeWebhookDeliveryIndex(db); err != nil {
+23
View File
@@ -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"`
}
// ---- 支付尝试(收款账本)----
@@ -151,3 +154,23 @@ type Subscription struct {
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/...)
}
+1
View File
@@ -103,6 +103,7 @@ type PaidEvent struct {
DisputeRef string // 拒付号
ProviderPaymentRef string // 拒付关联的 PaymentIntent id
OutTradeNo string // 拒付解析出的原单号(可空)
Reason string // 拒付原因(渠道枚举,如 stripe fraudulent/product_not_received)
}
// QueryRequest — Provider.Query 入参:尝试的完整上下文快照,不是裸 provider_ref。
+30
View File
@@ -102,6 +102,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 {
@@ -259,6 +267,28 @@ func (p *Provider) VerifyCallback(_ context.Context, in provider.CallbackInput)
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
+85
View File
@@ -55,6 +55,12 @@ func fakeStripeAPI(t *testing.T) *httptest.Server {
// 与"已取消"无关的普通渠道拒绝(如权限/网络类),不应被误判成哨兵。
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)
}
@@ -304,6 +310,85 @@ func TestVerifyCustomerSubscriptionDeleted(t *testing.T) {
}
}
// 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) {
+29
View File
@@ -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
}
+44
View File
@@ -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)
}
}
+13
View File
@@ -26,6 +26,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) {
+22
View File
@@ -49,3 +49,25 @@ func TestListPendingAndExpire(t *testing.T) {
}
_ = time.Now
}
// 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)
}
}
+3 -1
View File
@@ -42,6 +42,7 @@ func main() {
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 及退款态)才放行
@@ -57,7 +58,7 @@ func main() {
// P5 多账户路由:按 config.routing.<channel> 选策略(缺省 round_robin)。
// limit_aware 用量数据源 P6 对账就绪前用空源(NopUsage,退化为 round_robin)。
acctPicker := accounts.NewRouter(acctReg, config.C.Routing, accounts.NopUsage{})
gw := gateway.New(orderStore, refundStore, pReg, acctPicker, productResolver, notifier, "cn", subStore)
gw := gateway.New(orderStore, refundStore, pReg, acctPicker, productResolver, notifier, "cn", subStore, chargebackStore)
router.SetupV2(r, gw)
if config.C.QuerySync.Enabled {
@@ -124,6 +125,7 @@ func autoMigrate(db *gorm.DB) {
&model.BizNotifyLog{},
&model.OrderV2{}, &model.Attempt{}, &model.Account{}, &model.Refund{}, &model.WebhookDelivery{}, // v2
&model.Subscription{}, // v2 recurring
&model.Chargeback{}, // v2 recurring — P8 Task6 拒付记录
); err != nil {
log.Fatalf("自动迁移失败: %v", err)
}