Beancount 技术交流贴
Beancount用户讨论了从自动化导入痛点到LLM辅助解析的实践,重点关注玩卡积分追踪、成本基础处理及数据安全,新增回复强调了PDF文件上传的隐私风险。
1. 关键信息
- (之前已归纳)历史数据导入难题:用户普遍面临银行只提供近1-2年QFX/CSV下载,更早数据(如PDF)需要手动处理或通过PDF解析工具(如Tabula)转换为CSV,再编写Importer导入。
- (之前已归纳)自动化导入方案:方案包括使用
beancount-import、自定义Python Importer(针对特定银行CSV格式)、或利用LLM(如GPT-4o-mini结合instructor)解析非结构化PDF/Markdown文本,生成结构化交易数据。 - (之前已归纳)点数追踪:追踪Amex MR等积分需要结合交易数据和API(如
ReadLoyaltyTransactions.v1)获取的积分记录,理想状态是交易(支出)和积分(收入/资产增加)在同一笔事务中体现。 - (之前已归纳)多币种/成本基础(Cost Basis):涉及外汇兑换时,需使用
@或@@语法处理汇率和总成本,同时需注意浮点数精度问题,建议使用小数形式记录整数金额以避免误差。 - (之前已归纳)税务与摊销(Amortization):对于预缴税款(如1040-ES),推荐使用
amortize_over插件,将一次性大额支出按月摊销到实际应税发生的月份,以获得更真实的月度财务图表。 - (之前已归纳)数据安全:讨论了将Beancount核心文件(明文)存储在Git/GitHub上的安全性问题,建议使用Git LFS、加密工具(如
git-crypt)或自建私有Git(如GitLab/Headscale)来保护敏感数据。 - PDF上传隐私风险: 用户提醒,在讨论或上传PDF文件时需特别注意,尤其是包含银行账号(account number)和路由号码(routing number)的检查账户(checking)PDF,这些信息应被编辑(redact)或避免上传。
2. 羊毛/优惠信息
- (之前已归纳)Amex MR点数导出:通过浏览器开发者工具(F12 -> Network)拦截
ReadLoyaltyTransactions.v1API请求,可获取accountToken,从而导出任意时间段(最远约两年)的MR交易详情JSON,比网页界面导出的CSV更全面。 - (之前已归纳)Costco/Uber GC MS:购买GC时获得的折扣(如$75买$100 GC)可记为
Income:Incentive或负向Expenses。对于有溢价的GC(如Uber Eats),建议将费用按实际消费金额(而非GC面值)记录,将折扣作为收入处理。
3. 最新动态
- (之前已归纳)LLM辅助导入的进展:用户展示了使用
marker(PDF转Markdown)结合instructor(Markdown转结构化JSON,使用GPT-4o-mini)的方案,初步效果良好,下一步计划通过历史数据Embedding匹配Payee和Posting Account。 - (之前已归纳)Beancount性能担忧:社区提到Beancount的Python实现(尤其是Plugin API)在数据量大时性能下降明显,并提及了C++重写项目(可能已暂停)。
- (之前已归纳)Plaid访问限制:有用户反馈Plaid的免费额度限制(100个连接)可能已收紧,部分用户需转向CSV/OFX导入或付费模式。
4. 争议或不同意见
- (之前已归纳)自动化 vs 隐私:Plaid等工具提供了极大的便利性,但涉及金融数据托管,部分用户(尤其关注隐私的大户)认为不可取,更倾向于手动/半自动的本地文件导入方案。
- (之前已归纳)Plaintext vs 结构化数据库:尽管Beancount的纯文本格式便于版本控制和社区交流,但对于复杂的库存管理、Cost Basis的自动转移(如股票账户A到B),纯文本和现有插件机制显得力不从心,可能需要更强大的数据库后端支持。
- PDF数据处理的隐私顾虑: 对于使用PDF解析的场景,用户明确指出应警惕上传包含敏感银行信息的PDF文件,即使是用于技术讨论,也应先进行数据清洗(redact)。
5. 行动建议
- (之前已归纳)历史数据处理:对于2年以上PDF,尝试使用
tabula-py(或pdfplumber,如153楼分享的脚本)提取表格数据,然后编写自定义Importer或使用beancount-import导入。 - (之前已归纳)点数记录:优先使用Amex MR API(通过F12拦截)获取完整点数记录,并尝试将其与交易记录合并导入,以实现精确的积分追踪。
- (之前已归纳)大额/非日常支出:采用
amortize_over插件对税款、大额保险等进行摊销处理,避免单笔巨额支出影响月度报表的可读性。 - (之前已归纳)数据安全:核心账本文件应使用Git进行版本控制,并考虑使用
git-crypt或部署在私有网络(如Tailscale/Cloudflare Access保护的Fava)中。 - 处理PDF时务必注意隐私: 避免上传包含银行账号和路由信息的PDF文件,如果必须分享或处理,应先使用工具(如
redact)移除敏感信息。
今天试用了一下Beancount,感觉蛮有意思的
感觉爱上了这个tools
想看看坛里有多少大佬在用这个记账/MS/点数
大家一起交流交流怎么规划accounts、管理帐本、以及更方便的记账呀
其实主要问题还是大部分银行只支持一到两年的QFX下载,再往前的要么直接用equity略过,要么只能自己一笔笔码了?对于这种只能下载statement的有没有什么好的办法呀
首先记账这种最需要自动化,不然账户一多每次手工导出都很麻烦。宁可自己 screen scrape 银行网站不如直接 Plaid。此时 Plaid 真的是神佑,免费 100 个账户而且还带简单的消费分类
【引用自 jefferyz】:
其实主要问题还是大部分银行只支持一到两年的QFX下载,再往前的要么直接用equity略过,要么只能自己一笔笔码了?对于这种只能下载statement的有没有什么好的办法呀
超过两年的记录的确烦,Plaid 也只支持提取两年的记录。暴力一点可以直接 PDF to text 然后正则
先自己记,之后用python写个小程序,用jaccard similarity match历史记录就成了,每周五分钟
对最开始是最麻烦的,尤其超出2年
我19年来的美国,有打算从19年开始简单的记一下,毕竟一直到22年2月我都一直只有一个checking,中间可能开了一张boa123而已。
然后22年尽我所能
23年和24年记得详细一些,毕竟开始MS了
【引用自 0.6cpp】:
暴力一点可以直接 PDF to text 然后正则
我现在弄了一个月的statement放到csv里面,但是用beancount-import导入始终没成功过
my_boa_importer = CSVImporter({
Col.DATE: 'Date',
Col.NARRATION1: 'Description',
Col.AMOUNT: 'Amount',
},
'Assets:CurrentAssets:US:Bank:BoA:Checking', # account
'USD', # currency
# regexps used by ImporterProtocol.identify() to identify the correct file
'Date,Description,Amount',
)
data_sources = [
dict(
module='beancount_import.source.generic_importer_source',
importer=my_boa_importer,
account='Assets:CurrentAssets:US:Bank:BoA:Checking',
directory=os.path.join(data_dir,'2019','BoA','CSV')
),
]
Date,Description,Amount
2019-09-05,MOBILE PURCHASE XXXXXXX,-1.6
实在是不知道错哪了
我开始记的时候选了一个月的一号直接全靠opening balance pad到
那时候是2019-09-01
你是时间长了
我看了看一共也就来美国五年,咬咬牙手动应该还是可以的
(只要我能学会怎么import csv)
我开始记账的时候(2019),也刚刚来美4年(2015)
其实我大部分帐还是手记的
那个importer会找到金额对的上的加上meta
【引用自 0.6cpp】:
首先记账这种最需要自动化,不然账户一多每次手工导出都很麻烦。宁可自己 screen scrape 银行网站不如直接 Plaid。此时 Plaid 真的是神佑,免费 100 个账户而且还带简单的消费分类
出卖金融隐私换便利不可取
就等啥时候Plaid推出一个新的跟EWS竞争的服务
【引用自 时空空】:
大部分帐还是手记的
我有用一些国产app记下了几乎这5年的所有账
但是直接导入beancount还是不太行,没有了原始交易的Description
【引用自 jefferyz】:
没有了原始交易的Description
金额账户对上了就行吧。。
【引用自 收束观测者】:
出卖金融隐私换便利不可取
但它真的很便利 开了这么多银行/CU/fintech 账户,实在是懒的全部自己写一套 scraper。搞定 auth 经常已经很烦了,然后还得反向 API 或者爬 DOM。其实这里 Plaid 是有竞争对手的(Finicity, MX, Akoya),然而作为个人开发者,这些你都用不了(要么连注册都不能要么只有 demo mode)。Plaid 没这么多废话,直接给你 live credentials
Edit: 当然了,我也期待像欧盟一样,立法让所有银行直接提供真正的 API
那直接pad到今天就行了
你用哪个? 我用随手记 导出到 csv 然后自己写了个脚本直接转成beancount格式。 现在还是用随手记记录,有个app还是方便。但是随时可以切换到beancount。
我用Budget
之前的逻辑跟现在我在beancount里写的逻辑出入较大
你说的有道理,我写个脚本直接转csv成beancount好了,先用FIXME代替全部
【引用自 0.6cpp】:
但它真的很便利
其实大部分人确实不用在乎Plaid是不是知道你每个月用几个套套
只不过泥潭有一小撮人确实需要比较小心
记账不如多出去旅旅游来的开心
直接让gpt4API帮你读
我是自己写了个PDF转 CSV的 tool (每个银行分别写个)
【引用自 zuiaiwufan】:
直接让gpt4API帮你读
试了一下,不太好用。大一点的文件读不出全部
【引用自 Dyyd】:
我是自己写了个PDF转 CSV的 tool (每个银行分别写个)
我靠这个感觉很牛逼的样子,咋写的呀
不需要API,用beancount记账三年了,多的时候几十个account,少的时候十几个。
每个月download常用account的csv,每半年download一次不常用account的csv,主流银行没有不提供csv的,parser写一次就全自动化了。
包括多账号股票持仓都是用beancount monitor。
入门推荐阅读 Beancount复式记账(一):为什么
我是每个银行都写一个python文件来读csv,然后用bean-extract
【引用自 Trey】:
每个月download常用account的csv,每半年download一次不常用account的csv
主要还是懒 我每两天看心情就跑一下,基本可以一键导入大部分账户,有些时候需要手工稍微清理+合并
【引用自 Trey】:
包括多账号股票持仓都是用beancount monitor
投资我是分开的一套,因为有梭哈记录的特殊需求(期权 greeks,开了什么组合,等等),但是后面单向 render 到 beancount
【引用自 jzj】:
bean-extract
好的我看看这个!
主要是现在前面两年不能导出csv了只能下载pdf
我用了Tabula来把pdf里的表格提取出来变成csv。
等我整理下弄个GitHub
太感谢了
我试图在网上搜教程,但很多都说的不太清晰。包括beancount有哪些plugin,importer怎么用等等。
我也打算学习一下,然后看看能不能写点更通俗易懂的教程出来。这东西用好了是真的好用,连点数都能track
记了账才意识到自己刚来美国居然只靠着全身上下的7000美金苟活过了第一年
image2232×376 17 KB
【引用自 jefferyz】:
QFX
还好是X不是另一个字母
【引用自 anon4256329】:
没办法直接指定转换后的 value
可以的,官网有这个说明
https://beancount.github.io/docs/beancount_language_syntax.html#costs-and-prices
100 USD @ 7.25 CNY 就是汇率
100 USD @@ 725.47 CNY 就是总价
Postings represent a single amount being deposited to or withdrawn from an account. The simplest type of posting includes only its amount:
2012-11-03 * "Transfer to pay credit card"
Assets:MyBank:Checking -400.00 USD
Liabilities:CreditCard 400.00 USD
If you converted the amount from another currency, you must provide a conversion rate to balance the transaction (see next section). This is done by attaching a “price” to the posting, which is the rate of conversion:
2012-11-03 * "Transfer to account in Canada"
Assets:MyBank:Checking -400.00 USD @ 1.09 CAD
Assets:FR:SocGen:Checking 436.01 CAD
You could also use the “@@” syntax to specify the total cost:
2012-11-03 * "Transfer to account in Canada"
Assets:MyBank:Checking -400.00 USD @@ 436.01 CAD
Assets:FR:SocGen:Checking 436.01 CAD
Beancount will automatically compute the per-unit price, that is 1.090025 CAD (note that the precision will differ between the last two examples).
哦对,突然想到了个我一直没动力修的地方:点数
理想情况是这样子的
2024-03-01 * "McDonald's"
Liabilities:CreditCards:AmexGold -8.15 USD
Expenses:Food:Other 8.15 USD
Income:Points:Amex:GlobalRestaurants -32 MR
Assets:Points:Amex 32 MR
但是两个数据源(Plaid 和从 ReadLoyaltyTransactions JSON 转换过来的)beancount-import 不会自动 merge。是时候重新修一修自己的 sketchy import pipeline 了
【引用自 0.6cpp】:
ReadLoyaltyTransactions
这是啥?????
我正在犯愁呢,怎么导出点数数据
上面的也是我的理想情况,但是要稍做修改
2024-03-01 * "McDonald's" ""
Liabilities:CreditCards:Amex:Gold -8.15 USD
Expenses:Food:Fast 8.15 USD
Income:Points:Amex:Gold:GlobalRestaurants3X -32 P_MR
Assets:Points:AmexMR 32 P_MR
2024-03-01 * "McDonald's" ""
Liabilities:CreditCards:Chase:SapphirePreferred -8.15 USD
Expenses:Food:Fast 8.15 USD
Income:Points:Chase:SapphirePreferred:Dining3X -32 P_UR
Assets:Points:ChaseUR:CSP 32 P_UR
顺便再请教一个问题
就是突然有一笔大额支出该怎么处理,这个支出算不上日常消费
例如学费
这样会导致这个图表没有什么可读性
Amex 看 MR 历史 的页面用的是这个 API:
https://functions.americanexpress.com/ReadLoyaltyTransactions.v1
从它的 request payload 里面找到卡对应的 accountToken(这个 token 是 SSR 注入进网页的,没找到方便的方法自动扣出来)之后,就可以这样
let res = await fetch('https://functions.americanexpress.com/ReadLoyaltyTransactions.v1', {
'method': 'POST',
'body': JSON.stringify({
'accountToken': 'xxx',
'productType': 'AEXP_CARD_ACCOUNT',
'offset': 0,
'limit': 30,
'transactionsFor': 'LOYALTY_ACCOUNT',
'startDate': '2023-11-01',
'endDate': '2023-11-30',
}),
'credentials': 'include',
});
await res.json()
里面还是有很多信息的
{
"cashValue": {
"value": 17.96,
"currencyType": "CASH",
"currencyCode": "USD",
"currencyDescription": "US Dollar"
},
"id": "XXX",
"category": "REWARD",
"type": "ACCELERATED",
"status": "POSTED",
"userStatus": "EARNED",
"postedDate": "2023-11-20",
"multiplier": 4,
"communicationId": "AMX000",
"rewardAmount": {
"value": 72,
"currencyType": "Points",
"currencyCode": "Points",
"currencyDescription": "Membership Rewards Points"
},
"baseMultiplier": 1,
"baseAmount": {
"value": 18,
"currencyType": "Points",
"currencyCode": "Points",
"currencyDescription": "Membership Rewards Points"
},
"incrementalMultiplier": 3,
"incrementalAmount": {
"value": 54,
"currencyType": "Points",
"currencyCode": "Points",
"currencyDescription": "Membership Rewards Points"
},
"industryCategoryId": "BGC0000149",
"industryCategory": "Global Restaurants",
"programTypeCd": "GLD",
"descriptions": "GRUBHUB",
"cardNumberLastFiveDigits": "XXXXX"
}
可以 filter 掉整个 account,但每次这么做的确有点烦就是了
https://fava.pythonanywhere.com/example-beancount-file/help/filters
按amex的风格,track点数是不是还要分pending和earned两个账户
Pending 用!标记一下就行
所有银行都会pending啊
对…但是只能filter include,不能filter exclude,就很麻烦
我记得token不会变,可以手动找一次存下来
对,不会变的
这个方式可以爬到任何时间的数据吗?官网有一年的limit
实际 API 能返回最多两年的数据,刚试了一下最早能爬到 2022/4 的交易
全自动化最烦的一点是 auth,amex 每改一下我的 playwright 逻辑就会爆炸。不过手动的话每个星期自己登录进去 F12 也不算太麻烦
不奢求全自动化了
只要能把交易扣下来方便导入,后面自己修改花不了多久
我会amortize,有插件搞
我一般只看月度图,比如这样
2024-03-03 * "Pay1040" "4868 extension" #tax #pride7306
Expenses:Tax:2023:Federal:Federal 3607.04 USD
amortize_months: 12
amortize_start: "2023-01-15"
Expenses:Financial:TransactionFee 2.20 USD
Assets:Balance:PrideCard -3609.24 USD
gist.github.com
https://gist.github.com/wzyboy/7dbf207c9a9d42ac2549232deb9a95d9
amortize_over.py
# Copyright (c) 2017 Cary Kempston
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all
This file has been truncated. show original
example.beancount
plugin "amortize_over"
2017-06-01 open Assets:Bank:Checking
2017-06-01 open Assets:Prepaid-Expenses
2017-06-01 open Expenses:Insurance:Auto
2017-06-01 * "Pay car insurance"
Assets:Bank:Checking -600.00 USD
Assets:Prepaid-Expenses
This file has been truncated. show original
嗯是个好主意
但还有个问题是我的学费有时候用flywire有时候自己交一点。。。
要不弄一个Equity:FamilySupport:Flywire好了,这样就不会导致income和assets太奇怪,也不至于偶尔有学费偶尔没有
Income:Gift
xmsl我只有Expenses:Gift
父母给的钱要记到Liability:Payable账户下面吧
那父母要买的保健品手机电脑衣服鞋什么的写进Assets:Receviables 吗
Payable 应该暂时也不必了
plugin
[(‘plugins.amortize_over’, None)]
这算开启成功了吗。。怎么没效果呢
当然是再来一个账户 Expenses:WriteOff:Parents 然后定期从Receivable里面往WriteOff里面转
Expenses:LOSS:Parents
interesting!让我来试试,我感觉这种bug不应该存在在v2才是
好像有一个tolerance可调
解决了。
2012-11-03 * "Transfer to account in Canada"
Assets:MyBank:Checking -400.00 USD @@ 436.01 CAD
Assets:FR:SocGen:Checking 436.01 CAD
这是官方示例,可以看到是在支出的账户apply @@ 而不是收到的账户
所以我把你的代码改成
** Assets:Schwab:EUR
2015-01-03 * "Currency Exchange"
Assets:Schwab:EUR 127.29 EUR
Assets:Schwab:JPY -5000 JPY @@ 127.29 EUR
就不会报错了
另一个办法,如果你的账本已经很大了没办法调的话,你可以用这个设置来允许误差(但是我怕以后会出什么幺蛾子。。。)
option "operating_currency" "USD"
option "infer_tolerance_from_cost" "true"
又试了一个解决办法
我的猜测是这个error的出现是因为在计算机语言中int和float(double)是没法直接比较的
-127.29 EUR @@ 5000 JPY
这个指令会把-127.29 EUR转换成5000.000000000000000000000001 JPY (或者4999.999999999999999999999999 JPY),所以才会有0.000000000000000000000001的误差
你只要这样在记录整数JPY的时候也用小数的形式就可以解决这个问题,例如:
** Assets:Schwab:JPY
2015-01-03 * "Currency Exchange"
Assets:Schwab:EUR -127.29 EUR @@ 5000.00 JPY
Assets:Schwab:JPY 5000.00 JPY
在beancount小数的比较中会自动处理误差,而整数的比较则要求非常精确
这也是为什么在大部分财务系统/语言中从不用float/double记录金钱amount,而是把XX.YY USD存储成int XX Dollar + int YY cents
https://beancount.github.io/docs/rounding_precision_in_beancount.html#proposal
这里有提到相关的内容
随便挑了一个importer跑,出来的所有transactions都只有一条会报错,怎么简单暴力改成能用的?
去源文件里把那条出错的删了?
是每个transaction记录读出来都只有一个account,删了就没东西啦
好像有默认用equity来balance,但还没弄懂怎么做
哦,他是一个ML模型
你第一次跑当然全是FIXME,手动修改几个之后就慢慢准了
btw我建议用beancount-import这个还有GUI,操作比较方便的
但我可能考虑的是自己写一个python脚本。因为我还想记录点数
哦我明天看看你说的,试用的是这个GitHub - redstreet/beancount_reds_importers: Simple ingesting tools for Beancount (plain text, double entry accounting software). More importantly, a framework to allow you to easily write your own importers.
好,你这个我也看看。我还找到几个适用于官方 importer的脚本
gist.github.com
https://gist.github.com/mterwill/7fdcc573dc1aa158648aacd4e33786e8
USAGE.md
Note: everything here is pretty specific to my usage/accounts and not written for public use... You'll probably have to tweak a bunch of stuff.
```bash
$ bean-extract config.py ~/Downloads # the csvs should be in here
```
config.py
import os, sys
# beancount doesn't run from this directory
sys.path.append(os.path.dirname(__file__))
# importers located in the importers directory
from importers import amex, chase, schwab, citi
CONFIG = [
chase.ChaseCCImporter('Liabilities:CC:Chase:Reserve', '0000'),
该文件已被截断。 显示原文
dedup.py
#!/usr/bin/env python3
"""
Helper script to find merchants close to one another.
"""
from fuzzywuzzy import fuzz
from itertools import combinations
from subprocess import check_output
该文件已被截断。 显示原文
文件不止三个。 显示原文
其实我有看到这个,因为自带的importer种类不多筛掉了
顺便展示一下这两天的成果
用笨方法从pdf里面抠出交易信息,然后简单map一下,手动输入的
image1449×468 11.7 KB
以及我的数据结构:
image569×1365 49.5 KB
image612×844 37.9 KB
嗯嗯刚刚看了一眼你那个importer,感觉很不错
至少README写的很“强”的样子
等我空了试试
今天刚到奥兰多,去嗨了
以前這樣記帳過一年,放local不小心全刪了
打算用copilot 整合所有financial institution, 再輸出tranx來手動加點數
但MR好像看不到被關掉副卡的點數
copilot好像也有时间限制,最多两年?
MR还看不到一年前的呢,苦恼的很
【引用自 MyLiverIsBad】:
以前這樣記帳過一年,放local不小心全刪了
你需要的是Git
我用github做版本管理…… 文本还是需要vcs
想了解一下先进经验来自动记录下面这样的“同一笔"交易
如果通过statement等渠道的import,应该会产生3-4个交易记录,虽然总数没错,但感觉不好看
2024-03-11 * "MS" "Fluz"
Assets:MS:Fluz:GCPrepaymemnt 99.54 USD
Liabilities:Amex:BBP -102.72 USD
Expenses:MS:Fees:Fluz 3.18 USD
Assets:Points:Receivable 102.72 *12 + 5,000 MR
Income:MS:Points -102.72 *12 - 5,000 MR
没办法这种麻烦的交易就要手动处理一下
也有好处,可以知道每一笔MS是不是都正常
Statement不支持你这个多币种的Points
然后我就发现了fluz经常在信用卡上扣的金额比网站上显示的多0.01
从每笔交易多刷0.01 太经典了
這0.01有什麼特殊用途嗎?這樣記帳數字對不上
Expenses:LOSS
或者
Expenses:FulzFuckingExtraCharge
意思就是每一单都多收0.01,fluz一年多赚很多钱啊
zh.m.wikipedia.org
One Cent Thief
《One Cent Thief》(马来语:Pencuri Satu Sen)为Astro Originals制作,改编自历史真实事件的2022年马来西亚电视剧。剧集于2022年10月8日起通过Astro Ria、Astro Vinmeen(马来语:Astro Vinmeen HD)、Astro双星及PRIMEtime(马来语:PRIMEtime (saluran TV Astro))同步播出。
“金钱不是万恶之源,没钱才是万恶之源”
没有大学文凭的银行员工Iman Shah(Syafiq Kyle 饰)是低薪一族。月薪仅有2000令吉的他迫切需要支付9万令吉的手术费用以及偿还父亲欠下的高利贷。他选择利用他高超的黑客技术,入侵了其就职的Bintang银行系统,并从银行的每个客户的银行账户中偷走一仙(一分钱),筹得了不少钱。后来,Iman的上司离奇死亡。他担心自己制作的病毒邮件东窗事发而打算销毁证据。殊不知,他的好友兼同事已经发现了他的秘密...
原本打算改邪归正的Iman再次被现实逼得走投无路,他突然想到国家不止有一家银行。于是,他决定再次利用他的黑客技术把钱拿到手...
github存你的财务数据是不是不太好啊
我们之前pl课完全是老师自己搞的lab,能直接搜到的GitHub repo都不太行
但是copilot会写它的代码 还能过不少testcase的那种
感觉肯定悄悄用private repo训练了
gitlab可破
加密可以 GitHub - AGWA/git-crypt: Transparent file encryption in git ,能选择只加密数据而不是代码(当然你也可以分 repo)
自己准备部署服务器的话还是直接上gitlab就好
但也会怕断电/断网啥的
这种东西还是不太放心,想 e2e 一下(虽然我在用 copilot 和 plaid 哈哈哈)
加密+gitlab就100%放心啦
不过我们这儿做网络安全的教授都用的gitlab
还说不会通过portal提交推荐信,如果要他写他必须用email发过去
用过beancount几年时间,并且也写了点东西。有自己用,也有给公司用(给公司用的方法就是把数据库里的数据提取出来生成beancount可以接收的东西)。
我觉得plaintext accounting实际上有很多的问题。
plaintext并不是最好的形式。
structured data就不要用什么plaintext吧。
现在的结果是每次都是先把plaintext读入,获得真正的结构,然后跑个程序做分析。
而很多对这个plaintext做改动的脚本还需要有能力直接写这个plaintext的能力。
真的理解了beancount内部构造,会发现完全可以存SQL数据库呀。
在不需要关注commodity的lot的时候整个世界都很简单。但一旦需要计算,并且还要开始manually assign lot的时候,一些简单的功能现有plaintext accounting工具都做不了。
例子:比如我买了一堆股票给账户A。之后某天我transfer了账户A到账户B。理论上所有的cost basis也要transfer过去。但这个plaintext accounting是做不了的。你要亲自手动(或者写脚本)把这个transfer完成。但如果之后某天你要改账户A的cost basis呢?账户B的这边又不会自动的更改。
当然,很多时候做account,改动是非常少见的。因为输入的时候,东西就非常的正确。
一直有兴趣做个好一点的accounting+inventory系统,直接从first principals开始。但太忙了。
应该还好。我在digital ocean的vps 几年都没重启过…
没有账号登陆信息还好…… 还是我要担心全世界知道我有多穷… 我的名义账户可能有100-200个 程序可能fail to understand
【引用自 IrishCoffee】:
我要担心全世界知道我有多穷
哈哈哈哈哈哈 以后就是担心全世界知道你有多富了怕被打劫了
【引用自 jefferyz】:
不会通过portal提交推荐信
这是因为能黑一下然后改成强推? 或者就只是单纯的学生大概猜到服务器存文件的目录之后能看到文档(?
【引用自 Chao】:
一直有兴趣做个好一点的accounting+inventory系统,直接从first principals开始。但太忙了。
直接是一个狠狠的期待住了
但这一定是一个很复杂的工程,要考虑到的case太多了
不知道,可能不信任portal
他说他的email有完整的安全机制啥啥的,但我寻思不还是学校邮箱吗,又不是他自己的域名
【引用自 Lunasol】:
github存你的财务数据是不是不太好啊
我也在思考这个问题,要不要把fava部署在公网上。但肯定要想个办法做密码保护
可以私网 加个ssh 反向穿透
再次强推 Tailscale,大概是最好用的 mesh VPN 了。在内网暴露 fava 就行
这个跟cloudflared有啥区别
是真正的 L3 VPN(WireGuard)不是反代,自动帮你打洞
tailscale的问题在于服务端知道你全部的node信息,太可怕了,如果只是为了内网穿透出去可以考虑tailscale的开源方案,反正你大概率也用不上tailscale的全部功能
workaround是不是每个lot可以对应一种“货币”
L3除了overhead小以外还有什么优势吗
termux 手机开了 然后google drive 备份数据就好了
首先 Tailscale 是点对点的,数据端对端加密而且不经过中心服务器(无法打洞的时候会走 relay,仍然是加密的)。你用了 Cloudflare 你的 TLS 在 CF 结束,他们那解密之后重新加密。
然后你可以很方便地暴露任何非 web 服务,比如 minecraft。Tailscale 很赞的一点是可以直接通过邮件给任何人发送邀请,会指导对面安装 Tailscale,整个过程十分友好。在你这边你可以轻松用 ACL 控制权限。Cloudflare Pro 其实也可以,但是每次连接很不方便,也不利于分享。
【引用自 peridot】:
tailscale的问题在于服务端知道你全部的node信息
对,metadata 会出去,但不影响你节点本身的安全防护。默认配置下 control plane 有能力凭空加入节点,但他们最近推出的 Tailnet Lock 就避免了这点。
【引用自 peridot】:
如果只是为了内网穿透出去可以考虑tailscale的开源方案
Headscale 的确很赞,还被官方钦点了。让我惊讶的一点是你即使自建 control plane 一样可以用官方的 relay(盲猜他们很大一部分 operational cost 在那)
话说实在看好 Tailscale 这种商业模式。作为 control plane,他们的价值来源于无法匹敌的 UI/UX(竞品 ZeroTier 半死不活的),精确避开高成本的开销(大多数情况不负责承载流量)。
是个方式。但一切都分割成不同的"货币"也会出现其他问题。比如我们有什么办法知道实际上某些货币应该在更高层面上是一样的东西呢?(你可以说可以弄subaccount,每个account只对应一个"货币",但还是导致自己要做的东西越来越多,越来越复杂,而这些看起来应该要在软件层面做好,减少人自己做错东西的概率。)
一个常见的解决方式是从来不考虑纯粹钱类型的accounting,而是考虑我们在做一个库存系统。
可以给货币(也就是商品)增加一系列的dimension。两个电脑虽然是一样的(如UPC相同),但是因为有个dimension “serial number”(或者cost basis,购买时间) 不一样,则这个区别就应该被记录下来。整个系统都应该track这个物品去了哪里。现代的物流、供应链在这方面就能做到差不多的极致。一整套东西可以溯源。
但是系统也需要支持一些常见的操作,让用户用起来不会太痛苦。比如要卖100台电脑,并不需要当场就要指定这100个serial number。而是最终发货的时候,记录了100个serial number的东西走掉了,并且系统应该认定我们满足了"卖100台电脑"这个任务。一个良好的系统要能在不同的抽象层工作,并且应该加入一些问题的detection。
比如常见的,beancount都支持的:
某些账户不可能为负数
一个transaction应该balance。
不常见的:
产品的平均卖出价格(比如某个upc,而不是单一的sn)应该高于买入价格(这种可以detect要么做账搞错了,要么公司层面做了错误的投资)
我们是不是真的卖了100台电脑?
这些东西可以帮助会计查账,之后会计封账,保证了账的正确。之后进行下一轮的记录。
作为普通只是买卖股票的来说的确离谱。因为个人就那么点小操作应该有能力自己把账做对。
对于公司(都不用多大,两个人在记账就行),或者很多不确定的记录,复杂度是指数级别增加的。因为各各小错误会叠加(可能都不是自己记录的),导致要花很久,到处查账才能找到究竟问题出在哪里。
完美的系统应该从逻辑级别入手,首先规定了哪些是可行的operation,之后可以跑compiler输出一个consistent的一系列transaction。或者找出inconsistency。
但这个不简单,甚至我在想这个问题的时候想到了一个子问题,我都不知道是否有多项式时间算法:
有几个银行账户互相之间的转账记录(假设钱都是瞬间到),但是转账时间都去掉了。你知道这些账户初始有多少钱,求是否可以把这些转账记录排序一下,使得任何时候任意账户里的钱都是非负的。
我觉得beancount的优势在于定义了格式标准,方便多方交流。使用数据库在封闭系统内部没有问题,功能更强大,效率高、速度快,但与外界没有一个统一的接口。如果以后各个银行、券商都能支持一种统一的格式(比如beancount)就好了。
【引用自 Chao】:
比如我买了一堆股票给账户A。之后某天我transfer了账户A到账户B。理论上所有的cost basis也要transfer过去。
用 {} syntax 可以区分和转移每一个 slot 啊。
beancount.github.io
Beancount Language Syntax - Beancount Documentation
【引用自 Chao】:
亲自手动(或者写脚本)把这个transfer完成。但如果之后某天你要改账户A的cost basis呢?账户B的这边又不会自动的更改。
【引用自 Chao】:
因为输入的时候,东西就非常的正确。
我的经验是,不要用脚本做自动的 transactions。用脚本 validate 手写的 transactions。
【引用自 Chao】:
真的理解了beancount内部构造,会发现完全可以存SQL数据库呀。
我觉得最大的好处应该是版本控制和文档管理。我用 plugin 对于一些重要的 transaction link 一些 statement,confirmation之类的PDF文件。Git LFS都已经有1G文件了。
【引用自 Chao】:
structured data就不要用什么plaintext吧。
【引用自 Chao】:
一直有兴趣做个好一点的accounting+inventory系统
数据量和plugin一多,beancount的速度太慢了。应该用rust/c/c++重写兼容现有syntax的系统,但是现有plugin的API也有问题,估计都不能用了。
【引用自 jefferyz】:
要不要把fava部署在公网上。但肯定要想个办法做密码保护
不是都用 cloudflared 了吗?用 Cloudflare Access + Google SSO 吧。
Cloudflare Docs
You can integrate Google authentication with Cloudflare Access without a Google Workspace account. The integration allows any user with a Google account to log in (if the Access policy allows them to reach the resource). Unlike the instructions for...
image743×643 17.6 KB
【引用自 lijunle】:
应该用rust/c/c++重写兼容现有syntax的系统
他已经在重写了
好像paused了吧。
As of summer 2020, a rewrite to C++ is underway. These are the documents related to that.
beancount.github.io
Beancount Documentation
Auto-generated markdown version
他plugin那个API signature就意味着数据不能只go through一次,performance必然上不来的。
bean-query问题, 如何抓取全部同时包含account A和account B的transactions,然后针对account A做总结
underway 是啥意思
翻译告诉我 正在进行中
Youshen大佬太厉害了,学的已经比我多了。我还在手动扣数据,
还没学会query
我也只是弄了些比较重要的,想方便看看最近的记录,很多繁琐的账户还没搞
【引用自 Youshen】:
想方便看看最近的记录
我也在想这个问题
昨天刚记录完2021的数据、在想是不是先用pad跳过22、23
把24先记上,可以开始看最近的数据
2020年 underway,但2024年都没有完成的话……
大胆猜测就是烂尾了
Edit: 我没有贬低原作者的意思,这refactor难度很大,perf和backward compatibility不可兼得。烂尾是太正常了。
如何记录一笔在Hyatt酒店买的$4快乐水呢
IMG_68931020×400 67.8 KB
(应该还要加上Q1 3n 3k bonus)
chase下载的QFX好像有问题
会出现重复的ofx_fitid
仔细看了一下,是这两笔交易的reference num就是一样的
这也太奇怪了
USD 4能赚这么多点数?怎么这么值啊。
这还真不算多
看看这个
image1010×301 35.8 KB
想再请教一下怎么找token呀
进入 MR 历史页面选好想看的卡,Inspect Element > Network 里面搜索 “ReadLoyaltyTransactions”,点进 payload 里面就可以看到对应的 token
感谢感谢!是clientCacheRevision后面的吗
不是,Payload 里面显示出来的形状类似于这里
{
'accountToken': 'xxx',
'productType': 'AEXP_CARD_ACCOUNT',
'offset': 0,
'limit': 30,
'transactionsFor': 'LOYALTY_ACCOUNT',
'startDate': '2023-11-01',
'endDate': '2023-11-30',
}
原来那段代码可以在 js console 里面跑,需要登陆 cookie,自己在外面调用的话就得解决登陆问题了
感谢!成功导出了!
最多两年的数据,对我来说只缺失了半个月的数据了!
现在头疼的就是怎么自动导入这些交易了
我等着抄这个importer作业了
感觉需要跟transaction的ofx merge一次
求抄MR importer 我实在是对不上MR每个月都在pad……
我觉得MR对账复杂是因为offer的过几天就入账,但消费的会一直pending到交minimum pay
这个也让我头痛
我现在的想法是先跑一个python/js把json里的数据全部转化成Beancount格式,因为里面有交易信息、金额、categories、点数、交易id(和ofx一致)、状态
所以本质上可以用json里的内容生成与beancount-importer的ofx导入兼容的Beancount Journals
然后再通过beancount-importer检查从银行下载的ofx,补足那些没有点数的交易
有个问题咨询一下各位大神
该怎么记录预付机票/酒店呢?
对于其它的预付我都有一个assets,例如assets:prepaid:gas
但我不知道要不要把已经定好的里程票记录在assets里面,这样会让balance sheet看起来有点怪怪,毕竟这部分点已经“不可用”了
遇事不决Equity
近日又尝试开始折腾。
第一步,用 marker 把 statement PDF 转为 Markdown。这个marker连US Bank那种transaction table乱七八糟的都能整理得干干净净,非常amazing!
from marker.converters.pdf import PdfConverter
from marker.models import create_model_dict
from marker.output import text_from_rendered
converter = PdfConverter(
artifact_dict=create_model_dict(),
)
rendered = converter(file_path)
text, _, images = text_from_rendered(rendered)
print(text)
第二步,用 Instructor 把非结构化的 Markdown 转为结构化的 JSON。
Statement class 定义如下。银行相关的 account name 会从文件里面读出,动态生成一个Enum作为validator。Closing date,account和balance可以直接生成balance directive。Transactions需要再做处理生成transaction directives。这一步现在调用OpenAI的API,尝试过用local llm做,不给OpenAI喂个人数据,但是速度和结果还是差太多了。
def get_accounts() -> list[str]:
beancount_file_path = "/path/to/my/accounts.beancount"
accounts_file = open(beancount_file_path, "r", encoding="utf-8")
accounts_lines = accounts_file.readlines()
accounts = []
for line in accounts_lines:
parts = line.strip().split(" ")
if (
len(parts) >= 4
and (
parts[2].startswith("Assets:Bank:")
or parts[2].startswith("Liabilities:CreditCard:")
or parts[2].startswith("Liabilities:Loan:")
)
):
accounts.append(parts[2])
return accounts
accounts = get_accounts()
Account = Enum("Account", ((a, a) for a in accounts), type=str)
class Transaction(BaseModel):
date: datetime = Field(description="Post date")
description: str = Field(description="Human readable transaction description")
amount: float = Field(description="Transaction amount")
is_credit: bool = Field(description="Is this a credit, e.g., payment or refund")
class BankStatement(BaseModel):
closing_date: datetime = Field(description="Statement closing date")
account: Account = Field(
description="Account name determined by bank, card and owner"
)
balance: float = Field(description="New balance after this statement")
is_credit: bool = Field(description="Is balance a credit, e.g., negative balance")
transactions: List[Transaction] = Field(
description="List of transactions in this statement"
)
openai_api_key = "sk-xxxxx"
client = instructor.from_openai(OpenAI(api_key=openai_api_key))
def extract(content: str) -> BankStatement:
statement = client.chat.completions.create(
model="gpt-4o-mini",
response_model=BankStatement,
messages=[
{
"role": "user",
"content": content,
}
],
)
return statement
现在就做到这一步为止,整体结果可以说非常的好!但是,这里的transaction想要生成Beancount的transaction,还缺乏payee和posting account。后面的想法是对以前的transactions做embedding,然后新的transaction去模糊查询以前的transaction,匹配出payee和posting account。一个transaction拥有多个posting的情况估计还是需要手动处理。
最后一步是要把生成的directives放在合适的文件。每个人都有自己的beancount文件划分方式,这里估计会重复利用上一步的embedding数据库,猜出应该存放的文件路径。最后需要工具放在文件的合适位置(例如文件内部使用时间排序)。
大伙有什么想法?
太牛了
我最近也在思考这个事情,我认为bean importer其实是一个高度定制化的东西
每个人对导入器的需求都不一样
就像我可能希望导入器可以自动生成对应的返点
然后我可能比较喜欢ofx导入,去重很方便
【引用自 jefferyz】:
ofx导入
我还没有试过OFX哦。提供这个文件的银行多吗?它本身就是结构化数据吧?
【引用自 jefferyz】:
可以自动生成对应的返点
这个目测很复杂。Groceries在不同银行还有不同定义,像UAR这种Apple Pay好像在 statement 在看不到具体信息吧。
【引用自 lijunle】:
提供这个文件的银行多吗?
只有大银行提供
【引用自 lijunle】:
它本身就是结构化数据吧?
是的,主要是会有一个ofx 交易 uuid,唯一的,查重很方便
【引用自 lijunle】:
Groceries在不同银行还有不同定义
我目前的想法是根据不同的银行/Account写一张不同的lookup table
但我还真不知道Groceries在不同银行还有不同定义?不都是一样的吗?
【引用自 lijunle】:
像UAR这种Apple Pay好像在 statement 在看不到具体信息吧
这些可能就要手动adjust一下了,或者默认UAR的消费都是Apple Pay,自动apply apple pay的points,然后少数非apple pay手动调整一下
其实我个人的想法是大部分的日常消费商户其实是比较固定的,如果能总结出lookup table,对于这部分经常重复交易的商户就很省事了。然后每次一旦有新的商户交易,再添加一条到DB就行了,不太需要用到LLM的
关键词->payee->Category
For each Credit Card account:
Category->CashBackRate->自动生成完整的一条记录
【引用自 jefferyz】:
Groceries在不同银行还有不同定义?
最典型的是Walmart吧。而Walmart又分Walmart Neighborhood Market和Walmart Supercemter。Neighborhood 那个有的时候有的地区会被算 groceries store的。
【引用自 jefferyz】:
主要是会有一个ofx 交易 uuid,唯一的
Wow,我在看chroma,他在添加记录的时候需要一个id。我才想起来beancount directive全程没有id这个概念,只能用index弱化表示。
你在导入之后,OFX的uuid会作为tag或者note挂在transaction下面吗?
【引用自 jefferyz】:
lookup table
Lookup table或者正则的最大的问题是,corner case处理不完,大量重复的人工工作,整体又不值得写code处理每一个小的corner case。我感觉积分这个也可以尝试用embedding+query history data的方式去先生成一遍,然后再修正。毕竟同一个店,同一种MS都是重复发生的,人工处理了第一遍,让LLM处理第二第三遍。
最后,积分记录会和 statement 上的数字 cross validate 一下吗?我的 transaction 上的信用卡的支出会最后匹配上 statement balance,以确保没有漏了transaction。积分记录多了少了好像也很难查出来?
【引用自 lijunle】:
最典型的是Walmart吧
bos没有Walmart,烦恼少一半
【引用自 lijunle】:
OFX的uuid会作为tag或者note挂在transaction下面吗
是的,会作为meta data
【引用自 lijunle】:
尝试用embedding+query history data的方式去先生成一遍
嗯嗯,也是个方案,可以试试
【引用自 lijunle】:
积分记录会和 statement 上的数字 cross validate 一下吗?
差不多每个季度或者半年check一次吧,然后稍微抹平一下diff。 有些statement上会有点数summary
而且amex是有api可以导出点数记录的
我最近用llm导入 手动纠错一张卡的话15-30min 12个月的
这个速度很快了,基于ofx还是pdf?
直接pdf给llm 输出后自己人眼校对 最终跟我银行自己的差3刀多
其实人眼校对看数字和加tag都挺必要的… 我手动加了些重要交易或者旅游之类的tag
问下 beancount 的前端 fava 有办法自定义 column 的顺序吗?
之前是按照 currency 的定义顺序来的,后来某个版本更新了以后变成了按首字母……
USD 就排到很后面,不是很好找,谢谢。
有点东西,用的哪个llm?反正我试过让chatgpt OCR 不好用
就chatgpt的4.5啊
可能我那个时候还是3的时代吧
大佬们,你们是支出的时候手动记账,然后账单出来把记录的支出和账单对账吗?
我的方案是依赖账单,然后用备忘录记录一下现金交易
我最近看到一个介绍备忘录+AI的视频,感觉可以结合一下。
依赖账单真的不会拖一年没整理吗…比如我
会 ,我现在也是
下个月应该总算忙完一段时间,再搞搞上面的方案
【引用自 jefferyz】:
amex是有api可以导出点数记录的
具体怎么搞啊
我发现 https://global.americanexpress.com/rewards/summary 可以导出CSV,但是每次只让选30天,感觉太少了
edit:上面有
【引用自 未知】:
Beancount 技术交流贴 玩卡
Amex 看 MR 历史 的页面用的是这个 API:
https://functions.americanexpress.com/ReadLoyaltyTransactions.v1
从它的 request payload 里面找到卡对应的 accountToken(这个 token 是 SSR 注入进网页的,没找到方便的方法自动扣出来)之后,就可以这样
let res = await fe…
自己发个request可以下载任意时间区间的(最早是两年前而不是Amex网页显示的一年前),赞!
(不过联名卡好像不行?)
【引用自 Trey】:
主流银行没有不提供csv的
BoA现在好像只提供pdf了?
如果有人正好写了BoA的parser的话伸个手,没有的话我过几天自己写一个
【引用自 liver】:
(不过联名卡好像不行?)
嗯,只有MR
想要
我的19年pdf终于有救了
感觉
【引用自 Dyyd】:
等我整理下弄个GitHub
挺容易烂尾的一个原因是 GitHub 一般是实名的,虽然能赚点 stars 但稍有不慎就把自己盒了
所以我直接发在这吧
第一步:
【引用自 jzj】:
我用了Tabula来把pdf里的表格提取出来变成csv。
具体来说我用了tabula-py,安装教程见 Getting Started — tabula-py documentation
import os
from tabula import convert_into
def convert_folder(folder):
for filename in os.listdir(folder):
if filename.endswith(".pdf"):
convert_into(
folder + filename,
folder + filename[:-4] + ".csv",
output_format="csv",
pages="all",
)
convert_folder("original-data/cc/boa/")
第二步:用 Importer 导入
import csv
import os
import re
from decimal import Decimal
from beangulp.importers import csvbase
from dateutil.parser import parse
from beancount.core import data, flags
from importers.utils import *
class BoAImporter(csvbase.Importer):
def __init__(self):
super().__init__(account="Liabilities:CC:BoA:Unknown", currency="USD")
def identify(self, filepath):
pattern = re.compile(r"^\d{2}/\d{2}/\d{2}$") # a date
with open(filepath, "r") as f:
for row in csv.reader(f):
if pattern.fullmatch(row[0].split(" ", 1)[0]): # Date Description
# Date[,]Description,[,]Location,Amount
# Just check the first row now
return 4 <= len(row) <= 5
return False
def account(self, filepath):
card_name = os.path.basename(filepath).split(".")[0]
self.importer_account = f"Liabilities:CC:BoA:{card_name}"
return self.importer_account
def extract(self, filepath, existing):
entries = []
account = self.account(filepath)
expense_account = "Expenses:Misc"
pattern = re.compile(r"^\d{2}/\d{2}/\d{2}$") # a date
with open(filepath, "r") as f:
for lineno, row in enumerate(csv.reader(f)):
if len(row) == 5:
# Sometimes it's parsed like:
# Date,Descri,ption,Location,Amount
# or:
# Date,Description,Location,Amount
# Normalize to
# Date Description,,Location,Amount
row[0] = row[0] + " " + row[1] + row[2]
elif len(row) == 4 and len(row[1]) > 0:
row[0] = row[0] + " " + row[1]
date_and_description = row[0].split(" ", 1)
if not pattern.fullmatch(date_and_description[0]):
if row[0].startswith("Cash Transactions"):
expense_account = "Expenses:Misc"
elif row[0].startswith("Dining"):
expense_account = "Expenses:Food:Dining"
elif row[0].startswith("Recreation"):
expense_account = "Expenses:Entertainment:Events"
elif row[0].startswith("Food Store"):
expense_account = "Expenses:Food:Groceries"
elif row[0].startswith("Department Store"):
expense_account = "Expenses:Shopping"
elif row[0].startswith("Electronics"):
expense_account = "Expenses:Shopping"
elif row[0].startswith("Other Stores/Retail"):
expense_account = "Expenses:Shopping"
elif row[0].startswith("Services"):
expense_account = "Expenses:Misc"
elif row[0].startswith("Other Travel/Transportation"):
expense_account = "Expenses:Travel:Misc"
elif row[0].startswith("Airline"):
expense_account = "Expenses:Travel:Airline"
elif row[0].startswith("Hotels"):
expense_account = "Expenses:Travel:Hotel"
elif row[0].startswith("Health Care"):
expense_account = "Expenses:Medical"
elif row[0].startswith("Utilities"):
expense_account = "Expenses:Utilities:Misc"
elif row[0].startswith("Education"):
expense_account = "Expenses:Misc"
continue
try:
date = parse(date_and_description[0]).date()
payee = date_and_description[1]
narration = ""
tags = set()
links = set()
currency = "USD"
units_raw = row[-1].replace(",", "")
if units_raw.endswith("CR"):
units = data.Amount(
Decimal(units_raw[:-2]), currency
) # positive value for credit
else:
units = data.Amount(
-Decimal(units_raw), currency
) # negative value for liabilities
# Create a transaction.
txn = data.Transaction(
self.metadata(filepath, lineno, row),
date,
flags.FLAG_OKAY,
payee,
narration,
tags,
links,
[],
)
txn.postings.append(
data.Posting(account, units, None, None, None, None)
)
txn.postings.append(
data.Posting(expense_account, -units, None, None, None, None)
)
# Apply user processing to the transaction.
txn = self.finalize(txn, row)
entries.append(txn)
except Exception as ex:
# Report input file location of processing errors. This could
# use Exception.add_note() instead, but this is available only
# with Python 3.11 and later.
raise RuntimeError(
f"Error processing {filepath} line {lineno + 1} with values {row!r}"
) from ex
return entries
Known issue: Tabula 可能会忽略一个 category 只有少数几条 transaction 的情况,也即没有识别出这部分是一个 tabular,以为是 plain text 于是忽略了。所以需要手动检查一下比较小的 category 以及 PDF 每页最上面、最下面有没有漏的。
tbh,我一直没学会bean官方的Importer咋用
之前都是用的那个有webui的importer
正好用这个例子学习一下
有大佬用来记录股票交易日志吗?没有什么思路,来请教一下
(可能理解有误,敬请指正)
不想 已经不能用 的事物在Balance Sheet显示为Asset
直接Liabilities:CreditCard, Expenses:Travel如何?
这感觉比较像 Accrual Accounting vs Cash Basis Accounting的问题
Accrual懒一点就都用Assets:AccountsReceivable, Liabilities:AccountsPayable完事
在登机前记为prepaid,之后从prepaid改为expense,
Assets:Prepaid:Travel:Flight 50000 mr
Assets:Points:Airline:JAL -50000 mr
Expenses:Travel:Flight 50000 mr
Assets:Prepaid:Travel:Flight -50000 mr
我用过这个
github.com/jbms/beancount-import
beancount_import/source/schwab_csv.py
master
"""Schwab.com brokerage transaction source.
Imports transactions from Schwab.com brokerage/banking history CSV files.
To use, first you have to download Schwab CSV data into a directory on your filesystem. If
you have a structure like this:
financial/
data/
schwab/
transactions/
positions/
And you download your transaction history CSV into `transactions/` and your positions
statement CSV into `positions/`, then you could specify your beancount-import source like
this:
dict(module="beancount_import.source.schwab_csv",
transaction_csv_filenames=glob.glob("data/schwab/transactions/*.csv"),
position_csv_filenames=glob.glob("data/schwab/positions/*.csv"),
此文件已被截断。 显示原始文件
然后让copilot照着写了一个robinhood的,但问题挺多的就不分享了。。
首先感谢一下 @Eric23 的 BoA PDF to CSV script,解决了 Tabula 的这一问题:
【引用自 liver】:
Tabula 可能会忽略一个 category 只有少数几条 transaction 的情况
【引用自 未知】:
Beancount入门喂饭贴 玩卡
Beancount 是什么?来自chatgpt的解释
Beancount 是一个基于纯文本的复式记账工具,适合个人或家庭进行系统化的财务管理。它由 Python 实现,是完全开源的,核心理念是通过记账提升对财务状况的认知,实现财务自由。
特点
最近一直在研究数据迁移,最开始用mint,之后开始用copilot,现在想把所有数据迁移到beancount然后可以放在nas的dock…
但我发现这个script也有几个问题,于是修了一下。由于我是先回的这个楼所以决定还是在这个楼里更新了……
修复的问题有:
退款格式是1,234.56CR,匹配不上;
有几个类别名字带/,比如Other Travel/Transportation,会匹配不上;
如果一年某个类别是净退款,多一个负号会导致匹配不上;
有几个特定大类左侧margin会固定多出一段话(Health,Education,Travel and Transportation),其中Travel不用管,因为这段话只有两行,会被蓝字部分吃掉,但是另外两类有三行,所以大类下属的第一个小类前面就多了一个前缀。这两类第三行分别是www.irs.gov 和457" at www.irs.gov ,所以可以用一个optional group把它们过滤掉。
#!/usr/bin/env python3
"""
PDF → CSV 适配截图中版式
输出列: Date,Description,Amount,Category
"""
import re
from pathlib import Path
import pandas as pd
import pdfplumber
from tqdm import tqdm
PDF_DIR = Path("original-data/cc/boa") # ← 改为你的目录
OUT_CSV = PDF_DIR / "boa_cc_all.csv"
# ---------- 正则 ----------
DATE_RE = re.compile(r"^\d{1,2}/\d{1,2}/\d{2}$") # 10/13/22
AMT_RE = re.compile(r"^\d[\d,]*\.\d{2}(CR)?$") # 36.97 或 1,234.56CR
CAT_RE = re.compile(
r'^(?:(?:457" at )?www\.irs\.gov )?([A-Z][A-Za-z&/ ]+)\s+-?\$\d[\d,]*\.\d{2}$'
)
# Other Travel/Transportation $1,234.56
# Hotels -$500.00
# www.irs.gov Health Care $25.00
# 457" at www.irs.gov Education $25.00
def infer_year(path: Path) -> int:
m = re.search(r"_(\d{4})-", path.stem)
return int(m.group(1)) if m else 1900
def parse_pdf(pdf_path: Path):
rows, yr = [], infer_year(pdf_path)
category = "UNCLASSIFIED"
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
for line in (page.extract_text() or "").splitlines():
line = line.strip()
if not line:
continue
# 1) 是否蓝色大类行
m_cat = CAT_RE.match(line)
if m_cat:
category = m_cat.group(1)
continue
parts = line.split()
if len(parts) < 3:
continue
date_tok, amount_tok = parts[0], parts[-1]
if not (DATE_RE.match(date_tok) and AMT_RE.match(amount_tok)):
continue
# ① 日期
if len(date_tok.split("/")[-1]) == 2: # 已经带 YY
date = date_tok # 10/13/22
else: # 只有 MM/DD
date = f"{date_tok}/{yr}" # 10/13/2023
# ② 描述 & 金额
desc = " ".join(parts[1:-1])
amt = amount_tok.replace(",", "")
if amt.endswith("CR"):
amt = "-" + amt[:-2] # Remove 'CR', prepend '-'
rows.append([date, desc, amt, category])
return rows
def batch():
all_rows = []
for pdf in tqdm(sorted(PDF_DIR.glob("*.pdf")), desc="Parsing"):
all_rows.extend(parse_pdf(pdf))
if not all_rows:
print("⚠️ 没抓到交易。若 PDF 是扫描件请先 OCR;否则把 probe 输出给我再调。")
return
pd.DataFrame(
all_rows, columns=["Date", "Description", "Amount", "Category"]
).to_csv(OUT_CSV, index=False)
print(f"✅ CSV saved → {OUT_CSV}")
if __name__ == "__main__":
batch()
【引用自 Eric23】:
有大佬用来记录股票交易日志吗?
就普通地记录买入卖出啊,股票交易是标准模版了。
Fidleity的导入可以参考我这个,但是还是很多corner case,我删除了一些涉及个人隐私的case。
fidelity_stocks.py
#!/usr/bin/env python
import csv
import locale
import sys
from datetime import datetime, timedelta
from os import path
from typing import List, NamedTuple, Optional
locale.setlocale(locale.LC_ALL, "en_US.UTF-8")
SPECIAL_SYMBOL_LOOKUP = {
"063679872": "FNGU",
"06746P621": "VXX",
"156700106": "LUMN",
"25459W771": "YINN",
"69318FAG3": "BOND-69318FAG3",
"69352JAN7": "BOND-69352JAN7",
"74347W148": "UVXY",
"747301AC3": "BOND-747301AC3",
"83088V102": "WORK",
}
OPTION_ACTIONS = [
"OPENING",
"CLOSING",
]
class AccountName(NamedTuple):
payee: str
income: str
asset: str
class Transaction(NamedTuple):
accountName: AccountName
date: datetime
action: str
symbol: str
description: str
securityType: str
quantity: float
price: float
commission: float
fees: float
interest: float
amount: float
settlementDate: Optional[datetime]
currency: Optional[str] = None
exchangeRate: Optional[float] = None
@staticmethod
def parseRow(accountName, csvRow: List[str]):
values = [value.strip(" ") for value in csvRow]
date = datetime.strptime(values[0], "%m/%d/%Y")
action = values[1]
if action.startswith(
"DIVIDEND RECEIVED FIDELITY GOVERNMENT "
) or action.startswith("DIVIDEND RECEIVED FIDELITY TREASURY MONEY MARKET FUND"):
# Move the cash dividend to its purchase date to match statement.
date = date - timedelta(2)
if len(values) == 12:
txn = Transaction(
accountName=accountName,
date=date,
action=action,
symbol=Transaction._lookupSymbol(values[2]),
description=values[3],
securityType=values[4],
quantity=float(values[5]) if values[5] else 0,
price=float(values[6]) if values[6] else 0,
commission=float(values[7]) if values[7] else 0,
fees=float(values[8]) if values[8] else 0,
interest=float(values[9]) if values[9] else 0,
amount=float(values[10]) if values[10] else 0,
settlementDate=datetime.strptime(values[11], "%m/%d/%Y")
if values[11]
else None,
)
elif len(values) == 16:
txn = Transaction(
accountName=accountName,
date=date,
action=action,
symbol=Transaction._lookupSymbol(values[2]),
description=values[3],
securityType=values[4],
quantity=float(values[7]) if values[7] else 0,
currency=values[8],
price=float(values[9]) if values[9] else 0,
exchangeRate=float(values[10]) if values[10] else None,
commission=float(values[11]) if values[11] else 0,
fees=float(values[12]) if values[12] else 0,
interest=float(values[13]) if values[13] else 0,
amount=float(values[14]) if values[14] else 0,
settlementDate=datetime.strptime(values[15], "%m/%d/%Y")
if values[15]
else None,
)
else:
raise Exception("Not recongize the schema format")
return txn
@staticmethod
def _lookupSymbol(symbol: str) -> str:
if symbol in SPECIAL_SYMBOL_LOOKUP:
return SPECIAL_SYMBOL_LOOKUP[symbol]
elif symbol.startswith("-"):
return symbol[1:]
elif len(symbol) == 1:
return f"STOCK-{symbol}"
else:
return symbol
def toBeancountFormat(self) -> str:
line1 = f'{self.date:%Y-%m-%d} * "{self.accountName.payee}" "{self.action}"'
if self.action.startswith("YOU BOUGHT ESPP### ") or self.action.startswith(
"ESPP### "
):
line2 = f" Assets:Investment:StockPurchaseProgram {self.amount:.3f} USD"
line3 = f' {self.accountName.asset} {self.quantity:.3f} {self.symbol} {"{}"} @@ {abs(self.amount):.3f} USD'
return f"{line1}\n{line2}\n{line3}\n"
elif (
(
self.action.startswith("YOU BOUGHT ")
and not self.action.startswith("YOU BOUGHT CLOSING ")
)
or self.action.startswith("YOU SOLD OPENING ")
or self.action.startswith("OPENING ")
or (self.action.startswith("-- ") and self.amount < 0)
):
line2 = f" {self.accountName.asset} {self.amount:.3f} USD"
line3 = f' {self.accountName.asset} {self.quantity:.3f} {self.symbol} {"{}"} @@ {abs(self.amount):.3f} USD'
return f"{line1}\n{line2}\n{line3}\n"
elif (
(
self.action.startswith("YOU SOLD ")
and not self.action.startswith("YOU SOLD OPENING ")
)
or self.action.startswith("YOU BOUGHT CLOSING ")
or self.action.startswith("CLOSING ")
or (self.action.startswith("-- ") and self.amount >= 0)
or self.action.startswith("IN LIEU OF FRX SHARE ")
):
line2 = f" {self.accountName.asset} {self.amount:.3f} USD"
line3 = f' {self.accountName.asset} {self.quantity:.3f} {self.symbol} {"{}"} @@ {abs(self.amount):.3f} USD'
line4 = f" {self.accountName.income}"
return f"{line1}\n{line2}\n{line3}\n{line4}\n"
elif self.action.startswith(
"DIVIDEND RECEIVED FIDELITY GOVERNMENT "
) or self.action.startswith(
"DIVIDEND RECEIVED FIDELITY TREASURY MONEY MARKET FUND"
):
line1 = f'{self.date:%Y-%m-%d} * "{self.accountName.payee}" "{self.action}"'
line2 = f' note-settlement-date: "{self.date + timedelta(2):%Y-%m-%d}"'
line3 = f" {self.accountName.asset} {self.amount:.3f} USD"
line4 = f" {self.accountName.income}"
return f"{line1}\n{line2}\n{line3}\n{line4}\n"
elif (
self.action.startswith("DIVIDEND ")
or self.action.startswith("INTEREST EARNED ")
or self.action.startswith("SHORT-TERM CAP GAIN ")
or self.action.startswith("LONG-TERM CAP GAIN ")
or self.action.startswith("ADJUSTMENT (CREDIT ADJUSTMENT) QUAL DIV ")
):
line2 = f" {self.accountName.asset} {self.amount:.3f} USD"
line3 = f" {self.accountName.income}"
return f"{line1}\n{line2}\n{line3}\n"
elif (
self.action.startswith(
"REINVESTMENT FIDELITY GOVERNMENT CASH RESERVES (FDRXX)"
)
or self.action.startswith(
"REINVESTMENT FIDELITY GOVERNMENT MONEY MARKET (SPAXX)"
)
or self.action.startswith(
"REINVESTMENT FIDELITY TREASURY MONEY MARKET FUND"
)
or self.action.startswith(
"EXCHANGED TO SPAXX FIDELITY GOVERNMENT MONEY MARKET (SPAXX)"
)
or self.action.startswith(
"EXCHANGED TO FZFXX FIDELITY TREASURY MONEY MARKET FUND (FZFXX)"
)
or self.action.startswith(
"EXCHANGED TO FDRXX FIDELITY GOVERNMENT CASH RESERVES (FDRXX)"
)
or self.action.startswith("PURCHASE INTO CORE ACCOUNT ")
or self.action.startswith(
"REDEMPTION FROM CORE ACCOUNT FIDELITY TREASURY MONEY MARKET FUND (FZFXX)"
)
or self.action.startswith(
"REDEMPTION FROM CORE ACCOUNT FIDELITY GOVERNMENT CASH RESERVES (FDRXX)"
)
or self.action.startswith("REINVESTMENT CASH ")
):
return ""
elif self.action.startswith("REINVESTMENT "):
line2 = f" {self.accountName.asset} {self.amount:.3f} USD"
line3 = f' {self.accountName.asset} {self.quantity:.3f} {self.symbol} {"{}"} @@ {-self.amount:.3f} USD'
return f"{line1}\n{line2}\n{line3}\n"
elif self.action.startswith("FEE CHARGED ") or self.action.startswith(
"MARGIN INTEREST "
):
line2 = f" {self.accountName.asset} {self.amount:.3f} USD"
line3 = f" {self.accountName.income}"
return f"{line1}\n{line2}\n{line3}\n"
elif self.action.startswith("FOREIGN TAX PAID ") or self.action.startswith(
"ADJ FOREIGN TAX PAID "
):
line2 = f" {self.accountName.asset} {self.amount:.3f} USD"
line3 = " Expenses:Tax:Foreign:Investment"
return f"{line1}\n{line2}\n{line3}\n"
elif self.action.startswith("ASSIGNED ") or self.action.startswith(
"EXERCISED "
):
line2 = f' {self.accountName.asset} {self.quantity:.3f} {self.symbol} {"{}"} @@ 0.000 USD'
line3 = f" {self.accountName.income}"
return f"{line1}\n{line2}\n{line3}\n"
elif self.action.startswith("EXPIRED "):
line2 = f' {self.accountName.asset} {self.quantity:.3f} {self.symbol} {"{}"} @@ {-self.amount:.3f} USD'
line3 = f" {self.accountName.income}"
return f"{line1}\n{line2}\n{line3}\n"
elif (
self.action.startswith("DISTRIBUTION ")
or self.action.startswith("NORMAL DISTRIBUTION ")
or self.action.startswith("PARTIAL DISTRIBUTION ")
):
return f"; {self.date:%Y-%m-%d} DISTRIBUTION {self.description} @ {self.quantity} {self.symbol}\n"
elif self.action.startswith("ROLLOVER "):
return f"; {self.date:%Y-%m-%d} {self.action} {self.quantity} {self.symbol} @ {self.amount}\n"
elif (
self.action.startswith("CONV TO ROTH IRA ")
or self.action.startswith("CONV. TO ROTH IRA ")
or self.action.startswith("ROTH CONVERSION ")
):
return f"; {self.date:%Y-%m-%d} CONVERT TO ROTH IRA {self.description} @ {self.quantity} {self.symbol}\n"
elif self.action.startswith("CASH CONTRIBUTION "):
return f"; {self.date:%Y-%m-%d} CASH CONTRIBUTION @ {self.amount:.3f} USD\n"
elif self.action.startswith("TOTAL CY RECHAR ") or self.action.startswith(
"PARTIAL CY RECHAR "
):
return f"; {self.date:%Y-%m-%d} RECHARACTERIZE {self.description} @ {self.quantity} {self.symbol}\n"
elif (
self.action.startswith("Electronic Funds Transfer ")
or self.action.startswith("DIRECT DEPOSIT ")
or self.action.startswith("DIRECT DEBIT ")
or self.action.startswith("TRANSFERRED FROM ")
or self.action.startswith("TRANSFERRED TO ")
or self.action.startswith("JOURNALED JNL ")
or self.action.startswith("JOURNALED SPP ")
or self.action.startswith("JOURNALED VS ")
or self.action.startswith("JNL ")
or self.action.startswith("SPP ")
or self.action.startswith("SHORT VS MARGIN MARK TO MARKET ")
):
return ""
elif self.action.startswith("JOURNALED PROMO OFFER "):
line2 = f" {self.accountName.asset} {self.amount:.3f} USD"
line3 = " Income:Bank:Bonus:Fidelity"
return f"{line1}\n{line2}\n{line3}\n"
else:
raise Exception(f"Not support action in transaction:\n{self}")
def __lt__(self, other):
assert isinstance(other, Transaction)
if self.date != other.date:
return self.date < other.date
elif self.symbol != other.symbol:
return self.symbol < other.symbol
else:
aOptionAction = self.action.split(" ")[2]
bOptionAction = other.action.split(" ")[2]
if aOptionAction in OPTION_ACTIONS and bOptionAction in OPTION_ACTIONS:
return aOptionAction > bOptionAction
else:
return self.action < other.action
def main():
inputFile = sys.argv[1]
if not inputFile:
raise Exception("Provide the Fidelity CSV file as argument")
accountName = _find_account_name(inputFile)
with open(inputFile, "r") as input:
content = [line for line in input.readlines() if line.startswith(" ")]
reader = csv.reader(content)
txns = [
Transaction.parseRow(accountName, row)
for row in reader
if row[1].strip(" ")
]
txns.sort()
outputFile = path.splitext(inputFile)[0] + ".beancount"
with open(outputFile, "w") as output:
for txn in txns:
beancount = txn.toBeancountFormat()
if beancount != "":
output.write(beancount + "\n")
def _find_account_name(inputFile) -> AccountName:
filename = path.basename(inputFile)
if filename.startswith("401k") or "Account_12345678" in filename:
return AccountName(
payee="Fidelity 401k",
income="Income:Trade:401k",
asset="Assets:Investment:401k:PreTax",
)
elif "Account_23456789" in filename:
return AccountName(
payee="Fidelity Stock",
income="Income:Trade:Fidelity",
asset="Assets:Investment:BonusAccount",
)
else:
raise Exception(f'Not support account for file "{filename}"')
if __name__ == "__main__":
main()
Fidelity 401k BrokageLink 的CSV有另外一个格式,可以参考下面的导入代码。
fidelity_401k.py
#!/usr/bin/env python
import csv
import locale
import sys
from datetime import datetime
from os import path
from typing import NamedTuple
locale.setlocale(locale.LC_ALL, "en_US.UTF-8")
STOCK_LOOKUP = {
"ARTISAN MID CAP": "ARTMX",
"BROKERAGELINK": "BROKERAGELINK",
"BTC LP IDX 2020 N": "LIMKX",
"BTC LPATH IDX 2030 N": "LINIX",
"BTC LPATH IDX 2040 N": "LIKIX",
"BTC LPATH IDX 2050 N": "LIPIX",
"BTC LPATH IDX 2060 N": "LIZKX",
"BTC LPATH IDX RET N": "LIRIX",
"BTC SHRT-TERM INV": "MDLMX",
"DFA SM/MD CAP VAL": "DFSVX",
"FID CONTRA POOL CL 3": "FCNTX",
"FID GR CO POOL CL 3": "FDGRX",
"INTL GROWTH ACCOUNT": "FIGFX",
"INTL VALUE ACCOUNT": "FIVLX",
"PIM ALL A ALL AUTH I": "PAUIX",
"PIM INFL RESP MA IS": "PIRMX",
"PIMCO TOTAL RETURN": "PTTRX",
"VAN IS S&P500 IDX TR": "VFINX",
"VANG RUS 1000 GR TR": "VRGWX",
"VANG RUS 1000 VAL TR": "VRVIX",
"VANG RUS 2000 GR TR": "VRTGX",
"VANG ST BD IDX IS PL": "VBIPX",
}
class Transaction(NamedTuple):
date: datetime
stockName: str
action: str
amount: float
shares: float
symbol: str
@staticmethod
def parseRow(csvRow):
if csvRow[1] not in STOCK_LOOKUP:
raise Exception(f'Cannot lookup symbol for "{csvRow[1]}"')
txn = Transaction(
datetime.strptime(csvRow[0], "%m/%d/%Y"),
csvRow[1],
csvRow[2],
float(csvRow[3]),
float(csvRow[4]),
STOCK_LOOKUP[csvRow[1]],
)
return txn
def toBeancountFormat(self) -> str:
if self.stockName == "BROKERAGELINK":
return f"; {self.date:%Y-%m-%d} {self.action} to {self.stockName} @@ {self.amount:.3f} USD\n"
line1 = (
f'{self.date:%Y-%m-%d} * "Fidelity 401k" "{self.action} - {self.stockName}"'
)
if self.action in ["Contributions", "Exchange In"]:
account = "PreTax" if self.date.month < 6 else "AfterTax"
line2 = f" Assets:Investment:401k:{account} {-self.amount:.3f} USD"
line3 = f' Assets:Investment:401k:{account} {self.shares:.3f} {self.symbol} {"{}"} @@ {self.amount:.3f} USD'
return f"{line1}\n{line2}\n{line3}\n"
elif self.action == "Exchange Out":
line2 = f" Assets:Investment:401k:PreTax {-self.amount:.3f} USD"
line3 = f' Assets:Investment:401k:PreTax {self.shares:.3f} {self.symbol} {"{}"} @@ {-self.amount:.3f} USD'
line4 = " Income:Trade:401k"
return f"{line1}\n{line2}\n{line3}\n{line4}\n"
elif self.action == "Dividends":
line2 = f" Income:Trade:401k {-self.amount:.3f} USD"
line3 = f' Assets:Investment:401k:PreTax {self.shares:.3f} {self.symbol} {"{}"} @@ {self.amount:.3f} USD'
return f"{line1}\n{line2}\n{line3}\n"
elif self.action == "Withdrawals":
line2 = f' Assets:Investment:401k:AfterTax {self.shares:.3f} {self.symbol} {"{}"} @@ {-self.amount:.3f} USD'
line3 = f" Assets:Investment:IRA {-self.amount:.3f} USD"
line4 = " Income:Trade:401k"
return f"{line1}\n{line2}\n{line3}\n{line4}\n"
elif self.action == "Change on Market Value" or self.action == "Transfers":
return ""
else:
raise Exception(f'Not support action: "{self.action}"')
def __lt__(self, other):
return self.date < other.date
def main():
inputFile = sys.argv[1]
if not inputFile:
raise Exception("Provide the Fidelity CSV file as argument")
with open(inputFile, "r") as input:
reader = csv.reader(input)
txns = [Transaction.parseRow(row) for row in reader if row[0] != "Date"]
txns.sort()
outputFile = path.splitext(inputFile)[0] + ".beancount"
with open(outputFile, "w") as output:
for txn in txns:
beancount = txn.toBeancountFormat()
if beancount != "":
output.write(beancount + "\n")
if __name__ == "__main__":
main()
有个更接近会计学的问题,我不知道怎么组织语言比较好,用chatgpt写了一下:
大家好,我最近在用 Beancount 记账,有个关于税务的问题想请教一下大家。
我在每个月的工资单上故意把预扣税设得比较低,这样每个月到手的钱比较多。但是到了次年报税时,我需要一次性补交一大笔联邦税给 IRS。
从记账角度来说,这个“补税”发生在 2026 年 4 月,但其实是因为我 2025 年的收入导致的,对吧?我感觉这样一笔记为 2026 年的支出不太合理。
更进一步说,我觉得这笔税也不应该一次性记账,而应该在 2025 年的每个月工资里摊一摊。毕竟如果 IRS 每个月都预扣准确,我每个月税后收入就会比较“真实”。
不知道有没有人也有类似做法?有没有更好的建议?
2025-01-04 * "AciPayments" "Pay 1040-ES 2024" #tax
Expenses:Tax:2024:Federal:Federal 10000.00 USD
amortize_months: 12
amortize_start: "2024-01-15"
...
我是这样操作的
会计学+Beancount双萌新想问个情形:对于打折入gc之类的之后再花出去大家都是怎么记的?
Amazon, Target 这种就不说了。像Ubereat,DD那样东西有溢价靠打折gc比原价便宜的情形是怎么处理的呢?
我在Costoco花$75买了$100 Uber gc充了进去,多出来的25我是打算放一个类似Income:Incentive的下面的。然后比如说在Ubereat上点了个pizza花了$25 Uber gc(直接点原价$20),假如Expense直接记25的话累加起来会导致外食的总花费看上去虚高。还是应该一开始入账的时候就在Asset:Giftcard:Uber 只记成$75然后花费都是最后价格*0.75?
给GC个特别的currency(类似book股票),每次买的时候都有对应的cost basis,FIFO就行。
【引用自 wxy2792】:
还是应该一开始入账的时候就在Asset:Giftcard:Uber 只记成$75然后花费都是最后价格*0.75?
这样会很麻烦。建议前者。 你是买gc的时候就省了一个total 25。你可以记为income,或者作为expensive,记为负就行了。但这一笔都会记录为当月,也会有点问题。
楼上给个currency也行,UDSUEATS 这样。这样最准确。
嗯,amazon这种无溢价类似cash equivalent的我就是这么弄的 但就是头疼ubereat这种。晚上回去整理研究一下
25刀算income:gc. 溢价本来就是个虚拟定义,你可以把expense单独算在一个food:ubereat跟“正常”food 分开
大佬,能问下你是咋弄”amortize_start:“的?这个plugin只弄了month但我想像你这样自己定义从什么时候开始amortize,编程小白跟copilot捣鼓了好久还弄不出来
Copyright (c) 2017 Cary Kempston
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
from collections import namedtuple
from beancount.core.data import Account, Transaction, Entries, Posting
from beancount.core.amount import Amount
from datetime import date
from dateutil.relativedelta import relativedelta
plugins = (‘amortize_over’,)
AmortizationError = namedtuple(‘AmortizationError’, ‘source message entry’)
def amortize_over(entries : Entries, unused_options_map, amortize_account=“Assets:Prepaid-Expenses”):
“”“Repeat a transaction based on metadata.
Args:
entries: A list of directives. We’re interested only in the
Transaction instances.
unused_options_map: A parser options dict.
Returns:
A list of entries and a list of errors.
Example use:
This plugin will convert the following transactions
2017-06-01 * “Amortize car insurance over six months”
AssetsChecking -600.00 USD
Expenses:Insurance:Auto
amortize_months: 3
into the following transactions over six months:
2017-06-01 * Pay car insurance
AssetsChecking -600.00 USD
Assets:Prepaid-Expenses 600.00 USD
2017-06-01 * Amortize car insurance over six months
Assets:Prepaid-Expenses -200.00 USD
Expenses:Insurance:Auto 200.00 USD
2017-07-01 * Amortize car insurance over six months
Assets:Prepaid-Expenses -200.00 USD
Expenses:Insurance:Auto 200.00 USD
2017-08-01 * Amortize car insurance over six months
Assets:Prepaid-Expenses -200.00 USD
Expenses:Insurance:Auto 200.00 USD
“””
new_entries = []
errors = []
for entry in entries:
if isinstance(entry, Transaction):
for i, posting in enumerate(entry.postings):
if posting.meta is not None and "amortize_months" in posting.meta:
a_entires, a_errors = amortize_transaction(entry, posting, amortize_account)
new_entries.extend(a_entires)
errors.extend(a_errors)
# change posting to amotize_account
entry.postings[i] = posting._replace(account=amortize_account)
new_entries.append(entry)
return new_entries, errors
def split_amount(amount, periods):
if periods == 1:
return [amount]
amount_this_period = amount / periods
amount_this_period = amount_this_period.quantize(amount)
return [amount_this_period] + split_amount(amount - amount_this_period, periods - 1)
def amortize_transaction(entry, posting_to_amortize: Posting, amortize_account: str):
new_entries = []
errors = []
if sum(['amortize_months' in p.meta for p in entry.postings]) > 1:
error = AmortizationError(
entry.meta,
'Can only amortized one of the postings.',
entry
)
errors.append(error)
return new_entries, errors
periods = posting_to_amortize.meta['amortize_months']
start_date = entry.date
if 'amortize_start' in posting_to_amortize.meta:
start_date = date.fromisoformat(posting_to_amortize.meta.get('amortize_start'))
amount = abs(entry.postings[0].units.number)
currency = entry.postings[0].units.currency
monthly_amounts = split_amount(amount, periods)
for (n_month, monthly_number) in enumerate(monthly_amounts):
new_postings = []
# posting_to_amortize change amount
new_monthly_number = monthly_number
if posting_to_amortize.units.number < 0:
new_monthly_number = -monthly_number
amortized_posting = posting_to_amortize._replace(units=Amount(number=new_monthly_number,
currency=currency))
new_pos
随便改的
感谢!我再研究一下
【引用自 时空空】:
posting_to_amortize._replace(units=Amount(number=new_monthly_number,
currency=currency))
new_pos
最后这个new_pos看上去像被截掉了?
你找原本插件那个对比一下吧
我也是照着那个改的
有大佬用fidelity csv导beancount记录持仓吗?我发现卖出的时候没给成本,不知道怎么用FIFO,求大佬给个code让我学学
不手动选的话,给 Fidelity account 的 open entry 加 FIFO 就可以了
我尝试加了FIFO,如果我不指定lot的话,会把卖出的变成一个卖空的持仓,您清楚是什么情况吗?
這樣子寫:
2023-05-12 * "Fidelity Stock" "YOU BOUGHT CLOSING TRANSACTION CALL (XSP) CBOE MINI SPX INDEX MAY 12 23 $390 (100 SHS) (Margin)"
Assets:Investment:Fidelity -2271.680 USD
Assets:Investment:Fidelity 1.000 XSP230512C390 {} @@ 2271.680 USD
Income:Trade:Fidelity
但说实话,我好久没处理 investment transactions 了
现在Plaid还有免费100个connection吗?我只拿到了这个You have Limited Production access, which lets you access live data from institutions that don’t use OAuth.
我记得他们好早之前就改了,好像在test里面用掉一定的transaction就要转pay as you go还是啥,我已经换成自己下载csv然后导入了
希望了
自问自答:还是可以连一些的,赞
对了 喂给codex之后好很多了, 帮我整理了下
但是我个人没claude code的订阅,否则根据我做公司项目的经验, claude code效果会更好。
我大概的prompt
强制校准和预测和点数prompt
今天是2月11日 把我说的全部都记录进去,可能需要建立未来预测的新文件和miles pts rewards相关的文件。 在report里生成markdown告诉我 要怎么写query语句之类的看预测的未来, 在fava里看可视化看未来预期的balance现金流 ,以及需要rewards你写入我的转化比率,告诉我怎么enable rewards看我总额和怎么exclude rewards再查看的guidance 。 保留我给你的prompt到prompt history你可以建立新txt。
wechat 2026 2月11 有2486 人民币。 C1VX credit card 欠债1009.27 2月11日 强制平
2月9日还款311刀 从usbank还款的
1月8日从chase checking还款6267刀
12月8日从usbank还款1153.29刀
现在C1VX省117679 miles 2月11日
usbank 2月11日balance是2998.02刀 强制平
另外C1VX travel portal 还有 232.02 余额可以用于买机票酒店 2月11日 ,注意notes过期日期
C1VX payment date 8号每个月 不知道你打算怎么写预测功能
amex每个月payment date23号 要在2月23还款987.51
C1VX要在3月8日还款
然后随便chat parse pdf 差不多就行。 因为主要靠pad的强制校准的数字
我是有git的所以哪怕强制pad校准的数字错了也不怕有哪几行错了……意识到不对再改也来得及
注意checking pdf最好别上传 有account和routing number
【引用自 Lunasol】:
注意checking pdf最好别上传 有account和routing number
PDF应可以redact敏感个人信息?