很惭愧,作为会计人,我从来没有好好对待过记账这件事。即便朋友安利了记账神器 Beancount,也一直打不起精神去用。很好奇大家都是怎么使用 Beancount 的呢?
懒人有懒福,为了实现最小程度的人工介入,我研究了一番 importers,并拿它们完成了 2024 的账单分析。最后的效果感觉还不错,在这里分享这一过程的经验,不过本文更大的作用还是健忘人士手册。
Why Beancount?
Beancount 是一款开源、基于纯文本的记账与财务管理工具,采用了「复式记账」的思想,通过简单的文本格式来记录和追踪个人或企业的财务数据。Beancount 有配套的 Python 库以及诸如 Fava 之类的可视化 web 界面工具,可以帮助更好的理解财务状况。
在我看来,Beancount 的优势主要有两点:除了没有安全问题之外,就是更高的可控性,上限是非常高的。利用 Beancount 可以灵活地对特定交易进行筛选、查询或修改,甚至使用正则匹配,这一点在微信或支付宝账单或一些常见的记账 App 里几乎做不到。
但也要明确,Beancount 并不一定适合所有人。如果你:
觉得支付宝微信账单已经够用;
没有对个人财务进行统计和优化的意愿;
接触过的「最接近编程」的东西是 Excel 函数(比如说我) ,也不打算投入几个小时的时间学习;
对开源解决方案没有特别的执念;
完全不懂命令行操作;
那么 beancount 大概率不适合你。
如果以上都无法劝退你,欢迎继续往下看。本文预计包含以下内容:
对「会计恒等式」的简易解释;
代码小白驯服 VS Code 珍贵影像;
代码小白驯服 importers 珍贵影像;
使用 Git 进行版本管理;
使用 BQL(Beancount Query Language)对账单进行简单查询与统计。
除此之外,你还需要 GitHub 账号、代码编辑器(如VS Code),以及配置好 Python。
阅读完本文你预计获得:基于导出的微信和支付宝账单的 CSV 自动生成 beancount 文件、自行定制规则来实现对不同消费的分类、对个人财务状况的基本统计与优化,以及驯服代码的虚假成就感。
适用场景 & 个人体验
对我而言,一年对一两次账其实已经差不多了,因为账单构成并不复杂。体验下来,2024 的消费基本上重复了内心的预设,但具体的金额还是让我吃了一惊。汇总统计的好处之一就是像「点外卖究竟花了多少钱」之类的问题可以很快找出答案。让我们一起祈祷微信支付宝不要动辄更新账单结构吧。
不过,我也发现 Beancount 更完美的使用情境在于:基金会或项目的专项资金管理 ——这种需要对每笔资金进出做详细记录、并需要灵活查询的场景里,Beancount 的优势会非常明显。
ps:最近才发现支付宝账单里可以设置不计入收支的账户,下次试试看感觉说不定能排除大部分转账交易。
pps:个人感觉 beancount 官方文档写得不好,有问题直接 Google 比较快。
由于个人水平有限,其中难免有不够完善或需要改进的地方,欢迎指点。
复式记账
在正式进入 beancount 的介绍前,先来说说复式记账。
会计恒等式
复式记账基于会计恒等式:
资产 = 负债 + 所有者权益 资产=负债+所有者权益
资 产 = 负 债 + 所 有 者 权 益
我们先从企业的视角出发,在理解企业视角之后,你会发现个人视角下的复式记账也一目了然。会计恒等式在企业管理中尤其重要,因为企业与所有者往往是分离的。而会计恒等式的出发点正是,企业的 = 所有者的 。
更直白一点:
左边(资产):是企业「拥有或控制的经济资源」,即企业用来从事生产经营的直接工具。
右边(负债 + 所有者权益):是企业的「资金来源」,包括外部借入的资金(负债)和所有者提供的资金(所有者权益)。
借增贷减 / 借减贷增
首先,这里的借和贷都不用去纠结它传统意义上的意思,只用把它当成一个记号就好。
会计恒等式实际上是企业的 = 所有者的 ,等式左边的所有交易都是借增贷减,等式右边的所有交易都是借减贷增。因此,资产、成本、费用 等,为企业生产活动直接产生的交易,借增贷减 。而负债、所有者权益、收入类 的交易,则是借减贷增 。
如,企业使用 2000 元现金购买了一批一次性的办公用品,会计分录为:
借:管理费用 2000 (费用增加记借方)
贷:现金 2000 (资产减少记贷方)
如果把企业视角简化到个人,更关注「我的资产、我的负债、我的收入和我的支出」,那么记账规则可以这样理解:
资产类和支出类账户 (我的钱、我的消费):借增、贷减
负债类和收入类账户 (我的负债、我的收入):借减、贷增
如,花 1000 元存款进行超市购物:
借:购物费用 1000 (消费增加)
贷:银行存款 1000 (资产减少)
另外,复式记账(借贷记账)还有一个原则:有借必有贷,借贷必相等。
举个例子
在 Beancount 中,每一笔交易的格式一般是:
1 2 3 4 YYYY-mm-dd * ["Payee"] "Narration" posting 1 posting 2 posting 3
为了方便理解,来看下面的示例:
1 2 3 4 5 6 7 8 9 ; 1. 工资收入 2024-01-01 * "工资入账" Assets:Bank 10000 CNY ; 资产增加 Income:Salary -10000 CNY ; 收入增加 ; 2. 日常消费 2024-01-15 * "超市购物" Expenses:Groceries 1000 CNY ; 支出增加 Assets:Bank -1000 CNY ; 资产减少
这里有一点需要指出。在 Beancount 里,所有账户默认是在「借方(Debit)」,那么对于 Assets、Expenses 类账户,正数表示在借方,增加,负数表示在贷方,减少。但是对于 Liabilities 和 Income 类账户,则刚好是相反的,正数表示在借方,减少,负数表示在贷方,增加。
这就是为什么 Income:Salary
在记入 10000 CNY 时,写成了 -10000(负数)。虽然表面上看是「负数表示收入增加」,但在会计逻辑中其实是「贷方记增」的体现。
Beancount 入门
复式记账是方法论,而 Beancount 则是支持复式记账的工具,接下来介绍 Beancount 的基础使用。
Beancount 是一个 Python 项目,确定本地已经装好 Python 后,命令行输入:
1 pip install beancount fava
其中 fava 是关联软件,为 Beancount 提供一个更漂亮的 Web 界面,建议同时安装。
项目结构
新建一个文件夹如 beancount-2024
,接下来所有操作均在此目录下进行。你可以参考如下结构来管理文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 beancount-2024 ├─ main.bean ├─ income │ └─ income.bean ├─ accounts │ └─ accounts.bean ├─ csv_files │ ├─ ali │ │ ├─ 2024. csv │ └─ wechat │ └─ 2024. csv └─ bean_files ├─ wechat-2024.bean ├─ ali-2024.bean
其中,main.bean
是主账本文件,可以通过 include
语法引用所有子文件,汇总生成完整账本。
如果你计划使用 Git 来做版本控制,可以在该文件夹下初始化 git 仓库(git init
),然后新建一个 .gitignore
文件填入以下内容,来排除 CSV 文件、Python 缓存文件等:
1 2 3 4 csv_files/ __pycache__/ *.pyc .DS_Store
账户设置
首先需要在 accounts/accounts.bean
中开立账户并定义分类。示例:
accounts/accounts.bean 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 1990-06-28 open Assets:Card:CMB:1234 1990-06-28 open Assets:WeChat 1990-06-28 open Assets:Alipay 1990-06-28 open Liabilities:Alipay:HuaBei 1990-06-28 open Liabilities:DouyinMonthlyPayment 1990-06-28 open Expenses:Entertainment 1990-06-28 open Expenses:Health 1990-06-28 open Expenses:Groceries 1990-06-28 open Expenses:Shopping 1990-06-28 open Expenses:Food 1990-06-28 open Expenses:Transport 1990-06-28 open Expenses:Utilities 1990-06-28 open Expenses:Services 1990-06-28 open Expenses:Travel 1990-06-28 open Expenses:Repayment 1990-06-28 open Income:WeChat 1990-06-28 open Income:Alipay 1990-06-28 open Income:Salary:TA
然后在 main.bean
中包含它们,示例:
main.bean 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ;; -*- mode: beancount -*- ;【一、账本设置】 option "title" "账本名称" option "operating_currency" "CNY" option "insert_pythonpath" "true" 1990-01-01 custom "fava-option" "language" "en" ;【二、账户设置】 include "accounts/accounts.bean" ;【三、交易记录】 include "bean_files/wechat-2024.bean" include "bean_files/ali-2024.bean" include "income/income.bean"
记录交易
基本记账,记账语法为:
1 2 3 4 5 YYYY-mm-dd * ["Payee"] "Narration" posting 1 posting 2 posting 3 ...
比如:
1 2 3 2024-01-15 * "午餐" "食堂" Expenses:Food 30 CNY Assets:Bank:Checking
在这个例子里,Beancount 会自动计算并为 Assets:Bank
补上 -30 CNY
。
手动记账虽然灵活,但有时候还是嫌麻烦。于是才有了自动导入 这个主题。
自动导入
Beancount 的自动导入依赖「importers」,它会解析从微信、支付宝等平台导出的 CSV 文件,将其转换为符合 Beancount 语法的交易记录。
我使用了这个开源项目:china_bean_importers ,并基于它做了一些自定义修改。
基础配置
git clone 或者 git submodule add china_bean_importers
项目到 beancount-2024
目录下,根据项目的 readme 进行配置,安装必要的依赖;
在 china_bean_importers
中找到示例配置文件 config.example.py
,拷贝并改名为 config.py
(等效于运行 cp config.example.py config.py
),然后根据你的需求去调整里面的账户映射等信息;
在根目录新建 import_config.py
,示例内容如下:
import_config.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import osimport syssys.path.append(os.path.dirname(os.path.realpath(__file__))) from china_bean_importers import wechat, alipay_web, alipay_mobile, boc_credit_card, boc_debit_card, cmb_debit_cardfrom config import config CONFIG = [ wechat.Importer(config), alipay_web.Importer(config), alipay_mobile.Importer(config), boc_credit_card.Importer(config), boc_debit_card.Importer(config), cmb_debit_card.Importer(config), ]
最终目录(示例)如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 beancount-2024 ├─ import_config.py ├─ config.py ├─ china_bean_importers ├─ main.bean ├─ income │ └─ income.bean ├─ accounts │ └─ accounts.bean ├─ csv_files │ ├─ ali │ │ ├─ 2024. csv │ └─ wechat │ └─ 2024. csv └─ bean_files ├─ wechat-2024.bean ├─ ali-2024.bean
其中:
import_config.py
:主导入脚本,用于解析 CSV 账单,并生成 Beancount 格式的账单文件。
config.py
:即从 china_bean_importers 里复制出来的 config.example.py
,配置导入器的行为,例如定义如何解析支付宝、微信等账单文件。
处理常见的特殊交易:支付宝
importer 虽好,但不可能完全覆盖所有场景,尤其是支付宝和微信账单中常常有各种「特殊」或「未计入收支」的条目,需要在 Importer 代码里做一些筛选、排除或重分类。以下是我根据个人情况做的一些修改示例。
ps:我的账单都是通过手机端 App 导出。
自动转入的交易
有些从余额宝或类似理财产品自动转入 的记录,若没有匹配到关键词,可能被误认作支出类交易。例如支付宝账单某个自动转入的记录:
1 2 3 4 2024-07-24 * "华安基金管理有限公司" "余额宝-自动转入" #confirmation-needed imported_category: "投资理财" Assets:Alipay -5.00 CNY Expenses:Unknown
但自动转入的记录其实是其他交易的结转:
1 2 3 4 2024-07-24 * "杭州闲鱼信息技术有限公司" "闲鱼签到奖励" imported_category: "转账红包" Assets:Alipay 5.00 CNY Income:Alipay:RedPacket
解决方式:1、新建关键词的匹配规则,2、跳过这类交易。
本着重要性原则(materiality),我跳过了这类交易。在 extract
方法内加入一段逻辑:
china_bean_importers/alipay_mobile/__init__.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # parse some basic info ( time, category, payee, payee_account, narration, direction, amt, method, status, serial, ) = row[:10] time = parse(time) units = amount.Amount(D(amt), "CNY") metadata["serial"] = serial + if "自动转入" in narration: # skip internal transfers + continue
如果你需要对每个账户的余额作更精细的控制,不建议直接跳过,可以编写规则以匹配这类交易。
交易关闭的交易
在支付宝中,存在两类交易关闭:已付款后退款、订单未付款就取消。
对于后者,可以通过 method == ""
来筛选。因为我暂时没搞明白 beancount 中更好的注释方法,所以我暂时将它们挑出来 continue
跳过了。
对于前者,比较简洁的处理方式是减记费用,借记资产。
原代码似乎会将退款记收入,我改了一下,直接减记费用。
china_bean_importers/alipay_mobile/__init__.py 1 2 3 4 5 6 7 8 9 if "交易关闭" in status and method == "" : continue if "退款" in narration or "退款成功" in status: expense = True units = -units tags.add("refund" )
AA 转账
对于我来说,从他人那里收到的转账,大部分情况都是分摊费用、并不是真正的「收入」。对于 importers 导入的记录,很难去实现在其中挑出应收账款,所以我在逻辑上使用现金流法,默认将转账都视为「抵消支出」,即借记 Assets、贷记 Expenses。
按照这个逻辑,我对代码做了相应修改:
china_bean_importers/alipay_mobile/__init__.py 1 2 3 4 5 6 7 8 9 10 11 12 expense = None # determine direction if direction == "支出": expense = True + if category == "收入": + expense = False elif direction == "收入": - expense = False + expense = True + units = -units
向银行卡转账的交易
我也不记得为什么要改这个地方了,难道是在 config.py
改了没效果?。另外,下面的代码我将抖音月付的还款改为费用支出而不是负债,因为我没找到抖音在哪里可以导出账单,也懒得再开一个抖音的账户去追踪资金变化。问就是重要性原则
china_bean_importers/alipay_mobile/__init__.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 account2 = None if "抖音月付" in narration: account2 = source_config["douyin_expense_account" ] expense = True if category == "投资理财" : if "余额宝-转出到银行卡" in narration: account1 = source_config["yuebao_account_transfer" ] account2 = find_account_by_card_number(self.config, match_card_tail(method)) tags.add("internal-transfers" ) elif "余额宝-单次转入" in narration: account1 = find_account_by_card_number(self.config, match_card_tail(method)) account2 = source_config["yuebao_account_transfer" ] units = units tags.add("internal-transfers" ) elif category == "转账红包" and direction != "不计收支" :
处理常见的特殊交易:微信
对于微信账单,思路与支付宝类似,只是对应的字段名和逻辑判断不同。例如,对「退款」交易进行负向记账、对「群收款」进行默认支出或收入的区分、对「转账」设置白名单等。
退款
china_bean_importers/wechat/__init__.py 1 2 3 4 5 6 7 8 9 10 11 if type == "微信红包-退款" or method == "/" : account1 = source_config["account" ] elif method == "零钱" or status in [ "已存入零钱" , "已到账" , "充值完成" , "提现已到账" , ]: account1 = source_config["account" ] elif method == "零钱通" : account1 = source_config["lingqiantong_account" ]
群收款和转账
因为我的转账交易中收入比较少,故做了这样的处理:转账和群收款默认减记费用,同时设置一个转账白名单,在白名单里的人转账记为收入。
china_bean_importers/wechat/__init__.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 elif "群收款" == type : narration = "群收款" if payee and any (keyword in payee for keyword in source_config.get("group_income_whitelist" , [])): account2 = ( source_config["group_payment_income_account" ] if not expense else source_config["group_payment_expense_account" ] ) else : account2 = source_config["group_payment_expense_account" ] expense = True if not expense: units = -units elif "转账" == type : if payee and any (keyword in payee for keyword in source_config.get("transfer_income_whitelist" , [])): account2 = ( source_config["transfer_income_account" ] if not expense else source_config["transfer_expense_account" ] ) else : account2 = source_config["transfer_expense_account" ] expense = True if not expense: units = -units
同时在 config.py
里加入:
config.py 1 2 3 4 5 6 7 8 9 "wechat" : { "transfer_income_whitelist" : [ "A" , "张三" , "李四" , ], "group_income_whitelist" : ["Alice" ], },
category mapping
china_bean_importers/wechat/__init__.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 if account2 is None : if type in source_config["category_mapping" ]: account2 = source_config["category_mapping" ][type ] elif new_account: account2 = new_account metadata.update(new_meta) tags = tags.union(new_tags) if account2 is None : account2 = unknown_account(self.config, expense) if ( status in ["支付成功" , "已存入零钱" , "已转账" , "对方已收钱" ] or "已到账" in status ): pass elif "退款" in status: tags.add("refund" ) expense = True
执行导入与生成账本
在完成各种自定义后,即可通过以下命令来生成 Beancount 文件:
1 2 3 4 5 6 bean-extract import_config.py csv_files/ali/2024.csv > bean_files/ali-2024.bean bean-extract import_config.py csv_files/wechat/2024.csv > bean_files/wechat-2024.bean
如果遇到「Unknown card number XXX on line 23」之类的报错,多半是因为 CSV 里出现了某个银行卡尾号,而你尚未在 config.py
的 card_accounts
字典中进行登记,需要补上。
config.py 1 2 3 4 5 6 7 8 9 10 11 "card_accounts" : { "Liabilities:Card" : { "BoC" : ["1234" , "5678" ], "CMB" : ["1111" , "2222" ], }, "Assets:Card" : { "BoC" : ["4321" , "8765" ], "CMB" : ["3333" , "4444" ], }, },
查询与统计
通过 bean-extract
生成 .bean
文件后,就可以在主账本 main.bean
中 include
这些文件,然后运行 fava main.bean
在浏览器中查看。
在浏览器中打开 http://localhost:5000
就能看到账本。
Income Statement: Expenses wise
Fava 的左侧菜单里有各类财务报表和可视化图表,按照我的记账逻辑,主要看 Income Statement、Journal 和 Query 栏目足矣。右侧则是明细列表。常用功能包括:
Filter :可以通过输入账户名、标签(tag)、正则表达式来筛选交易;
Query :可以使用 BQL(Beancount Query Language)对账目进行更高级查询。
示例查询
点击左边 Query 进入查询界面,输入以下内容:
1 2 3 4 5 6 7 8 9 10 11 SELECT date , payee, narration, account, number as amount, balance, id WHERE account ~ 'Expenses' ORDER BY balance desc ;
上面这条查询会选出所有 Expenses
开头的账户支出记录,并按余额从大到小排序。将 balance
换为 amount
即可按金额从大到小查看交易。
如果想排除某些类别,可再加上:
1 2 3 AND NOT account ~ 'edu' AND NOT account ~ 'travel'
其中 ~
后面代表正则。也可以统计各类别的总金额:
1 2 3 4 5 6 7 8 9 10 11 12 13 SELECT account, COUNT (date ) AS transaction_count, sum (position) AS total WHERE account ~ 'Expenses' AND NOT account ~ 'edu' AND NOT account ~ 'travel' GROUP BY account ORDER BY sum (position);
还可以通过 any_meta
或 entry_meta
来对 metadata 进行查询。如查询美团平台的支出:
1 2 3 4 5 6 7 8 9 10 11 12 13 SELECT date , payee, narration, account, number as amount, balance, entry_meta('platform' ) as platform, id WHERE account ~ 'Expenses' and entry_meta('platform' ) ~ '美团' ORDER BY balance desc ;
如果想筛选特定 tags,可再加上:
按 meta 的平台数据进行分组:
1 2 3 SELECT sum (position), any_meta('platform' ) as platformWHERE account ~ "Expenses"GROUP BY platform
按 payee 交易数量统计:
1 SELECT payee, count (payee) ORDER BY count (payee) desc
或者:
1 SELECT account, payee, count (account) ORDER BY account
关于 BQL 的更多用法可见 Beancount Query Language 。
另外值得一提的是, Fava 里 query 可以与右上角过滤器结合使用、同样支持正则。比如:
在右上角 Filter by tag, payee, ...
输入 "某?大"
可以搜索到所有与校园卡相关的交易。
支持多标签筛选,如 -#confirmation-needed -#refund
,可以和正则搭配如 -#confirmation-needed -#refund -"测..度"
所有可 select 的属性包括(help attributes
):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 The attribute names on postings and directives equivalent to the names of columns that we make available for query. Entries: - : description - : id - : type entry.date : date entry.date.day : day entry.date.month : month entry.date.year : year entry.flag : flag entry.links : links entry.meta["filename"] : filename entry.meta["lineno"] : lineno entry.narration : narration entry.payee : payee entry.tags : tags Postings: - : balance - : description - : id - : location - : other_accounts - : type - : weight entry.date : date entry.date.day : day entry.date.month : month entry.date.year : year entry.flag : flag entry.links : links entry.meta["filename"] : filename entry.meta["lineno"] : lineno entry.narration : narration entry.payee : payee entry.tags : tags posting : change posting : position posting.account : account posting.cost.currency : cost_currency posting.cost.date : cost_date posting.cost.label : cost_label posting.cost.number : cost_number posting.flag : posting_flag posting.price : price posting.units.currency : currency posting.units.number : number
提示
config.py
映射顺序
如果对相同交易同时满足多个规则,需要注意配置文件中规则的优先级,会以代码中的匹配先后顺序为准。
批量给交易打标签
Beancount 支持 pushtag
和 poptag
。在这两个关键字之间的所有交易,都会自动附带指定标签。比如:
1 2 3 4 5 6 7 8 9 pushtag #2024-hangzhou-trip 2024-05-01 * "高铁票" "" Expenses:Travel:Train 200 CNY Assets:Card:CMB:1234 -200 CNY 2024-05-03 * "景区门票" "" Expenses:Entertainment 100 CNY Assets:Card:CMB:1234 -100 CNY poptag #2024-hangzhou-trip
这样就能在之后用标签 #2024 -hangzhou-trip 对所有相关交易进行统计或筛选。
结语
2024 年账单体验
我的账单整体上没有特别意外,印象最深的依然是「买得不多,外卖太多」,比起逛超市或逛街花得更多。进一步印证了懒人经济。
至此,本文介绍了为什么选择 Beancount、它的复式记账原理、以及如何利用 importers 来将微信和支付宝的账单 CSV 自动转成 Beancount 文件。再结合 Fava 的可视化和 BQL 查询,做进一步的数据分析。
如果你的日常财务真的不太复杂,那就大可一年或半年导一次账——这或许已经比从未审视过消费结构要强得多。你也可以再去发掘更多 Beancount 的用法,例如管理专项资金、预算编制、金融投资收益分析等。
由于个人水平有限,其中难免有不够完善或需要改进的地方,欢迎大家留言进行讨论。
祝您记账愉快。