代码 只下交易账单永远对不上银行到账:微信资金账单里的手续费与逐笔余额

2026-11-12 09:02:51

只下交易账单永远对不上银行到账:微信资金账单里的手续费与逐笔余额

对账脚本常遇到两个对不上的情况:订单表当天成功支付 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 片段按文档整理,未在真实商户号上跑过全量账单。记账时间是否固定为北京时间,文档未明确,跨时区财务系统需要自行核对,此项未实测。业务名称 / 业务类型的取值会随业务调整,新值的处理逻辑以线上日志为准。

复制全文 生成海报 微信支付 对账 资金账单 Go CSV

推荐文章

程序员茄子在线接单