月底对账,永远差一笔
「摊位」每月初和渠道对账。财务把渠道导出的账单和本地支付表按流水号比对,总能发现一笔对不上:渠道账单里有一笔 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 券模板、发放与核销 。
常见错误与边界
- 退款时直接修改支付记录状态。原支付从报表里消失,渠道账单里却仍有支付和退款两条,差额永远补不平。
- 对账只比日总额。单笔支付和退款错配会被总额掩盖,必须按流水号逐条比对。
- 把本地多出的记录自动删除。多出可能是测试数据、重复对账或渠道延迟,必须先人工确认。
- 缺失和金额差异混在一起处理。金额不一致时补单会重复入账,两类差异要分开出报告。
- 退款不校验累计金额。多次部分退款可能超过原支付,渠道会拒绝,本地却已经标记成功。
- 只统计成功退款。并发退款会同时通过校验,退款中的金额要先占用额度。
- 冲正和退款共用一个状态。财务无法区分纠错和售后,退款原因分析也会失真。
- 对账任务没有幂等。重跑时同一笔缺失被补偿多次,必须有流水号唯一约束兜底。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。