分账的账要能倒推回去

杜恒 0 阅读 技术沉淀

06 那篇里那笔 128 元,钱是进来了。这一篇讲它的下一步:平台抽 5%,门店挂靠在代理商 C1 名下、代理商抽 2%,渠道手续费 0.6% 由商户承担,商户能提走的是多少。

按分算是 12,800 分:手续费 76.8 取 77,平台 640,代理商 256,门店到手 11,827,也就是 118.27 元。

算这一步不难,麻烦的是算完之后。商户第二天问“为什么是 118.27 不是 119”,你要能答;用户退 40 元,分出去的钱要有来路;平台改费率,改之前的单不能跟着变。所以我建议别把它存在一列里,让它每次都是从流水算出来的。

余额得是查出来的,不是加出来的

最常见的写法是在商户表上挂一列 balance,支付确认时 update ... set balance = balance + ?,提现时减掉。后来会陆续长出 frozen、total_income、withdrawn 这几列,每多一列,就多一处要记得同步改的地方,而改它们的地方散在回调、查单、核销、退款这些路径上,哪条漏了,这张表就跟真实余额差一块。

这套写法在一种情况下必坏:任何一次业务失败之后,你不确定那一步到底加过没有。06 那篇的回调重发、任务重试、人工补推,全都会撞上它。一旦开始怀疑,唯一的修法是从头重放一遍业务,而你手上只有那个已经错的数,没有能重放的东西。

余额应该是 sum(分录) 的结果,不是被加减的字段。要落地就是三张表。

账户:谁是主体,钱处在什么状态

CREATE TABLE acct_account (
  id         BIGINT      NOT NULL AUTO_INCREMENT,
  owner_type VARCHAR(16) NOT NULL,   -- platform / merchant / agent / external
  owner_id   BIGINT      NOT NULL,   -- 指向 biz_store / biz_agent,平台填 0
  kind       VARCHAR(16) NOT NULL,   -- payable 待结算 / available 可提现 / frozen 提现冻结 / advance 平台垫付
                                      -- in_transit 渠道在途(owner 是 external)/ fee 渠道手续费户(owner 是 platform)
  currency   CHAR(3)     NOT NULL DEFAULT 'CNY',
  opened_at  DATETIME    NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_owner (owner_type, owner_id, kind, currency)
);

owner_id 指向 03 那篇建的 biz_store 和 biz_agent,账户不重新发明主体,只给主体挂钱的状态。external 是渠道和银行那一侧,它不代表你拥有的钱,只是为了让每张凭证两边相等而存在的对侧账户,后面会用到。

流水:一次变动,两个方向

CREATE TABLE acct_entry (
  id          BIGINT       NOT NULL AUTO_INCREMENT,
  voucher_id  BIGINT       NOT NULL,
  account_id  BIGINT       NOT NULL,
  direction   CHAR(3)      NOT NULL,   -- IN 进账 / OUT 出账
  amount_cent BIGINT       NOT NULL,   -- 永远正数,方向看 direction
  biz_no      VARCHAR(64)  NOT NULL,   -- 订单号 / 退款单号 / 提现单号
  happened_at DATETIME     NOT NULL,   -- 业务发生时间,不是插入时间
  line_no     INT          NOT NULL,   -- 凭证内行号,pair() 顺序分配
  PRIMARY KEY (id),
  UNIQUE KEY uk_voucher (voucher_id, line_no),
  KEY idx_voucher_acc (voucher_id, account_id),
  KEY idx_acc (account_id, happened_at)
);

happened_at 用业务时间这件事容易被糊过去。如果只有插入时间,月底那笔晚上十一点五十多的核销就会掉进下个月的账,商户按自然月看账单时对不上,而你没法改——记录已经插进去了。所以结算按它筛,插入时间只用来排查。

uk_voucher 按行号建,不按账户组合建。退款冲销常见几条腿的 IN 落在同一个外部账户上,键要是 (voucher_id, account_id, direction),第二条腿当场撞键、整张凭证回滚,退款永远冲不进去。防重入的职责在凭证层——uk_biz 保证同一个业务编号只 POST 一张,分录层只保证行号不重复。

