QuickBooks 到 Beancount 迁移指南
阶段一:从 QuickBooks 导出数据
迁移五年的数据,第一步是把所有 QuickBooks 记录以可用的格式导出来。QuickBooks 桌面版和 QuickBooks 在线版有不同的导出选项:
1.1 QuickBooks 桌面版 – 导出选项
IIF (Intuit Interchange Format): QuickBooks 桌面版可以将列表(如会计科目表、客户、供应商)导出为 .IIF
文本文件。在 QuickBooks 桌面版中,进入 文件 (File) → 实用程序 (Utilities) → 导出 (Export) → 列表到 IIF 文件 (Lists to IIF),然后选择你需要的列表(例如,会计科目表、客户、供应商)。这将生成一个包含账户名称、类型和列表数据的文本文件。IIF 是一种专有但易于解析的纯文本格式。用它来获取你的会计科目表和联系人列表,以便在 Beancount 中参考。
总分类账/日记账(通过 CSV): 对于交易数据,QuickBooks 桌面版没有一键式完整导出功能,但你可以使用报表。推荐的方法是导出所需日期范围内的总日记账(所有交易)。在 QuickBooks 桌面版中,打开 报表 (Reports) → 会计与税务 (Accountant & Taxes) → 日记账 (Journal),将日期设置为从最早的交易到今天,然后点击 导出 (Export) → Excel。在移除报表页眉/页脚和空列后,将结果另存为 CSV。确保数值数据是干净的:包含小数(例如 3.00
而不是 3
),没有多余的引号,CSV 中没有货币符号或双重负号。CSV 文件应包含 日期 (Date)、交易号 (Trans #)、名称 (Name)、账户 (Account)、备注 (Memo)、借方 (Debit)、贷方 (Credit)、余额 (Balance) 等列(或根据报表格式只有单个金额列)。
提示: QuickBooks 桌面版 2015+ 也可以通过 查找 (Find) 对话框导出交易。使用 编辑 (Edit) → 查找 (Find) → 高级 (Advanced),设置五年的日期范围,然后将结果导出为 CSV。警告: 某些版本将导出限制在 32,768 行。如果你的数据量非常大,请逐年(或分更小的块)导出以避免数据被截断,然后再将它们合并。确保日期范围不重叠以防止重复。
其他格式 (QBO/QFX/QIF): QuickBooks 桌面版可以通过 .QBO
(Web Connect) 或 .QFX/.OFX
文件导入银行交易,但对于从 QuickBooks 导出,这些不是常规选项。如果你的目标只是提取银行交易,你可能已经从银行那里获得了 QBO/OFX 文件。然而,对于完整的分类账导出,请坚持使用 IIF 和 CSV。QuickBooks 桌面版不能直接导出到 QIF (Quicken Interchange Format) 格式,除非使用第三方工具。如果你确实找到了获取 QIF 的方法,请注意一些账本工具(旧版的 Ledger 2.x)可以读取 QIF,但在我们的流程中,最好还是使用 CSV。
1.2 QuickBooks 在线版 – 导出选项
内置 Excel/CSV 导出: QuickBooks 在线版 (QBO) 提供了一个导出数据工具。进入 设置 ⚙ → 工具 (Tools) → 导出数据 (Export Data)。在导出对话框中,使用报表 (Reports) 标签选择数据(例如总分类账或交易列表),并使用列表 (Lists) 标签选择列表(会计科目表等),选择所有日期 (All dates),然后导出到 Excel。QuickBooks 在线版将下载一个 ZIP 文件,其中包含所选报表和列表的多个 Excel 文件(例如,利润表、资产负债表、总分类账、客户、供应商、会计科目表等)。然后你可以将这些 Excel 文件转换为 CSV 进行处理。
交易明细报表: 如果 QBO 的默认导出不包含单个总分类账文件,你可以手动运行一个详细报表:
- 导航到报表 (Reports) 并找到按账户交易明细 (Transaction Detail by Account)(在某些 QBO 版本中是总分类账 (General Ledger))。
- 将报表期间 (Report period) 设置为完整的五年范围。
- 在报表选项下,将分组依据 (Group by) 设置为无 (None)(以便列出单个交易而不是小计)。
- 自定义列,至少包括:日期 (Date)、交易类型 (Transaction Type)、编号 (Number)、名称 (Name, Payee/Customer)、备注/描述 (Memo/Description)、账户 (Account)、借方 (Debit)、贷方 (Credit)(或单个金额列),以及余额 (Balance)。如果使用了类别 (class) 或地点 (location),也请包含它们。
- 运行报表,然后导出到 Excel (Export to Excel)。
这将生成一个包含所有交易的详细分类账。将其另存为 CSV。每一行代表一笔交易的一个分录 (split/posting)。之后你需要按交易对这些行进行分组以进行转换。
会计科目表及其他列表: QuickBooks 在线版可以通过 会计 (Accounting) → 会计科目表 (Chart of Accounts) → 批量操作 (Batch Actions) → 导出到 Excel (Export to Excel) 来导出会计科目表。这样做可以获取账户名称和类型。同样,如果你想保留名称作为元数据,也请导出客户、供应商等列表。
QuickBooks Online API (可选): 对于编程方法,Intuit 为 QBO 数据提供了 REST API。高级用户可以创建一个 QuickBooks Online 应用(需要开发者账户),并使用 API 以 JSON 格式获取数据。例如,你可以查询 Account
端点获取会计科目表,查询 JournalEntry
或 GeneralLedger
报表端点获取交易。有像 python-quickbooks
这样的 Python SDK 可以封装 API。然而,使用 API 涉及 OAuth 身份验证,对于一次性迁移来说有些小题大做,除非你偏爱自动化。对于大多数情况,** 手动导出为 CSV/Excel 更简单且不易出错**。
阶段二:转换和清理数据
一旦你有了 CSV (和/或 IIF) 格式的 QuickBooks 数据,下一步就是将其转换为 Beancount 的纯文本账本格式。这包括解析导出的文件,将 QuickBooks 账户映射到 Beancount 的会计科目表,以及按 Beancount 语法格式化交易。
2.1 使用 Python 解析 QuickBooks 导出文件
使用 Python 将确保转换的准确性和可复现性。我们将概述两个关键任务的脚本:** 导入会计科目表和转换交易**。
账户导入和映射: 在添加交易之前,在 Beancount 中设置好账户至关重要。QuickBooks 的账户有类型(银行、应收账款、费用等),我们将它们映射到 Beancount 的层级结构(资产、负债、收入、费用等)。例如,我们可以使用如下映射:
# QuickBooks 账户类型到 Beancount 根类别的映射
AccountTypeMap = {
'BANK': 'Assets',
'CCARD': 'Liabilities',
'AR': 'Assets', # 应收账款作为资产
'AP': 'Liabilities', # 应付账款作为负债
'FIXASSET': 'Assets',
'OASSET': 'Assets', # 其他资产
'OCASSET': 'Assets', # 其他流动资产
'LTLIAB': 'Liabilities', # 长期负债
'OCLIAB': 'Liabilities', # 其他流动负债
'EQUITY': 'Equity',
'INC': 'Income',
'EXP': 'Expenses',
'EXINC': 'Income', # 其他收入
'EXEXP': 'Expenses', # 其他费用
}
使用 QuickBooks 桌面版的 IIF 导出文件或 QBO 的账户列表 CSV,我们获取每个账户的名称和类型。然后:
-
创建 Beancount 账户名称: QuickBooks 有时在账户名称中使用冒号 (
:
) 来表示子账户(例如 “Current Assets:Checking”)。Beancount 使用相同的冒号表示法来表示层级。你通常可以直接重用该名称。如果 QuickBooks 账户名称不以类别开头,则在前面加上映射的类别。例如,一个类型为BANK
、名为 "Checking" 的 QuickBooks 账户在 Beancount 中将变为Assets:Checking
。一个EXP
(费用) 账户 "Meals" 变为Expenses:Meals
,依此类推。 -
确保命名有效: 移除或替换任何可能混淆 Beancount 的字符。QuickBooks 允许名称中包含
&
或/
等字符。明智的做法是剔除或替换特殊字符(例如,用and
替换&
,移除斜杠或空格)。此外,确保转换后所有账户名称都是唯一的——QuickBooks 可能允许在不同父账户下有相同的子账户名,这没问题,但在 Beancount 中,完整的名称(包括父账户)必须是唯一的。如果需要,重命名或附加一个限定符以区分它们。 -
输出账户开设指令: 在 Beancount 中,每个使用的账户都必须用
open
指令开设。你可以选择一个在第一笔交易之前的日期(例如,如果迁移 2019–2023 年的数据,对所有开设指令使用2018-12-31
或更早的日期)。脚本将写出如下行:2018-12-31 open Assets:Checking USD
2018-12-31 open Expenses:Meals USD
为每个账户都这样做(假设 USD 是主要货币)。为每个账户使用适当的货币(见下面的多币种说明)。
交易转换: 主要挑战是将 QuickBooks 导出的交易(CSV)转换为 Beancount 条目。每个 QuickBooks 交易(发票、账单、支票、日记账分录等)可能有多条分录(行),必须将它们收集到一个 Beancount 交易中。
我们将使用 Python 的 CSV 阅读器来迭代导出的行并累积成分录:
import csv
from collections import defaultdict
# 从 QuickBooks 日记账 CSV 中读取所有行
rows = []
with open('quickbooks_exported_journal.csv', 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for line in reader:
rows.append(line)
# 按交易分组(假设 'Trans #' 标识交易)
transactions = defaultdict(list)
for line in rows:
trans_id = line.get('Trans #') or line.get('Transaction ID') or line.get('Num')
transactions[trans_id].append(line)
现在 transactions
是一个字典,其中每个键是交易 ID/编号,值是该交易的分录列表。接下来,我们将每个组转换为 Beancount 格式:
def format_date(qb_date):
# QuickBooks 的日期可能是 "12/31/2019" 这样的格式
m, d, y = qb_date.split('/')
return f"{y}-{int(m):02d}-{int(d):02d}"
output_lines = []
for trans_id, splits in transactions.items():
# 如果需要,按行顺序对分录排序(通常它们是按顺序导出的)
splits = sorted(splits, key=lambda x: x.get('Line') or 0)
first = splits[0]
date = format_date(first['Date'])
payee = first.get('Name', "").strip()
memo = first.get('Memo', "").strip()
# 交易标题
output_lines.append(f"{date} * \"{payee}\" \"{memo}\"")
if first.get('Num'): # 如果有参考编号,则包含它
output_lines.append(f" number: \"{first['Num']}\"")
# 遍历每个分录/记账
for split in splits:
acct_name = split['Account'].strip()
# 将 QuickBooks 账户名映射到 Beancount 账户(使用之前的映射)
beancount_acct = account_map.get(acct_name, acct_name)
# 确定带符号的金额:
amount = split.get('Amount') or ""
debit = split.get('Debit') or ""
credit = split.get('Credit') or ""
if amount:
# 某些导出文件有一个 Amount 列(贷方为负数)
amt_str = amount
else:
# 如果有单独的 Debit/Credit 列
amt_str = debit if debit else f"-{credit}"
# 为安全起见,移除数字中的逗号
amt_str = amt_str.replace(",", "")
# 附加货币
currency = split.get('Currency') or "USD"
amt_str = f"{amt_str} {currency}"
# 分录的备注/描述
line_memo = split.get('Memo', "").strip()
comment = f" ; {line_memo}" if line_memo else ""
output_lines.append(f" {beancount_acct:<40} {amt_str}{comment}")
# 交易结束 – 空行
output_lines.append("")
这个脚本逻辑执行以下操作:
-
将日期格式化为 Beancount 的 YYYY-MM-DD 格式。
-
使用收款人 (Name) 和备注 (Memo) 作为交易的叙述。例如:
2020-05-01 * "ACME Corp" "Invoice payment"
(如果没有收款人,你可以使用 QuickBooks 的交易类型或留空收款人引号)。 -
如果存在参考编号(支票号、发票号等),则添加一个
number
元数据。 -
迭代每一条分录行:
- 使用字典
account_map
(从会计科目表步骤中填充)将 QuickBooks 账户名映射到 Beancount 账户。 - 确定金额。根据你的导出文件,可能有一个金额列(带有正/负值)或独立的借方和贷方列。上面的代码处理了这两种情况。它确保贷方表示为负金额,因为在 Beancount 中,每个记账都使用带符号的单个数字。
- 附加货币(除非存在不同的货币列,否则假设为 USD)。
- 用账户、金额和带有行备注的注释写入 Beancount 记账行。例如:
Assets:Checking 500.00 USD ; Deposit
Income:Sales -500.00 USD ; Deposit
这反映了一笔 500 美元的存款(从收入到支票账户)。
- 使用字典
-
列出所有分录后,用一个空行分隔交易。
多币种处理: 如果你的 QuickBooks 数据涉及多种货币,请在每个记账上包含货币代码(如上所示)。确保外币账户以该货币开设。例如,如果你有一个欧元银行账户,你会输出 open Assets:Bank:Checking EUR
,并且该账户中的交易将使用 EUR。Beancount 支持多币种账本并会跟踪隐式转换,但如果你想在报表中转换为基础货币,可能需要添加汇率的价格条目。还建议在 Beancount 文件的顶部声明你的主要经营货币(例如,option "operating_currency" "USD"
)。
运行转换: 保存 Python 脚本(例如,qb_to_beancount.py
)并在你导出的文件上运行它。它应该会生成一个包含所有账户和交易的 .beancount
文件。
2.2 处理边缘情况和数据清理
在转换过程中,请注意以下常见的陷阱以及如何解决它们:
-
账户名称不匹配: QuickBooks 的账户名称可能与 Beancount 的层级名称冲突。例如,QuickBooks 可能有两个不同的父账户,每个都有一个名为 "Insurance" 的子账户。在 Beancount 中,
Expenses:Insurance
必须是唯一的。在导出前通过重命名其中一个(例如,“Insurance-Vehicle” vs “Insurance-Health” )来解决此问题,或者在脚本中将它们映射到唯一的 Beancount 账户。一致的命名约定(无特殊字符,并使用层级结构)将省去很多麻烦。如果需要,使用重映射文件的方法:维护一个旧名称 → 新 Beancount 名称的 CSV 或字典,并在转换期间应用它(我们的示例代码使用了一个account_map
,并且可以从文件中加载覆盖项)。 -
日期和格式: 确保所有日期格式一致。上面的脚本将 M/D/Y 规范化为 ISO 格式。另外,如果你的五年跨度跨越了年终,请注意财政年度与日历年度的问题。Beancount 不关心财政年度的界限,但你以后可能为了方便而按年拆分文件。
-
数值精度: QuickBooks 处理货币到分,所以以分为单位工作通常没问题。理想情况下,CSV 中的所有金额都应有两位小数。如果任何金额变成了整数(没有小数)或带有逗号/括号(表示负数),请在脚本中清理它们(去除逗号,将
(100.00)
转换为-100.00
等)。如果按照指示正确导出 CSV,应该已经避免了这些格式问题。 -
负数和符号: QuickBooks 报表有时将负数显示为
-100.00
或(100.00)
,甚至在某些 Excel 导出中显示为--100.00
。清理步骤应该处理这些情况。确保每笔交易的借贷方总和为零。Beancount 会强制执行这一点(如果不平衡,导入时会抛出错误)。 -
交易重复: 如果你必须分批导出交易(例如,逐年或逐账户),请小心合并它们,不要重叠。检查一年的第一笔交易是否也是前一批的最后一笔,等等。在边界处很容易意外复制一些交易。如果你怀疑有重复,可以按日期对最终的 Beancount 条目进行排序并查找相同的条目,或者使用 Beancount 的唯一交易标签来捕获它们。一种策略是将 QuickBooks 交易号作为元数据包含进来(例如,使用
Trans #
或发票号作为txn
标签或quickbooks_id
元数据),然后确保这些 ID 没有重复。 -
不平衡的分录 / 暂记账户: QuickBooks 可能有奇怪的情况,比如一笔交易有不平衡,QuickBooks 会自动将其调整到“期初余额权益”或“留存收益”账户。例如,在设置初始账户余额时,QuickBooks 通常会将差额记入一个权益账户。这些会出现在导出的交易中。Beancount 将要求显式平衡。你可能需要引入一个用于期初余额的权益账户(通常是
Equity:Opening-Balances
)来镜像 QuickBooks。在账本的第一天有一个建立所有账户期初余额的条目是很好的做法(见阶段五)。 -
多币种边缘情况: 如果使用多币种,QuickBooks 的导出可能会以本国货币或其原生货币列出所有金额。理想情况下,获取每个账户的原生货币数据(QuickBooks 在线版的报表通常会这样做)。在 Beancount 中,每个记账都带有一个货币。如果 QuickBooks 提供了汇率或本国货币换算,你可以忽略这些,并依赖 Beancount 的价格条目。如果 QuickBooks 没有导出汇率,你可能需要手动添加关键日期的价格记录(例如,使用 Beancount 的
price
指令)以匹配估值。然而,对于基本的账本完整性,只要交易以其原始货币平衡就足够了——除非你想要相同的报告,否则不必明确记录未实现损益。 -
应收账款 / 应付账款: QuickBooks 跟踪发票和账单的详细信息(到期日、支付状态等),这些在纯账本中不会完全转移。你会得到应收(A/R)和应付(A/P)的交易(发票增加 A/R,付款减少 A/R 等),但不会有 发票文件或每个发票的客户余额。因此,迁移后,你应该验证 Beancount 中的 A/R 和 A/P 账户余额是否等于 QuickBooks 中客户/供应商的未结余额。如果你需要跟踪发票,可以使用 Beancount 的元数据(例如,包含一个
invoice
标签或链接)。QuickBooks 的发票号应该已经通过Num
或Memo
字段导出了——我们的脚本将Num
保留为交易元数据中的number: "..."
。 -
不活动或已关闭的账户: IIF 导出文件可能包含不活动的账户(如果你选择包含它们)。导入它们没问题(如果它们真的不活动,它们将没有交易并且余额为零)。你可以在最后一笔交易日期之后,用
close
指令在 Beancount 中将它们标记为已关闭。这可以使你的账本保持整洁。例如:2023-12-31 close Expenses:OldAccount ; migrated after migration
这是可选的,主要是为了整洁。
通过仔细清理和映射上述数据,你将拥有一个在结构上与你的 QuickBooks 数据相匹配的 Beancount 账本文件。下一步是验证它在数值上也与 QuickBooks 相匹配。
阶段三:数据验证和对账
验证是会计数据迁移中至关重要的一步。我们需要确保 Beancount 账本与 QuickBooks 账簿精确到每一分钱。可以使用多种策略和工具: