Skip to content
Phone animation 宇宙尽头的餐馆

关于使用 Beancount 记账我所知道的一切

阅读完本文你预计获得:基于微信和支付宝账单自动生成 beancount 文件、自行定制规则来实现对不同消费的分类、对个人财务状况的基本统计与优化,以及驯服代码的虚假成就感。

· 14 min

Changelog

很惭愧,作为会计人,我从来没有好好对待过记账这件事。即便朋友安利了记账神器 Beancount,也一直打不起精神去用。很好奇大家都是怎么使用 Beancount 的呢?

懒人有懒福,为了实现最小程度的人工介入,我研究了一番 importers,并拿它们完成了 2024 的账单分析。最后的效果感觉还不错,在这里分享这一过程的经验,不过本文更大的作用还是健忘人士手册。

Why Beancount?#

Beancount 是一款开源、基于纯文本的记账与财务管理工具,采用了「复式记账」的思想,通过简单的文本格式来记录和追踪个人或企业的财务数据。Beancount 有配套的 Python 库以及诸如 Fava 之类的可视化 web 界面工具,可以帮助更好的理解财务状况。

在我看来,Beancount 的优势主要有两点:除了没有安全问题之外,就是更高的可控性,上限是非常高的。如果您本身就有记账的习惯,我还是非常推荐尝试 Beancount 的。使用复式记账的 Beancount 可以更准确地追踪财务状况,灵活地对特定交易进行筛选、查询或修改,使用正则匹配,在此基础上进行灵活的统计分析与可视化……我甚至可以说,Beancount 是实现您的任何想法的载体。这一点在微信或支付宝账单或一些常见的记账 App 里几乎不可能做到。

但也要明确,Beancount 并不一定适合所有人。如果你:

那么 Beancount 大概率不适合你。

如果以上都无法劝退你,欢迎继续往下看。本文预计包含以下内容:

除此之外,你还需要 GitHub 账号、代码编辑器(如 VS Code),配置好 Python 和(可选)Git。

阅读完本文你预计获得:基于导出的微信和支付宝账单的 CSV 自动生成 beancount 文件、自行定制规则来实现对不同消费的分类、对个人财务状况的基本统计与优化,以及驯服代码的虚假成就感。其中,处理常见的特殊交易、插件(Plugins)、自定义查询 Dashboards 为非必要章节,可按需选读。

适用场景 & 个人体验#

对我而言,一年对一两次账其实已经差不多了,因为账单构成并不复杂。体验下来,2024 的消费基本上重复了内心的预设,但具体的金额还是让我吃了一惊。汇总统计的好处之一就是像「点外卖究竟花了多少钱」之类的问题可以很快找出答案。让我们一起祈祷微信支付宝不要动辄更新账单结构吧。

不过,我也发现 Beancount 更完美的使用情境在于:基金会或项目的专项资金管理——这种需要对每笔资金进出做详细记录、并需要灵活查询的场景里,Beancount 的优势会非常明显。

ps:最近才发现支付宝账单里可以设置不计入收支的账户,下次试试看感觉说不定能排除大部分转账交易。

pps:个人感觉 beancount 官方文档写得不好,有问题直接 Google 比较快。

由于个人水平有限,其中难免有不够完善或需要改进的地方,欢迎指点。

复式记账#

在正式进入 beancount 的介绍前,先来说说复式记账。

会计恒等式#

复式记账基于会计恒等式:

资产=负债+所有者权益资产=负债+所有者权益

我们先从企业的视角出发,在理解企业视角之后,你会发现个人视角下的复式记账也一目了然。会计恒等式在企业管理中尤其重要,因为企业与所有者往往是分离的。而会计恒等式的出发点正是,企业的 = 所有者的

更直白一点:

左边(资产):是企业「拥有或控制的经济资源」,即企业用来从事生产经营的直接工具。

右边(负债 + 所有者权益):是企业的「资金来源」,包括外部借入的资金(负债)和所有者提供的资金(所有者权益)。

借增贷减 / 借减贷增#

首先,这里的借和贷都不用去纠结它传统意义上的意思,只用把它当成一个记号就好。

会计恒等式实际上是企业的 = 所有者的,等式左边的所有交易都是借增贷减,等式右边的所有交易都是借减贷增。因此,资产、成本、费用等,为企业生产活动直接产生的交易,借增贷减。而负债、所有者权益、收入类的交易,则是借减贷增

如,企业使用 2000 元现金购买了一批一次性的办公用品,会计分录为:

借:管理费用 2000 (费用增加记借方) 贷:现金 2000 (资产减少记贷方)

如果把企业视角简化到个人,更关注「我的资产、我的负债、我的收入和我的支出」,那么记账规则可以这样理解:

  1. 资产类和支出类账户(我的钱、我的消费):借增、贷减
  2. 负债类和收入类账户(我的负债、我的收入):借减、贷增

如,花 1000 元存款进行超市购物:

借:购物费用 1000 (消费增加)
贷:银行存款 1000 (资产减少)

另外,复式记账(借贷记账)还有一个原则:有借必有贷,借贷必相等

举个例子#

在 Beancount 中,每一笔交易的格式一般是:

YYYY-mm-dd * ["Payee"] "Narration"
posting 1
posting 2
posting 3

为了方便理解,来看下面的示例:

; 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 后,命令行输入:

Terminal window
pip install "beancount<3" fava

其中 fava 是关联软件,为 Beancount 提供一个更漂亮的 Web 界面,建议同时安装。

NOTE

Beancount 3 已于 2024 年 6 月正式发布,fava 自 v1.30 (2024-12-29)开始支持 Beancount 3,但查询语法及 importers 导入工作流有较大变化,为了使用 importers 和 query 的一些功能,本文内容基于 Beancount 2.x,如果您希望尝试 Beancount 3,请参考官方文档:Installing Beancount

项目结构#

新建一个文件夹如 beancount-2024,接下来所有操作均在此目录下进行。你可以参考如下结构来管理文件:

beancount-2024
├─ main.bean # 主账本文件,引用所有子文件
├─ income # 记录收入相关的交易
│ └─ income.bean
├─ accounts # 定义账户信息
│ └─ accounts.bean
├─ csv_files # 存放原始账单 CSV 文件
│ ├─ ali # 支付宝账单
│ │ ├─ 2024.csv
│ └─ wechat # 微信账单
│ └─ 2024.csv
└─ bean_files # 导入后生成的 Beancount 文件
├─ wechat-2024.bean
├─ ali-2024.bean

其中,main.bean 是主账本文件,可以通过 include 语法引用所有子文件,汇总生成完整账本。

如果你计划使用 Git 来做版本控制,可以在该文件夹下初始化 git 仓库(git init),然后新建一个 .gitignore 文件填入以下内容,来排除 CSV 文件、Python 缓存文件等:

csv_files/
__pycache__/
*.pyc
.DS_Store

账户设置#

首先需要在 accounts/accounts.bean 中开立账户并定义分类。示例:

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 中包含它们,示例:

;; -*- 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"

除了手动维护 accounts.bean 文件,你也可以在 main.bean 中写入 plugin "beancount.plugins.auto_accounts" 来自动开户。

记录交易#

基本记账,记账语法为:

YYYY-mm-dd * ["Payee"] "Narration"
posting 1
posting 2
posting 3
...

比如:

2024-01-15 * "午餐" "食堂"
Expenses:Food 30 CNY
Assets:Bank:Checking

在这个例子里,Beancount 会自动计算并为 Assets:Bank 补上 -30 CNY
手动记账虽然灵活,但有时候还是嫌麻烦。于是才有了自动导入这个主题。

自动导入#

Beancount 的自动导入依赖「importers」,它会解析从微信、支付宝等平台导出的 CSV 文件,将其转换为符合 Beancount 语法的交易记录。

我使用了这个开源项目:china_bean_importers,并基于它做了一些自定义修改。

