""" FP&A Test Suite — stdlib only, no pytest Run: python tests/test_fpa.py python tests/test_fpa.py --api # include live API tests python tests/test_fpa.py --url http://localhost:9000 """ import csv import json import os import sys import traceback import urllib.request import urllib.error from dataclasses import dataclass, field from typing import Callable, List, Optional DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "csv") API_BASE = os.getenv("FPA_API_URL", "http://localhost:8080") # ── Tiny test runner ────────────────────────────────────────────────────────── @dataclass class Result: name: str passed: bool skipped: bool = False message: str = "" class Suite: def __init__(self, name: str): self.name = name self.results: List[Result] = [] def run(self, label: str, fn: Callable): try: fn() self.results.append(Result(label, passed=True)) except SkipTest as e: self.results.append(Result(label, passed=False, skipped=True, message=str(e))) except AssertionError as e: self.results.append(Result(label, passed=False, message=str(e))) except Exception as e: self.results.append(Result(label, passed=False, message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}")) class SkipTest(Exception): pass def skip(reason: str): raise SkipTest(reason) # ── CSV helper ──────────────────────────────────────────────────────────────── def read_csv(filename: str) -> List[dict]: path = os.path.join(DATA_DIR, filename) if not os.path.exists(path): skip(f"{filename} not found — run generators/generate_data.py first") with open(path, newline="") as f: return list(csv.DictReader(f)) # ── HTTP helpers (stdlib only) ──────────────────────────────────────────────── def http_get(url: str, timeout: int = 5): try: with urllib.request.urlopen(url, timeout=timeout) as resp: body = json.loads(resp.read().decode()) return resp.status, body except urllib.error.HTTPError as e: try: body = json.loads(e.read().decode()) except Exception: body = {} return e.code, body except Exception: return 0, {} def http_post(url: str, payload: dict, timeout: int = 10): data = json.dumps(payload).encode() req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") try: with urllib.request.urlopen(req, timeout=timeout) as resp: raw = resp.read() body = json.loads(raw.decode()) if raw else {} return resp.status, body except urllib.error.HTTPError as e: try: body = json.loads(e.read().decode()) except Exception: body = {} return e.code, body except Exception: return 0, {} def http_put(url: str, payload: dict, timeout: int = 10): data = json.dumps(payload).encode() req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="PUT") try: with urllib.request.urlopen(req, timeout=timeout) as resp: raw = resp.read() body = json.loads(raw.decode()) if raw else {} return resp.status, body except urllib.error.HTTPError as e: return e.code, {} except Exception: return 0, {} def api_available() -> bool: status, _ = http_get(f"{API_BASE}/api/v1/health", timeout=2) return status == 200 # ── Assertion helpers ───────────────────────────────────────────────────────── def assert_true(condition, message: str = ""): if not condition: raise AssertionError(message or "Expected True, got False") def assert_eq(actual, expected, message: str = ""): if actual != expected: raise AssertionError(message or f"Expected {expected!r}, got {actual!r}") # ── Reference data ─────────────────────────────────────────────────────────── # # Departments and GL accounts are CREATED via the API before any budget/actual. # seed_reference_data() POSTs them and stores returned auto-increment IDs into # DEPARTMENT_IDS and GL_ACCOUNTS at runtime — nothing is hardcoded. # Uses ON CONFLICT upsert on the Go side so re-runs are safe. BUDGET_VERSION = "v1" # matches BudgetVersion enum in your Go service # Runtime ID maps — populated by seed_reference_data() DEPARTMENT_IDS: dict = {} GL_ACCOUNTS: dict = {} # category name → {"id": int, "code": str} _DEPARTMENT_DEFS = [ # code, name, cost_center — matches departments table exactly {"code": "REV", "name": "Revenue", "cost_center": "CC-100", "active": True}, {"code": "ENG", "name": "Engineering", "cost_center": "CC-200", "active": True}, {"code": "SAL", "name": "Sales", "cost_center": "CC-300", "active": True}, {"code": "MKT", "name": "Marketing", "cost_center": "CC-400", "active": True}, {"code": "OPS", "name": "Operations", "cost_center": "CC-500", "active": True}, {"code": "FIN", "name": "Finance", "cost_center": "CC-600", "active": True}, ] _GL_ACCOUNT_DEFS = [ # code, description, type (revenue|cogs|opex|capex|headcount), favour_high # favour_high=True means over-budget is good (i.e. revenue beating target) # Revenue {"code": "4000", "description": "SaaS Subscription Revenue", "type": "revenue", "favour_high": True}, {"code": "4100", "description": "Professional Services Revenue","type": "revenue", "favour_high": True}, # COGS {"code": "5000", "description": "Cost of Goods — Product", "type": "cogs", "favour_high": False}, {"code": "5100", "description": "Cost of Goods — Service", "type": "cogs", "favour_high": False}, # Opex {"code": "6000", "description": "Salaries and Wages", "type": "opex", "favour_high": False}, {"code": "6100", "description": "Software and SaaS Tools", "type": "opex", "favour_high": False}, {"code": "6200", "description": "Travel and Expenses", "type": "opex", "favour_high": False}, {"code": "6300", "description": "Marketing and Paid Media", "type": "opex", "favour_high": False}, {"code": "6400", "description": "Cloud Infrastructure", "type": "opex", "favour_high": False}, {"code": "6500", "description": "Contractors and Freelancers","type": "opex", "favour_high": False}, {"code": "6600", "description": "Office and Facilities", "type": "opex", "favour_high": False}, # Capex {"code": "7000", "description": "Capital Expenditure", "type": "capex", "favour_high": False}, # Headcount {"code": "9200", "description": "Headcount Cost", "type": "headcount", "favour_high": False}, ] # Maps CSV category names → GL codes so the seeder can find the right account _CSV_CATEGORY_TO_GL_CODE = { "Product": "4000", "Service": "4100", "Salaries": "6000", "Software & Tools": "6100", "Travel": "6200", "Marketing Spend": "6300", "Cloud Infrastructure": "6400", "Contractors": "6500", "Office & Facilities": "6600", "P&L Summary": "4000", # roll up to revenue GL for summary lines "Cash Flow": "7000", "Headcount": "9200", } def seed_reference_data() -> Optional[str]: """ POST all departments and GL accounts to the API and store returned IDs. Returns an error string on first failure, or None on success. Safe to call multiple times — Go side uses ON CONFLICT(code) upsert. Populates: DEPARTMENT_IDS — {"Engineering": 3, ...} keyed by department name GL_ACCOUNTS — {"4000": {"id": 1, "code": "4000"}, ...} keyed by GL code """ for defn in _DEPARTMENT_DEFS: status, body = http_post(f"{API_BASE}/api/v1/departments", defn) if status not in (200, 201): return f"POST /departments '{defn['name']}' failed ({status}): {body}" DEPARTMENT_IDS[defn["name"]] = body["id"] for defn in _GL_ACCOUNT_DEFS: status, body = http_post(f"{API_BASE}/api/v1/gl-accounts", defn) if status not in (200, 201): return f"POST /gl-accounts '{defn['code']}' failed ({status}): {body}" GL_ACCOUNTS[defn["code"]] = {"id": body["id"], "code": defn["code"]} return None # success def gl(category: str) -> dict: """ Resolve a CSV category name to a seeded GL account dict {"id": int, "code": str}. Looks up via _CSV_CATEGORY_TO_GL_CODE then into the runtime GL_ACCOUNTS map. """ code = _CSV_CATEGORY_TO_GL_CODE.get(category) if code is None: raise AssertionError( f"No GL code mapping for CSV category '{category}'. " f"Add it to _CSV_CATEGORY_TO_GL_CODE." ) acct = GL_ACCOUNTS.get(code) if acct is None: raise AssertionError( f"GL code '{code}' (for '{category}') not in runtime map — " f"was seed_reference_data() called? Known codes: {list(GL_ACCOUNTS.keys())}" ) return acct def period_to_fiscal(period: str) -> tuple: """'2023-04' → (2023, 4)""" year, month = period.split("-") return int(year), int(month) # ── suite_revenue — full API round-trip ─────────────────────────────────────── # # Flow: # 1. Load CSV rows (local data validation) # 2. POST each row as a CreateBudgetRequest → /api/v1/budgets # 3. POST actuals for the same rows → /api/v1/actuals/ingest # 4. GET /api/v1/variance → verify amounts round-trip correctly def suite_revenue() -> Suite: """ Maps to Go structs: CreateBudgetRequest: fiscal_year, fiscal_period, version, department_id, gl_account_id, amount, currency, notes, created_by Actual (ingest): fiscal_year, fiscal_period, department_id, gl_account_id, gl_code, amount, currency, source """ s = Suite("Revenue — CSV + API round-trip") if not api_available(): s.run("API reachable", lambda: skip(f"Go API not running at {API_BASE}")) return s rows = [] # ── Step 1: load and validate CSV data ──────────────────────────────────── def load(): rows.extend(read_csv("revenue_budget_vs_actuals.csv")) s.run("CSV loads without error", load) s.run("CSV has 48 rows (24 months × 2 revenue types)", lambda: assert_eq(len(rows), 48)) s.run("CSV revenue types are Product and Service only", lambda: assert_eq({r["revenue_type"] for r in rows}, {"Product", "Service"})) def check_csv_variance(): for r in rows: diff = float(r["actual_amount"]) - float(r["budget_amount"]) assert_true(abs(diff - float(r["variance"])) < 0.01, f"CSV variance mismatch in {r['period']}") s.run("CSV variance = actual − budget", check_csv_variance) # ── Step 2: seed departments + GL accounts ──────────────────────────────── # Must succeed before any budget POST — these rows are the FK parents. def seed_refs(): err = seed_reference_data() assert_true(err is None, err or "seed_reference_data failed") s.run("POST /api/v1/departments + /api/v1/gl-accounts (seed FK parents)", seed_refs) # ── Step 3: POST budgets ────────────────────────────────────────────────── # One CreateBudgetRequest per CSV row. # We only post the first period (2023-01) for both types to keep the # test focused; the loader handles the full dataset. budget_ids: dict[str, int] = {} # key: "revenue_type:period" → returned id def post_product_budget(): row = next(r for r in rows if r["revenue_type"] == "Product" and r["period"] == "2023-01") fy, fp = period_to_fiscal(row["period"]) payload = { "fiscal_year": fy, "fiscal_period": fp, "version": BUDGET_VERSION, "department_id": DEPARTMENT_IDS["Revenue"], "gl_account_id": gl("Product")["id"], "amount": float(row["budget_amount"]), "currency": "USD", "notes": "Product revenue budget — csv import", "created_by": "test_suite", } status, body = http_post(f"{API_BASE}/api/v1/budgets", payload) assert_true(status in (200, 201), f"POST /budgets failed ({status}): {body}") budget_id = body.get("id") assert_true(budget_id is not None, "Response missing 'id' field") budget_ids["Product:2023-01"] = budget_id s.run("POST /api/v1/budgets — Product revenue 2023-01", post_product_budget) def post_service_budget(): row = next(r for r in rows if r["revenue_type"] == "Service" and r["period"] == "2023-01") fy, fp = period_to_fiscal(row["period"]) payload = { "fiscal_year": fy, "fiscal_period": fp, "version": BUDGET_VERSION, "department_id": DEPARTMENT_IDS["Revenue"], "gl_account_id": gl("Service")["id"], "amount": float(row["budget_amount"]), "currency": "USD", "notes": "Service revenue budget — csv import", "created_by": "test_suite", } status, body = http_post(f"{API_BASE}/api/v1/budgets", payload) assert_true(status in (200, 201), f"POST /budgets failed ({status}): {body}") budget_ids["Service:2023-01"] = body.get("id") s.run("POST /api/v1/budgets — Service revenue 2023-01", post_service_budget) # ── Step 4: POST actuals ────────────────────────────────────────────────── # Ingest actual amounts for the same period so variance can be computed. ingested_actuals: list[dict] = [] def post_actuals(): period_rows = [r for r in rows if r["period"] == "2023-01"] records = [] for r in period_rows: fy, fp = period_to_fiscal(r["period"]) account = gl(r["revenue_type"]) records.append({ "fiscal_year": fy, "fiscal_period": fp, "department_id": DEPARTMENT_IDS["Revenue"], "gl_account_id": account["id"], "gl_code": account["code"], "amount": float(r["actual_amount"]), "currency": "USD", "source": "test_suite_csv", }) status, body = http_post(f"{API_BASE}/api/v1/actuals/ingest", {"records": records}) assert_true(status in (200, 201), f"POST /actuals/ingest failed ({status}): {body}") # Store returned actuals for later assertions returned = body.get("actuals") or body.get("records") or [] ingested_actuals.extend(returned) s.run("POST /api/v1/actuals/ingest — Product + Service 2023-01", post_actuals) # ── Step 5: GET variance and verify numbers ─────────────────────────────── variance_data: list[dict] = [] def fetch_variance(): url = f"{API_BASE}/api/v1/variance?fiscal_year=2023&fiscal_period=1" status, body = http_get(url) assert_true(status == 200, f"GET /variance failed ({status}): {body}") # API may return a list directly or wrapped in a key items = body if isinstance(body, list) else body.get("variance") or body.get("data") or [] assert_true(len(items) > 0, "Variance response is empty — data may not have landed") variance_data.extend(items) s.run("GET /api/v1/variance returns entries for 2023-01", fetch_variance) def verify_product_variance(): csv_row = next(r for r in rows if r["revenue_type"] == "Product" and r["period"] == "2023-01") expected_budget = float(csv_row["budget_amount"]) expected_actual = float(csv_row["actual_amount"]) expected_variance = float(csv_row["variance"]) # Find the matching variance entry by GL account id entry = next( (v for v in variance_data if v.get("gl_account_id") == gl("Product")["id"] or v.get("gl_code") == gl("Product")["code"]), None ) assert_true(entry is not None, f"No variance entry found for Product (GL {gl('Product')['id']})") api_budget = float(entry.get("budget_amount") or entry.get("budget") or 0) api_actual = float(entry.get("actual_amount") or entry.get("actual") or 0) api_variance = float(entry.get("variance") or entry.get("variance_amount") or 0) assert_true(abs(api_budget - expected_budget) < 1.0, f"Budget mismatch: API={api_budget} CSV={expected_budget}") assert_true(abs(api_actual - expected_actual) < 1.0, f"Actual mismatch: API={api_actual} CSV={expected_actual}") assert_true(abs(api_variance - expected_variance) < 1.0, f"Variance mismatch: API={api_variance} CSV={expected_variance}") s.run("Variance amounts match CSV for Product 2023-01", verify_product_variance) def verify_service_variance(): csv_row = next(r for r in rows if r["revenue_type"] == "Service" and r["period"] == "2023-01") expected_budget = float(csv_row["budget_amount"]) expected_actual = float(csv_row["actual_amount"]) expected_variance = float(csv_row["variance"]) entry = next( (v for v in variance_data if v.get("gl_account_id") == gl("Service")["id"] or v.get("gl_code") == gl("Service")["code"]), None ) assert_true(entry is not None, f"No variance entry found for Service (GL {gl('Service')['id']})") api_budget = float(entry.get("budget_amount") or entry.get("budget") or 0) api_actual = float(entry.get("actual_amount") or entry.get("actual") or 0) api_variance = float(entry.get("variance") or entry.get("variance_amount") or 0) assert_true(abs(api_budget - expected_budget) < 1.0, f"Budget mismatch: API={api_budget} CSV={expected_budget}") assert_true(abs(api_actual - expected_actual) < 1.0, f"Actual mismatch: API={api_actual} CSV={expected_actual}") assert_true(abs(api_variance - expected_variance) < 1.0, f"Variance mismatch: API={api_variance} CSV={expected_variance}") s.run("Variance amounts match CSV for Service 2023-01", verify_service_variance) # ── Step 6: update a budget and verify variance shifts ──────────────────── def update_and_reverify(): budget_id = budget_ids.get("Product:2023-01") if not budget_id: skip("Product budget id not captured — skipping update test") csv_row = next(r for r in rows if r["revenue_type"] == "Product" and r["period"] == "2023-01") revised_amount = float(csv_row["budget_amount"]) * 1.10 # +10% revision fy, fp = period_to_fiscal("2023-01") status, body = http_put( f"{API_BASE}/api/v1/budgets/{budget_id}", { "fiscal_year": fy, "fiscal_period": fp, "version": BUDGET_VERSION, "department_id": DEPARTMENT_IDS["Revenue"], "gl_account_id": gl("Product")["id"], "amount": revised_amount, "currency": "USD", "notes": "revised +10% mid-cycle", "created_by": "test_suite", } ) assert_true(status in (200, 204), f"PUT /budgets/{budget_id} failed ({status}): {body}") # Re-fetch variance — the variance amount should now reflect the new budget url = f"{API_BASE}/api/v1/variance?fiscal_year=2023&fiscal_period=1" status2, body2 = http_get(url) assert_true(status2 == 200, f"Re-fetch variance failed ({status2})") items = body2 if isinstance(body2, list) else body2.get("variance") or body2.get("data") or [] entry = next( (v for v in items if v.get("gl_account_id") == gl("Product")["id"] or v.get("gl_code") == gl("Product")["code"]), None ) assert_true(entry is not None, "Product variance entry missing after budget update") api_budget = float(entry.get("budget_amount") or entry.get("budget") or 0) assert_true(abs(api_budget - revised_amount) < 1.0, f"Updated budget not reflected: API={api_budget} expected={revised_amount:.2f}") s.run("PUT /api/v1/budgets/{id} — variance reflects revised budget", update_and_reverify) return s # ── Remaining CSV-only suites (unchanged) ───────────────────────────────────── def suite_opex() -> Suite: s = Suite("Opex CSV") rows = [] def load(): rows.extend(read_csv("opex_budget_vs_actuals.csv")) s.run("loads without error", load) s.run("has rows", lambda: assert_true(len(rows) > 0)) s.run("all departments present", lambda: assert_true( {"Engineering", "Sales", "Marketing", "Operations"}.issubset( {r["department"] for r in rows} ))) def check_no_zero(): for r in rows: assert_true(float(r["budget_amount"]) > 0, f"Zero budget: {r['department']} / {r['category']}") s.run("no zero budget amounts", check_no_zero) def check_sign(): for r in rows: actual = float(r["actual_amount"]) budget = float(r["budget_amount"]) variance = float(r["variance"]) exp = 1 if actual > budget else (-1 if actual < budget else 0) got = 1 if variance > 0 else (-1 if variance < 0 else 0) assert_eq(got, exp, f"Variance sign wrong in {r['period']} {r['category']}") s.run("variance sign matches actual vs budget", check_sign) return s def suite_pl() -> Suite: s = Suite("P&L CSV") rows = [] def load(): rows.extend(read_csv("pl_income_statement.csv")) s.run("loads without error", load) s.run("exactly 24 rows", lambda: assert_eq(len(rows), 24)) def check_rev_sum(): for r in rows: total = float(r["product_revenue"]) + float(r["service_revenue"]) assert_true(abs(total - float(r["total_revenue"])) < 0.05, f"Revenue sum mismatch in {r['period']}") s.run("total_revenue = product + service", check_rev_sum) def check_gross(): for r in rows: gp = float(r["total_revenue"]) - float(r["total_cogs"]) assert_true(abs(gp - float(r["gross_profit"])) < 0.05, f"Gross profit mismatch in {r['period']}") s.run("gross_profit = revenue − cogs", check_gross) def check_margin(): for r in rows: gm = float(r["gross_margin_pct"]) assert_true(30 <= gm <= 90, f"Gross margin {gm}% out of range in {r['period']}") s.run("gross margin between 30% and 90%", check_margin) def check_ebitda(): for r in rows: ebitda = float(r["gross_profit"]) - float(r["total_opex"]) assert_true(abs(ebitda - float(r["ebitda"])) < 0.05, f"EBITDA mismatch in {r['period']}") s.run("ebitda = gross_profit − opex", check_ebitda) return s def suite_cashflow() -> Suite: s = Suite("Cash Flow CSV") rows = [] def load(): rows.extend(read_csv("cash_flow.csv")) s.run("loads without error", load) s.run("exactly 24 rows", lambda: assert_eq(len(rows), 24)) def check_net(): for r in rows: total = (float(r["net_operating_cash_flow"]) + float(r["net_investing_cash_flow"]) + float(r["net_financing_cash_flow"])) assert_true(abs(total - float(r["net_change_in_cash"])) < 0.05, f"Cash flow sum mismatch in {r['period']}") s.run("net_change = operating + investing + financing", check_net) def check_pre_series_a(): for r in rows: if r["period"] <= "2023-05": assert_true(float(r["closing_cash_balance"]) > 0, f"Negative cash too early: {r['period']}") s.run("cash balance positive before Series A (pre 2023-06)", check_pre_series_a) def check_series_a(): june = next((r for r in rows if r["period"] == "2023-06"), None) assert_true(june is not None, "2023-06 row missing") assert_true(float(june["equity_raised"]) > 0, "Series A not recorded in 2023-06") s.run("Series A visible in 2023-06", check_series_a) return s def suite_headcount() -> Suite: s = Suite("Headcount CSV") rows = [] def load(): rows.extend(read_csv("headcount_workforce.csv")) s.run("loads without error", load) s.run("has rows", lambda: assert_true(len(rows) > 0)) s.run("status only Active or Terminated", lambda: assert_true({r["status"] for r in rows}.issubset({"Active", "Terminated"}))) def check_fte(): for r in rows: fte = float(r["headcount_fte"]) assert_true(0 < fte <= 1.0, f"FTE {fte} out of range for {r['employee_id']}") s.run("FTE between 0 and 1.0", check_fte) def check_salary(): for r in rows: assert_true(float(r["annual_salary_budget"]) > 0) s.run("all salaries positive", check_salary) def check_growth(): jan = [r for r in rows if r["period"] == "2023-01" and r["status"] == "Active"] dec = [r for r in rows if r["period"] == "2024-12" and r["status"] == "Active"] assert_true(len(dec) >= len(jan), f"Headcount shrank: {len(jan)} in Jan 2023 → {len(dec)} in Dec 2024") s.run("headcount grows from 2023-01 to 2024-12", check_growth) return s # ── Reporter ────────────────────────────────────────────────────────────────── PASS = "\033[32m✓\033[0m" FAIL = "\033[31m✗\033[0m" SKIP = "\033[33m⊘\033[0m" BOLD = "\033[1m" RESET = "\033[0m" def run_suites(suites: List[Suite]) -> bool: total = passed = failed = skipped = 0 for suite in suites: print(f"\n{BOLD}{suite.name}{RESET}") for r in suite.results: total += 1 if r.skipped: skipped += 1 print(f" {SKIP} {r.name}") print(f" {r.message}") elif r.passed: passed += 1 print(f" {PASS} {r.name}") else: failed += 1 print(f" {FAIL} {r.name}") for line in r.message.strip().splitlines(): print(f" {line}") print(f"\n{'─' * 48}") color = "\033[32m" if failed == 0 else "\033[31m" print(f"{color}{BOLD}{passed}/{total} passed{RESET}" + (f" {SKIP} {skipped} skipped" if skipped else "") + (f" {FAIL} {failed} failed" if failed else "")) print() return failed == 0 # ── Entry point ─────────────────────────────────────────────────────────────── if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="FP&A test suite — no dependencies needed") parser.add_argument("--api", action="store_true", help="Include revenue round-trip API test (requires Go API running)") parser.add_argument("--url", default=None, help="Override API base URL") args = parser.parse_args() if args.url: API_BASE = args.url # suite_revenue always runs — it skips the API steps gracefully if not available suites = [ suite_revenue(), suite_opex(), suite_pl(), suite_cashflow(), suite_headcount(), ] ok = run_suites(suites) sys.exit(0 if ok else 1)