fix(v2): P4 终审波——歧义渠道错误留 processing(ErrRefundRejected 哨兵)/ 拒绝fixture签名 / 错误映射测试 / GetRefund 日志
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -3,6 +3,7 @@ package gateway
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pay/internal/model"
|
||||
@@ -93,9 +94,20 @@ func (g *Gateway) Refund(ctx context.Context, in RefundInput) (*RefundResult, er
|
||||
// 渠道退款:refundID 作渠道幂等键。
|
||||
refundRef, pstatus, rerr := rp.Refund(ctx, att.ProviderRef, refundID, in.AmountMinor, in.Reason)
|
||||
if rerr != nil {
|
||||
_, _ = g.refunds.MarkRefundStatus(refundID, model.RefundProcessing, model.RefundFailed, "", time.Now())
|
||||
_ = g.enqueueRefundEvent(o, att, r, "refund.failed", "")
|
||||
return &RefundResult{RefundID: refundID, Status: string(model.RefundFailed)}, rerr
|
||||
if errors.Is(rerr, provider.ErrRefundRejected) {
|
||||
// 渠道明确拒绝:钱确定没退成,安全标 failed 并释放预留额度(RefundSum
|
||||
// 守卫按 status<>failed 计,见 store.CreateRefundGuarded)。
|
||||
_, _ = g.refunds.MarkRefundStatus(refundID, model.RefundProcessing, model.RefundFailed, "", time.Now())
|
||||
_ = g.enqueueRefundEvent(o, att, r, "refund.failed", "")
|
||||
return &RefundResult{RefundID: refundID, Status: string(model.RefundFailed)}, rerr
|
||||
}
|
||||
// 歧义渠道错误(超时/网络/5xx 等结果不确定的失败):不改状态,留 processing
|
||||
// 继续占用预留额度——渠道那边可能已经实际执行了退款,标 failed 会在没有真退成
|
||||
// 的情况下释放额度,让同单能再退一次造成超退,而且 P6 只扫 processing 态的
|
||||
// RefundStuckAlertTask 对账/告警会因为状态被误标 failed 而永远扫不到它。
|
||||
// 退款结果不确定,留 processing 待人工核实/对账收敛。
|
||||
log.Printf("[gateway] refund %s 渠道结果不确定(非 ErrRefundRejected),留 processing 待人工/对账: %v", refundID, rerr)
|
||||
return &RefundResult{RefundID: refundID, Status: string(model.RefundProcessing)}, rerr
|
||||
}
|
||||
switch pstatus {
|
||||
case provider.PaidSucceeded:
|
||||
|
||||
@@ -3,6 +3,7 @@ package gateway_test
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pay/internal/gateway"
|
||||
@@ -115,3 +116,71 @@ func TestRefundManualForCryptoLikeChannel(t *testing.T) {
|
||||
t.Fatalf("complete webhook = %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// 渠道明确拒绝(errors.Is(rerr, provider.ErrRefundRejected))→ 现行为:标 failed,
|
||||
// 释放预留额度(RefundSum 守卫按 status<>failed 计,故 failed 后同单能再退全款)。
|
||||
func TestRefundChannelRejectedMarksFailed(t *testing.T) {
|
||||
g, fp, spy, orders := newGateway(t)
|
||||
rejectErr := fmt.Errorf("channel: 交易已完结: %w", provider.ErrRefundRejected)
|
||||
fp.EnableRefund("", provider.PaidFailed, rejectErr)
|
||||
no := createAndPay(t, g, fp, orders)
|
||||
nCalls := len(spy.calls)
|
||||
|
||||
res, err := g.Refund(context.Background(), gateway.RefundInput{
|
||||
OutTradeNo: no, AmountMinor: 9990000, Reason: "test", BizSystem: "pangolin",
|
||||
})
|
||||
if !errors.Is(err, provider.ErrRefundRejected) {
|
||||
t.Fatalf("err = %v want wrap ErrRefundRejected", err)
|
||||
}
|
||||
if res == nil || res.Status != string(model.RefundFailed) {
|
||||
t.Fatalf("res = %+v want status=failed", res)
|
||||
}
|
||||
got, gerr := g.GetRefund(res.RefundID)
|
||||
if gerr != nil || got.Status != string(model.RefundFailed) {
|
||||
t.Fatalf("GetRefund = %+v, %v want status=failed", got, gerr)
|
||||
}
|
||||
if len(spy.calls) != nCalls+1 || spy.calls[len(spy.calls)-1]["event_type"] != "refund.failed" {
|
||||
t.Fatalf("渠道拒绝应发 refund.failed 事件, calls=%+v", spy.calls)
|
||||
}
|
||||
// 额度已释放(failed 不占 reserved)→ 同单再退全款不应被超退守卫拦下(即便这次
|
||||
// fp 仍预置拒绝、第二笔也会 failed,关键是不撞 ErrRefundAmountInvalid)。
|
||||
if _, err := g.Refund(context.Background(), gateway.RefundInput{
|
||||
OutTradeNo: no, AmountMinor: 29990000, BizSystem: "pangolin",
|
||||
}); errors.Is(err, gateway.ErrRefundAmountInvalid) {
|
||||
t.Fatalf("failed 退款不应继续占额度,再退全款不应被超退守卫拒: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 歧义渠道错误(超时/网络/5xx 等,不满足 errors.Is(..., ErrRefundRejected))→ 不得标
|
||||
// failed:留 processing 继续占预留额度,交人工/P6 RefundStuckAlertTask 对账收敛。
|
||||
func TestRefundAmbiguousChannelErrorStaysProcessing(t *testing.T) {
|
||||
g, fp, spy, orders := newGateway(t)
|
||||
ambiguousErr := errors.New("channel: dial tcp: i/o timeout")
|
||||
fp.EnableRefund("", provider.PaidFailed, ambiguousErr)
|
||||
no := createAndPay(t, g, fp, orders)
|
||||
nCalls := len(spy.calls)
|
||||
|
||||
res, err := g.Refund(context.Background(), gateway.RefundInput{
|
||||
OutTradeNo: no, AmountMinor: 9990000, Reason: "test", BizSystem: "pangolin",
|
||||
})
|
||||
if err == nil || errors.Is(err, provider.ErrRefundRejected) {
|
||||
t.Fatalf("err = %v want plain(non-rejected) error", err)
|
||||
}
|
||||
if res == nil || res.Status != string(model.RefundProcessing) {
|
||||
t.Fatalf("res = %+v want status=processing(留待人工/对账,不得标 failed)", res)
|
||||
}
|
||||
got, gerr := g.GetRefund(res.RefundID)
|
||||
if gerr != nil || got.Status != "processing" {
|
||||
t.Fatalf("GetRefund = %+v, %v want status=processing(不得改状态)", got, gerr)
|
||||
}
|
||||
// 歧义错误不应发 refund.failed 事件(渠道结果未定,不能对业务方宣告失败)。
|
||||
if len(spy.calls) != nCalls {
|
||||
t.Fatalf("歧义错误不应发退款事件, calls=%+v", spy.calls)
|
||||
}
|
||||
// 预留额度仍被占用(processing 计入 reserved)→ 同单再退到超额应被守卫拒。
|
||||
if _, err := g.Refund(context.Background(), gateway.RefundInput{
|
||||
OutTradeNo: no, AmountMinor: 25000000, BizSystem: "pangolin",
|
||||
}); !errors.Is(err, gateway.ErrRefundAmountInvalid) {
|
||||
t.Fatalf("processing 应继续占额度,超额再退应被超退守卫拒, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,12 @@ func (h *GatewayHandler) CreateRefund(c *gin.Context) {
|
||||
func (h *GatewayHandler) GetRefund(c *gin.Context) {
|
||||
v, err := h.g.GetRefund(c.Param("refund_id"))
|
||||
if err != nil {
|
||||
util.RespondError(c, http.StatusNotFound, "refund_not_found", "退款单不存在")
|
||||
if errors.Is(err, store.ErrRefundNotFound) {
|
||||
util.RespondError(c, http.StatusNotFound, "refund_not_found", "退款单不存在")
|
||||
return
|
||||
}
|
||||
log.Printf("[v2 refund] 查询退款单失败 %s: %v", c.Param("refund_id"), err)
|
||||
util.RespondError(c, http.StatusInternalServerError, "get_refund_failed", "查询失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, v)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
@@ -129,3 +130,131 @@ func TestManualRefundAdminFlow(t *testing.T) {
|
||||
t.Fatalf("order = %s want refunded", o.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundHTTPErrorMapping table-drives writeRefundErr(POST /refunds)与
|
||||
// CompleteRefund(POST /admin/refunds/:id/complete)的错误 → {status,code} 映射,
|
||||
// 断言 P4 final-review item 3 点名的四种场景。
|
||||
func TestRefundHTTPErrorMapping(t *testing.T) {
|
||||
payOrder := func(t *testing.T, g *gateway.Gateway, orders *store.OrderStore, fp *fake.Provider, orderNo string) {
|
||||
t.Helper()
|
||||
atts, _ := orders.ListAttemptsByStatus(model.AttemptPending, 10)
|
||||
var ref string
|
||||
for _, a := range atts {
|
||||
if a.OutTradeNo == orderNo {
|
||||
ref = a.ProviderRef
|
||||
}
|
||||
}
|
||||
fp.SetQueryResult(ref, provider.PaidEvent{ProviderRef: ref, Status: provider.PaidSucceeded, PaidAmountMinor: 29990000, PaidCurrency: "USDT"})
|
||||
if _, err := g.SyncPendingAttempts(context.Background(), 10); err != nil {
|
||||
t.Fatalf("sync: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
run func(t *testing.T) *http.Response
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
name: "未知订单 → 404 order_not_found",
|
||||
run: func(t *testing.T) *http.Response {
|
||||
r, _, _, _ := buildRefundEngine(t)
|
||||
return signedRefundReq(t, r, map[string]any{"out_trade_no": "no-such-order", "amount_minor": 100, "biz_system": "pangolin"})
|
||||
},
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantCode: "order_not_found",
|
||||
},
|
||||
{
|
||||
// item 4:GetRefund 对 store.ErrRefundNotFound 精确映射 404 refund_not_found
|
||||
// (而非把所有查询失败都笼统 404)。
|
||||
name: "未知退款单号(GET /refunds/:id)→ 404 refund_not_found",
|
||||
run: func(t *testing.T) *http.Response {
|
||||
r, _, _, _ := buildRefundEngine(t)
|
||||
req, _ := http.NewRequest(http.MethodGet, "/api/v2/refunds/no-such-refund-id", nil)
|
||||
return serve(t, r, req)
|
||||
},
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantCode: "refund_not_found",
|
||||
},
|
||||
{
|
||||
name: "订单未结算(仍 pending)→ 409 order_not_refundable",
|
||||
run: func(t *testing.T) *http.Response {
|
||||
r, g, _, _ := buildRefundEngine(t)
|
||||
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-pending"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
return signedRefundReq(t, r, map[string]any{"out_trade_no": res.OrderNo, "amount_minor": 100, "biz_system": "pangolin"})
|
||||
},
|
||||
wantStatus: http.StatusConflict,
|
||||
wantCode: "order_not_refundable",
|
||||
},
|
||||
{
|
||||
name: "超退金额(超过可退余额)→ 422 refund_amount_invalid",
|
||||
run: func(t *testing.T) *http.Response {
|
||||
r, g, orders, fp := buildRefundEngine(t)
|
||||
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-over"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
payOrder(t, g, orders, fp, res.OrderNo)
|
||||
return signedRefundReq(t, r, map[string]any{"out_trade_no": res.OrderNo, "amount_minor": 29990000 + 1, "biz_system": "pangolin"})
|
||||
},
|
||||
wantStatus: http.StatusUnprocessableEntity,
|
||||
wantCode: "refund_amount_invalid",
|
||||
},
|
||||
{
|
||||
name: "重复人工完成(已非 manual_pending)→ 409 not_manual",
|
||||
run: func(t *testing.T) *http.Response {
|
||||
t.Setenv("PAY_ADMIN_TOKEN", "adm-tok")
|
||||
r, g, orders, fp := buildRefundEngine(t)
|
||||
res, err := g.CreateOrder(context.Background(), gateway.CreateOrderInput{SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-dup"})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
payOrder(t, g, orders, fp, res.OrderNo)
|
||||
// fp 未 EnableRefund → manual_pending
|
||||
mr, err := g.Refund(context.Background(), gateway.RefundInput{OutTradeNo: res.OrderNo, AmountMinor: 29990000, BizSystem: "pangolin"})
|
||||
if err != nil {
|
||||
t.Fatalf("refund: %v", err)
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodPost, "/api/v2/admin/refunds/"+mr.RefundID+"/complete", jsonBody(mustJSON(map[string]any{"provider_refund_ref": "tron-tx"})))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Pay-Admin", "adm-tok")
|
||||
if resp := serve(t, r, req); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("first complete status = %d want 200", resp.StatusCode)
|
||||
}
|
||||
// 第二次 complete:refund 已 succeeded,非 manual_pending → 应 409。
|
||||
req2, _ := http.NewRequest(http.MethodPost, "/api/v2/admin/refunds/"+mr.RefundID+"/complete", jsonBody(mustJSON(map[string]any{"provider_refund_ref": "tron-tx-2"})))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
req2.Header.Set("X-Pay-Admin", "adm-tok")
|
||||
return serve(t, r, req2)
|
||||
},
|
||||
wantStatus: http.StatusConflict,
|
||||
wantCode: "not_manual",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
resp := c.run(t)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if resp.StatusCode != c.wantStatus {
|
||||
t.Fatalf("status = %d want %d, body=%s", resp.StatusCode, c.wantStatus, body)
|
||||
}
|
||||
var out struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
t.Fatalf("decode body %s: %v", body, err)
|
||||
}
|
||||
if out.Code != c.wantCode {
|
||||
t.Fatalf("code = %q want %q, body=%s", out.Code, c.wantCode, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package alipay
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -55,7 +56,11 @@ func (p *Provider) Refund(ctx context.Context, providerRef, refundID string, amo
|
||||
return "", provider.PaidFailed, fmt.Errorf("alipay: 退款请求失败: %w", err)
|
||||
}
|
||||
if rsp.IsFailure() {
|
||||
return "", provider.PaidFailed, fmt.Errorf("alipay: 退款被拒: %w", rsp.Error)
|
||||
// 渠道明确拒绝(如 code=40004 ACQ.TRADE_HAS_FINISHED):钱确定没退成,
|
||||
// wrap ErrRefundRejected 让 gateway.Refund 能安全标 failed 释放预留额度。
|
||||
// 与之相对,前面 TradeRefund 调用本身失败(超时/网络错误)不 wrap 此哨兵——
|
||||
// 那种情况渠道侧是否已实际退款不确定,不能标 failed(见 item 1)。
|
||||
return "", provider.PaidFailed, fmt.Errorf("alipay: 退款被拒: %w", errors.Join(provider.ErrRefundRejected, rsp.Error))
|
||||
}
|
||||
// 退款在支付宝侧以 out_request_no 定位;refundRef 回传我方退款单号(= out_request_no)。
|
||||
return refundID, provider.PaidSucceeded, nil
|
||||
|
||||
@@ -9,12 +9,14 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
sw "github.com/smartwalle/alipay/v3"
|
||||
|
||||
"github.com/wangjia/pay/internal/money"
|
||||
"github.com/wangjia/pay/internal/provider"
|
||||
ali "github.com/wangjia/pay/internal/provider/alipay"
|
||||
)
|
||||
@@ -117,6 +119,10 @@ func TestAlipayRefundIdempotentNoFundChange(t *testing.T) {
|
||||
|
||||
// 渠道拒绝(如金额超出可退余额)返回 code!=10000 的失败响应 —— TradeRefund 应把
|
||||
// rsp.Error 包出去,refundRef 留空,状态归 PaidFailed。
|
||||
// TestAlipayRefundChannelRejected 的响应必须像 fakeAlipayRefund 一样用"支付宝侧"私钥
|
||||
// 签名——否则 DecodeNotification/TradeRefund 在验签阶段就报错,走的是传输错误分支
|
||||
// (rsp 根本拿不到),永远到不了下面要验证的 rsp.IsFailure() 分支,原始 fixture 未签名
|
||||
// 这个测试其实是假绿的(见 P4 item 2)。
|
||||
func TestAlipayRefundChannelRejected(t *testing.T) {
|
||||
aliKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
@@ -128,7 +134,12 @@ func TestAlipayRefundChannelRejected(t *testing.T) {
|
||||
"sub_code": "ACQ.TRADE_HAS_FINISHED", "sub_msg": "交易已完结",
|
||||
}
|
||||
nodeJSON, _ := json.Marshal(node)
|
||||
resp := map[string]any{"alipay_trade_refund_response": json.RawMessage(nodeJSON)}
|
||||
h := sha256.Sum256(nodeJSON)
|
||||
sig, _ := rsa.SignPKCS1v15(rand.Reader, aliKey, crypto.SHA256, h[:])
|
||||
resp := map[string]any{
|
||||
"alipay_trade_refund_response": json.RawMessage(nodeJSON),
|
||||
"sign": base64.StdEncoding.EncodeToString(sig),
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
@@ -139,7 +150,30 @@ func TestAlipayRefundChannelRejected(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("want 渠道拒绝返回 error")
|
||||
}
|
||||
// 真正走到 rsp.IsFailure() 分支后,error 须满足 errors.Is(..., ErrRefundRejected)——
|
||||
// 与 item 1 配套:gateway.Refund 靠这个哨兵区分"渠道确定性拒绝"(可标 failed)
|
||||
// vs"结果不确定"(须留 processing)。
|
||||
if !errors.Is(err, provider.ErrRefundRejected) {
|
||||
t.Fatalf("err = %v want wrap ErrRefundRejected", err)
|
||||
}
|
||||
if status != provider.PaidFailed || ref != "" {
|
||||
t.Fatalf("refund result: ref=%q status=%s", ref, status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoneyFormatStripsTrailingZerosForAlipay 记录一个既定行为(防静默回归):
|
||||
// money.Format 对整元金额去尾零,19900 分(199.00 元)格式化成 "199" 而非 "199.00"。
|
||||
// 上面所有 alipay refund 用例传的 19900 分走的正是这条路径(见 p.Refund 内
|
||||
// money.Format(amountMinor,"CNY") 再作为 RefundAmount 传给 alipay.trade.refund)。
|
||||
// 支付宝接受这种去尾零形态的金额字符串,且与建单路径(Create → money.Format)同源、
|
||||
// 已被 TestParseFormatRoundTrip 覆盖一致性,这里单独钉一行防止 alipay refund 路径
|
||||
// 依赖的具体字符串形态被静默改掉。
|
||||
func TestMoneyFormatStripsTrailingZerosForAlipay(t *testing.T) {
|
||||
got, err := money.Format(19900, "CNY")
|
||||
if err != nil {
|
||||
t.Fatalf("money.Format(19900,CNY): %v", err)
|
||||
}
|
||||
if got != "199" {
|
||||
t.Fatalf("money.Format(19900,CNY) = %q want %q(既定去尾零行为)", got, "199")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,14 @@ type QueryRequest struct {
|
||||
var (
|
||||
ErrUnknownMethod = errors.New("provider: unknown method")
|
||||
ErrNotSupported = errors.New("provider: capability not supported")
|
||||
|
||||
// ErrRefundRejected — 渠道对退款请求的一个"确定性"拒绝(如金额超出可退余额、
|
||||
// 交易已完结等业务规则拒绝),而非超时/网络错误/5xx 这类结果不确定的失败。
|
||||
// RefundingProvider.Refund 的实现须在能确认渠道明确拒绝时,让返回的 error 满足
|
||||
// errors.Is(err, ErrRefundRejected)(errors.Join 或 %w 包裹均可);网关据此区分
|
||||
// "钱确定没退成可以标 failed 释放额度" vs "退没退不确定,必须留 processing 占额度
|
||||
// 交人工/对账收敛"(P6 RefundStuckAlertTask)。
|
||||
ErrRefundRejected = errors.New("provider: refund rejected by channel")
|
||||
)
|
||||
|
||||
// Provider — 每个支付渠道实现的统一接口(设计 §4.1 PaymentProvider)。
|
||||
|
||||
@@ -7,6 +7,7 @@ package stripe
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -65,6 +66,14 @@ func (p *Provider) Refund(_ context.Context, providerRef, refundID string, amoun
|
||||
params.SetIdempotencyKey(refundID)
|
||||
rf, err := p.sc.Refunds.New(params)
|
||||
if err != nil {
|
||||
// *stripe.Error + HTTPStatusCode 4xx = 渠道对这次请求的确定性拒绝(参数/状态
|
||||
// 类错误,如金额超出可退余额、PaymentIntent 已全额退过);5xx/网络错误(err 不是
|
||||
// *stripe.Error,或是但状态码 5xx)结果不确定,不 wrap ErrRefundRejected ——
|
||||
// 与 alipay 同一判定原则(见 provider.ErrRefundRejected 注释 / item 1)。
|
||||
var serr *gostripe.Error
|
||||
if errors.As(err, &serr) && serr.HTTPStatusCode >= 400 && serr.HTTPStatusCode < 500 {
|
||||
return "", provider.PaidFailed, fmt.Errorf("stripe: 退款被拒: %w", errors.Join(provider.ErrRefundRejected, err))
|
||||
}
|
||||
return "", provider.PaidFailed, fmt.Errorf("stripe: 退款请求失败: %w", err)
|
||||
}
|
||||
return rf.ID, mapRefundStatus(rf.Status), nil
|
||||
|
||||
Reference in New Issue
Block a user