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