《互联网产品的服务端架构·交易、增长与平台化》3.3 对账、退款与冲正

对账时本地少一笔、退款时直接改支付单状态,是财务永远对不平的两个根源。文章以渠道账单为准计算缺失记录,把退款建模为引用原支付流水号单独记账的新资金流,并在提交前校验累计退款不超过原金额,错误信息直接给出超出多少分的差额,同时区分退款与冲正。

月底对账,永远差一笔

「摊位」每月初和渠道对账。财务把渠道导出的账单和本地支付表按流水号比对,总能发现一笔对不上:渠道账单里有一笔 99 元的支付,本地没有;同时本地有一笔 99 元的订单状态是「已退款」,渠道账单里却既有支付又有退款两条记录。

开发查了半天才发现,退款功能当初是这么写的:

UPDATE payments SET status = 'refunded', refunded_at = now() WHERE trade_no = ?;

支付记录被就地改成了「已退款」,支付金额从报表里消失,渠道侧却仍然保留原始支付和一笔独立的退款。财务按「支付减退款」算净额,本地按「有效支付」算,两边的口径从根上就不一样。

对账以渠道账单为准

对账的第一原则是:渠道账单是资金的权威记录,本地表是要被校验的一方。本地记录可以因为掉单、重复消费、手工修数据而失真,渠道账单来自真实资金流,优先级更高。

差异类型表现处理方式是否自动
本地缺失渠道有支付,本地无记录查单补单,走幂等入账可自动,需告警
本地多出本地有支付,渠道无记录极可能是伪造或测试数据,冻结排查不自动
金额不一致流水号相同,金额不同以渠道为准,人工复核不自动
状态不一致渠道成功,本地失败或处理中按渠道结果推进,幂等补偿可自动
退款缺失渠道有退款,本地无退款单补退款记录,核对退款去向人工复核

这张表里只有「本地缺失」和「状态不一致」适合自动补偿,因为方向明确:渠道说钱到了,本地补上。反过来「本地多出」不能自动删除,必须先确认是不是测试数据、重复对账或渠道侧延迟。

退款是新的资金流

退款不是把支付记录改成「已退款」,而是产生一笔新的、方向相反的资金流。支付是用户付给商户,退款是商户退给用户;两者金额可能相等,也可能部分退款,时间也不同。把支付记录就地改状态,等于把两笔资金流压成一条,任何按日、按渠道、按币种的报表都会失真。

正确模型是:退款单引用原支付流水号,单独记录退款金额、退款流水号和状态。原支付记录保持不变,已退款总额由退款单聚合得出。这样既能支持多次部分退款,也能在渠道账单里逐条对上退款记录。

冲正和退款不是一回事

冲正通常用于纠正一笔错误的交易,比如重复扣款、金额录错、系统故障导致的错误入账。它往往发生在当日或结算前,目标是让账务回到「这笔交易没发生过」的状态。退款则是用户真实发起的资金退回,可能发生在几天甚至几个月后,是正常的售后行为。

两者的账务表达不同:冲正可能冲销原交易,退款一定是一笔新的反向流水。代码里如果只有一个 refund 接口,至少要在类型上区分 refund 和 reversal,否则财务无法判断这笔钱是「纠正错误」还是「正常退款」。

失败场景:退款改状态,差额永远补不平

回到开头的事故。第一笔退款发生后,本地支付表把那笔 99 元改成了「已退款」,支付金额字段还在,但所有按 status = 'paid' 聚合的报表都不再统计它。渠道账单里,这笔钱先是 +99 的支付,后来是 -99 的退款,净额 0。

财务第一次对账时,用渠道净额 0 去对本地「有效支付」,本地也是 0,看起来平了。但到了季度审计,需要按支付笔数统计交易量,本地少了那一笔,渠道有;需要按退款原因分析售后,本地没有退款单,只有一条被改状态的支付记录。更严重的是部分退款:用户退了 30 元,代码把整条支付记录改成「已退款」,剩下的 69 元既不在支付里,也不在退款里,直接从账上消失。

