Compare commits

..
2 Commits
Author SHA1 Message Date
nikola 2306e78329 add revolut 2026-08-08 15:28:24 +02:00
nikola 53c2f857b8 add tr 2026-08-08 14:56:54 +02:00
3 changed files with 274 additions and 6 deletions
+24 -6
View File
@@ -2,6 +2,7 @@ import csv
import re import re
import subprocess import subprocess
import sys import sys
from decimal import Decimal
from pathlib import Path from pathlib import Path
# extract transaction from izpisek pdf # extract transaction from izpisek pdf
TRANSACTION_START = re.compile(r"^\d{2}\.\d{2}\.\d{2}\s") TRANSACTION_START = re.compile(r"^\d{2}\.\d{2}\.\d{2}\s")
@@ -24,6 +25,14 @@ def pdf_to_text(pdf_path):
return result.stdout return result.stdout
def to_number(text):
"""Statements write 1.234,56 and mark a negative balance with a trailing minus."""
text = text.strip()
if text.endswith("-"):
text = "-" + text[:-1]
return Decimal(text.replace(".", "").replace(",", "."))
def parse_transaction(line): def parse_transaction(line):
m = TRANSACTION_LINE.match(line.rstrip()) m = TRANSACTION_LINE.match(line.rstrip())
account = "" account = ""
@@ -33,15 +42,12 @@ def parse_transaction(line):
account = f account = f
else: else:
desc_parts.append(f) desc_parts.append(f)
balance = m.group("balance")
if balance.endswith("-"):
balance = "-" + balance[:-1]
return { return {
"date": m.group("date"), "date": m.group("date"),
"desc": " ".join(desc_parts), "desc": " ".join(desc_parts),
"account": account, "account": account,
"amount": m.group("amount"), "amount": f"{to_number(m.group('amount')):+.2f}",
"balance": balance, "balance": f"{to_number(m.group('balance')):.2f}",
"currency": "EUR", "currency": "EUR",
} }
@@ -70,13 +76,25 @@ def extract_transactions(pdf_path):
return rows return rows
def check_balances(rows, path):
"""Every balance must be the previous one plus the transaction amount."""
for prev, row in zip(rows, rows[1:]):
expected = Decimal(prev["balance"]) + Decimal(row["amount"])
if expected != Decimal(row["balance"]):
print(f" warning: {path.name} {row['date']} {row['desc'][:40]!r} "
f"balance {row['balance']} != {expected}")
def main(): def main():
pdf_dir = Path(sys.argv[1]) pdf_dir = Path(sys.argv[1])
out_path = pdf_dir.with_suffix(".csv") out_path = pdf_dir.with_suffix(".csv")
all_rows = [] all_rows = []
for pdf_path in sorted(pdf_dir.glob("*.pdf")): for pdf_path in sorted(pdf_dir.glob("*.pdf")):
all_rows.extend(extract_transactions(pdf_path)) rows = extract_transactions(pdf_path)
# consecutive izpiski of one account, so the chain carries over the file boundary
check_balances(all_rows[-1:] + rows, pdf_path)
all_rows.extend(rows)
with open(out_path, "w", newline="") as f: with open(out_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["date", "desc", "account", "amount", "balance", "currency"]) writer = csv.DictWriter(f, fieldnames=["date", "desc", "account", "amount", "balance", "currency"])
+95
View File
@@ -0,0 +1,95 @@
import csv
import re
import sys
from collections import Counter
from datetime import datetime
from decimal import Decimal
from pathlib import Path
# convert revolut account statement csv exports into the same shape as nlb.py / trade_republic.py
STATEMENTS = "account-statement*.csv"
IBAN = re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b")
FIELDS = ["date", "desc", "account", "amount", "balance", "currency", "type"]
def decimals(text):
return len(text.partition(".")[2])
def scale(source):
"""Revolut writes JPY without decimals and EUR with two, so follow the file."""
places = {}
for r in source:
seen = [decimals(r[c]) for c in ("Amount", "Fee", "Balance") if r[c]]
places[r["Currency"]] = max(seen + [places.get(r["Currency"], 0)])
return places
def extract_transactions(path):
print(path)
with open(path, newline="") as f:
source = list(csv.DictReader(f))
source.sort(key=lambda r: r["Completed Date"])
places = scale(source)
rows = []
skipped = Counter()
for r in source:
if r["State"] != "COMPLETED" or not r["Balance"]:
skipped[r["State"]] += 1
continue
digits = places[r["Currency"]]
fee = Decimal(r["Fee"] or 0)
# the fee is deducted alongside the amount rather than booked separately
amount = Decimal(r["Amount"]) - fee
desc = r["Description"]
if fee:
desc += f" (fee {fee:.{digits}f})"
account = IBAN.search(desc)
rows.append({
"date": datetime.strptime(r["Completed Date"][:10], "%Y-%m-%d").strftime("%d.%m.%y"),
"desc": desc,
"account": account.group() if account else "",
"amount": f"{amount:+.{digits}f}",
"balance": f"{Decimal(r['Balance']):.{digits}f}",
"currency": r["Currency"],
"type": r["Type"],
})
for state, count in sorted(skipped.items()):
print(f" skipped {count} {state or 'unfinished'} transactions")
return rows
def check_balances(rows, path):
"""Every balance must be the previous one plus the transaction amount."""
for currency in dict.fromkeys(row["currency"] for row in rows):
chain = [row for row in rows if row["currency"] == currency]
for prev, row in zip(chain, chain[1:]):
expected = Decimal(prev["balance"]) + Decimal(row["amount"])
if expected != Decimal(row["balance"]):
print(f" warning: {path.name} {row['date']} {row['desc'][:40]!r} "
f"balance {row['balance']} != {expected}")
def main():
target = Path(sys.argv[1])
paths = sorted(target.glob(STATEMENTS)) if target.is_dir() else [target]
out_path = target.with_suffix(".csv")
if out_path in paths:
sys.exit(f"{out_path} would overwrite the statement it reads; pass the directory instead")
all_rows = []
for path in paths:
rows = extract_transactions(path)
check_balances(rows, path)
all_rows.extend(rows)
with open(out_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
writer.writerows(all_rows)
print(f"Wrote {len(all_rows)} transactions to {out_path}")
if __name__ == "__main__":
main()
+155
View File
@@ -0,0 +1,155 @@
import csv
import re
import subprocess
import sys
from datetime import datetime
from decimal import Decimal
from pathlib import Path
# extract transactions from trade republic account statement pdf
HEADER = re.compile(r"^\s*DATE\b.*\bMONEY IN\b.*\bMONEY OUT\b.*\bBALANCE\b")
TEXT_COLUMNS = ("DATE", "TYPE", "DESCRIPTION")
MONEY_COLUMNS = ("MONEY IN", "MONEY OUT", "BALANCE")
AMOUNT = re.compile(r"-?€\s?-?[\d,]+\.\d{2}")
TOKEN = re.compile(r"\S+")
FULL_DATE = re.compile(r"^\d{2} [A-Z][a-z]{2} \d{4}$")
IBAN = re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b")
AMOUNT_SLACK = 5
def pdf_to_text(pdf_path):
result = subprocess.run(
["pdftotext", "-layout", str(pdf_path), "-"],
capture_output=True, text=True, check=True,
)
return result.stdout
def column_starts(header_line):
return {c: header_line.index(c) for c in TEXT_COLUMNS + MONEY_COLUMNS}
def nearest(pos, cols):
return min(cols, key=lambda c: abs(pos - cols[c]))
def to_number(text):
return Decimal(text.replace("", "").replace(",", "").replace(" ", ""))
def join_wrapped(chunks):
"""Glue description fragments split across lines, honouring hyphen wraps."""
out = ""
for chunk in chunks:
if not out:
out = chunk
elif len(out) > 1 and out.endswith("-") and not out.endswith(" -"):
out += chunk
else:
out += " " + chunk
return out
def parse_block(lines, cols):
"""A block is the consecutive lines making up one transaction.
Layout varies between statements: everything on one line, or the date,
type and description stacked across three lines. Both are handled by
assigning every token to the column its start position is closest to.
"""
text_cols = {c: cols[c] for c in TEXT_COLUMNS}
money_cols = {c: cols[c] for c in MONEY_COLUMNS}
words = {c: [] for c in TEXT_COLUMNS}
desc_chunks = []
amounts = {c: None for c in MONEY_COLUMNS}
for line in lines:
cut = len(line)
for m in AMOUNT.finditer(line):
if m.start() < cols["MONEY IN"] - AMOUNT_SLACK:
continue
cut = min(cut, m.start())
amounts[nearest(m.start(), money_cols)] = to_number(m.group())
words["DESCRIPTION"] = []
for m in TOKEN.finditer(line[:cut]):
words[nearest(m.start(), text_cols)].append(m.group())
if words["DESCRIPTION"]:
desc_chunks.append(" ".join(words["DESCRIPTION"]))
date = " ".join(words["DATE"])
if not FULL_DATE.match(date) or amounts["BALANCE"] is None:
return None
desc = join_wrapped(desc_chunks)
account = IBAN.search(desc)
amount = (amounts["MONEY IN"] or 0) - (amounts["MONEY OUT"] or 0)
return {
"date": datetime.strptime(date, "%d %b %Y").strftime("%d.%m.%y"),
"desc": desc,
"account": account.group() if account else "",
"amount": f"{amount:+.2f}",
"balance": f"{amounts['BALANCE']:.2f}",
"currency": "EUR",
"type": " ".join(words["TYPE"]),
}
def blocks(lines):
block = []
for line in lines:
if line.strip():
block.append(line)
elif block:
yield block
block = []
if block:
yield block
def extract_transactions(path):
print(path)
rows = []
for page_text in pdf_to_text(path).split("\f"):
page_lines = page_text.split("\n")
header = next((i for i, l in enumerate(page_lines) if HEADER.match(l)), None)
if header is None:
continue
cols = column_starts(page_lines[header])
for block in blocks(page_lines[header + 1:]):
row = parse_block(block, cols)
if row:
rows.append(row)
return rows
def check_balances(rows, path):
"""Every balance must be the previous one plus the transaction amount."""
for prev, row in zip(rows, rows[1:]):
expected = Decimal(prev["balance"]) + Decimal(row["amount"])
if expected != Decimal(row["balance"]):
print(f" warning: {path.name} {row['date']} {row['desc'][:40]!r} "
f"balance {row['balance']} != {expected}")
def main():
target = Path(sys.argv[1])
paths = sorted(target.glob("*.pdf")) if target.is_dir() else [target]
out_path = target.with_suffix(".csv")
all_rows = []
for path in paths:
rows = extract_transactions(path)
check_balances(rows, path)
all_rows.extend(rows)
with open(out_path, "w", newline="") as f:
writer = csv.DictWriter(
f, fieldnames=["date", "desc", "account", "amount", "balance", "currency", "type"]
)
writer.writeheader()
writer.writerows(all_rows)
print(f"Wrote {len(all_rows)} transactions to {out_path}")
if __name__ == "__main__":
main()