Beancount 中的余额断言
Martin Blais,2014 年 7 月
http://furius.ca/beancount/doc/proposal-balance
本文总结了所有命令行记账系统中余额断言的不同语义,并为 Beancount 提出了新的总余额和文件顺序运行断言语法。
动机
Beancount 和 Ledger 都实现了余额断言。这些断言为系统提供了检查点,可用于验证数据录入的完整性1。
传统会计原则指出,用户永远不能更改过去——修正过去需要插入新条目以撤销之前的错误2——但我们在命令行记账社区对此持不同意见:我们希望保留重建过去的能力,更重要的是,能够在原始数据录入处修正过去的错误。是的,我们是业余爱好者,但我们致力于通过挑战现有概念来简化记账流程,只要不破坏已知余额,我们认为这是完全合理的。这些已知余额正是我们通过余额断言提供的。
另一种看待这些余额断言的方式是,它们只是各种账户对账单上报告的最终金额,如下图所示。
根据此讨论帖,我们发现当前版本的 Ledger(3.0)和 Beancount(2.0b)对余额断言存在不同的解释。

部分断言与完整断言
Beancount 中的断言目前如下所示:
2012-02-04 balance Assets:CA:Bank:Checking 417.61 CAD
该指令的含义是:"请断言在 2012 年 2 月 3 日结束时,账户 'Assets:CA:Bank:Checking' 的库存中恰好包含 417.61 加元单位"(由于在 Beancount 中余额断言指定在日期的起始时刻,因此日期显示为 4 日)。该断言未涉及账户库存中的其他商品。例如,如果账户中包含美元单位,则不会对其进行检查。我们将这种解释称为部分余额断言或单商品断言。
另一种断言是详尽的:"请断言在 2012 年 2 月 3 日结束时,账户 'Assets:CA:Bank:Checking' 的库存中仅包含 417.61 加元单位。" 我们称之为完整余额断言。要实现这种断言,必须支持指定库存的完整内容。目前尚不支持此功能。
还需注意,我们断言的是库存的数量单位,而非其成本基础。
文件断言与日期断言
对于断言的运行余额,存在两种不同的解释和实现方式:
-
Beancount 首先对所有指令进行排序,并在指令日期的开始时验证余额。在前面的例子中,这意味着“在 2012-02-04 任何交易发生之前的余额”。我们将这种机制称为日期断言或基于日期的断言。
-
Ledger 在解析阶段会持续计算每个账户的库存余额,并在文件中断言出现的位置执行检查。我们将这种机制称为文件断言、文件顺序断言或基于文件的断言。这类断言不与任何特定日期关联(这在 Ledger 中略具误导性,因为断言是附加在交易的分录上的,似乎暗示它们发生在交易日期,但实际上并非如此,它们严格依据文件中的位置生效)。
顺序与歧义
这两种断言的一个重要区别是,文件断言不是顺序无关的。例如,考虑以下输入文件:
;; Credit card account
2014/05/01 opening
Liabilities:CreditCard $-1000.00
Expenses:Opening-Balances
2014/05/12 dinner
Liabilities:CreditCard $-74.20
Expenses:Restaurant
;; Checking account
2014/06/05 salary
Assets:Checking $4082.87
Income:Salary
2014/06/05 cc payment
Assets:Checking $-1074.20 = $3008.67
Liabilities:CreditCard = $0
如果你将信用卡还款移动到信用卡账户部分,相同的交易集将失败:
;; Credit card account
2014/05/01 opening
Liabilities:CreditCard $-1000.00
Expenses:Opening-Balances
2014/05/12 dinner
Liabilities:CreditCard $-74.20
Expenses:Restaurant
2014/06/05 cc payment
Assets:Checking $-1074.20 = $3008.67
Liabilities:CreditCard = $0
;; Checking account
2014/06/05 salary
Assets:Checking $4082.87
Income:Salary
这种细微的问题对初学者来说可能难以理解。将文件断言移至其独立的、无日期的指令中,可能更能体现其语义,例如如下形式:
balance Assets:Checking = $3008.67
缺少日期表明该检查并非应用于某个特定时间点。
日内断言
另一方面,基于日期的断言由于其顺序无关性,排除了日内断言的可能性,即在同一账户、同一天内,两个交易记录之间发生的余额断言。
目前 Beancount 不支持日内断言。
需要注意的是,尽管存在这一局限,这并未造成太大问题,因为通常可以通过适当增加或减少一天,或直接跳过无法实现的断言来规避(跳过断言并无大碍,因为它们是可选的,仅用于提供确定性;只要在跳过断言之后的某个日期仍有断言存在,就不会构成严重问题)。
不过,为基于日期的断言找到解决日内断言的方法会更好。一个有趣的想法是扩展语义,使余额检查在与该断言同一天的所有交易中,按文件顺序应用,即在断言前后直接发生的交易中进行平衡,例如以下情况将能平衡:
2013-05-05 balance Assets:Checking 100 USD
2013-05-20 * "Interest payment"
Assets:Checking 12.01 USD
Income:Interest
2013-05-20 balance Assets:Checking 112.01 USD
2013-05-20 * "Check deposit"
Assets:Checking 731.73 USD
Assets:Receivable
一旦出现其他日期的指令,这种顺序就会被打破。
另一个想法是始终将余额断言以文件顺序作为第二排序键(在日期之后),并按此顺序应用。不过我不确定这种做法是否容易理解。
日初与日末
最后,为完整起见,值得指出的是,日期断言必须明确定义其在一天中的生效时间。在 Beancount 中,目前它们在日初生效。
提供一个在日末生效的日期断言替代版本(例如“balance_end”)或许值得考虑。Beancount v1 曾支持此功能(“check_end”),但在 v2 重写时被移除,因为当时尚不明确其必要性。单一语义的简洁性本身也有其优势。
状态
Ledger 3.0 目前仅支持对交易的部分文件顺序断言。
Beancount 2.0 目前仅支持在每天开始时的部分基于日期的断言。
提案
我建议对 Beancount 的余额断言进行以下改进。
文件断言
文件断言应作为插件提供。它们的格式如下:
2012-02-03 file_balance Assets:CA:Bank:Checking 417.61 CAD
理想情况下,为了明确它们严格按文件顺序生效,它们不应包含日期,类似这样:
file_balance Assets:CA:Bank:Checking 417.61 CAD
但这样会破坏其他指令语法的一致性,而且仅仅为了这一点就让原本非常规整简单的解析器变得更加复杂……所有其他指令都以日期和单词开头,其他行则基本被忽略。这将与这一惯例有所偏离。最后,保留一个日期仍然很有用,以便在渲染的日记中插入这些指令。因此,我正在考虑保留日期。如果你决定使用这些特殊的断言,你应该清楚它们的含义。
我也不喜欢将断言附加到交易上的做法;交易语法已经足够复杂,应保持简洁。这应该是一个独立的指令,只有少数人会使用。
为实现此功能,插件只需根据文件位置(使用条目的 fileloc 属性)对所有指令重新排序,忽略日期,并从上到下重新计算累计余额并应用检查。这完全可以像基于日期的断言一样通过后处理完成,而不会干扰任何其他处理过程。
此外,将此功能作为插件实现的另一个优势是,不使用该指令的用户无需承担计算这些库存的开销。
完整断言
Beancount 应通过当前的余额断言指令支持完整断言。它们并非非常重要,但可能很有用。
可能的语法建议:
2012-02-03 balance* Assets:CA:Bank:Checking 417.61 CAD, 162 USD
2012-02-03 balance Assets:CA:Bank:Checking = 417.61 CAD, 162 USD
2012-02-03 balance full Assets:CA:Bank:Checking 417.61 CAD, 162 USD
2012-02-03 balance
Assets:CA:Bank:Checking 417.61 CAD
Assets:CA:Bank:Checking 162 USD
我仍不确定哪种最佳,目前看来这主要取决于个人偏好。