Skip to content

Commit 635fc64

Browse files
TrackD BYOD: fix gnucash_gl adapter interface + report shape
1 parent 3eaa686 commit 635fc64

4 files changed

Lines changed: 325 additions & 2 deletions

File tree

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
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+
}

src/pystatsv1/trackd/byod.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,12 @@ def _get_adapter(name: str | None) -> TrackDAdapter:
248248
from .adapters.core_gl import CoreGLAdapter
249249

250250
return CoreGLAdapter()
251+
if n == "gnucash_gl":
252+
from .adapters.gnucash_gl import GnuCashGLAdapter
253+
254+
return GnuCashGLAdapter()
251255
raise TrackDDataError(
252-
f"Unknown adapter: {name}.\n" "Use one of: passthrough, core_gl"
256+
f"Unknown adapter: {name}.\n" "Use one of: passthrough, core_gl, gnucash_gl"
253257
)
254258

255259

tests/test_trackd_byod_adapter_selection_cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ def test_trackd_byod_normalize_uses_adapter_from_config(tmp_path: Path, capsys)
2222
assert "unknown adapter" in out
2323
assert "passthrough" in out
2424
assert "core_gl" in out
25+
assert "gnucash_gl" in out

tests/test_trackd_byod_normalize_cli.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,65 @@ def test_trackd_byod_normalize_core_gl_adapter_allows_noncanonical_headers_and_c
9898
assert rows[0]["debit"] == "1234.00"
9999
assert rows[1]["debit"] == "-200.00"
100100
assert rows[2]["credit"] == "2000.00"
101-
assert rows[0]["Memo"] == "hi"
101+
102+
103+
def test_trackd_byod_normalize_gnucash_gl_adapter_consumes_export_and_emits_core_gl_contract(
104+
tmp_path: Path,
105+
capsys,
106+
) -> None:
107+
proj = tmp_path / "byod"
108+
109+
rc_init = main(["trackd", "byod", "init", "--dest", str(proj), "--profile", "core_gl"])
110+
assert rc_init == 0
111+
112+
# Switch adapter to gnucash_gl.
113+
cfg_path = proj / "config.toml"
114+
cfg = cfg_path.read_text(encoding="utf-8")
115+
cfg_path.write_text(cfg.replace('adapter = "passthrough"', 'adapter = "gnucash_gl"'), encoding="utf-8")
116+
117+
# A tiny GnuCash "Export Transactions to CSV" example (complex layout; one transaction, two splits).
118+
(proj / "tables" / "gl_journal.csv").write_text(
119+
"Date,Transaction ID,Number,Description,Notes,Commodity/Currency,Void Reason,Action,Memo,Full Account Name,Account Name,Amount With Sym.,Amount Num.,Value With Sym.,Value Num.,Reconcile,Reconcile Date,Rate/Price\n"
120+
"2026-01-20,6d0b834e77b16d5a0eeb0f92f0dd1681,000001,Test,,CURRENCY::CAD,,,,Expenses:Auto:Gas,Gas,\"$10.00\",10.00,\"$10.00\",10.00,n,,1\n"
121+
"2026-01-20,6d0b834e77b16d5a0eeb0f92f0dd1681,000001,Test,,CURRENCY::CAD,,,,Assets:Current Assets:Cash in Wallet,Cash in Wallet,\"-$10.00\",-10.00,\"-$10.00\",-10.00,n,,1\n",
122+
encoding="utf-8",
123+
)
124+
125+
rc = main(["trackd", "byod", "normalize", "--project", str(proj)])
126+
out = capsys.readouterr().out.lower()
127+
128+
assert rc == 0
129+
assert "adapter: gnucash_gl" in out
130+
131+
# Ensure outputs exist and are parseable.
132+
import csv
133+
134+
gl_path = proj / "normalized" / "gl_journal.csv"
135+
coa_path = proj / "normalized" / "chart_of_accounts.csv"
136+
137+
assert gl_path.exists()
138+
assert coa_path.exists()
139+
140+
with gl_path.open("r", encoding="utf-8", newline="") as f:
141+
reader = csv.DictReader(f)
142+
rows = list(reader)
143+
144+
assert len(rows) == 2
145+
146+
# Expense increases => debit; asset decreases => credit.
147+
exp = next(r for r in rows if r["account_id"].startswith("Expenses"))
148+
cash = next(r for r in rows if r["account_id"].startswith("Assets"))
149+
150+
assert exp["debit"] == "10.00"
151+
assert exp["credit"] in ("", "0.00")
152+
153+
assert cash["credit"] == "10.00"
154+
assert cash["debit"] in ("", "0.00")
155+
156+
with coa_path.open("r", encoding="utf-8", newline="") as f:
157+
reader = csv.DictReader(f)
158+
coa_rows = list(reader)
159+
160+
ids = {r["account_id"] for r in coa_rows}
161+
assert "Expenses:Auto:Gas" in ids
162+
assert "Assets:Current Assets:Cash in Wallet" in ids

0 commit comments

Comments
 (0)