第二个变种是退款没有校验累计金额。用户连续发起三次 99 元退款,每次代码都只检查「支付存在且状态不是已退款」,第一次改状态后,后两次看到状态已变,有的实现直接拒绝,有的实现又创建了退款单但没有校验总额,最终退出去 297 元。渠道侧会拒绝超额退款,但本地退款单已经标记成功,财务对账时本地退款比渠道多两笔。

第三个变种是对账只比总额不比流水号。渠道和本地的日总额碰巧相等,但其中一笔支付和一笔退款被错配,单笔差异被总额掩盖,直到用户投诉才发现钱退错了人。

用 Missing 和 ValidRefund 固定对账语义

reconcile.go 做两件事:找出渠道有、本地没有的记录;校验一笔退款加上历史退款是否超过原支付金额。

// file: internal/payment/reconcile.go
// tier: L1
// verify: go test ./internal/payment/

package payment

import (
	"errors"
	"fmt"
	"sort"
)

var (
	// ErrRefundAmount 表示退款金额不是正整数。
	ErrRefundAmount = errors.New("payment: refund amount must be positive")
	// ErrRefundExceedsPaid 表示本次退款加上历史退款超过了原支付金额。
	ErrRefundExceedsPaid = errors.New("payment: refund exceeds paid amount")
	// ErrRefundedNegative 表示历史退款总额为负,数据已经不可信。
	ErrRefundedNegative = errors.New("payment: refunded total must not be negative")
)

// Record 是对账文件里的一行,也是本地交易表里的一行。AmountCents 以分为
// 单位;Channel 参与排查,但本案例假设 TradeNo 在全局唯一。
type Record struct {
	Channel     Channel
	TradeNo     string
	AmountCents int64
}

// Missing 返回远端有、本地没有的记录,按 TradeNo 升序排列。
//
// 对账以渠道账单(remote)为准,本地缺失通常意味着掉单或回调丢失。这里
// 只比较 TradeNo 是否存在,不比较金额;金额不一致属于另一类差错,不能
// 用“缺失”掩盖。远端重复的 TradeNo 只保留第一条,避免同一笔被补偿两次。
//
// 如果接入的渠道允许不同 Channel 复用同一个 TradeNo,应把键改成
// (Channel, TradeNo),本案例不做这层扩展。
func Missing(local, remote []Record) []Record {
	if len(remote) == 0 {
		return nil
	}
	localSet := make(map[string]struct{}, len(local))
	for _, r := range local {
		localSet[r.TradeNo] = struct{}{}
	}
	seen := make(map[string]struct{}, len(remote))
	out := make([]Record, 0, len(remote))
	for _, r := range remote {
		if _, ok := localSet[r.TradeNo]; ok {
			continue
		}
		if _, ok := seen[r.TradeNo]; ok {
			continue
		}
		seen[r.TradeNo] = struct{}{}
		out = append(out, r)
	}
	sort.Slice(out, func(i, j int) bool {
		return out[i].TradeNo < out[j].TradeNo
	})
	return out
}

// Refund 是一笔新的退款资金流。它不是把原支付记录改成“已退款”,而是
// 引用原支付流水号,单独记录金额和时间。
type Refund struct {
	PaymentTradeNo string
	AmountCents    int64
}

