代码 Stripe 拒付处理:due_by=0 不是今天到期,证据只交一次就锁死

2026-10-20 09:01:57

Stripe 拒付处理:due_by=0 不是今天到期,证据只交一次就锁死

文档:How disputes work、Respond to disputes、Use the API to respond to disputes、Dispute object,API 版本 2026-09-30.endive,字段以账号版本为准。

拒付不是退款,accept 和 counter 是两条路

持卡人向发卡行提出争议后,卡组织会先从商户的 Stripe 余额扣走争议金额 + 争议费,这笔钱在整个争议期间被冻结。此时只有两个动作:在期限内提交证据(counter,抗辩),或者接受(accept,确认不抗辩)。抗辩要额外付 countered fee,而且只有一次机会。

争议开放期间,不能对这笔 payment 发起普通退款。文档原话是 You can't issue a refund outside the dispute process while the dispute is open——想退钱只能 accept,is_charge_refundable 只在特定条件下为 true。

inquiry(预警)常被当成小事。部分卡组织(Amex、Discover 常见,Mastercard/Visa 已不用)在正式 chargeback 前先发 inquiry,API 里叫 warning,状态是 warning_needs_response / warning_under_review / warning_closed。这个阶段应答不收争议费,但不应答可能升级成正式 chargeback,而且多半打不赢;Discover 的 inquiry 未应答会直接变成不可抗辩争议。Dashboard 里点 accept inquiry 不等于解决争议,必须提交证据。warning 挂 120 天未升级,Stripe 标记 closed。

时限上,卡组织一般允许持卡人在原交易后 120 天内发起争议(旅行、演出票等从活动日开始算)。chargeback 创建后有 7–21 天应答窗口(取决于卡组织),提交证据后发卡行裁决 60–75 天,全流程 2–3 个月,无法加速(除非 accept)。

状态机与两个事件

状态枚举:warning_needs_response、warning_under_review、warning_closed、needs_response、under_review、won、lost、prevented。prevented 表示在变成正式 chargeback 之前被阻止。

需要落库的字段:id(du_ 开头)、charge、payment_intent(nullable)、amount、currency、reason、status、evidence{}、evidence_details{due_by, has_evidence, past_due, submission_count}、balance_transactions(0–2 笔,对应扣款与返还)、is_charge_refundable、payment_method_details.card.case_type(chargeback / inquiry / compliance / resolution / block)、network_reason_code。

reason 枚举:fraudulent、product_not_received、product_unacceptable、subscription_canceled、duplicate、credit_not_processed、unrecognized、general、customer_initiated、bank_cannot_process、check_returned、debit_not_authorized、incorrect_account_details、insufficient_funds、noncompliant。

事件只有两个要处理:charge.dispute.created(创建时)、charge.dispute.closed(裁决后,status 变 won/lost)。Dispute 对象不直接引用 invoice/subscription,要自己通过 charge 或 payment_intent 关联到订单。另有极少数 late win:lost 之后又变 won,由发卡行驱动,Stripe 无法预测,所以别把 lost 当成终态写死业务逻辑。

五个容易写错的地方

due_by=0 不是今天到期。 due_by 是 Unix 时间戳;发卡行不允许应答时该字段为 0(文档:Will be 0 if the customer's bank or credit card company doesn't allow a response for this particular dispute)。当成“今天到期”会天天误报,当成“已过期”会漏掉一批不可应答的争议。

证据只有一次机会,且更新任一字段会全量覆盖。 submission_count 通常是 1,提交后不能修改、不能补交。更新 evidence hash 里任一字段,会把 hash 里所有字段一起提交给银行。文本类证据合计上限 150,000 字符。文件类字段(customer_communication、service_documentation、receipt、refund_policy、shipping_documentation、customer_signature、cancellation_policy、duplicate_charge_documentation、uncategorized_file)要先用 File Upload API 上传,purpose=dispute_evidence,拿到 file_upload id 再填。文件合计上限 4.5 MB,Mastercard 合计 19 页。银行不会看外链、音视频,也不会看“请联系客服”的说明。

暂存不等于安全。 evidence[submit]=false 时证据只暂存在 dispute 上,不送银行,之后再发一次 submit=true 才真正提交;默认是 submit=true。所以第一次写入就要显式带 false。更要命的是:暂存后再次更新,整个 hash 会重新提交——等于把没写完的证据送出去了。

争议期间不能自行退款。 见上文,想退只能 accept。

多笔争议按 dispute id 建表。 一笔 payment 可能有多笔 dispute:不同 reason code、不同商品行、多次 capture。拿 charge id 当主键会直接覆盖。争议金额也不一定等于原交易金额:汇率换算、订阅多笔合并成一笔、部分争议、部分退款后再全额争议,都会让两者对不上。

Go:webhook 幂等落库与到期扫描

下面两段是思路示意,未实测,验签和错误处理按线上补齐。核心是唯一键 dispute.id,重复事件走 UPDATE 而不是 INSERT。

