BoA现在好像只提供pdf了?
如果有人正好写了BoA的parser的话伸个手,没有的话我过几天自己写一个
BoA现在好像只提供pdf了?
如果有人正好写了BoA的parser的话伸个手,没有的话我过几天自己写一个
嗯,只有MR
想要
我的19年pdf终于有救了
感觉
挺容易烂尾的一个原因是 GitHub 一般是实名的,虽然能赚点 stars 但稍有不慎就把自己盒了
所以我直接发在这吧
第一步:
具体来说我用了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
我用过这个
然后让copilot照着写了一个robinhood的,但问题挺多的就不分享了。。
首先感谢一下 @Eric23 的 BoA PDF to CSV script,解决了 Tabula 的这一问题:
但我发现这个script也有几个问题,于是修了一下。由于我是先回的这个楼所以决定还是在这个楼里更新了……
修复的问题有:
1,234.56CR
,匹配不上;/
,比如Other Travel/Transportation
,会匹配不上;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()
就普通地记录买入卖出啊,股票交易是标准模版了。
Fidleity的导入可以参考我这个,但是还是很多corner case,我删除了一些涉及个人隐私的case。
#!/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有另外一个格式,可以参考下面的导入代码。
#!/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就行。
这样会很麻烦。建议前者。 你是买gc的时候就省了一个total 25。你可以记为income,或者作为expensive,记为负就行了。但这一笔都会记录为当月,也会有点问题。
楼上给个currency也行,UDSUEATS 这样。这样最准确。
嗯,amazon这种无溢价类似cash equivalent的我就是这么弄的 但就是头疼ubereat这种。晚上回去整理研究一下
25刀算income:gc. 溢价本来就是个虚拟定义,你可以把expense单独算在一个food:ubereat跟“正常”food 分开
大佬,能问下你是咋弄”amortize_start:“的?这个plugin只弄了month但我想像你这样自己定义从什么时候开始amortize,编程小白跟copilot捣鼓了好久还弄不出来
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
随便改的