Files
scripts/halifax/clean-halifax-actual-2.py
2026-01-17 09:10:20 +00:00

163 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
import csv
from decimal import Decimal, InvalidOperation
import sys
from pathlib import Path
import json
from datetime import datetime
SCRIPT_DIR = Path(__file__).resolve().parent
STATE_FILE = SCRIPT_DIR / "halifax_state.json"
def load_state():
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except json.JSONDecodeError:
# If it ever gets corrupted, start fresh
return {"seen_keys": []}
return {"seen_keys": []}
def save_state(state):
STATE_FILE.write_text(json.dumps(state, indent=2))
def normalize_text(s: str) -> str:
# collapse whitespace + lowercase
return " ".join(s.split()).lower()
def make_key(date_str: str, payee: str, desc: str, amount: Decimal) -> str:
"""
Build a dedupe key: normalized date + payee + description + amount.
We normalize:
- date into ISO (YYYY-MM-DD)
- payee/desc into lowercased, collapsed whitespace
- amount to 2 decimal places
"""
# Halifax gives dd/mm/yyyy convert to ISO for consistency
date_str = date_str.strip()
try:
# If it's already ISO, this will work too
try:
d = datetime.fromisoformat(date_str).date()
except ValueError:
d = datetime.strptime(date_str, "%d/%m/%Y").date()
norm_date = d.isoformat()
except Exception:
# If parsing fails for some reason, just use the raw string
norm_date = date_str
norm_payee = normalize_text(payee)
norm_desc = normalize_text(desc)
norm_amount = amount.quantize(Decimal("0.01"))
return f"{norm_date}|{norm_payee}|{norm_desc}|{norm_amount}"
def convert(in_path, out_path):
# Load state
state = load_state()
seen_keys = set(state.get("seen_keys", []))
written_count = 0
skipped_count = 0
# Read Halifax CSV and write a cleaned CSV for Actual
with open(in_path, newline="", encoding="utf-8-sig") as f_in, \
open(out_path, "w", newline="", encoding="utf-8") as f_out:
reader = csv.DictReader(f_in)
# Output columns tailored for Actuals CSV import
fieldnames = ["Date", "Payee", "Description", "Amount"]
writer = csv.DictWriter(f_out, fieldnames=fieldnames)
writer.writeheader()
for row in reader:
if not row:
continue
# Date in Halifax export (dd/mm/yyyy)
date = (row.get("Transaction Date") or "").strip()
if not date:
# Skip lines without a date
continue
desc = (row.get("Transaction Description") or "").strip()
payee = desc or (row.get("Transaction Type") or "").strip()
# Halifax has separate Debit / Credit columns
debit_str = (row.get("Debit Amount") or "").replace(",", "").strip()
credit_str = (row.get("Credit Amount") or "").replace(",", "").strip()
amount = None
# Debits (money going out) → negative
if debit_str:
try:
amount = -Decimal(debit_str)
except InvalidOperation:
amount = None
# Credits (money coming in) → positive
elif credit_str:
try:
amount = Decimal(credit_str)
except InvalidOperation:
amount = None
# If we still don't have an amount, skip the row
if amount is None:
continue
key = make_key(date, payee, desc, amount)
if key in seen_keys:
skipped_count += 1
continue
seen_keys.add(key)
written_count += 1
writer.writerow({
"Date": date,
"Payee": payee,
"Description": desc,
"Amount": f"{amount:.2f}",
})
# Keep the state from growing forever remember last N keys
max_keys = 50000
seen_list = list(seen_keys)
if len(seen_list) > max_keys:
seen_list = seen_list[-max_keys:]
save_state({"seen_keys": seen_list})
print(f"Written cleaned file to {out_path}")
print(f" New transactions written: {written_count}")
print(f" Transactions skipped as duplicates: {skipped_count}")
def main():
if len(sys.argv) < 2:
print("Usage: python clean-halifax-actual.py input.csv [output.csv]")
sys.exit(1)
in_path = Path(sys.argv[1])
if len(sys.argv) >= 3:
out_path = Path(sys.argv[2])
else:
# Default to inputname_actual.csv
out_path = in_path.with_name(in_path.stem + "_actual.csv")
convert(in_path, out_path)
if __name__ == "__main__":
main()