// ValidRefund 校验一笔退款是否可以提交。paidCents 是原支付金额,
// alreadyRefunded 是此前已成功退款或退款中的累计金额。累计退款不能超过
// 原支付金额;超额时错误信息带上超出多少分,便于直接定位。
func ValidRefund(r Refund, paidCents, alreadyRefunded int64) error {
	if r.AmountCents <= 0 {
		return fmt.Errorf("%w: %d", ErrRefundAmount, r.AmountCents)
	}
	if alreadyRefunded < 0 {
		return fmt.Errorf("%w: %d", ErrRefundedNegative, alreadyRefunded)
	}
	remaining := paidCents - alreadyRefunded
	if remaining < 0 {
		return fmt.Errorf("%w: paid %d, refunded %d, over by %d", ErrRefundExceedsPaid, paidCents, alreadyRefunded, -remaining)
	}
	if r.AmountCents > remaining {
		return fmt.Errorf("%w: refund %d, remaining %d, over by %d", ErrRefundExceedsPaid, r.AmountCents, remaining, r.AmountCents-remaining)
	}
	return nil
}

几个设计点:

  • Missing 以 remote 为基准,只返回远端有、本地无的记录。本地多出的记录不会出现在结果里,需要另一条查询单独处理,避免把「多出」和「缺失」混成一个列表。
  • 结果按 TradeNo 升序,保证同一批数据每次跑出来的顺序一致,方便 diff 和重跑。远端重复的 TradeNo 只保留第一条,防止同一笔被补偿两次。
  • Missing 只比较流水号是否存在,不比较金额。金额不一致是另一类差异,混进「缺失」会导致重复补单。
  • Refund 只引用原支付流水号,不改原支付记录。退款金额、退款流水号和状态应该落在独立的退款表里。
  • ValidRefund 用 paidCents - alreadyRefunded 算剩余可退金额。累计退款超过原支付时,错误信息带上 over by 差额,财务和客服可以直接看到超了多少分。
  • 历史退款总额为负时直接报错。负值说明数据已经损坏,继续计算只会把错误放大。

alreadyRefunded 应该包含「已成功」和「退款中」的金额。只统计成功退款会允许并发退款同时通过校验,最终超额;退款中的金额要先占用额度,失败后再释放。生产环境还需要在数据库层加约束或行锁,纯函数校验挡不住并发。

测试:缺失排序、去重与退款差额

对账和退款的测试重点在边界:空输入、重复流水号、刚好退完、超出一分钱。

// file: internal/payment/reconcile_test.go
// tier: L1
// verify: go test ./internal/payment/

package payment

import (
	"errors"
	"strings"
	"testing"
)

func TestMissingReturnsRemoteOnlySorted(t *testing.T) {
	local := []Record{
		{Channel: Alipay, TradeNo: "t2", AmountCents: 200},
	}
	remote := []Record{
		{Channel: Alipay, TradeNo: "t3", AmountCents: 300},
		{Channel: Alipay, TradeNo: "t1", AmountCents: 100},
		{Channel: Alipay, TradeNo: "t2", AmountCents: 200},
	}
	got := Missing(local, remote)
	if len(got) != 2 {
		t.Fatalf("missing size = %d; want 2", len(got))
	}
	if got[0].TradeNo != "t1" || got[1].TradeNo != "t3" {
		t.Fatalf("order = %s,%s; want t1,t3", got[0].TradeNo, got[1].TradeNo)
	}
}

func TestMissingIgnoresLocalOnlyAndKeepsAmount(t *testing.T) {
	local := []Record{
		{Channel: Alipay, TradeNo: "t1", AmountCents: 100},
		{Channel: Alipay, TradeNo: "t9", AmountCents: 900},
	}
	remote := []Record{
		{Channel: Wechat, TradeNo: "t1", AmountCents: 100},
		{Channel: Alipay, TradeNo: "t8", AmountCents: 800},
	}
	got := Missing(local, remote)
	if len(got) != 1 || got[0].TradeNo != "t8" || got[0].AmountCents != 800 {
		t.Fatalf("missing = %+v; want only t8 with amount 800", got)
	}
}

func TestMissingDeduplicatesRemote(t *testing.T) {
	remote := []Record{
		{Channel: Alipay, TradeNo: "t2", AmountCents: 200},
		{Channel: Alipay, TradeNo: "t2", AmountCents: 200},
	}
	got := Missing(nil, remote)
	if len(got) != 1 || got[0].TradeNo != "t2" {
		t.Fatalf("missing = %+v; want one t2", got)
	}
}

