Repository navigation
Replies: 1 comment
RFC 0001 附录配合 0001-import-pipeline.md 阅读。
A. 逐账单格式特征
B. 能力矩阵
C. 已知问题清单修复建议都只有一句,展开讨论建议各自开 issue。 C.1 Go provider(master
|
| 级别 | 位置 | 问题 | 建议 |
|---|---|---|---|
| P0 | cib_debit/cib_debit.go#L107、#L184 | 整行任意单元格含"说明"就停止读取,后面的交易静默丢失(已复现) | 只在首列等于"说明"时停止 |
| P0 | ccb/parse.go#L116-L164 | 支出列为负(冲正)时金额被置为 0(已复现) | 按 收入 − 支出 计算带符号金额 |
| P0 | hsbchk/parse.go#L23、#L80 | 只检查 len < 5,却读取 line[5],5 列文件直接 panic(已复现) |
检查改为 < 6,或让余额币种可选 |
| P0 | huobi/parse.go#L60-L61 | 手续费为空时数组越界 panic(已复现) | 先判空 |
| P1 | spdb_debit/spdb_debit.go#L103 | 整行含"合计"就停止读取 | 只看首列 |
| P1 | alipay/alipay.go#L82 | 写死跳过 22 行。deg-provider-template 里的样例账单多一行说明,master 解析失败(已复现) | 按表头内容定位 |
| P1 | alipay/parse.go#L36、mt/parse.go#L29 | 用 float32 解析金额,1234567.89 变成 1234567.88(已复现);mt 按字节切掉 ¥,遇到全角 ¥ 报错 |
改用精确小数,统一清洗货币符号 |
| P1 | jd/jd.go#L197 | 删掉小数点后按整数解析,50、50.5 这类金额会差 10~100 倍(京东是否导出这种金额未验证) |
改用精确小数 |
| P1 | cib_debit/parse.go#L10-L20 | 不认识的币种一律当 CNY | 用币种表映射,认不出就报错 |
| P1 | boc/parse.go#L21-L23 | 非人民币一律当 USD(港币被记成 USD,已复现) | 同上 |
| P1 | citic/parse.go#L31-L35 | 币种取结算币种,金额却取交易金额,外币交易金额错误;币种也没传进 IR | 用结算金额记账,原币写进 metadata |
| P1 | icbc/convert.go#L13-L21 | 币种没有进 IR,美元交易被记成 CNY(已复现) | 映射成 ISO 后写进 Currency |
| P1 | analyser/citic#L70-L74、analyser/bocom_credit#L70-L74、analyser/bmo#L69-L74、analyser/td#L69-L74 | 只匹配 item/type,peer 不参与匹配,只写 peer 的规则会匹配全部交易;bocom_credit 一条 peer + ignore 规则丢掉整个文件(已复现)。这几家的文档示例用的都是 peer |
实现该字段,或者加载时对不支持的字段报错 |
| P1 | analyser/icbc#L73-L88 | 只用了 peer/type/txType/status。status 依赖从未写入的 metadata,永远不命中;只写 item 的规则命中全部交易(已复现) |
同上 |
| P1 | analyser/abc_debit#L43-L56 | 没有规则时返回 DefaultMinus/Plus,有任意规则后改用 defaultCashAccount。bmo、bocom_credit、ccb、citic、cmb、hsbchk、icbc、td 是同一写法(未逐一附行号) | 不论有没有规则,都先按方向套用本账户 |
| P1 | hsbchk/config.go#L16 | 用的是 yaml: tag,viper/mapstructure 读不到 sep(已复现);oklink 同理 |
改用 mapstructure: tag |
| P1 | hsbchk/parse.go#L118-L120 | DEBIT 一律取负;金额符号与方向列不一致时输出 --12.00(已复现) |
用带符号金额,方向冲突时报错 |
| P1 | bmo/bmo.go#L57-L68 | 按行号区分信用卡和借记卡;没有前导说明行时丢一行,并把入账日期当成金额(已复现) | 按表头内容判断 |
| P1 | ibkr/ibkr.go#L86-L87 | 不是 STK/CASH 的资产类别(OPT、FUT、BOND…)被静默丢弃(已复现) | 告警或报错 |
| P1 | ibkr/ibkr.go#L136-L137 | 佣金按成交币种记账,忽略 ibCommissionCurrency;外汇交易的佣金没有入账(已复现) |
佣金单独一条腿,用自己的币种 |
| P1 | compiler/beancount/template.go#L143 | 证券成本按单价 %.3f、数量 %.2f 输出,碎股和高精度价格不平(ibkr、huobi 已复现);htsec、hxsec、ibkr 共用这个模板 |
成本写总额,保留原始精度 |
| P1 | oklink/oklink.go#L283 | 未认证代币的合约地址被直接当作 commodity,样例输出约 940 笔语法错误 | commodity 映射或清洗,或者默认忽略 |
| P1 | oklink/oklink.go#L916 | 规则里的 currency 会把资产腿的币种一起改掉(已复现) |
删掉该字段,或只作用于对方腿并加价格 |
| P1 | htsec/htsec.go#L80-L109 | WASM 入口没有做中签两行合并,CLI 和 WASM 结果不同 | 抽成公共函数 |
| P1 | compiler/ledger | 不处理 runtime 模板产生的 Postings,import -t ledger 输出没有账户的分录(已复现) |
与 beancount 编译器共用结构化分录 |
| P2 | oklink/oklink.go#L149 | 解析失败的行只打日志就跳过 | 报错,或在汇总里计数 |
| P2 | wechat/parse.go#L15 | 服务费取备注里第一个两位小数,备注里有别的数字时会取错 | 锚定"服务费"之后的数字 |
| P2 | bocom_credit/util.go#L89 | 一个未知类型(例如年费)就让整个文件失败 | 记到 FIXME 并标记,或者补全类型 |
另外还有:tag 与 tags、status 与 orderStatus 并存;time 在 oklink 里表示日期区间;hxsec 首条命中即停,其他 analyser 是后命中覆盖先命中。多家文档示例里的字段或取值实际不存在(htsec、hxsec、huobi、mt、ccb 等)。
C.2 runtime 模板引擎
"master" 一列是在 97a4f65 上复现的结果。"未核对"表示只在开发中引擎上复现过。
| 问题 | master |
|---|---|
forceAmountDirection 把金额取绝对值,冲正方向反了 |
同样存在 |
| 没有规则设置 from/to 时,输出没有账户的分录 | 同样存在 |
| posting 引用不存在的列,生成省略金额的腿;不做平衡校验 | 同样存在 |
import -t ledger 分录里的账户丢失 |
同样存在 |
未声明 dateFormat 时逐行猜日期格式,01/02 优先于 02/01 |
同样存在 |
| 日期解析失败只报 "runtime v2 rule did not set date",不带行号和原值 | 同样存在 |
skipInvalidRows 吞掉所有行级错误;表头不一致时把首行当数据、按位置映射。icbc v1 模板 + v2 账单:退出码 0、0 笔交易 |
同样存在 |
| 未知方法、参数解析失败时静默丢弃,参数原样拼进结果 | 同样存在 |
| 条件里 ISO 日期被当作算式比较 | 不存在,仅开发中引擎 |
空 metadata 输出成 key: "" |
不存在,仅开发中引擎 |
| 编译器总是输出 payee 和 narration 两段 | 仅开发中引擎(新行为更符合 beancount 语义,但 expected 需要重新生成) |
< 运算符与 <列名> 写法冲突:amount < 100 && <x> == "a" 报错 |
未核对 |
加法让小数位膨胀(0.85000000);format 不支持宽度和补零,失败时返回原值 |
未核对 |
| 金额为正时类型推断仍然返回"支出" | 未核对 |
| 日期解析依赖本机时区 | 未核对 |
| registry:不校验 sha256,没有缓存和超时,不检查 versions 列表 | 未核对 |
| 开发分支的 excelize 是 2.5.0,master 已经是 2.11.0,读到的证券代码丢前导零 | 开发分支问题,rebase 后应能解决 |
C.3 dev-v3 模板
链接前缀:https://github.com/deb-sig/deg-provider-template/blob/a00f7c1ce5bfc7b3b008d9329761d1159df4f505/
| 模板 | 问题 |
|---|---|
| cmb-credit rules.yaml#L8、#L17 | 年份写死 2024/<记账日>,2025 年的账单被记成 2024 年(已复现) |
| ccb rules.yaml#L15 | accountNum 写死样例值 |
| icbc-debit-v2 rules.yaml#L12 | cardName 写死样例值 |
| mt rules.yaml#L72 | starter 规则里有带个人 ID 的账户名(同文件还有两处) |
| bmo-、td、hsbchk-、abc_debit、htsec、hxsec、huobi 等 | 本人账户写死在 templateRules 里 |
| td | 缺 dateFormat,同一文件混用日/月与月/日(已复现) |
| hsbchk-debit | 描述放进了 payee,应放 narration(verify DIFF 的原因) |
| icbc-* | 没有设置 narration;用 skipInvalidRows 当作尾部过滤器 |
| bocom_credit | 用子串判断方向,"退货 消费退款"同时命中收入、支出两条规则,最后被记成支出(已复现);未知类型输出没有账户的分录 |
| citic-credit、boc-debit | 照抄了 Go 的币种 bug(交易金额 + 写死 CNY;非人民币当 USD) |
| alipay | 用 交易订单号 ~ _ 丢弃退款,部分退款和跨文件退款静默消失(已复现);缺 options.title;skipLeadingRows: 22 换成表头定位后新旧账单都能解析 |
| 服务费拆分写在 personalRules 里,按银行复制了三份;只支持 xlsx | |
| jd | 用 .number 解析 131.77(已退款89.84) 失败(verify FAIL);只为凑样例的 payee 规则放在了 templateRules |
| spdb_debit | expected 是用模板重新生成的,与 Go 输出相比丢了 tags |
| hxsec | sourceHeaders 用英文别名加位置映射,真实中文表头完全不校验 |
| huobi | 同币种手续费从现金账户扣,照抄了 Go 的写法;成本按单价写,bean-check 不平(已复现) |
| 大部分模板 | 个人规则收入、支出各写一条(条数见 RFC 2.2) |
D. 外部格式与工具参考
D.1 格式
| 格式 | 要点 | 参考 |
|---|---|---|
| OFX / QFX / QBO | 1.x 是 SGML,2.x 是 XML;TRNAMT 带符号;FITID 唯一;LEDGERBAL 余额;有银行把信用卡的符号导反 |
ofxtools、信用卡符号反转的讨论 |
| QIF | 没有币种、没有 ID;投资交易的数量永远为正,方向看动作 | Wikipedia |
| camt.053 / MT940 | 金额无符号,方向由借贷标识决定;有期初、期末余额;一个文件可以有多个账户 | camt.053、MT940 |
| BAI2 | 按记录类型分层;方向由类型码决定;88 号是续行记录 | Huntington |
| IBKR Flex | 多个段、多个账户;佣金有自己的币种;IBKR 会新增字段,解析时宜宽松 | ibflex |
| 开放银行 API | 各家符号约定相反(Plaid 正数 = 流出,SimpleFIN 正数 = 流入);pending 与 booked 分开 | Plaid、SimpleFIN、GoCardless |
| 国内账单导出方式 | 多家银行只提供加密 PDF 或邮件账单;招行已出账单是 PDF | china_bean_importers、各平台账单导出方法、Beancount-Trans 文档 |
D.2 工具
| 工具 | 本 RFC 借鉴的地方 | 参考 |
|---|---|---|
| beangulp | 导入器接口分成 identify / extract;按日期窗口和金额判断重复 | importer.py、similar.py |
| smart_importer | 分类可以作为一个独立的可选步骤 | repo |
| beancount-import | 未知的一腿记 FIXME;跨数据源合并同一笔交易;复核界面 | repo |
| hledger CSV rules | 收支两列、条件规则、余额断言的声明式写法;也说明了只用"最新日期"做去重的局限 | manual |
| ledger-autosync | 每条分录带来源 ID,用于幂等导入 | repo |
| Firefly III | 给列指定角色;按外部 ID 或内容哈希去重 | roles、duplicates |
| Actual Budget | 导入的原始 payee 永远不变;规则分阶段执行 | rules、importing |
| GnuCash | 记住"文件账户 → 账本账户"的映射;导入前逐条确认 | manual |
| BeanHub | 解析和分类分成两个库;交易带 import-id,可以原地更新 | blog、beanhub-extract |
| china_bean_importers | 用白名单/黑名单避免支付 App 与银行卡流水重复记账;按卡号末四位找账户;PDF 密码 | repo |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
RFC:重新设计 DEG 的账单导入 —— 云端格式模板 + 本地个人规则
0. 摘要
问题
from/to决定方向。结果是:个人规则要按收入、支出各写一遍;格式知识被复制到用户本地,用户的账户却写进了云端模板;还有若干情况会静默产出错账。提议
account_key标识。路线:先修正确性(Go 主干止血、v3 引擎 fail-closed),再引入规范交易和本地绑定,最后补读取层、关联层和证券分录。见第 13 节。
1. 背景与调研方法
DEG 目前有两条导入路径:
translate --provider X:23 个 Go provider 包,按变体拆开约 29 种账单——支付平台 4 种、国内借记卡 9 种、国内信用卡 6 种、海外银行 5 种、证券/加密 5 种。import <模板>:runtime 模板引擎([feature] 新增基于规则的 provider #231),模板放在 deg-provider-template,dev-v3分支目前有 26 个模板。这次调研做了:
bean-check校验。社区里反复出现的问题也指向同一组根因:#162(账单重复)、#124(微信退款怎么配规则)、#103(targetAccount 与 defaultCashAccount 的增减关系)、#134(规则能覆写哪些字段)、#200(规则文档冲突)、#194(本地怎么新增解析器)。
2. 现状:问题和证据
2.1 Go provider
逐条清单和代码链接见附录 C,这里只按类型归纳:
peer,icbc 的item。一条只写peer的 ignore 规则会丢掉整个文件(已复现)%.3f、数量%.2f截断,碎股和高精度价格不平;港股数字代码、合约地址被直接当作 commoditytag/tags、status/orderStatus并存;time在 oklink 表示日期区间;hxsec 首条命中即停,其余是后者覆盖前者结论:每家银行一套 Go 代码,等于同一条管线写了 23 遍,也有 23 套各不相同的 bug 和配置写法。
2.2 runtime 模板(v3)
dev-v3 模板依赖若干尚未合入 master 的引擎能力(如表头定位、
postingsMode),下文称"开发中引擎"。verify 结果。 用开发中引擎跑 26 个模板:16 个与 expected 一致,9 个不一致,1 个报错。10 个异常都已定位,没有一个是读表本身的问题:
key: "",Go 版跳过format不支持补零options.title;.number无法解析131.77(已退款89.84)一致也不等于正确:spdb 的 expected 是用模板重新生成的,与 Go 输出相比已经丢了 tag;citic、boc 的模板把 Go 的币种 bug 一起照抄了。
静默产出错账的路径(均已复现;最后一列是 master
97a4f65上的复现结果):支出=-67;cib隔日冲账 -5.50;最小样例-5.00输出Expenses 5.00skipInvalidRows全部吞掉)dateFormat:同一文件里13/05/2023按日/月、05/01/2023按月/日解析。日期解析失败时只报 "rule did not set date",不带行号和原值date > "2025-01-01"得到 false.replace,开发中引擎在逗号后有空格时失效;两者都把参数原样拼进结果,不报错-t ledger时,分录里的账户全部丢失(ledger 编译器不处理 Postings)规则繁琐。 金额先取绝对值,再由
from/to决定方向,所以同一个分类要按收入、支出各写一条:云端和本地放反了。
2024/<记账日>(2025 年的账单会被记成 2024 年,已复现);mt 的 starter 规则里还有带个人 ID 的账户名。订单号 ~ "_"丢弃退款(部分退款和跨文件退款会静默消失)、"忽略新增证券"。config init把它们复制到本地之后,就不再随云端更新。分录是手写的 beancount 文本。 证券、币币交易的分录形态几乎一样,每个模板却手写 2~4 遍
{…} @@语法,精度和 commodity 的 bug 跟着一起被复制;这些文本也没办法输出成 ledger。2.3 根因
2.4 值得保留的部分
精确小数运算;registry 按日期固定模板版本(pin);能力声明遇到不认识的就拒绝;表头定位要求唯一匹配;模板和个人规则分文件存放的方向;现有的契约测试写法。
3. 目标与非目标
目标
非目标(本 RFC 不做)
4. 总体架构
4.1 三部分职责
判断一样东西放哪,问两个问题:
Liabilities:CMB")。4.2 管线
detectread/contextfields/rowslink+ 本地参数rulesExpenses:FIXME)并标记checks--strict决定)为什么拆成这么多阶段: 每个阶段只看上一阶段的输出,写在哪一层就只影响那一层。分类规则拿到的永远是已经确定了方向和币种的交易,不需要知道这份账单原来是"收/支两列"还是"借贷方向列"。
5. 规范交易(Canonical Transaction)
规范交易是规范化阶段的输出,也是分类规则唯一能看到的东西(另外可以只读访问
raw)。source{template, variant, pin, file, row}deg-src元数据account_key类别/值card/1234、method/花呗、sub/002、addr/0x…、acct/U123456datetimetzbooked_dateamountcurrencyoriginal{amount, currency}fees[{amount, currency, kind}]counterparty{name, account, bank}descriptionchannelkindnormalnormaltransferrefundreversalfeeinterestdividendtaxfxtradeadjuststatusstatus_rawbookedpendingclosedfailedids{order, merchant_order, txn, trade, hash, serial}balance{amount, currency}trade{security, side, qty, price, total, settle_date}kind=trade时related{refund_of, reversal_of, group, pair}rawflagsunclassifiedambiguous_pairpossible_duplicateunknown_type…给规则用的派生字段(只读):
direction(in/out)、abs_amount、weekday。两条关键约定:
account_key经本地绑定表得到;对方账户由分类规则或按kind的默认值得到。引擎根据amount的符号决定谁在借方、谁在贷方。6. 云端格式模板
6.1 目录与发布
6.2 template.yaml 的结构
schemaidnamepinrequiresrequires列出用到的引擎能力,引擎不支持就拒绝加载detect/variantsreadcontextmapsfieldsrowslinkslotspositionfeepnlcommoditychecksparams6.3 示例:招行信用卡
只描述格式,不出现任何个人账户:
yearFrom是引擎内置语义:账单月份是 1 月、交易是 12 月时,年份自动减 1。现在的模板只能写死2024/<记账日>。6.4 示例:兴业借记卡(多文件 + 换汇配对)
6.5 示例:证券交割单(槽位 + 内置分录)
分录由引擎生成:现金腿、持仓腿
{成本}、费用腿,卖出时再加盈亏腿。成本按total写总额,数量和价格保留账单的原始精度。模板不再手写{…} @@。6.6 模板里的表达式
表达式只在云端模板里使用,用户的本地规则不需要算术和字符串处理。
<列名>、ctx.名字、param.名字、raw.列名、已经算出的规范字段。num(x[, 空值默认])、coalesce(…)、join(分隔符, …)(跳过空值)、extract(x, 正则)、replace(x, a, b)、trim、pad(x, 宽度)、map(表, 值[, 默认])、lower、upper。+ - * /。结果的小数位按输入中最多的那个,不会出现10.0000。num遇到认不出的文本、extract不匹配、map找不到键(且没给默认值)、参数语法不对,都带行号报错。为什么改用函数调用,而不是现在的
<列>.method()链: 参数里的空格、引号、逗号都按普通语法解析,不会再有"逗号后加空格就失效"这类问题;类型也更明确。7. 本地配置
7.1 结构
本地配置是一个文件,放在用户自己的账本仓库里,可以用
include拆分:对比现状:dev-v3 的 cmb-debit 里,"电费"要写成"电费支出"和"电费收入"两条。上面第一条规则对缴费和退费都生效,退费时引擎自动把同一个账户放到另一侧。
7.2 账户绑定表
<模板 id>:<类别>/<值>,值可以用*通配,精确匹配优先于通配。deg init <模板> <账单>扫一遍账单,把发现的卡号、付款方式、子账户、槽位全部写进本地文件的骨架,用户只需要填账户名。methodAccount、cashAccount、positionAccount。7.3 分类规则
条件语法
source(模板 id)、key(account_key)、raw.列名、ctx.名字。HH:MM比较,金额按精确小数比较,其余按字符串比较。不会再把日期当成算式。动作
accountpayeenarrationflagtagslinksmetadataslotsignore: <原因>语义
narration: "{description} - {counterparty}"),没有算术。为什么这样定:
7.4 拆分分录(待讨论)
少数场景需要把一笔拆到多个对方账户(例如合租的房租)。建议用结构化写法,而不是 beancount 文本:
8. 引擎内置能力
yearFrom补年份normal两腿;有fees时加费用腿(费用可以是另一种币种);transfer;fx(@@总价);trade买、卖(成本写总额、保留原始精度、费用腿、卖出盈亏腿);dividend+taxbalance);账单合计核对deg-id、deg-src;空元数据不输出;beancount 与 ledger 用同一套分录--strict下有未决项就失败;--report输出 JSON报告示例:
8.1 去重与幂等
deg-id: "<模板>:<account_key>:<id>"。--existing main.bean读取已有账本里的deg-id,跳过已经导入的交易。为什么分级: 只有账单 ID 能确定是同一笔;哈希能防止同一份文件导两次;模糊匹配误删的代价远高于漏删,所以只提示。
8.2 跨来源重复
同一笔消费会同时出现在微信/支付宝账单和银行卡、信用卡流水里(#162)。第一步用
channel字段加本地ignore规则解决,例如"招行信用卡流水里描述含'支付宝'的,以支付宝账单为准"。自动的跨来源配对放到以后,等收集到足够的真实样例再设计。9. 出错策略(fail-closed)
headerless时才按列位置读map找不到值;表达式出错unknown: flag记到 FIXME 并标记defaults对应的账户,打!标记,计入报告现在的
skipInvalidRows(跳过一切出错的行)拆成两件事:一是模板显式声明的尾部和跳过规则,二是真正的错误。后者不能静默。10. 版本与分发
几个概念要分开:
icbc-debiticbc-debit的 13 列版和 15 列版icbc-debit@2026-10-01deg/template/2deg-rules/2read.xml、link.pair现在 icbc 用
-v1/-v2两个 id 区分格式,选错也不报错。改成同一个 id 下的变体后,用户不需要知道银行改过几次版。registry 与缓存
sha256,下载后校验;声明最低引擎版本。--offline只用缓存。模板仓库的 CI
bean-check通过、ledger 输出也通过检查。latest指向新版本,旧 pin 保留。11. 兼容与迁移
translate --provider X继续可用。Go provider 冻结,只修 bug;对应模板通过与 Go 输出的对照测试后,改为调用新引擎;最终删除各银行的 Go 代码,只保留通用读取器。deg migrate,把旧配置转换成新的本地配置:defaultCashAccount,或作本账户用的defaultMinusAccountaccounts绑定methodAccount+methodaccounts绑定method/…cashAccountpositionAccountcommissionAccountpnlAccountaccounts绑定slot/…peercounterparty ~ …itemdescription ~ …type/txType/categoryraw.列名 == …,或kind == …status/orderStatusstatus == …time/timestamp_rangetime >= … && time <= …/date >= … && date <= …minPrice/maxPriceabs_amount >= …/abs_amount <= …sep+ 多个值in [...]或||fullMatch==(不写则是~)targetAccountaccounttag/tagstagsignoreignore: <原因>accounts。转换器拿不准的地方输出注释,让用户确认。12. 其他账单格式
抖音支付、拼多多、携程,以及光大、平安、邮储等银行,没有找到可靠的导出格式资料,需要用户提供脱敏样例。
13. 路线图
bean-checkaccount_key与绑定表;不分方向的分类规则;deg init;内置 normal/transfer/fee 分录;deg-rules/2;deg migratebean-checkA 和 B 可以并行,也可以在本 RFC 定稿之前开始。C 是规则格式的分水岭:之后的步骤只增加能力,不再改变用户的写法。
14. 考虑过的其他方案
splitflags让它以后可以作为一个可选的分类步骤接入15. 待讨论
account_key的写法(card/1234还是card:1234),以及通配语法。Assets:FIXME:<key>并标记?本 RFC 倾向报错。raw.列名?允许的话,本地规则会依赖账单列名,模板改版时可能失效。倾向于允许,但模板升级时对用到raw的规则给出提示。--existing去重是解析完整账本,还是只扫描deg-id元数据行。translate和import两个命令最终是否合并。All reactions