凭证:一件事的全部分录

CREATE TABLE acct_voucher (
  id          BIGINT      NOT NULL AUTO_INCREMENT,
  type        VARCHAR(24) NOT NULL,   -- PAY_CONFIRM / SPLIT / FEE_TRUE_UP / REFUND_REVERSE / ADVANCE_RECOVER / WITHDRAW_FREEZE / WITHDRAW_PAID / WITHDRAW_REVERSE
  biz_no      VARCHAR(64) NOT NULL,   -- 支付确认和 SPLIT 用订单号,FEE_TRUE_UP 用账单批次号+订单号,退款类用退款单号,提现类用提现单号
  batch_no    VARCHAR(32) NOT NULL DEFAULT '',  -- 结算批次;支付确认和核销时还没有批次,跑批时回填
  rule_id     BIGINT          NULL,       -- 用了哪版分账规则
  rule_snapshot JSON          NULL,       -- SPLIT 时固化各腿比例、基数、取整方向、手续费随不随退
  status      VARCHAR(16) NOT NULL,       -- POSTED;撤销只新记一张冲销凭证,不改这里
  origin_voucher_id BIGINT    NULL,       -- 冲销类凭证指向原凭证
  reversed_by BIGINT          NULL,
  posted_at   DATETIME    NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_biz (type, biz_no)
);

凭证是业务事件在账上的名字,它管的是一件事的全部分录:一笔核销、一次退款冲销、一单提现。uk_biz (type, biz_no) 让它天生幂等,重复核销请求插入第二张 SPLIT 时直接撞键,你抓住这个异常返回已有凭证就行。

三张表立起来之后,多了一条能长期用的不变量:每张凭证名下所有分录的进账合计等于出账合计。它不需要业务规则参与,纯查库,所以能被脚本每天检查,也能在写错代码的当天被检查出来。

余额就是那条 group by:

select a.kind, coalesce(sum(if(e.direction = 'IN', e.amount_cent, -e.amount_cent)), 0) as cent
  from acct_account a
  left join acct_entry e on e.account_id = a.id
 where a.owner_type = 'merchant' and a.owner_id = #{storeId}
 group by a.kind;

商户后台每次打开都要这个数,等 acct_entry 涨到几十万行,这条 group by 就慢了。慢就物化,每天全量重算一次:

CREATE TABLE acct_balance (
  account_id BIGINT   NOT NULL,
  cent       BIGINT   NOT NULL,   -- 重算时刻的 sum(分录)
  calc_at    DATETIME NOT NULL,
  PRIMARY KEY (account_id)
);

区别在于它是算出来的,谁都不许 update 它。但快照只服务商户后台的展示和报表:凡是要拿余额做判断的地方,提现申请的守卫、冻结时的校验,必须在事务里实时跑上面那条 sum,findForUpdate 锁住账户行再算。快照落后一天,拿它守提现,就是拿不存在的钱放款。重算完的报警也别拿两份快照比,差值就是当天的正常业务量,天天非零,报着报着就没人看了;要比就和同一时点的实时 sum 比,对不上才说明有人动了不该动的表。

一笔 128 元在账上走一遍

一笔 128 元的资金路径:支付、核销拆账、四份分账、退款按原分录冲回

先是支付确认。这张凭证写在 06 那篇的 apply() 里,回调和查单都走到它;钱这时候还说不清算谁的,整笔挂在门店的待结算上。

到店核销触发 SPLIT,把待结算的 12,800 分一步拆成四腿。核销和 SPLIT 要在同一个事务里提交:拆开的话核销成功、SPLIT 失败,uk_biz 只保证重试安全,不会替你触发重试。

第二天渠道账单回来,你按 0.6% 预估的 77 分不是实际收的数,账单上是 74,差的那 3 分要回补给门店。

时间 凭证 账户动向 金额(分)
20:14 PAY_CONFIRM(支付确认) 渠道在途 → 门店 S1 待结算 12,800
21:03 核销 SPLIT(拆账) 待结算 12,800 分成:S1 可提现 11,827/平台 640/代理商 C1 256/渠道手续费 77 12,800
次日 09:20 FEE_TRUE_UP(手续费找平) 实际手续费 74,多的 3 从手续费户回拨到 S1 可提现 3

手续费这一张不能省。为了少写一步,把 0.6% 直接算进分账公式、差额当没有,月底商户账单和渠道流水就会差出一堆两三分的尾数,谁也对不平。正确的位置是预估先进账,账单回来再补一张调整凭证,差额单独看得见。渠道费率有活动价、有单笔下限、也有退款不退手续费的情况,这些都不归你定,你只能事后按账单调。

同一张 SPLIT 里那四个数要能解释为什么这么分,所以分录上得留得下线索。我在 acct_voucher.rule_id 之外还会把当时的比例 JSON 塞进 rule_snapshot 那一列:规则表以后要改,改完不能影响历史重算。

分账规则要带生效区间,尾差要提前定给谁

CREATE TABLE split_rule (
  id            BIGINT      NOT NULL AUTO_INCREMENT,
  scope_type    VARCHAR(16) NOT NULL,   -- store / agent / global
  scope_id      BIGINT      NOT NULL,
  target_type   VARCHAR(16) NOT NULL,   -- platform / agent / merchant
  target_id     BIGINT      NOT NULL,
  rate_bp       INT         NOT NULL,   -- 万分比:500 = 5%
  base_type     VARCHAR(8)  NOT NULL DEFAULT 'GROSS',  -- GROSS 按应付总额算比例 / NET 按扣掉手续费之后算
  fee_bearer    VARCHAR(16) NOT NULL,   -- merchant / platform
  remainder_to  VARCHAR(16) NOT NULL,   -- platform / merchant / largest
  valid_from    DATETIME    NOT NULL,
  valid_to      DATETIME    NULL,
  PRIMARY KEY (id),
  KEY idx_scope (scope_type, scope_id, valid_from)
);

比例存万分比整数,别在库里放 0.05 这种小数。字段类型选 decimal(6,4) 看着更准确,但 Java 那边 BigDecimal 一路传下来,除不尽时的取舍只要散在两个方法里,两边就能差一分。整数加一个约定,能少掉一整个错误类别。

valid_to 为空代表当前生效。改费率是关掉旧行、插新行,不是 update 旧行的 rate_bp。这条要写成硬约束,因为它决定你能不能重跑结算。

客户说上个月算错了、要按新费率重算,只要新费率在表里、旧单的分录还带着当时的 rule_id,重跑结果就是可解释的;那行规则一旦被 update 掉,这个数从此没法重现,你只能承认对不上。这里有个容易混的区分:重跑结算,是用当时的规则把当时的分录重新算一遍,看代码有没有算错;重新分账,是拿现在的规则再分一遍,那是改价。前者随时可以做,后者是商务动作,要审批、要补一张调整凭证,不能让一个定时任务顺手干。

除不尽的情况一定会出现。一笔 1 元钱的单,三方各 1/3,分完是 33、33、34,多出来的那一分给谁,要定死并写在 remainder_to 里。代码里只写“按顺序凑够总额”,三个月后没人知道那一分是怎么分掉的;而商户真的会为这一分提工单,尤其是那种一天几千单、每单都往同一个方向差一分的商户。

比例基数也要定:是应付总额 12,800,还是扣掉手续费之后的 12,723。同样 5%,换个基数每单差 4 分,一万单就是 400 元。这 400 元是商户少拿还是平台少拿,规则表里要写明白——base_type 那一列就是干这个的,别留在某个方法的第三行。

规则选取按业务时间取,不按当前时间取。核销在 23:59:58 发生、任务在 00:00:04 跑,正好跨过改费率的点,取哪一版结果就不同了。同一单同时命中门店级和全局级规则时按 store、agent、global 的顺序取生效行,各条规则在 base_type 定的同一基数上各算各的,remainder_to 只在最后一腿凑整时生效;门店挂在哪个代理商名下读 03 那篇的挂靠关系,scope_id 只负责选规则,回答不了“这单归 C1”。

