|
| 1 | +# SPDX-License-Identifier: MIT |
| 2 | +"""GnuCash CSV export adapter (GL splits -> core_gl normalized tables). |
| 3 | +
|
| 4 | +This adapter consumes the output of: |
| 5 | +
|
| 6 | + File -> Export -> Export Transactions to CSV |
| 7 | +
|
| 8 | +with "Simple Layout" unchecked (the multi-line/complex export). |
| 9 | +
|
| 10 | +It then produces the Track D "core_gl" normalized tables: |
| 11 | +
|
| 12 | + normalized/chart_of_accounts.csv |
| 13 | + normalized/gl_journal.csv |
| 14 | +
|
| 15 | +We infer `account_type` and `normal_side` from the top-level account group |
| 16 | +(Assets/Liabilities/Equity/Income/Expenses). This is intentionally pragmatic: |
| 17 | +it keeps the adapter free and cross-platform without requiring GnuCash APIs. |
| 18 | +""" |
| 19 | + |
| 20 | +from __future__ import annotations |
| 21 | + |
| 22 | +import csv |
| 23 | +from dataclasses import dataclass |
| 24 | +from decimal import Decimal, InvalidOperation |
| 25 | +from typing import Any |
| 26 | + |
| 27 | +from .base import NormalizeContext |
| 28 | +from .mapping import clean_cell, normalize_col_name, parse_money |
| 29 | +from .._errors import TrackDDataError |
| 30 | + |
| 31 | + |
| 32 | +@dataclass(frozen=True) |
| 33 | +class _AcctMeta: |
| 34 | + account_type: str |
| 35 | + normal_side: str |
| 36 | + |
| 37 | + |
| 38 | +_ROOT_META = { |
| 39 | + "assets": _AcctMeta("Asset", "Debit"), |
| 40 | + "liabilities": _AcctMeta("Liability", "Credit"), |
| 41 | + "equity": _AcctMeta("Equity", "Credit"), |
| 42 | + "income": _AcctMeta("Revenue", "Credit"), |
| 43 | + "expenses": _AcctMeta("Expense", "Debit"), |
| 44 | +} |
| 45 | + |
| 46 | + |
| 47 | +def _acct_meta_from_full_name(full_name: str) -> _AcctMeta: |
| 48 | + root = (full_name.split(":", 1)[0] if full_name else "").strip().lower() |
| 49 | + meta = _ROOT_META.get(root) |
| 50 | + if not meta: |
| 51 | + raise TrackDDataError( |
| 52 | + "GnuCash export uses unexpected top-level account group: " |
| 53 | + f"{root!r}. Expected one of: Assets, Liabilities, Equity, Income, Expenses." |
| 54 | + ) |
| 55 | + return meta |
| 56 | + |
| 57 | + |
| 58 | +def _to_decimal_money(value: str) -> Decimal: |
| 59 | + """Parse an amount into a Decimal, keeping sign.""" |
| 60 | + |
| 61 | + cleaned = parse_money(value) |
| 62 | + if cleaned == "": |
| 63 | + return Decimal("0") |
| 64 | + try: |
| 65 | + return Decimal(cleaned) |
| 66 | + except InvalidOperation as exc: # pragma: no cover |
| 67 | + raise TrackDDataError(f"Invalid money amount: {value!r}") from exc |
| 68 | + |
| 69 | + |
| 70 | +def _fmt_2dp(x: Decimal) -> str: |
| 71 | + """Format with 2 decimals, but use blank for zero. |
| 72 | +
|
| 73 | + Track D templates typically leave the non-side empty (rather than "0.00"). |
| 74 | + """ |
| 75 | + |
| 76 | + q = x.quantize(Decimal("0.01")) |
| 77 | + if q == Decimal("0.00"): |
| 78 | + return "" |
| 79 | + return f"{q:.2f}" |
| 80 | + |
| 81 | + |
| 82 | +class GnuCashGLAdapter: |
| 83 | + name = "gnucash_gl" |
| 84 | + |
| 85 | + def normalize( |
| 86 | + self, |
| 87 | + ctx: NormalizeContext, |
| 88 | + ) -> dict[str, Any]: |
| 89 | + """Normalize a GnuCash transactions export to the core_gl contract. |
| 90 | +
|
| 91 | + Notes |
| 92 | + ----- |
| 93 | + Users should export from: |
| 94 | + File -> Export -> Export Transactions to CSV |
| 95 | + with "Simple Layout" unchecked (complex/multi-line). |
| 96 | + """ |
| 97 | + |
| 98 | + # This adapter currently targets the minimal core_gl contract. |
| 99 | + if ctx.profile != "core_gl": |
| 100 | + raise TrackDDataError( |
| 101 | + f"gnucash_gl adapter currently supports profile 'core_gl' only (got {ctx.profile!r})." |
| 102 | + ) |
| 103 | + |
| 104 | + tables_dir = ctx.tables_dir |
| 105 | + normalized_dir = ctx.normalized_dir |
| 106 | + |
| 107 | + # We expect the GnuCash export to be placed at tables/gl_journal.csv. |
| 108 | + # (BYOD init creates both required files; users overwrite gl_journal.csv.) |
| 109 | + src_path = tables_dir / "gl_journal.csv" |
| 110 | + if not src_path.exists(): |
| 111 | + raise TrackDDataError( |
| 112 | + "Missing tables/gl_journal.csv. Put the GnuCash export CSV here and re-run normalize." |
| 113 | + ) |
| 114 | + |
| 115 | + with src_path.open("r", encoding="utf-8", newline="", errors="replace") as f: |
| 116 | + reader = csv.DictReader(f) |
| 117 | + fieldnames = reader.fieldnames or [] |
| 118 | + |
| 119 | + norm_to_src: dict[str, str] = {normalize_col_name(h): h for h in fieldnames} |
| 120 | + |
| 121 | + def _col(*aliases: str) -> str | None: |
| 122 | + for a in aliases: |
| 123 | + if a in norm_to_src: |
| 124 | + return norm_to_src[a] |
| 125 | + return None |
| 126 | + |
| 127 | + col_date = _col("date") |
| 128 | + col_txn_id = _col("transaction_id") |
| 129 | + col_number = _col("number") |
| 130 | + col_desc = _col("description") |
| 131 | + col_full_acct = _col("full_account_name") |
| 132 | + col_acct = _col("account_name") |
| 133 | + col_amt = _col("amount_num") |
| 134 | + |
| 135 | + missing = [ |
| 136 | + k |
| 137 | + for k, v in { |
| 138 | + "Date": col_date, |
| 139 | + "Transaction ID": col_txn_id, |
| 140 | + "Number": col_number, |
| 141 | + "Description": col_desc, |
| 142 | + "Full Account Name": col_full_acct, |
| 143 | + "Amount Num.": col_amt, |
| 144 | + }.items() |
| 145 | + if v is None |
| 146 | + ] |
| 147 | + if missing: |
| 148 | + raise TrackDDataError( |
| 149 | + "GnuCash export is missing required columns: " |
| 150 | + + ", ".join(missing) |
| 151 | + + ". Make sure you exported 'Transactions to CSV' with Simple Layout unchecked." |
| 152 | + ) |
| 153 | + |
| 154 | + normalized_dir.mkdir(parents=True, exist_ok=True) |
| 155 | + |
| 156 | + # Collect splits and a derived chart of accounts. |
| 157 | + splits: list[dict[str, str]] = [] |
| 158 | + coa: dict[str, _AcctMeta] = {} |
| 159 | + |
| 160 | + for row in reader: |
| 161 | + date = clean_cell(row[col_date]) |
| 162 | + txn_id = clean_cell(row[col_txn_id]) |
| 163 | + doc_id = clean_cell(row[col_number]) |
| 164 | + desc = clean_cell(row[col_desc]) |
| 165 | + |
| 166 | + full_acct = clean_cell(row[col_full_acct]) |
| 167 | + if not full_acct and col_acct: |
| 168 | + full_acct = clean_cell(row[col_acct]) |
| 169 | + if not full_acct: |
| 170 | + # Skip empty rows. |
| 171 | + continue |
| 172 | + |
| 173 | + meta = _acct_meta_from_full_name(full_acct) |
| 174 | + coa.setdefault(full_acct, meta) |
| 175 | + |
| 176 | + amt = _to_decimal_money(clean_cell(row[col_amt])) |
| 177 | + |
| 178 | + debit = Decimal("0") |
| 179 | + credit = Decimal("0") |
| 180 | + if meta.normal_side == "Debit": |
| 181 | + if amt >= 0: |
| 182 | + debit = amt |
| 183 | + else: |
| 184 | + credit = -amt |
| 185 | + else: # Credit-normal |
| 186 | + if amt >= 0: |
| 187 | + credit = amt |
| 188 | + else: |
| 189 | + debit = -amt |
| 190 | + |
| 191 | + splits.append( |
| 192 | + { |
| 193 | + "txn_id": txn_id, |
| 194 | + "date": date, |
| 195 | + "doc_id": doc_id, |
| 196 | + "description": desc, |
| 197 | + "account_id": full_acct, |
| 198 | + "debit": _fmt_2dp(debit) if debit != 0 else "", |
| 199 | + "credit": _fmt_2dp(credit) if credit != 0 else "", |
| 200 | + } |
| 201 | + ) |
| 202 | + |
| 203 | + # Write normalized gl_journal.csv |
| 204 | + gl_path = normalized_dir / "gl_journal.csv" |
| 205 | + with gl_path.open("w", encoding="utf-8", newline="") as f: |
| 206 | + writer = csv.DictWriter( |
| 207 | + f, |
| 208 | + fieldnames=[ |
| 209 | + "txn_id", |
| 210 | + "date", |
| 211 | + "doc_id", |
| 212 | + "description", |
| 213 | + "account_id", |
| 214 | + "debit", |
| 215 | + "credit", |
| 216 | + ], |
| 217 | + ) |
| 218 | + writer.writeheader() |
| 219 | + for r in splits: |
| 220 | + writer.writerow(r) |
| 221 | + |
| 222 | + # Write normalized chart_of_accounts.csv |
| 223 | + coa_path = normalized_dir / "chart_of_accounts.csv" |
| 224 | + with coa_path.open("w", encoding="utf-8", newline="") as f: |
| 225 | + writer = csv.DictWriter( |
| 226 | + f, |
| 227 | + fieldnames=[ |
| 228 | + "account_id", |
| 229 | + "account_name", |
| 230 | + "account_type", |
| 231 | + "normal_side", |
| 232 | + ], |
| 233 | + ) |
| 234 | + writer.writeheader() |
| 235 | + for account_id in sorted(coa.keys()): |
| 236 | + meta = coa[account_id] |
| 237 | + leaf = account_id.split(":")[-1].strip() |
| 238 | + writer.writerow( |
| 239 | + { |
| 240 | + "account_id": account_id, |
| 241 | + "account_name": leaf, |
| 242 | + "account_type": meta.account_type, |
| 243 | + "normal_side": meta.normal_side, |
| 244 | + } |
| 245 | + ) |
| 246 | + |
| 247 | + return { |
| 248 | + "adapter": self.name, |
| 249 | + "profile": ctx.profile, |
| 250 | + "project": str(ctx.project_root), |
| 251 | + "tables_dir": str(ctx.tables_dir), |
| 252 | + "normalized_dir": str(ctx.normalized_dir), |
| 253 | + "files": [ |
| 254 | + {"dst": str(coa_path), "rows": len(coa)}, |
| 255 | + {"dst": str(gl_path), "rows": len(splits)}, |
| 256 | + ], |
| 257 | + } |
0 commit comments