之前在安卓用「那样记账」记账,换 iPhone 后发觉在 App Store 没有,想找一款跨平台的记账软件,期望功能:
- 记账
- (分类、分层的)统计图示
- 同步
找到 beancount [1],想起是之前 BYVoid 推荐过 [4]。本篇做入门简介,更详细的介绍见 beancount 的文档 [2] 和 BYVoid 的系列网志 [4]。
Beancount
beancount 属于纯文本记账 [5] 的工具之一,即所有收支记录都记在文本文件里,扩展名 .bean 或 .beancount,配有几款主流编辑器的高亮支持 [6]。
由于是文本文件,则可以用 git 同步。目前主要是在电脑用。手机端有个支持安卓和 iphone 的 beancount-mobile [7,8],还未认真研究过。
Single-entry vs. Double-entry Counting
beancount 的记账模式是复式记账(double-entry counting);相对地,多数普通记账软件(如那样记账)的记账模式应该是单式(single-entry counting)。
复式记账是专业的记账法,不过目前记的都是日常消费,单、复式于我只是形式不同,对复式的强大无甚体会。从形式上看:
- 「那样记账」将帐分成三类:收入、支出、转帐。对前两类,只涉及一个账户,即钱从那个帐户出、或入哪个账户,再加个标记用于统计图示。转帐就涉及两个账户,同复式。
- 复式记账将所有帐都记为转帐。每笔转帐至少涉及两个账户,也可以多个,分成两方:支出(debit)、收入(credit)方。这可能就是 double 的含义。
example
此处举一例对比「那样记账」和 beancount 的记法差异:
2025.1.5 在 Cotti Coffee 花 12.07 人民币买了一杯抹茶生椰拿铁,刷中国银行的卡。
若用「那样记账」记,这属于支出帐,所以在软件「支出」页面,点个「饮料」标签(预先创建的标签),输入时间、金额,帐户选中国银行卡(也是预先创建的账户),就完成记录。
若用 beancount 记,涉及两个账户:
- 中国银行卡
- 饮料
买奶茶就记为「中国银行」账户给「饮料」账户转了 12.07 元,在文件中写作:
2025-01-05 * "Cotti Coffee" "抹茶生椰拿鐵"
Expenses:Drinks 12.07 CNY
Assets:Checking:BoC -12.07 CNY
其中 Expenses:Drinks
和 Assets:Checking:BoC
是在别处定义的账户,此处省去。账户名是分层的,方便之后分层统计。也有打标签的语法,详见 [2,4]。「饮料」账户是一种抽象:因为这笔钱肯定是转到 Cotti Coffee 店铺的账户上去,只不过我们并不关注具体是打到哪间铺的哪个账户,而是将所有的卖饮料的店舖账户都抽象成一个 Expenses:Drinks
,这顺便也承担消费归类的功能。
第二、三行的金额可以省写其中一项,因为每笔转帐的数值和必须是零,理论基础是会计恒等式 [2,4]。
balance
balance
语句用来验账,如:
2025-01-10 balance Assets:Checking:BoC 1234.56 CNY
表示断言 Assets:Checking:BoC
账户由开户至 2025.1.10 前累积余额为 1234.56 CNY。此数从手机银行 / 动帐通知 / 存折打簿直接读出、手动输入,然后命令行执行 bean-check main.bean
(假设数簿文件为 main.bean,见后文),beancount 会按时间顺序累积余额,跟此数对比,对不上会报错,告诉你哪句 balance 不满足、差了几多。
有个时机问题:bean 文件中的条目书写顺序是任意的,beancount 会根据条目的时间戳排序,而忽略书写顺序。但时间戳精度为日,那同一日的转帐是算在 balance 语句前还是后呢?对此,beancount 假设 balance 语句(其实包括所有转帐以外的语句,如 open
)都先于同日的转帐语句,所以此例中 balance 语句只对比 Assets:Checking:BoC 累积到 2025-01-09 的余额,而不含 2025-01-10 的转帐。
为了避免出现 balance 报错时,重审因时间跨度太长、涉及条目太多而工作量过大,建议及时写 balance 并执行 bean-check 验账:
- 对常用的账户(如日常消费用银行卡),可以离上条 balance 语句 10 条左右转账之后就加一条,或一周一条;
- 对不常用账户(如借/还钱),可能很久才出现一次涉及到它的转帐,可以每次转帐后都写一条 balance 验账。
Environment
beancount 是用 python 写的。装好 python 后,参考 Installing Beancount,Windows 下装包命令:
pip install beanquery fava
Multi-file Accounting & Project Management
beancount 支持将帐分写在多个 .bean 文件内,于是可以当成一个项目用 git 管理。例如统一放到 my_accounting/ 文件夹中:
my_accounting/
|- make.bat # 自动生成 main.bean 的脚本
|- main.bean
|- accounts.bean
`- books/
|- 2024-12.bean
`- 2025-01.bean
此例从 2024 年 12 月开始用 beancount 记账,每个文件记一个月的帐,另有:
- main.bean:导入所有其它文件,方便后续统计图示;
- accounts.bean:定义账户,即开户。
accounts.bean
接上文举的例,定义两个账户:
- .bean 文件用分号
;
注释一行(从 Emacs 来?) - 开户时间可以早些,只要保证早于第一次转帐就行,不用纠结真实的开户时间。
- 账户名用冒号
:
分层,可任意多层。
; accounts.bean
; 指定使用的货币,任意多条
option "operating_currency" "CNY"
; option "operating_currency" "USD"
; 定义账户
2024-11-01 open Expenses:Drinks CNY
2024-11-01 open Assets:Checking:BoC CNY
账户名只有一个限制:顶层只能用 beancount 预定义的五个之一:Assets
、Equity
、Expenses
、Income
和 Liabilities
。举例如下:
; Asserts 资产:现金、储蓄卡、股票、房、车、应收款
2024-11-01 open Assets:Cash CNY
2024-11-01 open Assets:Checking:BoC CNY
2024-11-01 open Assets:Checking:WeChat CNY
2024-11-01 open Assets:Receivables CNY
; Equity 权益:初始账户额、用以补齐记错帐的虚假资金
2024-11-01 open Equity:Opening-Balances CNY
2024-11-01 open Equity:Error CNY
; Expenses 支出:衣食住行、医药、物器、税
2024-11-01 open Expenses:Clothing CNY
2024-11-01 open Expenses:Transport:Taxi CNY
2024-11-01 open Expenses:Healthcare:Treatment CNY
; Income 收入:工资、补贴、利息、利是
2024-11-01 open Income:Salary CNY
2024-11-01 open Income:Interest:BoC CNY
2024-11-01 open Income:LuckyMoney CNY
; Liabilities 负债:信用卡、应付款
2024-11-01 open Liabilities:CreditCard:Huabei CNY
2024-11-01 open Liabilities:Payables CNY
main.bean
此文件就导入所有其余 .bean 文件:
; main.bean
option "title" "iTom's Ledger"
; 导入开户信息
include "accounts.bean"
; 导入帐簿
include "books/2024-12.bean"
include "books/2025-01.bean"
make.bat
数簿多了之后,手写 main.bean 有些麻烦。可以用脚本自动生成 main.bean,如:
@REM make.bat
@echo off
setlocal enabledelayedexpansion
echo Create main.bean, importing books.
set mainf=main.bean
@REM 创建 main.bean
echo ; main.bean> %mainf%
echo option "title" "iTom's Ledger">> %mainf%
@REM 导入开户文件
echo include "accounts.bean">> %mainf%
@REM 递归导入 books/ 内数簿
echo ; books>> %mainf%
call :import_books books
@REM 验帐
bean-check %mainf%
goto :eof
:import_books
for %%f in (%~1\*.*) do (
set ext=%%~xf
@REM convert path separator: \ to /
set cvtf=%%f
set cvtf=!cvtf:\=/!
if "!ext!" == ".bean" (
echo include "!cvtf!">> %mainf%
) else if "!ext!" == ".beancount" (
echo include "!cvtf!">> %mainf%
)
)
for /d %%d in (%~1\*) do (
call :import_books %%d
)
exit /b
Visualisation
可以用 fava [3] 生成网页版统计图示:
fava main.bean
然后浏览器打开提示的网址,一般是 http://127.0.0.1:5000
。
Querying
bean-query
提供像 SQL 一样的查询功能 [9]。这也可以用来做更灵活的分类统计,如:统计 2025 年 5 月的三餐消费总额。假设相关账户如下:
2023-07-01 open Expenses:Food:Meal:Breakfast CNY
2023-07-01 open Expenses:Food:Meal:Lunch CNY
2023-07-01 open Expenses:Food:Meal:Supper CNY
那就先在命令行执行 bean-query main.bean
进入其交互环境,然后查询:
SELECT account, SUM(position)
FROM date >= 2025-05-01 AND date < 2025-06-01
WHERE account ~ 'Food:Meal';
输出形如:
account sum_posit
------------------------- ---------
Expenses:Food:Meal:Supper 77.8 CNY
Expenses:Food:Meal:Lunch 110.4 CNY
上述命令实际用时没有换行,这里为了清楚加了换行。其中:
SELECT
中,account
就是定义的账户名,如Expenses:Food:Meal:Supper
;SUM
求和;position
不知确切含义,暂简单理解成动帐数目。(前文买奶茶的示例是一条 transcation entry,而 transcation 中每一行称为一条 posting,position 应该是指其中的数额,详见 [9]。)FROM
是 transcation entry 一级的筛选,此例中用来筛时间范围。可用help from
看所有可用的筛选字段。WHERE
是 posting 一级的筛选,此例用来筛账户名,~
是 beancount 定义的正则搜索运算,本例就是筛选 account(名)中含有Food:Meal
的 posting。SELECT
、SUM
、FROM
、AND
、WHERE
等关键字、函数名对大小写不敏感。
统计过去的数据可以找出消费规律、预测未来消费水平,为未来消费规划提供数据论据。