#!/usr/bin/env python3 # AUTHOR: Daesorin # CREATED: 2026-07-27 import sys from pathlib import Path from decimal import Decimal, InvalidOperation # CONFIGURATION # set default accounting buckets, fallback currency, and journal vault path DEFAULT_EXPENSE_ACCOUNT = "Expenses:Uncategorised" DEFAULT_INCOME_ACCOUNT = "Income:Uncategorised" DEFAULT_PAYMENT_ACCOUNT = "Assets:Banks:OPay" DEFAULT_CURRENCY = "NGN" NOTES_DIR = Path("~/obsidian/finances").expanduser() # ACCOUNT NORMALISATION # intercept legacy account strings and fix misrouted categories at compile time def normalize_account(account_str, currency, name): clean_account = account_str.strip() clean_name = name.lower() # auto resolve generic raenest root postings to multi currency leaf accounts if clean_account == "Assets:Banks:Raenest": if currency == "USD": return "Assets:Banks:Raenest:USD" if currency == "GBP": return "Assets:Banks:Raenest:GBP" if currency == "EUR": return "Assets:Banks:Raenest:EUR" return "Assets:Banks:Raenest:NGN" # intercept fee and charge keywords before portfolio redirects if any(w in clean_name for w in ("charge", "fee", "loss", "tax", "commission")): if "uncategorised" in clean_account.lower(): return "Expenses:Financial:BankLoss" return clean_account # redirect uncategorised investment transfers to asset portfolios if "uncategorised" in clean_account.lower(): if "cowrywise" in clean_name: return "Assets:Investments:Cowrywise" if "bybit" in clean_name: return "Assets:Investments:Bybit" if "octafx" in clean_name: return "Assets:Investments:OctaFX" return clean_account # FRONTMATTER PARSER # extract yaml between the opening and closing dashes without external libraries def parse_frontmatter(file_path): try: with open(file_path, "r", encoding="utf-8") as f: content = f.read() except OSError as e: print(f"warning: unable to read {file_path} ({e})", file=sys.stderr) return None if not content.startswith("---"): return None parts = content.split("---", 2) if len(parts) < 3: return None yaml_text = parts[1] data = {} for line in yaml_text.strip().split("\n"): # ignore empty lines and full line comments line = line.strip() if not line or line.startswith("#"): continue if ":" in line: key, val = line.split(":", 1) # strip inline yaml comments and surrounding quotation marks clean_val = val.split("#", 1)[0].strip().strip('\'"') data[key.strip().lower()] = clean_val return data # JOURNAL GENERATOR # convert parsed dictionary into an hledger double entry transaction string def generate_entry(data, file_path): try: date = data["date"] name = data.get("name", "untitled transaction") tx_type = data.get("type", "expense").lower() # read dynamic accounts or fallback to defaults currency = data.get("currency", DEFAULT_CURRENCY).strip().upper() raw_payment = data.get("payment_account", DEFAULT_PAYMENT_ACCOUNT) raw_target = data.get("account", DEFAULT_EXPENSE_ACCOUNT if tx_type not in {"income", "liability", "borrowing", "inflow"} else DEFAULT_INCOME_ACCOUNT) # normalise accounts against currency and description keywords payment_account = normalize_account(raw_payment, currency, name) target_account = normalize_account(raw_target, currency, name) # use decimal for precise financial arithmetic unit = Decimal(data.get("unit", "1")) amount = Decimal(data["amount"]) total = unit * amount # CHARGE RESOLUTION # extract bank transfer fees and pos agent charges without multiplying by unit transfer_charges = Decimal(data.get("transfer_charges", "0")) pos_charges = Decimal(data.get("pos_charges", "0")) # FOREX CONVERSIONS # handle dual currency exchanges and correct backwards conversion frontmatter if tx_type == "conversion": source_currency = data.get("source_currency", "USD").strip().upper() source_amount = Decimal(data.get("source_amount", "0")) * unit total_source_outflow = source_amount charge_postings = "" if pos_charges > 0: charge_postings += f" Expenses:Financial:POSFees {pos_charges:.2f} {source_currency}\n" total_source_outflow += pos_charges if transfer_charges > 0: charge_postings += f" Expenses:Financial:BankLoss {transfer_charges:.2f} {source_currency}\n" total_source_outflow += transfer_charges # fix reversed raenest conversion where foreign currency was logged as spent instead of local currency if ("raenest" in name.lower() or "raenest" in payment_account.lower()) and source_currency != DEFAULT_CURRENCY: entry = ( f"{date} * {name}\n" f" {payment_account} {source_amount:.2f} {source_currency}\n" f" {target_account} -{total:.2f} {currency} @@ {source_amount:.2f} {source_currency}\n" f"{charge_postings}\n" ) return entry entry = ( f"{date} * {name}\n" f" {target_account} {total:.2f} {currency}\n" f"{charge_postings}" f" {payment_account} -{total_source_outflow:.2f} {source_currency} @@ {total:.2f} {currency}\n\n" ) return entry # CASH FLOW ROUTING # route transactions based on whether value enters or leaves the payment account inflow_types = {"income", "liability", "borrowing"} # handle loans collected and standard inflows unless routed from equity windfalls if tx_type == "inflow" and not payment_account.lower().startswith("equity"): inflow_types.add("inflow") if tx_type in inflow_types: entry = ( f"{date} * {name}\n" f" {payment_account} {currency} {total:.2f}\n" f" {target_account} -{currency} {total:.2f}\n\n" ) else: charge_postings = "" total_outflow = total if pos_charges > 0: charge_postings += f" Expenses:Financial:POSFees {currency} {pos_charges:.2f}\n" total_outflow += pos_charges if transfer_charges > 0: charge_postings += f" Expenses:Financial:BankLoss {currency} {transfer_charges:.2f}\n" total_outflow += transfer_charges entry = ( f"{date} * {name}\n" f" {target_account} {currency} {total:.2f}\n" f"{charge_postings}" f" {payment_account} -{currency} {total_outflow:.2f}\n\n" ) return entry except (KeyError, InvalidOperation, TypeError) as e: # log malformed notes to standard error to prevent silent data loss print(f"warning: skipping malformed note '{file_path}' ({type(e).__name__})", file=sys.stderr) return "" # MAIN EXECUTION # scan the journal directory recursively and print compiled journal entries if __name__ == "__main__": if not NOTES_DIR.is_dir(): print(f"error: directory '{NOTES_DIR}' does not exist", file=sys.stderr) sys.exit(1) entries = [] # rglob enables recursive scanning through nested folder structures for file_path in sorted(NOTES_DIR.rglob("*.md")): data = parse_frontmatter(file_path) if data and "date" in data and "amount" in data: entry = generate_entry(data, file_path) if entry: entries.append(entry) print("".join(entries), end="")