第 21 章 账务与对账:支付系统真正的骨骼
所属:第五部分 · 玩家与风险(第 16–21 章) 前置:第 1 章(账本、原子性)、第 7 章(授权与请款分离)、第 18 章(Synapse 账本对不上)、第 20 章(合规动作最终落在账上) 本章新概念:复式记账、分录、对账、破账、幂等、状态机、异步回调
前面所有章节讲过的机制,最后都要落成这一章里的几张表和几条规则。
一、上一章留下的问题¶
上一章最后问:制裁筛查拦下一笔交易,这笔钱现在算在谁头上?一笔交易退回了,原来的记录怎么处理?某个用户的余额,凭什么证明就是这个数?
Neo Bank 解剖的 Synapse 案例已经给出了答案的反面——账本坏了,钱就等于不存在。
这一章讲怎么让账本不坏。
二、复式记账不是会计偏好,是强制要求¶
回到支付的本质那张表。一笔转账,Dean 减 100、Alice 加 100,两行必须同时改成功。
复式记账(double-entry,即每一笔资金变动同时记一借一贷,两边金额必须相等)把这条要求变成了数据结构上的约束。
一笔用户充值 100 元的分录(journal entry,即一笔记账动作的最小单位):
| 科目 | 借 | 贷 |
|---|---|---|
| 银行存款(我们在银行的钱) | 100 | |
| 应付用户款(我们欠这个用户的钱) | 100 | |
| 合计 | 100 | 100 |
关键在于:借贷必须相等,这是系统层面强制的。
它带来的性质是:“钱凭空多出来”或“钱凭空消失”这两件事,在数据结构上不可能发生。 任何余额异常,必然对应着某一笔分录不平衡,而不平衡的分录会被系统直接拒绝写入。
对比一下不用复式记账的做法。
最常见的那一种,叫在用户表上加一个余额字段:数据库里每个用户存一个余额数字,转账就是把一行的余额减 100、另一行加 100,改完就结束。
它的问题不是跑不通——它跑得通,很多系统就是这么起步的。问题在于它只存结果,不存过程:
| 做法 | 账上存的是什么 | 出错时的表现 | 能否发现 |
|---|---|---|---|
| 用户表加一个余额字段 | 只有余额这一个数 | 余额变成了一个错的数 | 很难,因为没有参照 |
| 复式记账 | 每一次变动的借贷分录,余额由分录算出来 | 分录写不进去,或一合计就发现借贷两边不等 | 立刻发现 |
这就是为什么支付系统必须用复式记账。 余额字段那种做法在功能上能跑通,在出错时无法自证。
三、对账:跟外面对,不是跟自己对¶
内部账本自洽只是第一步。第二步是拿它和外部对手方的流水逐笔比对,这叫对账(reconciliation)。
要对的对象至少三类:
| 对账对象 | 比对什么 | 差异的常见原因 |
|---|---|---|
| 合作银行的流水 | 我们账上的余额 vs 银行实际余额 | 在途资金、手续费未入账 |
| 卡组织的清算文件 | 我们记的交易 vs 卡组织清算的交易 | 授权未请款、退款时差(三段生命周期) |
| 各支付通道的对账单 | 每笔交易的金额、状态、费用 | 通道侧状态更新延迟 |
对不上的那一笔叫破账(break)。
处理破账的唯一正确态度:每一笔都必须查清并归零,不允许挂着。
理由是Neo Bank 解剖那三条教训里的第二条——累积的小差异会在压力下变成不可还原的黑洞。 Synapse 出事时,问题不是某一天突然少了 8500 万,是长期对不平的差异攒到再也拆不开。
一条实操标准:
每日对账,破账当日归零;无法当日查清的,必须有明确的责任人和预期解决时间,且金额单独挂账可见。
四、三个必须掌握的系统性质¶
这三条是支付系统和普通业务系统最大的区别。
幂等(idempotency)
定义:同一个请求重复发送多次,结果只生效一次。
为什么必须有:网络会超时。客户端发起一笔付款,等待响应时超时了——此时这笔付款到底成功了没有?客户端不知道,只能重试。
| 有幂等 | 无幂等 |
|---|---|
| 重试识别为同一笔,返回第一次的结果 | 重试变成第二笔付款,用户被扣两次 |
实现方式:每笔请求带一个由发起方生成的唯一标识(幂等键),服务端记录已处理过的键,重复的直接返回原结果。
没有幂等的支付接口,出重复扣款只是时间问题。
状态机
定义:每笔交易的状态只能沿着预先定义好的路径流转。
用三段生命周期那套卡交易状态举例:
| 当前状态 | 允许流转到 | 不允许 |
|---|---|---|
| 已授权 | 已请款、已撤销、已过期 | 已退款(没请款就没钱可退) |
| 已请款 | 已结算、已退款 | 已撤销(过了撤销窗口) |
| 已退款 | 终态 | 再次退款 |
不做状态机的后果:出现“已退款又被扣款”、“撤销之后又请款成功”这类非法状态。这类问题在生产环境极难排查,因为数据本身自相矛盾。
异步回调
定义:操作结果通过对方主动通知(回调)来告知,而不是靠同步等待返回。
三段生命周期讲过,卡交易的三段生命周期跨越一到两天。ACH跨越一到三天。没有任何同步接口能等这么久。
配套的三条要求:
| 要求 | 原因 |
|---|---|
| 回调必须可重试 | 你的服务可能刚好在宕机 |
| 回调处理必须幂等 | 重试会导致同一个回调收到多次 |
| 必须有主动查询兜底 | 回调可能永远不来,不能只依赖它 |
最后一条最常被忽略。 只做回调不做主动查询,遇到对方系统故障时,你的交易会永远卡在中间状态。
五、把前面讲过的都串起来¶
这一章的价值在于它是前面所有内容的落点。检验一下:
| 前面讲的机制 | 在账务上的体现 |
|---|---|
| 授权不等于扣钱(三段生命周期) | 授权只记备查,不生成正式分录;请款时才记 |
| ACH 可被退回(ACH) | 收入在退回窗口关闭前不能确认为最终收入 |
| 拒付(三段生命周期) | 需要一笔反向分录 + 一笔争议费分录 |
| 滚存准备金(五类风险) | 单独科目挂账,到期释放时再转出 |
| 预置资金(跨境成本四来源) | 各国资金池分别设科目,便于监控流动性 |
| 制裁筛查冻结(合规骨架) | 资金转入冻结科目,不计入用户可用余额 |
| FBO 账户(Neo Bank 解剖) | 内部账本必须能逐笔还原到每个用户 |
上面这七行是账务设计的最低检查清单。 Neo Bank 解剖 Synapse 那场事故,问题正落在最后一行上。
六、这一章引出的问题¶
到这里,第五部分讲完了,整个旧世界的骨架也就完整了:
- 钱怎么记(地基部分)
- 零售怎么付(卡的部分)
- 境内怎么转(银行轨道部分)
- 跨境怎么走(跨境部分)
- 谁在做、怎么赚钱、怎么不亏钱、怎么记账(玩家与风险部分)
现在可以问那个从Wise 模式末尾就悬着的问题了。原话是:
有没有一种资产,能让全球任意两方之间瞬时转移,且不需要在每个国家各趴一笔钱?
第六部分正面回答它。但回答之前要先把这种资产放回货币层级那张表——它是谁的负债?
下一章从这个问题开始。
七、自检题¶
- 为什么“在用户表上加一个余额字段、转账时直接加减这个数”的做法,在支付系统里不可接受?
- 你的服务收到一个支付成功的回调,处理时数据库写入失败。对方会重试。你的系统需要具备什么性质才不会出错?
- 一笔走 ACH 收进来的 1000 元,什么时候可以确认为最终收入?
八、参考答案¶
先自己答完再往下看。
- 因为它无法自证。这种做法只存余额这一个数,不存它是怎么变成这个数的。余额被写成一个错误的数值时,系统没有任何参照物可以发现这件事——你不知道正确值应该是多少,也不知道这个错误是什么时候、由哪一笔操作引入的。复式记账要求每次变动同时记一借一贷且金额相等,任何不平衡都会在写入时被拒绝,任何历史值都可以由分录流水重新算出来。前者只存结果,后者存了产生结果的全过程,这才是能被审计和还原的账本。
- 需要幂等。回调处理逻辑必须以一个唯一标识(通常是对方的交易号或回调事件 ID)为键,重复收到时识别为同一次并返回成功,而不是重复执行一次记账。同时还需要主动查询兜底——如果对方的重试次数用完了你还没处理成功,你要能自己去查这笔交易的最终状态。只做幂等不做主动查询,仍然可能永久卡住。
- 严格说要等退回窗口关闭。消费者账户的未授权退回(R10)窗口最长 60 天,所以最保守的做法是 60 天后确认。实务上通常按风险分层处理:低风险用户和小额交易在几个工作日后确认并计提坏账准备,高风险的延后。关键是这笔钱在到账当天不是最终收入——把它当成最终收入来做资金安排,是这类产品最常见的爆雷原因。
上一章:第 20 章 合规骨架:牌照、身份与监控 下一章:第 22 章 稳定币是什么:回到货币层级那张表