全部文章

账单去重不是一个布尔判断

abei 是我业余在做的一个开源记账工具:账单从邮箱收进来,各家的导出解析成统一的流水,AI 预填分类,最后由人确认入账。

导入时最先撞上的问题是去重。同一笔钱会以好几种样子出现:同一份账单导了两次;微信的一笔消费,又出现在绑定银行卡的账单里;一笔转账在两个账户里各记一次;退款和原单金额相同、方向相反。

动手设计匹配引擎之前,我先看了九个记账产品和开源项目是怎么做的。下面是那次调研的整理,调研日期是 2026 年 9 月 1 日。标了「未核实」的条目,是没找到公开文档、要以实际账单样本为准的。

一句话结论

去重不是一个布尔判断,而是五类不同的关系各自打分:同源重复、跨源同一笔、转账配对、退款关联、合并支付。

国外产品只解决了第一类和第三类。跨源同一笔(微信、支付宝对银行卡)是中国场景独有的难点,没有成熟产品可以照搬。

abei 的做法是:一个匹配引擎,加可配置的策略,每一次匹配的决定都落库,并且可以回滚。它替代了原来散在三处的去重逻辑。

一、各产品的去重判据

产品主判据时间窗口金额容差命中后的动作用户看不看得到
Firefly III Data Importerexternal_id(映射到 External identifier 列);也可比对描述和备注的组合无窗口,等值比对零容差直接不导入只在导入日志里,账本里没有痕迹
Firefly III 核心journal_meta.import_hash_v2,即整行 JSON 的 sha256;开了 errorIfDuplicate 就报错无零容差报错拒绝报错信息
Actual Budget先看 imported_id;没命中再模糊匹配(金额相同 + 日期 ±7 天 + 收款方,再退化为金额 + 日期 ±7 天)±7 天金额严格相等更新已有的那笔(包括把日期改成导入侧的日期),而不是丢掉新的一行交易被就地更新
YNAB导入时用 import_id(格式 YNAB:{amount}:{date}:{occurrence},账户内唯一);界面上把导入行和手记行配成 Matched官方文档没给具体天数(社区常说 ±10 天,未核实)金额一致标成 Matched,仍要人点 Approve;可以 Unmatch明确的配对和确认交互
beangulp(Beancount 的导入框架)similar 模块的启发式:日期窗口 + 金额比例容差 + 账户集合的子集关系window_days=2epsilon=0.05,即 5% 相对容差只给交易打 __duplicate__ 标记,不删也不合并由用户在文本里裁决
GnuCash 导入匹配器贝叶斯打分,三档阈值匹配天数可配打分制红、黄、绿三区,对应默认新增、待人选、默认对上导入匹配窗口里逐行可改
Plaidpending 变 posted 不是状态变更,而是一笔新交易,用 pending_transaction_id 指回旧的那笔,旧的那笔另外通知删除通常 1 到 5 个工作日,极端 14 天不适用由接入方自己合并由接入方决定
Lunch Money、Tiller以人工去重工具为主;Tiller 用 Import Tag 列标记某一次 CSV 导入,可以整批删掉重来无无人工完全可见
BeeCount 等国产开源导入前强制预览,整批原子回滚(未核实,没找到公开文档)————

补充几条:

二、中国场景的三个独有难点

1. 跨源同一笔:平台侧对银行侧

微信、支付宝的一笔消费,会同时出现在平台账单和绑定银行卡的账单里。国外产品没有这个概念,Plaid 是一个账户一份流水。

