只下交易账单永远对不上银行到账:微信资金账单里的手续费与逐笔余额
对账脚本常遇到两个对不上的情况:订单表当天成功支付 N 笔,交易账单也是 N 行,但资金账单明细有 N+几千行;金额上,渠道收款合计比交易订单金额少了一截,差额和当天手续费合计一分不差。继续看明细,会发现同一个微信支付业务单号出现两行,一行是 交易 / 收入,另一行是 扣除交易手续费 / 支出。
这不是重复数据,而是两个接口的计算规则不同:交易账单按业务记录,资金账单按账户余额变动记录。只下载 /v3/bill/tradebill,手续费、提现、分账、代金券这些资金变动就永远不在对账范围里。
接口文档:
两个接口的计算规则差
GET /v3/bill/fundflowbill 的 query 就三个字段:bill_date(必填,yyyy-MM-DD,仅支持近三个月)、account_type(选填,默认 BASIC)、tar_type(选填 GZIP)。返回 download_url / hash_type / hash_value,和交易账单一致,下载下来是 gzip 数据流。
两者的差别从账单行就能看出:
| 交易账单 tradebill | 资金账单 fundflowbill | |
|---|---|---|
| 账单行 | 一笔成功交易或退款 | 一次账户余额变动 |
| 视角 | 业务视角 | 账户视角 |
| 一笔支付 | 1 行 | 2 行(收入 + 手续费支出) |
资金账单额外包含交易账单里根本没有的类型:充值、提现、企业付款(商家转账)、分账、代金券/立减/折扣、退还交易手续费、退款关闭。这些都会改动账户余额,但不会出现在 tradebill 里。所以如果只下载交易账单,差额永远是「手续费 + 提现 + 分账 + …」,而且无法定位到具体是哪一笔。
三个账户都要下载
account_type 不传默认只返回基本账户。手续费扣在 FEES(手续费账户),运营活动走 OPERATION(运营账户)。只下载 BASIC,手续费流水整段看不见,对账时差额就像凭空消失。
完整对账要把 BASIC / OPERATION / FEES 三个都下载下来,各自是一条独立的余额序列,分别校验连续性,不要混在一起排序。
文件格式:反引号与汇总行
V3 资金账单是文本表格,明细在前、汇总在后,各自一行表头加多行数据。
明细表头:
记账时间,微信支付业务单号,资金流水单号,业务名称,业务类型,收支类型,收支金额(元),账户结余(元),资金变更提交申请人,备注,业务凭证号
在明细之后倒数两行是汇总:
资金流水总笔数,收入笔数,收入金额,支出笔数,支出金额
每个字段前会加一个反引号 `,目的是防止 Excel 把单号转成科学计数法,汇总行的数字也带。实际长这样:
`2018-02-01 04:21:23,`50000305742018020103387128253,`1900009231201802015884652186,`退款,`退款,`支出,`0.02,`0.17,`system,`缺货,`REF4200000068201801293084726067
`20.0,`17.0,`0.35,`3.0,`0.18
不先去掉反引号就直接解析,0.02 会变成 `0.02,string 转 decimal 会失败。首行表头本身不带反引号,所以按行去掉前缀要判断,稳妥做法是对所有字段统一 TrimPrefix("")`。
Go 解析:去掉反引号、跳过表头、遇汇总行退出
type Flow struct {
BookTime time.Time // 记账时间:资金实际增减时间,不是退款成功时间
WxTradeNo string // 微信支付业务单号,最长 128
FlowNo string // 资金流水单号,最长 128
BizName string // 业务名称,文档注明会随业务调整
BizType string // 业务类型,同样会调整,别硬编码穷举
Direction string // 收入 / 支出
Amount int64 // 分
Balance int64 // 分
Applicant string
Remark string
VoucherNo string // 业务凭证号,通常是商户订单号,退款/分账可能一对多
}
type Summary struct {
Total, IncomeCount, ExpenseCount int64
IncomeAmount, ExpenseAmount int64 // 分
}
func parseAmount(s string) (int64, error) {
s = strings.TrimPrefix(strings.TrimSpace(s), "`")
neg := strings.HasPrefix(s, "-")
s = strings.TrimPrefix(s, "-")
parts := strings.SplitN(s, ".", 2)
yuan, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, err
}
var cent int64
if len(parts) == 2 {
frac := parts[1] + "00"
cent, err = strconv.ParseInt(frac[:2], 10, 64)
if err != nil {
return 0, err
}
}
v := yuan*100 + cent
if neg {
v = -v
}
return v, nil
}
func ParseFundflow(r io.Reader, gzipped bool) ([]Flow, Summary, error) {
var src io.Reader = r
if gzipped { // tar_type=GZIP 时;不传则是明文数据流
zr, err := gzip.NewReader(r)
if err != nil {
return nil, Summary{}, err
}
defer zr.Close()
src = zr
}
cr := csv.NewReader(bufio.NewReader(src))
cr.FieldsPerRecord = -1 // 汇总行字段数和明细不同
var flows []Flow
var sum Summary
for {
rec, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, sum, err
}
if len(rec) == 0 {
continue
}
switch strings.TrimPrefix(rec[0], "`") {
case "记账时间": // 明细表头,首行不带反引号
continue
case "资金流水总笔数": // 汇总标题,值在下一行
vals, err := cr.Read()
if err != nil {
return nil, sum, err
}
sum.Total, _ = strconv.ParseInt(strings.TrimPrefix(vals[0], "`"), 10, 64)
sum.IncomeCount, _ = strconv.ParseInt(strings.TrimPrefix(vals[1], "`"), 10, 64)
sum.IncomeAmount, _ = parseAmount(vals[2])
sum.ExpenseCount, _ = strconv.ParseInt(strings.TrimPrefix(vals[3], "`"), 10, 64)
sum.ExpenseAmount, _ = parseAmount(vals[4])
return flows, sum, nil
}
if len(rec) < 8 {
continue
}
t, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimPrefix(rec[0], "`"), time.Local)
if err != nil {
return nil, sum, err
}
amt, err := parseAmount(rec[6])
if err != nil {
return nil, sum, err
}
bal, err := parseAmount(rec[7])
if err != nil {
return nil, sum, err
}
flows = append(flows, Flow{
BookTime: t, WxTradeNo: strings.TrimPrefix(rec[1], "`"),
FlowNo: strings.TrimPrefix(rec[2], "`"), BizName: strings.TrimPrefix(rec[3], "`"),
BizType: strings.TrimPrefix(rec[4], "`"), Direction: strings.TrimPrefix(rec[5], "`"),
Amount: amt, Balance: bal, Applicant: strings.TrimPrefix(rec[8], "`"),
Remark: strings.TrimPrefix(rec[9], "`"), VoucherNo: strings.TrimPrefix(rec[10], "`"),
})
}
return flows, sum, nil
}
金额全程用 int64 分,不要用 float64。收支金额(元) 和 账户结余(元) 都是两位小数的元,字符串切小数点在财务上比 ParseFloat 稳。文档里「收支类型是收入时表示余额的增加金额」这句话语义写重复了,实际就是收入增加、支出减少,以 收支类型 字段为准。
字段语义里几个不能想当然的点
- 记账时间:资金实际增减的时间,文档明确写了不是退款成功时间。退款行的这条时间是退款金额从账户扣走的时间,跟用户侧「退款到账」可以差好几天,拿它去对退款成功率会错。格式
yyyy-MM-dd HH:MM:SS。 - 业务名称 / 业务类型:粗分类(交易、退款、充值提现、企业付款、代金券立减折扣、分账)和细分类(交易、扣除交易手续费、退款、退款关闭、退还交易手续费),两者都注明会随业务调整。代码里不要拿枚举做穷举校验,未知值必须能写入数据库且不报错,否则微信新增一个业务类型,整批账单就会解析失败。
- 账户结余(元):便于校验连续性,这是资金账单相比交易账单最有价值的一列。
- 资金变更提交申请人:可能为空,如
员工账号@商户号、商户号API、system。 - 备注:可能为空,最长 2048。
- 业务凭证号:一般是商户订单号,是跟本地订单表 join 的键;但退款、分账不一定落在同一单上,关系要允许一对多。
余额连续性校验
思路很直接:按记账时间升序,上一笔结余 + 本笔变动 == 本笔结余,容差 1 分。首笔之前的初始余额用 首笔结余 - 首笔变动 反推(变动按收支类型取正负)。
func CheckBalance(flows []Flow) []int {
sort.SliceStable(flows, func(i, j int) bool {
if !flows[i].BookTime.Equal(flows[j].BookTime) {
return flows[i].BookTime.Before(flows[j].BookTime)
}
return flows[i].FlowNo < flows[j].FlowNo // 同秒顺序不保证,先稳定排序
})
var bad []int
for i, f := range flows {
d := f.Amount
if f.Direction == "支出" {
d = -d
}
if i == 0 {
opening := f.Balance - d // 与上一日结余或开户余额比对
_ = opening
continue
}
if flows[i-1].Balance+d != f.Balance {
bad = append(bad, i)
}
}
return bad
}
严格逐笔校验会遇到误报:同一秒内的多笔记录,微信侧不保证顺序。处理方式是先按 资金流水单号 稳定排序,仍不一致时退回校验日终汇总——用汇总行的 收入金额 - 支出金额,与次日首笔结余互相印证。中间出现 业务类型=充值/提现 时余额会有大跳,属于正常,不要当成异常。
取数与调用限制
- 当日账单次日 9 点开始生成,建议 10 点后再取,9 点整点取容易拿到未生成完的文件。
- 频率限制是商户号维度 3 QPS。多商户、多日期补数据时不要用 goroutine 一次性发起几十个请求,按商户号加令牌桶限速。
- 只支持近三个月。历史账单要自己归档写入数据库,过期之后下载不到,补账就只能走人工。
- V2 老接口
/pay/downloadfundflow需要双向证书,返回的是文本表格而不是 JSON,维护老系统时不要和 V3 的返回体混在同一个客户端里。
不要这样用
- 拿资金账单当订单列表:它没有商品、买家、支付方式这些维度,只有资金变动记录,做业务报表会缺字段。
- 用资金账单算「今天卖了多少」:手续费行是支出,混进营收合计会算少,营收看交易账单,资金账单看钱去哪了。
- 只下载 BASIC 就对账:手续费账户漏掉,差额会像是凭空丢失。
- 用
bill_date=今天立即取:账单还没生成,返回的是空或者报错。
交易账单本身的下载与解析(5 分钟有效的 download_url、GZIP、金额单位、汇总行)在站内 #7932 里写过,这篇是同一套对账流程的第二层,重点在资金流水的计算规则和余额校验。
未实测
文档字段与示例值来自微信支付官方商户文档,上面的 Go 片段按文档整理,未在真实商户号上跑过全量账单。记账时间是否固定为北京时间,文档未明确,跨时区财务系统需要自行核对,此项未实测。业务名称 / 业务类型的取值会随业务调整,新值的处理逻辑以线上日志为准。