func TestMissingEmptyInputs(t *testing.T) {
	if got := Missing(nil, nil); len(got) != 0 {
		t.Fatalf("nil inputs = %+v; want empty", got)
	}
	local := []Record{{Channel: Alipay, TradeNo: "t1"}}
	if got := Missing(local, []Record{{Channel: Alipay, TradeNo: "t1"}}); len(got) != 0 {
		t.Fatalf("identical sets = %+v; want empty", got)
	}
}

func TestValidRefundAcceptsWithinRemaining(t *testing.T) {
	cases := []struct {
		name     string
		refund   int64
		paid     int64
		refunded int64
	}{
		{"full", 9900, 9900, 0},
		{"partial", 1000, 9900, 0},
		{"exact remaining", 900, 1000, 100},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			r := Refund{PaymentTradeNo: "t1", AmountCents: tc.refund}
			if err := ValidRefund(r, tc.paid, tc.refunded); err != nil {
				t.Fatalf("ValidRefund = %v; want nil", err)
			}
		})
	}
}

func TestValidRefundRejectsNonPositiveAmount(t *testing.T) {
	for _, amount := range []int64{0, -1} {
		r := Refund{PaymentTradeNo: "t1", AmountCents: amount}
		if err := ValidRefund(r, 9900, 0); !errors.Is(err, ErrRefundAmount) {
			t.Fatalf("amount %d err = %v; want ErrRefundAmount", amount, err)
		}
	}
}

func TestValidRefundRejectsOverRemainingAndReportsDifference(t *testing.T) {
	r := Refund{PaymentTradeNo: "t1", AmountCents: 400}
	err := ValidRefund(r, 1000, 700)
	if !errors.Is(err, ErrRefundExceedsPaid) {
		t.Fatalf("err = %v; want ErrRefundExceedsPaid", err)
	}
	msg := err.Error()
	if !strings.Contains(msg, "over by 100") {
		t.Fatalf("message %q should contain difference 100", msg)
	}
}

func TestValidRefundRejectsAlreadyOverRefunded(t *testing.T) {
	r := Refund{PaymentTradeNo: "t1", AmountCents: 1}
	err := ValidRefund(r, 1000, 1200)
	if !errors.Is(err, ErrRefundExceedsPaid) {
		t.Fatalf("err = %v; want ErrRefundExceedsPaid", err)
	}
	if !strings.Contains(err.Error(), "over by 200") {
		t.Fatalf("message %q should contain difference 200", err.Error())
	}
}

func TestValidRefundRejectsNegativeRefundedTotal(t *testing.T) {
	r := Refund{PaymentTradeNo: "t1", AmountCents: 100}
	if err := ValidRefund(r, 1000, -1); !errors.Is(err, ErrRefundedNegative) {
		t.Fatalf("err = %v; want ErrRefundedNegative", err)
	}
}

TestMissingReturnsRemoteOnlySorted 构造本地有 t2、远端有 t1/t2/t3 的场景,断言只返回 t1 和 t3,且顺序按流水号升序。TestMissingIgnoresLocalOnlyAndKeepsAmount 确认本地多出的 t9 不会影响结果,远端记录的金额被原样保留。

TestMissingDeduplicatesRemote 验证远端重复的 t2 只返回一条,避免补偿任务重复处理。TestMissingEmptyInputs 覆盖 nil 输入和两边完全一致的情况。

