feat(pay-v2): P8 Task6 拒付 chargeback 记录 + chargeback.received
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/...)
|
||||
}
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user