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
+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) {