退款会把分过的账拆开

用户退 40 元,就是 4,000 分。原单已经分完,手续费找平也跑过了,分出去的这几方各退多少,只能按各家现在的余额倒推。

门店可提现 11,830、手续费户 74——找平那笔已经把 3 分回拨给门店了。平台 640 × 31.25% = 200,代理商 256 × 31.25% = 80,手续费 74 × 31.25% = 23.125 取 23,门店 11,830 × 31.25% = 3,696.875 取 3,697。三方扣减 3,977 加渠道退回的 23 分手续费,正好凑够退给用户的 4,000。

这组数刚好平,多数时候不平。不平的那一分必须有固定归属,否则同一笔退款在两个环境里跑出两个结果。

@Transactional
public void reverseRefund(Refund rf, Voucher origin) {
    List<SplitLeg> legs = splitCalculator.reverse(origin, rf.getAmountCent());  // 纯函数,可重放
    Voucher v = voucherRepo.tryCreate(VoucherType.REFUND_REVERSE, rf.getRefundNo(), legs);
    if (v.isReplay()) return;                 // 撞 uk_biz:同一个退款单号只冲一次
    for (SplitLeg leg : legs) {
        Account from = accountRepo.findForUpdate(leg.getAccountId());   // payable 或 available
        long take = Math.min(from.balanceCent(), leg.getAmountCent());
        if (take > 0)
            entryRepo.pair(v.getId(), from.getId(), take);              // 一次写两条、行号顺序分配:from 记 OUT,external 记 IN
        if (take < leg.getAmountCent())
            entryRepo.pair(v.getId(), advanceAccount(), leg.getAmountCent() - take);  // 扣不到的挂平台垫付
    }
}

splitCalculator.reverse 必须是纯函数:给定原凭证的分录和退款金额,输出唯一的分配结果。它能被单测覆盖,商户来质问那 40 元扣了谁的时候,你能把当时的分配原样跑一遍给他看。它还要吃一个政策位:手续费随不随退(渠道有退款不退手续费的情况),这一位随 rule_snapshot 固化,不然同一笔退款门店腿是 3,696 还是 4,000,取决于谁来算。

然后是这套账里唯一会让人夜里起来的地方:门店已经提现走了,账上没钱可扣。有人在这个时候把余额扣成负数,商户后台于是显示一个负数,客服完全解释不了这是什么意思;也有人让运营手动往账上转一笔,钱是平了,可它不在流水里,变成一个查不到来源的账外动作,下次对账又多出一条差异。

比较稳的做法是让平台先垫:冲不平的那部分记到 advance 账户——按商户开户,owner 是这家商户、kind 是 advance,uk_owner 天然支持——该商户下一笔 SPLIT 入账时优先扣回,扣回本身是一张 ADVANCE_RECOVER 凭证,全程可查。垫付账户的余额就是平台替这个商户担着的风险敞口,财务每月都看得到这个数,要不要因此收一道保证金、或者把新商户的前几单压在一个更长的冻结期里,是拿着这个数才能做的决定。它比负余额好的地方是不会污染商户的可用余额,提现判断也就不会错。

from.balanceCent() 读的是账户当前余额,所以这里用了 findForUpdate。05 那篇说过什么时候值得加行锁——需要读外部数据做守卫的时候。退款冲销同时来两笔(用户连点部分退款,或者任务重复跑),两个事务各自读到的余额都可能够。这条锁约定要升成全局的:任何写 acct_entry 的事务,先按 account_id 升序对涉及的账户 findForUpdate,再算余额再写分录,SPLIT 和 WITHDRAW_FREEZE(提现冻结)同样遵守,否则冲销在锁内算出的余额照样会被并发的 IN 插穿。

可用和冻结别做成一行里的两列

提现流程的动作序列不长:申请、审核、打款、回执,可能还要加一次驳回。账务上的难点只有一处:钱在申请到打款中间这段时间里,算谁的。