基础配置#

  1. git clone 或者 git submodule add china_bean_importers 项目到 beancount-2024 目录下,根据项目的 readme 进行配置,安装必要的依赖;
  2. china_bean_importers 中找到示例配置文件 config.example.py,拷贝并改名为 config.py(等效于运行 cp config.example.py config.py ),根据你的需求去调整里面的账户映射等信息,然后移动文件到根目录 beancount-2024 下;
  3. 在根目录 beancount-2024 新建 import_config.py,示例内容如下:
import os
import sys
sys.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_card
from config import config # your config file name
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),
]

最终目录(示例)如下:

beancount-2024
├─ import_config.py # 主导入脚本,用于解析账单
├─ config.py # importer 配置文件
├─ china_bean_importers # 定制化 importer 模块
├─ main.bean # 主账本文件,引用所有子文件
├─ income # 记录收入相关的交易
│ └─ income.bean
├─ accounts # 定义账户信息
│ └─ accounts.bean
├─ csv_files # 存放原始账单 CSV 文件
│ ├─ ali # 支付宝账单
│ │ ├─ 2024.csv
│ └─ wechat # 微信账单
│ └─ 2024.csv
└─ bean_files # 执行导入后生成的 Beancount 文件
├─ wechat-2024.bean
├─ ali-2024.bean

其中:

  1. import_config.py:主导入脚本,用于解析 CSV 账单,并生成 Beancount 格式的账单文件。

  2. config.py:即从 china_bean_importers 里复制出来的 config.example.py,配置导入器的行为,例如定义如何解析支付宝、微信等账单文件。

处理常见的特殊交易:支付宝#

importer 虽好,但不可能完全覆盖所有场景,尤其是支付宝和微信账单中常常有各种「特殊」或「未计入收支」的条目,需要在 Importer 代码里做一些筛选、排除或重分类。以下是我根据个人情况做的一些修改示例。

ps:我的账单都是通过手机端 App 导出。

自动转入的交易#

有些从余额宝或类似理财产品自动转入的记录,若没有匹配到关键词,可能被误认作支出类交易。例如支付宝账单某个自动转入的记录:

2024-07-24 * "华安基金管理有限公司" "余额宝-自动转入" #confirmation-needed
imported_category: "投资理财"
Assets:Alipay -5.00 CNY
Expenses:Unknown

但自动转入的记录其实是其他交易的结转:

2024-07-24 * "杭州闲鱼信息技术有限公司" "闲鱼签到奖励"
imported_category: "转账红包"
Assets:Alipay 5.00 CNY
Income:Alipay:RedPacket

解决方式:1、新建关键词的匹配规则,2、跳过这类交易。

本着重要性原则(materiality)1,我跳过了这类交易。在 extract 方法内加入一段逻辑:

# 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 跳过了。

对于前者,比较简洁的处理方式是减记费用,借记资产。

原代码似乎会将退款记收入,我改了一下,直接减记费用。

# 处理未付款取消的订单
if "交易关闭" in status and method == "":
continue
# 处理退款交易
if "退款" in narration or "退款成功" in status:
expense = True
units = -units
tags.add("refund")

AA 转账#

对于我来说,从他人那里收到的转账,大部分情况都是分摊费用、并不是真正的「收入」。对于 importers 导入的记录,很难去实现在其中挑出应收账款,所以我在逻辑上使用现金流法2,默认将转账都视为「抵消支出」,即借记 Assets、贷记 Expenses。

按照这个逻辑,我对代码做了相应修改:

expense = None
# determine direction
if direction == "支出":
expense = True
if category == "收入":
expense = False
elif direction == "收入":
expense = False
expense = True
units = -units

向银行卡转账的交易#

我也不记得为什么要改这个地方了,难道是在 config.py 改了没效果?。另外,下面的代码我将抖音月付的还款改为费用支出而不是负债,因为我没找到抖音在哪里可以导出账单,也懒得再开一个抖音的账户去追踪资金变化。问就是重要性原则