TestValidRefundAcceptsWithinRemaining 覆盖全额退款、部分退款和刚好退完三种合法情况。TestValidRefundRejectsNonPositiveAmount 拒绝 0 和负数。TestValidRefundRejectsOverRemainingAndReportsDifference 用支付 1000、已退 700、本次 400 的场景,断言错误里包含 over by 100。TestValidRefundRejectsAlreadyOverRefunded 覆盖历史退款已经超过支付金额的脏数据,差额是 200。TestValidRefundRejectsNegativeRefundedTotal 拒绝负的历史退款总额。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/payment/ -run 'TestMissing|TestValidRefund' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestMissingReturnsRemoteOnlySorted
--- PASS: TestMissingReturnsRemoteOnlySorted (0.00s)
=== RUN   TestMissingIgnoresLocalOnlyAndKeepsAmount
--- PASS: TestMissingIgnoresLocalOnlyAndKeepsAmount (0.00s)
=== RUN   TestMissingDeduplicatesRemote
--- PASS: TestMissingDeduplicatesRemote (0.00s)
=== RUN   TestMissingEmptyInputs
--- PASS: TestMissingEmptyInputs (0.00s)
=== RUN   TestValidRefundAcceptsWithinRemaining
=== RUN   TestValidRefundAcceptsWithinRemaining/full
=== RUN   TestValidRefundAcceptsWithinRemaining/partial
=== RUN   TestValidRefundAcceptsWithinRemaining/exact_remaining
--- PASS: TestValidRefundAcceptsWithinRemaining (0.00s)
    --- PASS: TestValidRefundAcceptsWithinRemaining/full (0.00s)
    --- PASS: TestValidRefundAcceptsWithinRemaining/partial (0.00s)
    --- PASS: TestValidRefundAcceptsWithinRemaining/exact_remaining (0.00s)
=== RUN   TestValidRefundRejectsNonPositiveAmount
--- PASS: TestValidRefundRejectsNonPositiveAmount (0.00s)
=== RUN   TestValidRefundRejectsOverRemainingAndReportsDifference
--- PASS: TestValidRefundRejectsOverRemainingAndReportsDifference (0.00s)
=== RUN   TestValidRefundRejectsAlreadyOverRefunded
--- PASS: TestValidRefundRejectsAlreadyOverRefunded (0.00s)
=== RUN   TestValidRefundRejectsNegativeRefundedTotal
--- PASS: TestValidRefundRejectsNegativeRefundedTotal (0.00s)
PASS
ok  	webapp/internal/payment	0.405s

ok webapp/internal/payment 表示 3.3 相关的用例全部通过。这些函数是纯逻辑,不连接渠道也不连接数据库;线上对账还要处理账单下载、编码、时区和人工复核。

小结

对账以渠道账单为准,先找「渠道有、本地无」的缺失记录,再单独处理本地多出和金额不一致。退款是新的资金流,引用原支付流水号单独记录,不能把支付记录就地改成「已退款」;冲正是纠错,退款是售后,两者在账务上要分开表达。

Missing 保证缺失记录的顺序和去重,ValidRefund 保证累计退款不超原金额,并把差额写进错误信息。到这里,支付的三段链路已经闭环:接入时把对象拆对,回调时保证只处理一次,日终时让账能对上。

阅读导航:上一节:3.2 回调、幂等与掉单 · 下一节:4.1 券模板、发放与核销 。

常见错误与边界

  • 退款时直接修改支付记录状态。原支付从报表里消失,渠道账单里却仍有支付和退款两条,差额永远补不平。
  • 对账只比日总额。单笔支付和退款错配会被总额掩盖,必须按流水号逐条比对。
  • 把本地多出的记录自动删除。多出可能是测试数据、重复对账或渠道延迟,必须先人工确认。
  • 缺失和金额差异混在一起处理。金额不一致时补单会重复入账,两类差异要分开出报告。
  • 退款不校验累计金额。多次部分退款可能超过原支付,渠道会拒绝,本地却已经标记成功。
  • 只统计成功退款。并发退款会同时通过校验,退款中的金额要先占用额度。
  • 冲正和退款共用一个状态。财务无法区分纠错和售后,退款原因分析也会失真。
  • 对账任务没有幂等。重跑时同一笔缺失被补偿多次,必须有流水号唯一约束兜底。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查