如果把可用和冻结做成账户行上的 available_cent 和 frozen_cent 两列,申请时一减一加,打款时冻结减掉,失败时反过来再加一次。两个动作各自都对,交叉起来就出问题:审核期间发生一次退款冲销,扣的是可用还是冻结说不清;商户在打款回执之前又提一单,两笔冻结混在一起分不出来源;财务要看这个月一共提走了多少,得对两列分别聚合。

所以用账户类型分开:available、frozen,每个状态一个账户,申请就是一次内部转账凭证。

-- 申请提现 10,000 分:WITHDRAW_FREEZE,biz_no 就是你自己生成的提现单号 withdraw_no
INSERT INTO acct_entry (voucher_id, line_no, account_id, direction, amount_cent, biz_no, happened_at) VALUES
  (101, 1, @s1_available, 'OUT', 10000, 'WD20261203001', '2026-12-03 10:12:03'),
  (101, 2, @s1_frozen,    'IN',  10000, 'WD20261203001', '2026-12-03 10:12:03');

-- 打款成功:WITHDRAW_PAID,钱离开你的账
INSERT INTO acct_entry (voucher_id, line_no, account_id, direction, amount_cent, biz_no, happened_at) VALUES
  (102, 1, @s1_frozen,          'OUT', 10000, 'WD20261203001', '2026-12-03 15:40:11'),
  (102, 2, @external_bank,      'IN',  10000, 'WD20261203001', '2026-12-03 15:40:11');

失败要打款回执之后新出一张 WITHDRAW_REVERSE,把 10,000 从 frozen 挪回 available,而不是去改前两张凭证的状态。前两张是历史事实:那一刻确实冻结了,那一刻确实发起过打款。要撤销只能新记一张,红冲这个动作在 06 那篇讲对账时也出现过,同一个理由。

提现单本身的状态机(待审核、已驳回、打款中、到账、失败)走 05 那篇讲的事件推进,别用 if (status != 1) 这种写法;幂等键用你自己生成的提现单号 withdraw_no,理由和 06 里退款那张表一样。打款接口重试时渠道给的新单号不能当判重依据。

驳回要不要产生分录?要,但只有一张 WITHDRAW_REVERSE——解冻就是 frozen 记 OUT、available 记 IN,不另造凭证类型。

申请时就冻结,驳回了用一张 WITHDRAW_REVERSE 解冻,这样账户上出现的每一笔钱都对应一个真实发起过的动作,不会出现申请单被删掉、frozen 里那 10,000 分还挂着的情况。删申请单在这种系统里不该被允许,只允许状态往前走。

商户号和证书要落表

钱是哪个商户号收的,决定了这笔账最后落到谁头上。商户号和证书因此也是账的一部分。

商户号写在 application.yml 里,就退回到 02 那篇讲过的那个问题:一个配置项只能有一份值。你的系统接入第三个客户时,它的第二个商户号没地方放:要带的不止是那个号,还有什么时候开始用、什么时候停用、和哪个主体绑定。这些是数据,要有表:

CREATE TABLE pay_merchant (
  id             BIGINT      NOT NULL AUTO_INCREMENT,
  channel        VARCHAR(16) NOT NULL,
  mch_id         VARCHAR(32) NOT NULL,
  app_id         VARCHAR(32)     NULL,
  owner_type     VARCHAR(16) NOT NULL,   -- customer / platform
  owner_id       BIGINT      NOT NULL,
  credential_ref VARCHAR(128) NOT NULL,  -- KMS / 凭据管理里的引用串,不放密钥本身
  key_version    INT         NOT NULL DEFAULT 1,
  cert_serial    VARCHAR(64)     NULL,   -- 渠道平台证书序列号,轮换就是它变
  valid_from     DATETIME    NOT NULL,
  valid_to       DATETIME        NULL,
  status         VARCHAR(16) NOT NULL,   -- active / paused / retired
  PRIMARY KEY (id),
  UNIQUE KEY uk_mch (channel, mch_id, key_version),
  KEY idx_owner (owner_type, owner_id, valid_from)
);

