From b0714ca758d19e60eec7e6b861937efbb7c0c4c8 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 18:52:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(pay-v2):=20P8=20Task6=20=E6=8B=92=E4=BB=98?= =?UTF-8?q?=20chargeback=20=E8=AE=B0=E5=BD=95=20+=20chargeback.received?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/chargeback_test.go | 171 ++++++++++++++++++++++++ internal/gateway/e2e_crypto_test.go | 3 +- internal/gateway/gateway.go | 22 +-- internal/gateway/gateway_test.go | 3 +- internal/gateway/settle.go | 54 +++++++- internal/gateway/settle_test.go | 3 +- internal/gateway/subscription_test.go | 3 +- internal/handler/gateway_test.go | 9 +- internal/handler/refund_test.go | 3 +- internal/model/testdb.go | 2 +- internal/model/v2.go | 23 ++++ internal/provider/provider.go | 1 + internal/provider/stripe/stripe.go | 30 +++++ internal/provider/stripe/stripe_test.go | 85 ++++++++++++ internal/store/chargeback.go | 29 ++++ internal/store/chargeback_test.go | 44 ++++++ internal/store/order_query.go | 13 ++ internal/store/order_query_test.go | 22 +++ main.go | 4 +- 19 files changed, 502 insertions(+), 22 deletions(-) create mode 100644 internal/gateway/chargeback_test.go create mode 100644 internal/store/chargeback.go create mode 100644 internal/store/chargeback_test.go diff --git a/internal/gateway/chargeback_test.go b/internal/gateway/chargeback_test.go new file mode 100644 index 0000000..457c0ba --- /dev/null +++ b/internal/gateway/chargeback_test.go @@ -0,0 +1,171 @@ +package gateway_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/store" +) + +// newChargebackGateway 装配一套独立的 gateway(渠道 "substripe",复用 subscription_test.go +// 的 fakeSubProvider——它把测试注入的 JSON 原样反序列化成 provider.PaidEvent,包括 +// Kind/DisputeRef/OutTradeNo 等 P8 新增字段,fake.Provider 的精简版协议做不到这点),额外 +// 暴露 *store.ChargebackStore 供断言落库情况(P8 Task6 专用,不复用 newGateway/newSubGateway +// 避免改动其多处既有调用签名)。 +func newChargebackGateway(t *testing.T) (*gateway.Gateway, *store.OrderStore, *store.ChargebackStore, *spyEnqueuer) { + t.Helper() + db := model.OpenTestDB(t) + orders := store.NewOrderStore(db) + refunds := store.NewRefundStore(db) + subs := store.NewSubscriptionStore(db) + chargebacks := store.NewChargebackStore(db) + preg := provider.NewRegistry() + preg.Register(&fakeSubProvider{sessionRef: "cs_cb_1"}) + areg := accounts.New([]config.AccountConfig{ + {AccountID: "cb-a1", Channel: "substripe", Region: "global", Enabled: true, Weight: 1}, + }) + picker := accounts.NewRouter(areg, nil, nil) + spy := &spyEnqueuer{} + g := gateway.New(orders, refunds, preg, picker, stubSubResolver{}, spy, "global", subs, chargebacks) + return g, orders, chargebacks, spy +} + +func seedPaidOrder(t *testing.T, orders *store.OrderStore, no string) { + t.Helper() + if err := orders.CreateOrder(&model.OrderV2{ + OutTradeNo: no, BizSystem: "pangolin", BizRef: "u-1", BizCode: "pro_month", + AmountMinor: 2999, Currency: "USD", Status: model.OrderPaidV2, + }); err != nil { + t.Fatalf("seed paid order: %v", err) + } +} + +// TestRecordChargebackHappyPath 覆盖 brief Step1 ①②:charge.dispute.created → 落 +// Chargeback 一行 + 原 order Disputed=true + webhook spy 收 chargeback.received,且不 +// 自动改订单状态机(仍是 paid,只是 Disputed 打标)。 +func TestRecordChargebackHappyPath(t *testing.T) { + g, orders, chargebacks, spy := newChargebackGateway(t) + seedPaidOrder(t, orders, "PAY-1") + + raw, err := json.Marshal(provider.PaidEvent{ + Kind: provider.EventChargeback, DisputeRef: "dp_1", OutTradeNo: "PAY-1", + ProviderPaymentRef: "pi_1", PaidAmountMinor: 2999, PaidCurrency: "USD", + Reason: "fraudulent", Status: provider.PaidFailed, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw}) + if err != nil || result != gateway.SettleProcessed { + t.Fatalf("HandleCallback = %v, %v, want SettleProcessed", result, err) + } + + o, err := orders.GetOrder("PAY-1") + if err != nil { + t.Fatalf("GetOrder: %v", err) + } + if !o.Disputed { + t.Fatalf("order.Disputed = false, want true") + } + if o.Status != model.OrderPaidV2 { + t.Fatalf("order.Status = %s, want unchanged paid(打标不改状态机)", o.Status) + } + + _ = chargebacks // 幂等落库由下面的重投用例断言(created=false) + + if len(spy.calls) != 1 { + t.Fatalf("webhook calls = %d, want 1: %+v", len(spy.calls), spy.calls) + } + c := spy.calls[0] + if c["event_type"] != gateway.EvtChargebackReceived || c["out_trade_no"] != "PAY-1" || c["dispute_ref"] != "dp_1" { + t.Fatalf("webhook payload = %+v", c) + } +} + +// TestRecordChargebackDuplicateNotDoubleRecordedOrEnqueued 拒付重投(Stripe 常见重投场景)→ +// Chargeback 表不双记(ON CONFLICT dispute_ref)、webhook 不双发(outbox 唯一键)。 +func TestRecordChargebackDuplicateNotDoubleRecordedOrEnqueued(t *testing.T) { + g, orders, _, spy := newChargebackGateway(t) + seedPaidOrder(t, orders, "PAY-2") + + raw, err := json.Marshal(provider.PaidEvent{ + Kind: provider.EventChargeback, DisputeRef: "dp_2", OutTradeNo: "PAY-2", + ProviderPaymentRef: "pi_2", PaidAmountMinor: 1999, PaidCurrency: "USD", + Reason: "duplicate", Status: provider.PaidFailed, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if _, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw}); err != nil { + t.Fatalf("HandleCallback#1: %v", err) + } + result2, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw}) + if err != nil { + t.Fatalf("HandleCallback#2: %v", err) + } + if result2 != gateway.SettleDuplicate { + t.Fatalf("replay result = %v, want duplicate", result2) + } + if len(spy.calls) != 1 { + t.Fatalf("webhook calls after replay = %d, want still 1: %+v", len(spy.calls), spy.calls) + } +} + +// TestRecordChargebackEmptyOutTradeNoNotEnqueued 覆盖 brief Step1 ③:out_trade_no 空 +// (订阅拒付,PI 无 metadata)→ 仍落 Chargeback 记录,但不入队业务 webhook(无法定位业务单)。 +func TestRecordChargebackEmptyOutTradeNoNotEnqueued(t *testing.T) { + g, _, chargebacks, spy := newChargebackGateway(t) + + raw, err := json.Marshal(provider.PaidEvent{ + Kind: provider.EventChargeback, DisputeRef: "dp_sub_1", OutTradeNo: "", + ProviderPaymentRef: "pi_sub_1", PaidAmountMinor: 999, PaidCurrency: "USD", + Reason: "fraudulent", Status: provider.PaidFailed, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw}) + if err != nil || result != gateway.SettleProcessed { + t.Fatalf("HandleCallback = %v, %v, want SettleProcessed(已记录未转发)", result, err) + } + if len(spy.calls) != 0 { + t.Fatalf("webhook calls = %d, want 0(无法定位业务单不转发): %+v", len(spy.calls), spy.calls) + } + // 重投同一空 out_trade_no dispute → Chargeback 仍不双记(created 幂等),同样不入队。 + again, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw}) + if err != nil || again != gateway.SettleDuplicate { + t.Fatalf("replay = %v, %v, want duplicate", again, err) + } + if len(spy.calls) != 0 { + t.Fatalf("webhook calls after replay = %d, want still 0", len(spy.calls)) + } + _ = chargebacks +} + +// TestRecordChargebackUnknownOrderNotBlocking out_trade_no 非空但查单失败(极端场景,如脏 +// 数据/竞态)→ 已记录 Chargeback,定位失败不阻断、不 panic,同样不转发。 +func TestRecordChargebackUnknownOrderNotBlocking(t *testing.T) { + g, _, _, spy := newChargebackGateway(t) + + raw, err := json.Marshal(provider.PaidEvent{ + Kind: provider.EventChargeback, DisputeRef: "dp_unknown", OutTradeNo: "NOPE", + ProviderPaymentRef: "pi_unknown", PaidAmountMinor: 500, PaidCurrency: "USD", + Reason: "fraudulent", Status: provider.PaidFailed, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + result, err := g.HandleCallback(context.Background(), "substripe", provider.CallbackInput{Raw: raw}) + if err != nil || result != gateway.SettleProcessed { + t.Fatalf("HandleCallback = %v, %v, want SettleProcessed(已记录,查单失败不阻断)", result, err) + } + if len(spy.calls) != 0 { + t.Fatalf("webhook calls = %d, want 0", len(spy.calls)) + } +} diff --git a/internal/gateway/e2e_crypto_test.go b/internal/gateway/e2e_crypto_test.go index f813cf5..16a9bcf 100644 --- a/internal/gateway/e2e_crypto_test.go +++ b/internal/gateway/e2e_crypto_test.go @@ -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{ diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 3eafa7d..658e11e 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -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 { diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index f15487a..15631b8 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -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 } diff --git a/internal/gateway/settle.go b/internal/gateway/settle.go index 38faa8b..6f594b4 100644 --- a/internal/gateway/settle.go +++ b/internal/gateway/settle.go @@ -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 diff --git a/internal/gateway/settle_test.go b/internal/gateway/settle_test.go index 5f7673f..04713c5 100644 --- a/internal/gateway/settle_test.go +++ b/internal/gateway/settle_test.go @@ -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"}) diff --git a/internal/gateway/subscription_test.go b/internal/gateway/subscription_test.go index 45f0017..9eba499 100644 --- a/internal/gateway/subscription_test.go +++ b/internal/gateway/subscription_test.go @@ -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 } diff --git a/internal/handler/gateway_test.go b/internal/handler/gateway_test.go index 3e4ff84..059d714 100644 --- a/internal/handler/gateway_test.go +++ b/internal/handler/gateway_test.go @@ -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 diff --git a/internal/handler/refund_test.go b/internal/handler/refund_test.go index e3297fa..42626eb 100644 --- a/internal/handler/refund_test.go +++ b/internal/handler/refund_test.go @@ -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 diff --git a/internal/model/testdb.go b/internal/model/testdb.go index e12d7f3..3a01643 100644 --- a/internal/model/testdb.go +++ b/internal/model/testdb.go @@ -32,7 +32,7 @@ func OpenTestDB(t *testing.T) *gorm.DB { t.Fatalf("open test db: %v", err) } if err := db.AutoMigrate(&OrderV2{}, &Attempt{}, &Account{}, &Refund{}, &WebhookDelivery{}, - &Product{}, &ProductPrice{}, &Subscription{}); err != nil { + &Product{}, &ProductPrice{}, &Subscription{}, &Chargeback{}); err != nil { t.Fatalf("migrate: %v", err) } if err := UpgradeWebhookDeliveryIndex(db); err != nil { diff --git a/internal/model/v2.go b/internal/model/v2.go index f5f4dcc..ecad990 100644 --- a/internal/model/v2.go +++ b/internal/model/v2.go @@ -72,6 +72,9 @@ type OrderV2 struct { DiscountReason string `gorm:"size:128"` PaidAt *time.Time ExpiresAt *time.Time // 整体购买窗口 + // Disputed 打标位(P8 Task6):渠道拒付(charge.dispute.created)命中该单时置 true, + // 仅留痕、不改状态机(§6 决策:不自动回收权益,业务方按 chargeback.received 自行冲正)。 + Disputed bool `gorm:"default:false"` } // ---- 支付尝试(收款账本)---- @@ -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/...) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 9b05b90..12e8708 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -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。 diff --git a/internal/provider/stripe/stripe.go b/internal/provider/stripe/stripe.go index 44007ca..7fe02dd 100644 --- a/internal/provider/stripe/stripe.go +++ b/internal/provider/stripe/stripe.go @@ -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 diff --git a/internal/provider/stripe/stripe_test.go b/internal/provider/stripe/stripe_test.go index 34b4c6c..f889253 100644 --- a/internal/provider/stripe/stripe_test.go +++ b/internal/provider/stripe/stripe_test.go @@ -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) { diff --git a/internal/store/chargeback.go b/internal/store/chargeback.go new file mode 100644 index 0000000..4d3f4f3 --- /dev/null +++ b/internal/store/chargeback.go @@ -0,0 +1,29 @@ +package store + +import ( + "fmt" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/wangjia/pay/internal/model" +) + +// ChargebackStore 持久化拒付留痕(P8 Task6)。与 SubscriptionStore/OrderStore 同惯例: +// 幂等靠 DB 唯一约束 + ON CONFLICT DO NOTHING,不在应用层加锁。 +type ChargebackStore struct{ db *gorm.DB } + +func NewChargebackStore(db *gorm.DB) *ChargebackStore { return &ChargebackStore{db: db} } + +// Create 幂等插入:重复 dispute_ref(渠道重投同一拒付)→ no-op(created=false)。 +// Chargeback 表对"能否定位业务单"保持中立——OutTradeNo 为空也照常落一行留痕。 +func (s *ChargebackStore) Create(cb *model.Chargeback) (bool, error) { + res := s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "dispute_ref"}}, + DoNothing: true, + }).Create(cb) + if res.Error != nil { + return false, fmt.Errorf("store.ChargebackStore.Create: %w", res.Error) + } + return res.RowsAffected > 0, nil +} diff --git a/internal/store/chargeback_test.go b/internal/store/chargeback_test.go new file mode 100644 index 0000000..72fd07f --- /dev/null +++ b/internal/store/chargeback_test.go @@ -0,0 +1,44 @@ +package store_test + +import ( + "testing" + + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/store" +) + +func TestChargebackCreateIdempotent(t *testing.T) { + s := store.NewChargebackStore(model.OpenTestDB(t)) + cb := &model.Chargeback{ + DisputeRef: "dp_1", OutTradeNo: "PAY-1", Channel: "stripe", + ProviderPaymentRef: "pi_1", AmountMinor: 2999, Currency: "USD", + Reason: "fraudulent", Status: "received", + } + created, err := s.Create(cb) + if err != nil || !created { + t.Fatalf("first create: created=%v err=%v", created, err) + } + // 同 dispute_ref 重投(Stripe 重投 charge.dispute.created)→ 幂等 no-op,不双记。 + again, err := s.Create(&model.Chargeback{ + DisputeRef: "dp_1", OutTradeNo: "PAY-1", Channel: "stripe", + ProviderPaymentRef: "pi_1", AmountMinor: 2999, Currency: "USD", + Reason: "fraudulent", Status: "received", + }) + if err != nil || again { + t.Fatalf("dup create: again=%v err=%v", again, err) + } +} + +// 订阅拒付常无法解析出业务单号(PI 不带 out_trade_no metadata)——Chargeback 表对此 +// 保持中立,OutTradeNo 空值也照常落一行留痕,不因空值而失败或跳过。 +func TestChargebackCreateWithEmptyOutTradeNo(t *testing.T) { + s := store.NewChargebackStore(model.OpenTestDB(t)) + created, err := s.Create(&model.Chargeback{ + DisputeRef: "dp_sub_1", OutTradeNo: "", Channel: "stripe", + ProviderPaymentRef: "pi_sub_1", AmountMinor: 999, Currency: "USD", + Reason: "fraudulent", Status: "received", + }) + if err != nil || !created { + t.Fatalf("create with empty out_trade_no: created=%v err=%v", created, err) + } +} diff --git a/internal/store/order_query.go b/internal/store/order_query.go index 06867df..4e93f5d 100644 --- a/internal/store/order_query.go +++ b/internal/store/order_query.go @@ -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) { diff --git a/internal/store/order_query_test.go b/internal/store/order_query_test.go index a3f235c..bf4b612 100644 --- a/internal/store/order_query_test.go +++ b/internal/store/order_query_test.go @@ -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) + } +} diff --git a/main.go b/main.go index 4cdabf5..01e4967 100644 --- a/main.go +++ b/main.go @@ -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. 选策略(缺省 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) }