dodil/erp-brick-consolidation
DODIL ERP brick: consolidation
tests/test_audit_v1.py
"""Consolidation v1 audit (2026-10-24): each MUST-fix defect proven by a strict xfail. Fixing the defect turns its test green — then drop the
mark. Every test goes through the doors (api) except where it only reads back what was stored."""
import json
from decimal import Decimal
from conftest import ALICE, NOBODY, api, as_role
from test_slice_b1 import BASE, add_rules, eliminate
from test_slice_b2 import CTRL2, ready_world
from test_slice_c1 import CHANGES, close_period, sale_world, sell
from test_slice_d import DOORS, RUN, odoo_world, rows_of
from world import ACC, CTRL, RULES, P, acquire, db, group, ok, sign_period, state, world
D = Decimal
SYSTEM = as_role("system")
RUNS = "/cons-migration-runs"
def at(period):
return f"/groups/GRP/group-periods/{period}"
def readiness_gate(period, name, who=CTRL):
return next(g for g in ok(api("GET", f"{at(period)}/readiness", who))["gates"] if g["gate"] == name)
# ── a calendar-year group, DE01 (EUR) bought 80% for 900,000.00 EUR ────────────────────────────────────────────────────
def calendar_world(gl, *, acquired_on="2026-01-01", equity_period="2025-12"):
gl.company("UK01", "GBP", fy_end=12)
gl.company("DE01", "EUR", fy_end=12)
ok(api("POST", "/groups", ACC, {"group_id": "GRP", "name": "Group", "group_currency": "GBP", "ledger_id": "IFRS", "group_chart_id": "GROUP",
"top_company_code": "UK01", "fiscal_year_end_month": "12"}))
ok(api("POST", "/groups/GRP/rules", ACC, {**RULES, "account_roles": RULES["account_roles"] + [
{"role": "group_retained_earnings", "valid_from_period": "2020-01", "group_account_id": "G-3200"}]}))
gl.set_rows("DE01", equity_period, [("G-1100", "1000000.00", "0.00"), ("G-3000", "-500000.00", "0.00"), ("G-3100", "-500000.00", "0.00")])
gl.rates[("EUR", "spot", acquired_on)] = D("0.860000")
for m in [f"2026-{i:02d}" for i in range(1, 13)]:
gl.rates[("EUR", "average", m)] = D("0.860000")
gl.rates[("EUR", "closing", "2026-10-31")] = D("0.860000")
gl.rates[("EUR", "closing", "2026-12-31")] = D("0.860000")
acquire("DE01", date_=acquired_on)
# ═══ MUST-1a: a purchase part-way through the year cannot be consolidated at all ═══════════════════════════════════════
def test_must_1a_a_calendar_year_group_that_bought_de01_on_1_april_eliminates_october(gl):
calendar_world(gl, acquired_on="2026-04-01", equity_period="2026-03")
gl.set_rows("DE01", "2026-10", [("G-1100", "1060000.00", "0.00"), ("G-3000", "-500000.00", "0.00"), ("G-3100", "-500000.00", "0.00"),
("G-4000", "-60000.00", "-10000.00")])
gl.set_rows("UK01", "2026-10", [("G-1600", "774000.00", "0.00", "DE01"), ("G-3000", "-774000.00", "0.00")])
ok(api("POST", "/groups/GRP/group-periods", ACC, {"period_id": "2026-10"}))
for step in ("collect-group-data", "translate-currencies"):
ok(api("POST", f"{at('2026-10')}/{step}", ACC, {}))
status, body = api("POST", f"{at('2026-10')}/run-eliminations", ACC, {})
assert status == 200, body
# ═══ year 2 of the calendar group: DE01 paid a 40,000.00 EUR dividend in 2026, 32,000.00 of it to UK01 ════════════════════
def year_two(gl):
"""FY2026 signed at 2026-12 (DE01 earned 50,000.00 EUR, paid 40,000.00 EUR dividend, every rate 0.86); January 2027 at 0.90."""
calendar_world(gl)
gl.set_rows("DE01", "2026-12", [("G-1100", "1010000.00", "0.00"), ("G-3000", "-500000.00", "0.00"), ("G-3100", "-500000.00", "0.00"),
("G-3100", "40000.00", "40000.00", "", "dividend"), ("G-4000", "-50000.00", "-50000.00")])
gl.set_rows("UK01", "2026-12", [("G-1600", "774000.00", "0.00", "DE01"), ("G-1100", "27520.00", "0.00"), ("G-3000", "-774000.00", "0.00"),
("G-4300", "-27520.00", "-27520.00", "DE01")])
ok(api("POST", "/groups/GRP/group-periods", ACC, {"period_id": "2026-12"}))
close_period("2026-12")
gl.rates[("EUR", "closing", "2027-01-31")] = D("0.900000")
gl.rates[("EUR", "average", "2027-01")] = D("0.900000")
gl.set_rows("DE01", "2027-01", [("G-1100", "1010000.00", "0.00"), ("G-3000", "-500000.00", "0.00"), ("G-3100", "-510000.00", "0.00")])
gl.set_rows("UK01", "2027-01", [("G-1600", "774000.00", "0.00", "DE01"), ("G-1100", "27520.00", "0.00"), ("G-3000", "-801520.00", "0.00")])
ok(api("POST", "/groups/GRP/group-periods", ACC, {"period_id": "2027-01"}))
def test_must_1b_last_years_result_stays_at_its_historical_amount_and_the_cta_is_the_true_retranslation(gl):
year_two(gl)
for step in ("collect-group-data", "translate-currencies"):
ok(api("POST", f"{at('2027-01')}/{step}", ACC, {}))
# net assets 1,010,000 x 0.90 = 909,000; equity at acquisition 860,000; post-acquisition reserves 10,000 EUR earned at 0.86 = 8,600
# -> the translation reserve (with the NCI's share) is 909,000 - 860,000 - 8,600 = 40,400 (credit)
[tr] = [e for e in db(lambda r: r.data.entries("GRP", "2027-01", 9)) if e["document_type"] == "translation" and e["status"] == "posted"]
assert sum(ln["amount"] for ln in tr["lines"]) == D("-40400.00")
def test_must_1c_the_minoritys_share_of_last_years_dividend_opens_the_next_year(gl):
year_two(gl)
carried = {(r["group_account_id"], r["company_code"]): D(r["opening"]) for r in db(lambda rp: rp.data.carry_forward("GRP", "FY2027"))}
# NCI: 20% of the 2026 result 50,000 x 0.86 = 8,600.00 credit, less 20% of the 40,000 x 0.86 dividend = 6,880.00 debit
assert carried.get(("G-3950", "DE01")) == D("-1720.00")
# ═══ MUST-1d: step acquisition fixes the equity twice ════════════════════════════════════════════════════════════════════
def test_must_1d_after_a_step_acquisition_the_equity_is_translated_once_at_the_date_control_was_obtained(gl):
world(gl)
add_rules(("G-4900", "average"), roles=[("gain_on_disposal", "G-4900")])
gl.set_rows("FR01", "2026-03", [("G-1100", "300000.00", "0.00"), ("G-3000", "-300000.00", "0.00")])
acquire("FR01", "30", "150000.00", "equity")
sign_period("2026-09")
gl.set_rows("FR01", "2026-09", [("G-1100", "300000.00", "0.00"), ("G-3000", "-300000.00", "0.00")])
gl.rates[("EUR", "spot", "2026-10-01")] = D("0.880000")
db(lambda r: r.data.add_translated([{"group_id": "GRP", "period_id": "2026-09", "run_no": 1, "company_code": "UK01", "group_account_id": "G-1600",
"partner_company": "FR01", "movement_type": "", "segment": "", "ytd_group": D("129000.00"), "rate_type": "closing",
"rate": D("1.000000")}]))
shown = ok(api("POST", CHANGES, ACC, {"group_id": "GRP", "kind": "ownership_change", "company_code": "FR01", "investor_company_code": "UK01",
"ownership_pct": "60", "method": "full", "effective_date": "2026-10-01", "consideration": "200000.00",
"previously_held_fair_value": "250000.00"}))
ok(api("POST", f"{CHANGES}/{shown['change_id']}/approve-structure-change", CTRL, {}))
gl.set_rows("FR01", P, [("G-1100", "300000.00", "0.00"), ("G-3000", "-300000.00", "0.00")])
gl.set_rows("UK01", P, [("G-1600", "774000.00", "0.00", "DE01"), ("G-1600", "301000.00", "0.00", "FR01"), ("G-3000", "-1075000.00", "0.00")])
for step in ("collect-group-data", "translate-currencies"):
ok(api("POST", f"{BASE}/{step}", ACC, {}))
fr = {r["group_account_id"]: r["ytd_group"] for r in db(lambda r: r.data.translated("GRP", P, state()[0].group_periods[-1].run_no))
if r["company_code"] == "FR01"}
assert fr["G-3000"] == D("-264000.00") # 300,000 EUR fixed at 0.88 on the day control was obtained
# ═══ MUST-1e: an uploaded company's year-to-date figure is stored as its month's movement ═════════════════════════════════
def _upload_month(period, ytd):
csv = f"group_account_id,partner_company,movement_type,segment,ytd_amount\nG-1100,,,,{ytd}\nG-4000,,,,-{ytd}\n"
ok(api("POST", "/groups/GRP/group-periods", ACC, {"period_id": period}))
ok(api("POST", f"{at(period)}/upload-reported-data", ACC, {"company_code": "US01", "currency": "USD", "csv": csv}))
ok(api("POST", f"{at(period)}/accept-reported-upload", CTRL, {"company_code": "US01", "currency": "USD"}))
def test_must_1e_a_company_uploaded_every_month_has_its_income_translated_month_by_month(gl):
from brick_consolidation.domain.group import GroupStructure
world(gl, acquired=False, opened=False)
gl.chart = {c: c for c in ("G-1100", "G-3000", "G-4000")}
for m, r in (("2026-08", "1.000000"), ("2026-09", "2.000000"), ("2026-10", "3.000000")):
gl.rates[("USD", "average", m)] = D(r)
for m in ("2026-04", "2026-05", "2026-06", "2026-07"):
gl.rates[("USD", "average", m)] = D("1.000000")
gl.rates[("USD", "closing", "2026-10-31")] = D("3.000000")
gl.set_rows("UK01", P, [("G-1100", "500.00", "0.00"), ("G-3000", "-500.00", "0.00")])
def structure(repo):
g, v = repo.groups.get("GRP")
g.group_structures.append(GroupStructure(group_id="GRP", company_code="US01", valid_from_period="2026-04", investor_company_code="UK01",
ownership_pct=D("100"), voting_pct=D("100"), method="full", change_id="elsewhere"))
repo.groups.save(g, v)
db(structure)
def signed(period):
def go(repo):
g, v = repo.groups.get("GRP")
next(x for x in g.group_periods if x.period_id == period).status = "signed"
repo.groups.save(g, v)
db(go)
for period, ytd in (("2026-08", "500.00"), ("2026-09", "1000.00"), (P, "2500.00")):
_upload_month(period, ytd)
if period != P:
signed(period)
ok(api("POST", f"{BASE}/collect-group-data", ACC, {}))
ok(api("POST", f"{BASE}/translate-currencies", ACC, {}))
us = {r["group_account_id"]: r["ytd_group"] for r in db(lambda r: r.data.translated("GRP", P, state()[0].group_periods[-1].run_no))
if r["company_code"] == "US01"}
assert us["G-4000"] == D("-6000.00") # 500 x 1 + 500 x 2 + 1,500 x 3
# ═══ MUST-1f: the period of a sale with intercompany balances can never be signed ═════════════════════════════════════════
def test_must_1f_the_sale_period_with_an_intercompany_balance_can_be_signed(gl):
sale_world(gl)
gl.set_rows("UK01", P, [("G-1100", "1055130.00", "0.00"), ("G-1300", "870.00", "0.00", "DE01"), ("G-3000", "-774000.00", "0.00"),
("G-4900", "-282000.00", "-282000.00", "DE01")])
gl.set_rows("DE01", P, [("G-1100", "1061000.00", "0.00"), ("G-2300", "-1000.00", "0.00", "UK01"), ("G-3000", "-500000.00", "0.00"),
("G-3100", "-500000.00", "0.00"), ("G-4000", "-60000.00", "-10000.00")])
ok(api("POST", f"{BASE}/recollect-group-data", ACC, {}))
ok(api("POST", f"{BASE}/translate-currencies", ACC, {}))
status, shown = sell()
assert status == 200, shown
ok(api("POST", f"{CHANGES}/{shown['change_id']}/approve-structure-change", CTRL, {}))
for step in ("collect-group-data", "translate-currencies"):
ok(api("POST", f"{BASE}/{step}", ACC, {}))
gl.recs[("UK01", "DE01")] = None
eliminate()
assert readiness_gate(P, "intercompany_within_tolerance")["met"], readiness_gate(P, "intercompany_within_tolerance")
# ═══ MUST-3: staleness ═════════════════════════════════════════════════════════════════════════════════════════════════
def test_must_3a_a_rule_change_after_the_runs_makes_them_stale(gl):
ready_world(gl)
add_rules(("G-1100", "historical"))
assert not readiness_gate(P, "no_step_stale")["met"]
def test_must_3c_a_group_change_after_the_runs_makes_them_stale(gl):
ready_world(gl)
ok(api("POST", "/groups", ACC, {"group_id": "GRP", "goodwill_life_years": "10"}))
assert not readiness_gate(P, "no_step_stale")["met"]
def test_must_3b_a_change_to_september_makes_october_stale(gl):
group(gl)
acquire()
gl.rates[("EUR", "closing", "2026-09-30")] = D("0.870000")
for p in ("2026-09", P):
gl.set_rows("DE01", p, [("G-1100", "1060000.00", "0.00"), ("G-3000", "-500000.00", "0.00"), ("G-3100", "-500000.00", "0.00"),
("G-4000", "-60000.00", "-10000.00")])
gl.set_rows("UK01", p, [("G-1600", "774000.00", "0.00", "DE01"), ("G-3000", "-774000.00", "0.00")])
ok(api("POST", "/groups/GRP/group-periods", ACC, {"period_id": "2026-09"}))
close_period("2026-09")
ok(api("POST", "/groups/GRP/group-periods", ACC, {"period_id": P}))
for step in ("collect-group-data", "translate-currencies", "run-eliminations"):
ok(api("POST", f"{BASE}/{step}", ACC, {}))
ok(api("POST", f"{at('2026-09')}/reopen-group-period", CTRL2, {"reason": "late accrual"}))
gl.set_rows("DE01", "2026-09", [("G-1100", "1059500.00", "0.00"), ("G-3000", "-500000.00", "0.00"), ("G-3100", "-500000.00", "0.00"),
("G-4000", "-59500.00", "-9500.00")])
ok(api("POST", f"{at('2026-09')}/recollect-group-data", ACC, {}))
assert not readiness_gate(P, "no_step_stale")["met"]
# ═══ MUST-4: self-approval — the preparer is only whoever ran the LAST step ════════════════════════════════════════════════
def test_must_4_whoever_ran_any_step_cannot_sign(gl):
world(gl)
gl.set_rows("DE01", P, [("G-1100", "1060000.00", "0.00"), ("G-3000", "-500000.00", "0.00"), ("G-3100", "-500000.00", "0.00"),
("G-4000", "-60000.00", "-10000.00")])
gl.set_rows("UK01", P, [("G-1600", "774000.00", "0.00", "DE01"), ("G-3000", "-774000.00", "0.00")])
ok(api("POST", f"{BASE}/collect-group-data", ALICE, {}))
ok(api("POST", f"{BASE}/translate-currencies", ALICE, {}))
eliminate() # the group accountant runs the last step
status, body = api("POST", f"{BASE}/sign-off-group-close", ALICE, {})
assert status == 409 and body.get("gate") == "second_person", (status, body)
# ═══ MUST-2/5: machine doors that rewrite a signed period ═════════════════════════════════════════════════════════════════
def test_must_2a_a_signed_periods_statement_cannot_be_overwritten(gl):
ready_world(gl)
ok(api("POST", f"{BASE}/sign-off-group-close", CTRL, {}))
row = next(r for r in rows_of("cons_statement") if r["period_id"] == P)
before = rows_of("cons_statement")
api("POST", "/statements", SYSTEM, {"report": row["report"], "group_id": "GRP", "period_id": P, "node_id": row["node_id"], "amount": "1.00"})
assert rows_of("cons_statement") == before
def test_must_2b_no_entry_is_posted_into_a_signed_period_through_the_auto_entry_door(gl):
ready_world(gl)
ok(api("POST", f"{BASE}/sign-off-group-close", CTRL, {}))
status, body = api("POST", "/cons-journals", SYSTEM, {"cons_journal_id": "forged", "group_id": "GRP", "period_id": P, "document_type": "manual",
"entry_no": 1, "approved_by": "group_controller@group.example"})
assert status != 200, body
def test_must_5_reads_need_cons_group_read(gl):
ready_world(gl)
for path in ("/cons-journals", "/structure-changes", "/statements", "/settings", "/cons-migration-runs", "/ai-findings"):
assert api("GET", path, NOBODY)[0] in (403, 404), path
# ═══ MUST-7: an Odoo fix after the first load is reconciled against Odoo's OLD figures ═════════════════════════════════════
def test_must_7_after_a_fix_in_odoo_the_reloaded_run_does_not_reconcile_equal_on_the_old_figures(gl):
odoo = odoo_world(gl)
ok(api("POST", RUNS, SYSTEM, {"cons_migration_run_id": RUN, "doors_json": DOORS}))
odoo.line_rows[0]["amount"] += D("5.00") # the fix in Odoo: journal 41 corrected
odoo.line_rows[1]["amount"] -= D("5.00")
ok(api("POST", f"{RUNS}/{RUN}/reload-cons-migration", SYSTEM, {}))
ok(api("POST", f"{RUNS}/{RUN}/reconcile-cons-migration", SYSTEM, {}))
checks = {c["check"]: c for c in json.loads(ok(api("GET", f"{RUNS}/{RUN}", as_role("owner")))["checks_json"])}
loaded = sorted(D(str(ln["amount"])) for ln in rows_of("cons_journal_line"))
odoo_now = sorted(ln["amount"] for ln in odoo.line_rows)
assert loaded == odoo_now or checks["consolidated_balance"]["status"] == "differs" or checks["journals"]["status"] == "differs"
# ═══ MUST-1b follow-up: the year's result is fixed on the role retained_earnings, never on a guessed account ════════════════
def _year_end_ready(gl, *, with_role):
calendar_world(gl)
if not with_role:
def drop(repo):
g, v = repo.groups.get("GRP")
g.account_roles = [r for r in g.account_roles if r.role != "retained_earnings"]
repo.groups.save(g, v)
db(drop)
gl.set_rows("DE01", "2026-12", [("G-1100", "1050000.00", "0.00"), ("G-3000", "-500000.00", "0.00"), ("G-3100", "-500000.00", "0.00"),
("G-4000", "-50000.00", "-50000.00")])
gl.set_rows("UK01", "2026-12", [("G-1600", "774000.00", "0.00", "DE01"), ("G-3000", "-774000.00", "0.00")])
ok(api("POST", "/groups/GRP/group-periods", ACC, {"period_id": "2026-12"}))
for step in ("collect-group-data", "translate-currencies", "run-eliminations"):
ok(api("POST", f"{at('2026-12')}/{step}", ACC, {}))
def test_year_end_without_the_retained_earnings_role_is_refused_and_listed_in_readiness(gl):
_year_end_ready(gl, with_role=False)
assert not readiness_gate("2026-12", "account_roles_complete")["met"]
status, body = api("POST", f"{at('2026-12')}/sign-off-group-close", CTRL, {})
assert status == 409 and body.get("gate") == "account_roles_complete" and body.get("fix") == "Settings: account role retained_earnings", body
def test_year_end_with_the_retained_earnings_role_signs_and_fixes_the_result_on_it(gl):
_year_end_ready(gl, with_role=True)
assert readiness_gate("2026-12", "account_roles_complete")["met"]
ok(api("POST", f"{at('2026-12')}/sign-off-group-close", CTRL, {}))
ok(api("POST", "/groups/GRP/group-periods", ACC, {"period_id": "2027-01"}))
fixed = [h for h in db(lambda r: r.data.historical("GRP", ["DE01"])) if h["change_id"] == "year_end:FY2026"]
assert {(h["group_account_id"], D(h["amount_group"])) for h in fixed if D(h["amount_group"])} == {("G-3100", D("-43000.00"))} # 50,000 x 0.86