# find from 商品说明 and 交易对方
account2 = None
if "抖音月付" in narration:
account2 = source_config["douyin_expense_account"]
expense = True
if category == "投资理财":
if "余额宝-转出到银行卡" in narration:
# Money from YueBao to bank card
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:
# Money from bank card to YueBao
account1 = find_account_by_card_number(self.config, match_card_tail(method))
account2 = source_config["yuebao_account_transfer"]
units = units # Make negative for sending bank card
tags.add("internal-transfers")
elif category == "转账红包" and direction != "不计收支":
# elif "抖音月付" in narration:
# account2 = source_config["douyin_monthly_payment_account"]

处理常见的特殊交易:微信#

对于微信账单,思路与支付宝类似,只是对应的字段名和逻辑判断不同。例如,对「退款」交易进行负向记账、对「群收款」进行默认支出或收入的区分、对「转账」设置白名单等。

退款#

if type == "微信红包-退款" or method == "/":
account1 = source_config["account"]
elif method == "零钱" or status in [
"已存入零钱",
"已到账",
"充值完成",
"提现已到账",
]: # 微信零钱
account1 = source_config["account"]
elif method == "零钱通":
account1 = source_config["lingqiantong_account"]

群收款和转账#

因为我的转账交易中收入比较少,故做了这样的处理:转账和群收款默认减记费用,同时设置一个转账白名单,在白名单里的人转账记为收入。

# 4. group payment
elif "群收款" == type:
narration = "群收款"
# Check if any whitelist keyword is in payee
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:
# Default to expense for group payments
account2 = source_config["group_payment_expense_account"]
expense = True
if not expense:
units = -units
# 5. transfer
elif "转账" == type:
# Check if payee is in income whitelist
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:
# Default to expense for all other transfers
account2 = source_config["transfer_expense_account"]
expense = True
if not expense: # If marked as income but not whitelisted
units = -units

同时在 config.py 里加入:

"wechat": {
"transfer_income_whitelist": [
"A",
"张三",
"李四",
],
"group_income_whitelist": ["Alice"],
},

category mapping#

if account2 is None:
if type in source_config["category_mapping"]:
account2 = source_config["category_mapping"][type]
# Then try the general matching
elif new_account:
account2 = new_account
metadata.update(new_meta)
tags = tags.union(new_tags)
# final fallback
if account2 is None:
account2 = unknown_account(self.config, expense)
# check status

这里的优先级是 category mapping > BDM。如果想让 BDM 优先,可以这样写:

if account2 is None:
# Try BDM matching first
new_account, new_meta, new_tags = match_destination_and_metadata(
self.config, narration, payee
)
if new_account:
account2 = new_account
metadata.update(new_meta)
tags = tags.union(new_tags)
# Then try category mapping if BDM didn't match
elif type in source_config["category_mapping"]:
account2 = source_config["category_mapping"][type]
# final fallback
if account2 is None:
account2 = unknown_account(self.config, expense)

执行导入与生成账本#

在完成各种自定义后,即可通过以下命令来生成 Beancount 文件:

Terminal window
# 支付宝账单
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.pycard_accounts 字典中进行登记,需要补上。

"card_accounts": {
       "Liabilities:Card": {
           "BoC": ["1234", "5678"],
           "CMB": ["1111", "2222"],
      },
       "Assets:Card": {
           "BoC": ["4321", "8765"],
           "CMB": ["3333", "4444"],
      },
  },

插件(Plugins)#

完成账单的导出后,可能还会存在一些交易需要特殊处理,这个时候可以通过 Beancount 插件实现额外的处理,避免直接修改账本文件。下面介绍两个插件的实现方法:

为非经常收入打标签#

对于支付宝和微信里的非经常收入转账,比如个人物品二手转让、临时性劳务报酬、偶然性投资收益、亲友非定期赠予等,交易记录中含有相应的 metadata,如:

2023-07-20 * "****1" "出售二手相机"
imported_category: "收入"
time: "00:42:32"
Assets:Alipay 3000.00 CNY
Income:Sales

如果想自动为所有 metadataimported_category 值为 "收入" 的交易添加 irregular 标签,可以通过以下步骤实现:

首先创建插件文件 plugins/tag_irregular_income.py,内容如下:

from beancount.core import data
__plugins__ = ['tag_irregular_income']
def tag_irregular_income(entries, options_map):
new_entries = []
for entry in entries:
if isinstance(entry, data.Transaction):
if entry.meta is not None:
category_meta = entry.meta.get("imported_category", None)
if category_meta is not None and "收入" in category_meta:
tags = set(entry.tags) if entry.tags else set()
tags.add("irregular")
entry = entry._replace(tags=tags)
new_entries.append(entry)
return new_entries, []

然后在 main.bean 中加入:plugin "plugins.tag_irregular_income"

这样,所有带有 imported_category: "收入" 的交易都会自动获得 irregular 标签。

按时间为日记账排序#

Beancount 默认按日期排序交易,同日期交易可能因顺序问题导致账户余额异常。同样的,可创建一个插件,添加时间维度排序逻辑来解决这个问题。代码见 sort_by_time.py

查询与统计#

通过 bean-extract 生成 .bean 文件后,就可以在主账本 main.beaninclude 这些文件,然后运行 fava main.bean 在浏览器中查看。

Terminal window
# 可视化
fava main.bean

在浏览器中打开 http://localhost:5000 就能看到账本。

Income Statement: Expenses wise
Income Statement: Expenses wise

Journal
Journal

Fava 的左侧菜单里有各类财务报表和可视化图表,按照我的记账逻辑,主要看 Income Statement、Journal 和 Query 栏目足矣。右侧则是明细列表。常用功能包括:

  1. Filter:可以通过输入账户名、标签(tag)、正则表达式来筛选交易;
  2. Query:可以使用 BQL(Beancount Query Language)对账目进行更高级查询。

示例查询#

点击左边 Query 进入查询界面,输入以下内容:

SELECT
date,
payee,
narration,
account,
number as amount,
balance,
id
WHERE
account ~ 'Expenses'
ORDER BY balance desc;

上面这条查询会选出所有 Expenses 开头的账户支出记录,并按余额从大到小排序。将 balance 换为 amount 即可按金额从大到小查看交易。

Query
Query

如果想排除某些类别,可再加上:

AND NOT account ~ 'edu'
AND NOT account ~ 'travel'

其中 ~ 后面代表正则。也可以统计各类别的总金额:

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_metaentry_meta 来对 metadata 进行查询。如查询美团平台的支出:

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,可再加上:

and ('refund' in tags)

按 meta 的平台数据进行分组:

SELECT sum(position), any_meta('platform') as platform
WHERE account ~ "Expenses"
GROUP BY platform

按 payee 交易数量统计:

SELECT payee, count(payee) ORDER BY count(payee) desc

或者:

SELECT account, payee, count(account) ORDER BY account

关于 BQL 的更多用法可见 Beancount Query Language

另外值得一提的是, Fava 里 query 可以与右上角过滤器结合使用、同样支持正则。比如:

所有可 select 的属性包括(help attributes ):

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

自定义查询 Dashboards#

起因是我在思考怎样能够自定义统计报表,因为每次查询都要输入 BQL 感觉挺麻烦的,而且大部分时候查询语句都差不多。

搜索了一下还真让我找到了一个很棒的项目:fava-dashboard,可以很全面地展示各类统计信息。3 4

看完后我只觉得实在是太强了!稍微改一下示例账户名称之类的配置就能很好地满足我目前的需求,还为未来记录资产股票投资等交易挖下了深坑!

不得不感叹自己上一节 # 查询与统计 纯属白写!虽然转念一想,如果不是先研究了 BQL,也不会想着去搜索,也就发现不了这个项目了。有的时候我也很无奈,明明连造轮子的等级都达不到,却似乎花了很多时间在上面。不过辛苦的路,留给自己走就好了。

在这里放两张图(更多可以去项目 repo 查看!):

Overview
Overview

Income and Expenses
Income and Expenses

另外,我指挥 DeepSeek 加入了总支出统计,方法为修改 Overview - Income and Expenses 下的 script 为:

script: |
const currencyFormatter = utils.currencyFormatter(ledger.ccy);
const months = utils
.iterateMonths(ledger.dateFirst, ledger.dateLast)
.map((m) => `${m.month}/${m.year}`);
// 处理原始堆叠数据
const amounts = {};
for (let query of panel.queries) {
amounts[query.name] = {};
for (let row of query.result) {
const value = row.value[ledger.ccy] ?? 0;
amounts[query.name][`${row.month}/${row.year}`] =
query.stack == "income" ? -value : value;
}
}
// 计算每月总收入和净收入
const totals = months.map((month) => {
let incomeTotal = 0;
let expensesTotal = 0;
panel.queries.forEach((query) => {
const val = amounts[query.name][month] ?? 0;
if (query.stack === "income") {
incomeTotal += Math.abs(val); // 收入存储为负数,需取绝对值
} else if (query.stack === "expenses") {
expensesTotal += val;
}
});
return {
month,
income: incomeTotal,
expenses: expensesTotal,
net: incomeTotal - expensesTotal,
};
});
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
valueFormatter: currencyFormatter,
formatter: (params) => {
const data = totals.find((t) => t.month === params[0].axisValue);
return `
${params
.map(
(p) =>
`${p.marker} ${p.seriesName}: ${currencyFormatter(
p.value
)}`
)
.join("<br>")}
<hr style="margin:5px 0">
<strong>总收入:</strong> ${currencyFormatter(
data?.income
)}<br>
<strong>总支出:</strong> ${currencyFormatter(
data?.expenses
)}<br>
<strong style="color: #FF9800;">净收入:</strong> ${currencyFormatter(
data?.net
)}
`;
},
},
legend: {
top: "bottom",
data: [...panel.queries.map((q) => q.name), "净收入"],
},
xAxis: {
type: "category",
data: months,
axisLabel: { rotate: 45 },
},
yAxis: {
type: "value",
axisLabel: { formatter: currencyFormatter },
},
series: [
// 堆叠柱状图系列
...panel.queries.map((query) => ({
type: "bar",
name: query.name,
stack: query.stack,
emphasis: { focus: "series" },
data: months.map((month) => amounts[query.name][month] ?? 0),
})),
// 净收入折线
{
type: "line",
name: "净收入",
symbol: "circle",
symbolSize: 8,
lineStyle: {
type: "dashed",
width: 2,
color: "#FF9800",
},
itemStyle: { color: "#FF9800" },
data: totals.map((t) => t.net),
label: {
show: true,
position: "top",
formatter: (params) => currencyFormatter(params.value),
},
},
],
onClick: (event) => {
const query = panel.queries.find((q) => q.name === event.seriesName);
if (query) {
const [month, year] = event.name.split("/");
const link = query.link.replace(
"{time}",
`${year}-${month.padStart(2, "0")}`
);
window.open(helpers.urlFor(link));
}
},
};

提示#

  1. config.py 映射顺序
    如果对相同交易同时满足多个规则,需要注意配置文件中规则的优先级,会以代码中的匹配先后顺序为准。

  2. 批量给交易打标签
    Beancount 支持 pushtagpoptag。在这两个关键字之间的所有交易,都会自动附带指定标签。比如:

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 的用法,例如管理专项资金、预算编制、金融投资收益分析等。

由于个人水平有限,其中难免有不够完善或需要改进的地方,欢迎大家留言进行讨论。

祝您记账愉快。

Footnotes#

  1. 在审计中,重要性,是指如果一项错报单独或连同其他错报可能影响财务报表使用者依据财务报表作出的经济决策,则该项错报是重大的。错报是指对财务报表金额和披露的错误或遗漏。

  2. 假设和朋友一起吃饭时你先支付了180元,之后收到朋友转账的90元,可以这样记录:支付时,借:餐饮费用 180,贷:银行存款 180;收到朋友转账时,借:银行存款 90,贷:餐饮费用 90。

  3. 项目作者写了一篇博文对实现思路作了简要说明:https://www.andreasgerstmayr.at/2023/03/12/dashboards-with-beancount-and-fava.html

  4. 如果默认的桑吉图无法正确绘制亏损,可以参考这个代码,来源:HBB's Blog

> cd ..