// 未实测
const upsertDispute = `
INSERT INTO disputes (id, charge_id, payment_intent, amount, currency, reason,
                      status, due_by, has_evidence, submission_count, event_created)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (id) DO UPDATE SET
  status           = EXCLUDED.status,
  due_by           = EXCLUDED.due_by,
  has_evidence     = EXCLUDED.has_evidence,
  submission_count = EXCLUDED.submission_count,
  event_created    = EXCLUDED.event_created
WHERE disputes.event_created < EXCLUDED.event_created` // 防乱序重投回退状态

func handleDisputeWebhook(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// 验签省略:stripe.Webhook.ConstructEvent(payload, sigHeader, secret)
		var ev struct {
			ID        string `json:"id"`
			Type      string `json:"type"`
			Created   int64  `json:"created"`
			Data      struct {
				Object struct {
					ID              string  `json:"id"`
					Charge          string  `json:"charge"`
					PaymentIntent   *string `json:"payment_intent"`
					Amount          int64   `json:"amount"`
					Currency        string  `json:"currency"`
					Reason          string  `json:"reason"`
					Status          string  `json:"status"`
					EvidenceDetails struct {
						DueBy           int64 `json:"due_by"`
						HasEvidence     bool  `json:"has_evidence"`
						SubmissionCount int   `json:"submission_count"`
					} `json:"evidence_details"`
				} `json:"object"`
			} `json:"data"`
		}
		if err := json.NewDecoder(r.Body).Decode(&ev); err != nil {
			http.Error(w, "bad payload", 400)
			return
		}
		if ev.Type != "charge.dispute.created" && ev.Type != "charge.dispute.closed" {
			w.WriteHeader(200)
			return
		}
		o := ev.Data.Object
		_, err := db.ExecContext(r.Context(), upsertDispute,
			o.ID, o.Charge, o.PaymentIntent, o.Amount, o.Currency, o.Reason,
			o.Status, o.EvidenceDetails.DueBy, o.EvidenceDetails.HasEvidence,
			o.EvidenceDetails.SubmissionCount, ev.Created)
		if err != nil {
			http.Error(w, "db error", 500) // 返回非 2xx 让 Stripe 重投
			return
		}
		w.WriteHeader(200)
	}
}

扫描任务只捞仍可应答的状态,按剩余时间分档,due_by=0 单独归类。

// 未实测:假设有一个 alert(disputeID, level) 打到你们的告警通道
func scanDueDisputes(ctx context.Context, db *sql.DB) error {
	rows, err := db.QueryContext(ctx, `
		SELECT id, due_by FROM disputes
		WHERE status IN ('needs_response','warning_needs_response')`)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var id string
		var dueBy int64
		if err := rows.Scan(&id, &dueBy); err != nil {
			return err
		}
		if dueBy == 0 {
			alert(id, "no_response_allowed") // 不可应答,走人工判断是否 accept
			continue
		}
		left := time.Until(time.Unix(dueBy, 0))
		switch {
		case left <= 24*time.Hour:
			alert(id, "due_24h")
		case left <= 72*time.Hour:
			alert(id, "due_72h")
		}
	}
	return rows.Err()
}

小时级 cron 跑一次,同一 dispute 同一档位只告警一次(记一张已发告警表或内存去重)。

费用与对账

争议接收费(dispute received fee)基本不退,赢了也不退;墨西哥例外,赢了或持卡人撤诉可能退。抗辩费(dispute countered fee)赢了才退;墨西哥、日本不收 countered fee。Visa/Mastercard compliance 争议如果抗辩,另收 500 USD(或等值)网络费,赢了退。

对账时看 charge 上的 balance_transactions,0–2 笔分别对应扣款和返还;won 是发卡行把本金退回 Stripe 再退给商户,lost 钱不回来。拒付率还会进卡组织的监控计划(monitoring program),过高会被罚,甚至进 MATCH 一类名单。

预防:EFW 阈值、3DS 和 Visa CE 3.0

EFW(early fraud warning,Visa TC40、Mastercard SAFE)不要求应答,但约 40% 的 Visa/Mastercard EFW 会变成欺诈争议。官方分析给的口径是:金额约等于争议费及以下时退款划算,金额超过争议费 35% 就不值得退;无差别全退不是好策略。另外,主动退款并不能消除欺诈报告,除非在 capture 后 2 小时内作为 reversal 处理。

3DS liability shift 覆盖的欺诈争议,Stripe 会自动补 ECI 等证据,不用手动填。Visa CE 3.0(针对 10.4 card absent fraud)会自动判定资格,预填字段不要改,改了可能丢资格。

哪些场景不适用

LPM(Klarna、PayPal)有自己的争议流程:裁决方是服务商而不是发卡行,窗口通常 180 天,费用结构不同,证据要求也不同,不能套用上面这套状态机。

不可抗辩的情况要知道:Cartes Bancaires 网络(仅 SEPA 商户)、尼日利亚本地支付方式;Discover 的 inquiry 未应答也会变成不可抗辩。争议结果是终局,Stripe 不支持仲裁(arbitration)阶段——所以任何想做“二次申诉”“升级仲裁”的工单系统,都不要建在 Stripe 的 dispute 上。

复制全文 生成海报 Stripe 支付对接 拒付处理 webhook Go

推荐文章

程序员茄子在线接单