社区里(deb-sig/double-entry-generator 的 Discussion #162)给过两条路线:

路线做法优点缺点
中转账户银行侧记成「银行 → 中转账户」,平台侧记成「中转账户 → 商户」,中转账户余额恒为 0,可以自查信息全保留,能对账账户树变复杂,普通用户看不懂
规则去重(主流)保留信息量最大的平台侧记录,忽略银行侧对应的那一行简单,账目干净从银行侧推余额会缺一段,需要配对留痕

关键信号是:秒级时间、金额,加上平台侧「支付方式」字段里的银行卡尾号。同一个讨论里也有人提出按秒级时间戳加金额比对的自动判重思路。

有状态的自动配对(记住配过的对、支持撤销)目前还是空白,bean-sieve 这类工具在尝试(未核实)。

2. 退款不能当成重复

退款和原单同商户、同金额、方向相反。正确的做法是保留反向的那条记录,再把两条关联起来,而不是删掉一条。

支付宝账单带「成功退款」和资金状态字段,可以按字段判定,不用靠猜(未核实:字段名以实际样本为准)。

3. 合并支付,一对多

一次支付拆成多行账单,或者多笔合并成一次扣款。没有成熟产品自动处理,子集和匹配的误报率太高。结论:只提示,不自动合并。

另外,微信把充值、提现、理财通、信用卡还款标成中性交易,支付宝有「不计收支」标记,两者都不该进收支统计(未核实:以实际账单样本的字段为准)。

三、命中之后,界面上怎么处理

档位代表适用的关系必须配套的东西
静默跳过Firefly 的导入器硬键完全命中的同源重复必须能查到跳过了什么、撞上了哪一笔。Firefly 正是反例:跳过之后无处可查
提示,等人确认YNAB 的 Matched(配对后仍要 Approve,可以拆开)、GnuCash 的黄区跨源同一笔、软信号的重复证据看得懂,一键拆开
阻断,打分GnuCash 的红黄绿三区金额高或者不确定性高的分数要能解释

撤销要有两级:整批撤销(像 Tiller 的 Import Tag,一次导入整体撤掉)和单条撤销。

四、哪些该让用户配置

按「用户真的会去调」排序:

  1. 按渠道设时间窗口:微信、支付宝是小时级,银行是天级。
  2. 金额容差:绝对值和相对比例取大的那个。beangulp 用 5% 相对容差,银行场景通常要绝对值。
  3. 唯一键字段的优先级:交易订单号,其次商家订单号,最后是指纹 hash。
  4. 各渠道谁更权威:跨源冲突时谁是主记录,默认平台侧。
  5. 严格程度的开关:像 Firefly 的 strictIdChecking、GnuCash 的三个阈值。
  6. 不开放各字段权重的细调:用户调不动,只会调坏。

五、abei 的做法

一个匹配引擎,五类关系各自打分

输入是规范化之后的一行:渠道、精确到秒的发生时间、金额、交易对方、商品、支付方式、业务类型、状态、收支方向、平台订单号、商家订单号、原始行的 hash。

关系判据默认动作
同源重复硬键(交易订单号)命中静默跳过并写日志;只有软信号命中的,等人确认
跨源同一笔秒级时间 + 金额 + 卡尾号分数高的自动折叠;平台侧是主记录,银行侧作为资金来源留痕
转账配对两侧账户 + 金额 + 时间窗口自动配对抵消;配不上的进「未配平」队列
退款关联反向金额 + 同商户 + 退款字段自动关联,不合并,也不记成收入
合并支付子集和永远只提示,不自动合并

每一次匹配输出一个决定:是哪种关系、指向哪几笔、分数、证据、建议的动作。证据必须能一条条读懂,比如「订单号一致」「时间差 3 秒」「卡尾号一致」。

状态与回滚

一次匹配从「待定」出发,要么自动合并,要么进入待复核;复核之后是确认或者否决;确认过的也可以再拆开。

默认阈值

这些是默认值,还在讨论,之后可以在管理端按渠道配置。

关系时间窗口金额容差
同源重复无(硬键等值)0
跨源同一笔±2 小时0
转账配对±3 天0
退款关联±90 天0

不采用的做法

来源

Firefly III

Actual Budget

YNAB

beangulp 和 Beancount

GnuCash

Plaid

Tiller

中文场景

全部文章