密钥本身不进库,库里那个 credential_ref 只是引用。备份和从库同步因此带不走密钥,轮换也变成一件数据操作:插入一行新版本,把旧行的 valid_to 关掉,而不是在配置中心改一个字符串再重启所有实例。

验签时按回调带来的证书序列号选密钥版本,不能直接用当前那版。渠道的证书轮换有双活窗口,回调又是会晚到的东西,用当前版本去验一条三天前的报文,结果就是 06 那篇那个场景:验签失败、渠道不再重发、钱收了但账没动。序列号要随报文一起落库:微信 v3 把它放在回调头 Wechatpay-Serial 里,不在报文体,只存 body 事后就找不到它;支付宝证书模式的 alipay_cert_sn 倒是在通知参数里。落库时把头和 body 存到一起,事后重验才能找到当初用的是哪把。

至于加一个渠道要动哪些地方,按一条钱走过的路线找就行:入口的验签和解析、金额单位换算、回调地址和商户号绑定、账单文件的表头和编码、退款单号怎么生成、有没有原生分账能力(没有的话就回到下一节的选择:存管、持牌归集或者不碰资金,自行结算打款能不能走要先过合规)。

这几处摆在一起,一个渠道适配器该实现什么已经很清楚了,别写成 WechatPayUtil 那种静态类,否则每加一个渠道就是把它复制一遍再改。

代收再结算之前,先问一句是不是二清

这套钱先进平台、再由平台分给商户的做法,技术上很干净,业务上踩一条监管线:平台代商户收款,再自行清算给商户,在没有支付业务许可的情况下,行业里通常管这叫“二次清算”。人民银行 2017 年那次无证经营支付业务整治(217 号文)针对的就是这个形态。

我不提供法律意见,具体到你的业务算不算,要让客户带着和支付机构签的合同去确认。技术上能说清的,是每条路径上钱到底在谁那里。所以顺序是先问合规、再定账户结构,反过来的代价是整套账务重来。

最省事的是渠道原生分账。微信的服务商模式、电商收付通,支付宝的直付通,都能把资金直接分到各主体账户,平台只收自己的服务费。分账比例有上限(常见是 30%,能不能提高看你签的产品和权限),分账指令还有时效。走这条,三张表还要,但语义变了:那个待结算账户不再是你的负债,只是一个统计口径,钱从来没进你的账。这一步不区分清楚,商户问“钱到哪了”,你会给他一张和渠道完全不同的答案。

客户里有金融背景的会主动提银行存管,或者持牌机构做资金归集:银行出一个记账簿接口,你的账只是它账的镜像。改造量大,好处是合规这条线有人替你走过。

还有一条是平台根本不碰资金,只做订单和信息流,各商户用各自的商户号收款,平台自己的收入靠月度账单去收服务费。这条路交付最快,账务也最简单,你只需要一张应收账单,上面那三张表都不用建。能在一开始就谈下来,就选它。

如果客户已经跑在平台代收上,要处理的不是纠错,是迁移:新单走分账接口,存量的待结算余额单独结一轮,切换的那条线画在批次号上,历史账就不会被新规则重算一遍。

发票和税怎么算、优惠和券的成本归谁、银行存管接口怎么接,这几件事都要等一个业务决定才有意义,写早了是白写。

不平的凭证要能在一条 SQL 里跳出来

能今天就接上的是那条不变量:每天把两边不平的凭证查出来,挂到定时任务上。返回的每一行都是一张写坏了的凭证;查出来是空的,只说明目前这几条代码路径自己还平,真正值得盯的是它什么时候从空变成有,那一天你多半刚上线了一个新的账务动作。

select v.id, v.type, v.biz_no,
       sum(if(e.direction = 'IN', e.amount_cent, -e.amount_cent)) as net
  from acct_voucher v
  left join acct_entry e on e.voucher_id = v.id      -- left:一条分录都没有的 POSTED 凭证也是坏凭证
 where v.status = 'POSTED'
 group by v.id, v.type, v.biz_no
having net <> 0 or count(e.id) = 0;

系列:上一篇 支付回调出错时系统一声不吭 | 下一篇 账不平时要能说出错在哪一层