Files
wanderbooks/oracle/gnucash/books.py
T
swaitsandClaude Opus 5.5 beb1fcf233 test(core): spec-blind suites and a live GnuCash oracle; four fixes they found
Max-testing pass, steps 2-4.

Spec-blind tests (rank 1-2): four agents, each given only the specs and
the public interface (doc comments and signatures, every body removed,
never src/ or the existing tests), wrote tests/spec_reports.rs (62),
spec_ledger.rs (72), spec_arap.rs (42), spec_assets_payroll.rs (50):
hand-computed examples per scenario and a proptest law per invariant
(conservation, partition, invariance, scaling, reversal cancels,
"a refusal leaves the books unchanged" over random invalid commands).
They found:

1. A refused payroll run changed the books: an all-zero run passed
   validation, then failed at post after creating seven payroll
   accounts and using up an entry id. validate_payroll_lines now
   refuses a run with no gross pay.
2. A refused add_account used up an account id (empty code, duplicate,
   unknown parent). The id is now taken only when the account is added.
3. Stale prose: close_period_to and members_equity still described the
   old last-member rounding.
4. Open (left #[ignore]d pending Stephen): depreciation_schedule's doc
   says "through the month containing" the date; the code prorates that
   month by days.

GnuCash oracle (rank 3, an oracle never the spec): `just oracle` builds
GnuCash 5.10 in a container pinned by base digest and package versions
(oracle/gnucash/) and runs tests/oracle_gnucash.rs: 200 seeded random
books built through wanderbooks (entries, foreign lines, reversals,
monthly closes), every account's balance at month ends and random dates
and each month's P&L, compared exactly: 15,800 comparisons. First run:
4,724 differences, all from foreign lines; without them, zero.
Adjudicated from principles (rules.md B-3): wanderbooks booked
foreign * rate unrounded ($108.34567); a ledger in one money holds no
fractions of its smallest unit, and its cash must match a bank. Stephen
decided: book in the base currency's minor unit, half away from zero.
add_line_fx now rounds; minor units per ISO 4217 list one (2026-09-17,
SIX), generated into Currency::minor_units and recorded in
docs/spec/sources/iso-4217.md. Replay now takes the journaled base
amount (replay_add_line_fx), so lines booked before replay exactly as
booked (contract: books_written_under_older_rules_still_open). Now:
zero differences, foreign lines included.

Tests: 449, from 223.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:39:06 -06:00

97 lines
3.2 KiB
Python

"""Build books in GnuCash and report its balances.
stdin: a JSON list of books:
{"accounts": [{"id": 1, "code": "1000", "name": "Cash", "type": "Asset"}],
"entries": [{"date": "2026-01-15", "description": "...",
"lines": [{"account": 1, "side": "debit", "amount": "12.34"}]}],
"as_of": ["2026-01-31", ...]}
stdout: a JSON list, one per book:
{"balances": {"2026-01-31": {"1": "12.34", ...}, ...}}
Balances are GnuCash's own: the sum of split values on the account through
the end of that day, debits positive, as an exact decimal string.
"""
import datetime, json, os, sys, tempfile
from decimal import Decimal
import gnucash
from gnucash import Account, GncNumeric, Session, SessionOpenMode, Split, Transaction
TYPES = {
"Asset": gnucash.ACCT_TYPE_ASSET,
"Liability": gnucash.ACCT_TYPE_LIABILITY,
"Equity": gnucash.ACCT_TYPE_EQUITY,
"Revenue": gnucash.ACCT_TYPE_INCOME,
"Expense": gnucash.ACCT_TYPE_EXPENSE,
}
def numeric(amount: str) -> GncNumeric:
d = Decimal(amount)
sign, digits, exp = d.as_tuple()
denom = 10 ** max(0, -exp)
num = int(d * denom)
return GncNumeric(num, denom)
def decimal_of(n) -> str:
return str(Decimal(n.num()) / Decimal(n.denom()))
def end_of_day(date: str) -> int:
d = datetime.date.fromisoformat(date)
return int(datetime.datetime(d.year, d.month, d.day, 23, 59, 59,
tzinfo=datetime.timezone.utc).timestamp())
def run(book_spec, path):
s = Session(f"xml://{path}", SessionOpenMode.SESSION_NEW_STORE)
try:
book = s.book
root = book.get_root_account()
usd = book.get_table().lookup("CURRENCY", "USD")
accounts = {}
for a in book_spec["accounts"]:
acct = Account(book)
acct.SetName(f'{a["code"]} {a["name"]}')
acct.SetCode(a["code"])
acct.SetType(TYPES[a["type"]])
acct.SetCommodity(usd)
root.append_child(acct)
accounts[a["id"]] = acct
for e in book_spec["entries"]:
d = datetime.date.fromisoformat(e["date"])
tx = Transaction(book)
tx.BeginEdit()
tx.SetCurrency(usd)
tx.SetDate(d.day, d.month, d.year)
tx.SetDescription(e.get("description", ""))
for line in e["lines"]:
value = numeric(line["amount"])
if line["side"] == "credit":
value = value.neg()
sp = Split(book)
sp.SetParent(tx)
sp.SetAccount(accounts[line["account"]])
sp.SetValue(value)
sp.SetAmount(value)
tx.CommitEdit()
out = {}
for date in book_spec["as_of"]:
t = end_of_day(date)
out[date] = {str(i): decimal_of(a.GetBalanceAsOfDate(t)) for i, a in accounts.items()}
return {"balances": out}
finally:
s.end()
def main():
books = json.load(sys.stdin)
results = []
with tempfile.TemporaryDirectory() as tmp:
for i, b in enumerate(books):
results.append(run(b, os.path.join(tmp, f"book{i}.gnucash")))
json.dump(results, sys.stdout)
if __name__ == "__main__":
main()