""" End-to-end Excel export test on small synthetic data (no large files). Generates a miniature Amazon transaction workbook (pivot 'Sheet1' + raw data sheet, 7 preamble rows, header row 8), runs the pipeline, exports the A/R Aging workbook, then reopens it to verify structure, cross-sheet formulas, and subtotal values. """ from __future__ import annotations from datetime import date import openpyxl import pytest from app.core.excel_export import export_workbook, FIELD_ORDER, PREAMBLE, HEADER_LABELS from app.core.pipeline import process TOTAL_IDX = FIELD_ORDER.index("total") def _row(date_time, settlement_id, txn_type, account_type, total, order_id=""): vals = [None] * len(FIELD_ORDER) vals[FIELD_ORDER.index("date_time")] = date_time vals[FIELD_ORDER.index("settlement_id")] = settlement_id vals[FIELD_ORDER.index("txn_type")] = txn_type vals[FIELD_ORDER.index("order_id")] = order_id vals[FIELD_ORDER.index("account_type")] = account_type vals[FIELD_ORDER.index("marketplace")] = "amazon.com" vals[FIELD_ORDER.index("product_sales")] = total vals[TOTAL_IDX] = total return vals def make_amazon_xlsx(path: str, order_rows: int = 12) -> None: wb = openpyxl.Workbook() pivot = wb.active pivot.title = "Sheet1" pivot["A1"] = "user pivot to ignore" ws = wb.create_sheet("USA Amazon Transactions") for line in PREAMBLE: ws.append([line]) ws.append([HEADER_LABELS[f] for f in FIELD_ORDER]) # header row 8 # transfers (received boundary for 200; in-transit for 300) ws.append(_row("Jan 6, 2026 9:00:00 AM PST", 200, "Transfer", "Standard Orders", -1000.0)) ws.append(_row("Jan 30, 2026 9:00:00 AM PST", 300, "Transfer", "Standard Orders", -2000.0)) ws.append(_row("Jan 12, 2026 9:00:00 AM PST", 250, "Transfer", "Invoiced Orders", -300.0)) # paid settlement 100 (Standard) ws.append(_row("Jan 5, 2026 1:00:00 PM PST", 100, "Order", "Standard Orders", 1000.0, "o-100")) # receivable settlement 200 (Standard): `order_rows` rows summing to 2000 per = 2000.0 / order_rows for i in range(order_rows): ws.append(_row("Jan 15, 2026 1:00:00 PM PST", 200, "Order", "Standard Orders", per, f"o200-{i}")) # receivable settlement 300 (Standard): 500 ws.append(_row("Jan 31, 2026 1:00:00 PM PST", 300, "Order", "Standard Orders", 500.0, "o-300")) # Invoiced: 150 paid, 250 receivable 80 ws.append(_row("Jan 10, 2026 1:00:00 PM PST", 150, "Order", "Invoiced Orders", 300.0, "i-150")) ws.append(_row("Jan 20, 2026 1:00:00 PM PST", 250, "Order", "Invoiced Orders", 80.0, "i-250")) wb.save(path) @pytest.fixture() def synth_file(tmp_path): p = tmp_path / "USA synthetic.xlsx" make_amazon_xlsx(str(p), order_rows=12) return str(p) _SUMMARY = { "available": True, "marketplace": "USA", "currency": "USD", "reporting_month": "2026-01", "opening_balance": 1000.0, "net_revenue": 2500.0, "disbursements": -500.0, "closing_receivable": 3000.0, "ledger": [ {"period": "Opening", "description": "Previous month closing balance", "debit": None, "credit": None, "balance": 1000.0}, {"period": "Closing", "description": "Month-end receivable", "debit": None, "credit": None, "balance": 3000.0}, ], } _JOURNAL = { "periods": [{"label": "01–31 Jan 2026"}], "lines": [{"key": "Sales", "gl_account": "Sales:Amazon USA", "values": [2500.0], "total": 2500.0}], "receivable": {"key": "Receivable", "gl_account": "AR:Amazon USA", "values": [3000.0], "total": 3000.0}, } def _run(synth_file, row_limit, with_reports=True): result = process([synth_file], month_end=date(2026, 1, 31), clearing_lag_days=2) out = str(synth_file) + ".aging.xlsx" export_workbook(result, [synth_file], out, reserves={("USA", "Standard Orders"): 0.0}, row_limit=row_limit, summary=_SUMMARY if with_reports else None, journal=_JOURNAL if with_reports else None) return result, out def test_export_structure_and_values(synth_file): result, out = _run(synth_file, row_limit=1_048_576) wb = openpyxl.load_workbook(out) # keep formulas # sheet order assert wb.sheetnames[:3] == ["Summary", "Detail", "COA"] assert "USA" in wb.sheetnames assert "Reconciliation" in wb.sheetnames assert "Exceptions" in wb.sheetnames assert "Processing Audit Trail" in wb.sheetnames usa = wb["USA"] # preamble + header assert usa["A8"].value == "date/time" assert usa["AD8"].value == "total" # transfer rows at top have blank account type (excluded from SUMIFS) assert usa["C9"].value == "Transfer" assert usa["I9"].value in (None, "") # subtotal formula references present formulas = [c.value for col in usa.iter_cols(min_col=30, max_col=30) for c in col if isinstance(c.value, str) and c.value.startswith("=SUMIFS")] assert any("Standard Orders" in f for f in formulas) assert any("Invoiced Orders" in f for f in formulas) # Detail references marketplace subtotal cells & rounds detail = wb["Detail"] add_formula = detail["B5"].value assert isinstance(add_formula, str) and "'USA'!" in add_formula assert detail["B7"].value.startswith("=ROUND(") # Summary references Detail USD cell assert "Detail!" in wb["Summary"]["B6"].value def test_receivable_value_computed_by_openpyxl(synth_file): """Load the generated tab, evaluate the SUMIFS ourselves, confirm 2500 + 80.""" result, out = _run(synth_file, row_limit=1_048_576) wb = openpyxl.load_workbook(out) usa = wb["USA"] # sum the AD data cells by account type from column I std = inv = 0.0 for r in range(9, usa.max_row + 1): acct = usa.cell(row=r, column=FIELD_ORDER.index("account_type") + 1).value tot = usa.cell(row=r, column=TOTAL_IDX + 1).value if not isinstance(tot, (int, float)): continue if acct == "Standard Orders": std += tot elif acct == "Invoiced Orders": inv += tot assert std == pytest.approx(2500.0, abs=0.01) assert inv == pytest.approx(80.0, abs=0.01) # engine agrees assert result.receivable.marketplaces["USA"].receivable_local == 2580 def test_full_workbook_includes_ledger_and_journal(synth_file): """The full audit workbook carries the AR Ledger and Journal Entry sheets.""" _, out = _run(synth_file, row_limit=1_048_576) wb = openpyxl.load_workbook(out) assert "AR Ledger" in wb.sheetnames assert "Journal Entry" in wb.sheetnames ledger = [c.value for row in wb["AR Ledger"].iter_rows() for c in row] assert "Opening" in ledger and "Closing" in ledger and 3000.0 in ledger journal = [c.value for row in wb["Journal Entry"].iter_rows() for c in row] assert "Sales" in journal and "Receivable" in journal def test_full_workbook_without_reports_still_valid(synth_file): """Ledger/journal sheets are optional — the workbook still builds without them.""" _, out = _run(synth_file, row_limit=1_048_576, with_reports=False) wb = openpyxl.load_workbook(out) assert "AR Ledger" not in wb.sheetnames assert wb.sheetnames[:3] == ["Summary", "Detail", "COA"] def test_sheet_split_when_over_row_limit(synth_file): """A tiny row_limit forces the USA marketplace across multiple sheets.""" result, out = _run(synth_file, row_limit=25) wb = openpyxl.load_workbook(out) usa_sheets = [n for n in wb.sheetnames if n.startswith("USA")] assert len(usa_sheets) >= 2, wb.sheetnames # Detail Additional-sales formula sums subtotal cells from every USA sheet add = wb["Detail"]["B5"].value for s in usa_sheets: assert f"'{s}'!" in add # ------------------------------------------------------------------ workbook must tie def test_detail_references_every_account_stream(synth_file): """ Detail/Summary must reference EVERY account stream's subtotal cell. compute_layouts() planned the subtotal rows with a stride of 2 while _finalize_marketplace_subtotals() writes them consecutively, so every stream after the first pointed at an empty cell. USA is the only marketplace with two streams, so the whole Invoiced Orders receivable silently vanished from Detail and Summary (Jan-2026: 67,854.71) while Reconciliation and COA in the same workbook showed it. """ result = process([synth_file], month_end=date(2026, 1, 31), clearing_lag_days=2) out = synth_file.replace(".xlsx", "-streams.xlsx") export_workbook(result, [synth_file], out) wb = openpyxl.load_workbook(out) ws = wb["USA"] # Where the subtotal formulas actually landed. actual = {} for row in ws.iter_rows(): for c in row: if isinstance(c.value, str) and c.value.startswith("=SUMIFS"): actual[ws.cell(row=c.row, column=c.column - 1).value] = c.coordinate streams = set(result.receivable.marketplaces["USA"].accounts) assert set(actual) == streams, f"a stream has no subtotal row: {actual} vs {streams}" detail_formula = wb["Detail"]["B5"].value for stream, coord in actual.items(): assert f"'USA'!{coord}" in detail_formula, ( f"Detail!B5 ({detail_formula}) does not reference the {stream} subtotal at {coord}" ) def test_summary_net_receivable_is_not_circular(synth_file): """'Net Receivable' referenced its own cell, so Excel warned and showed 0.""" result = process([synth_file], month_end=date(2026, 1, 31), clearing_lag_days=2) out = synth_file.replace(".xlsx", "-net.xlsx") export_workbook(result, [synth_file], out, allowance_for_returns=-100.0) ws = openpyxl.load_workbook(out)["Summary"] rows = {ws.cell(row=r, column=1).value: r for r in range(1, ws.max_row + 1)} net_row = rows["Net Receivable"] formula = ws.cell(row=net_row, column=7).value assert f"G{net_row}" not in formula, f"circular reference: G{net_row} in {formula}" assert f"G{rows['TOTAL']}" in formula and f"G{rows['Allowance for Sales Returns']}" in formula def test_every_received_payout_appears_on_the_tab(synth_file): """ A month can have several received payouts per stream; the workbook used to lift only the boundary one, so earlier bank receipts were absent from the entire file and the tab could not be hand-footed against the bank statement. """ result = process([synth_file], month_end=date(2026, 1, 31), clearing_lag_days=2) out = synth_file.replace(".xlsx", "-payouts.xlsx") export_workbook(result, [synth_file], out) ws = openpyxl.load_workbook(out)["USA"] in_book = sorted(r[TOTAL_IDX] for r in ws.iter_rows(min_row=9, values_only=True) if r and r[FIELD_ORDER.index("txn_type")] == "Transfer") received = sorted(t.amount for t in result.aggregation.transfers if t.received) assert in_book == received, f"workbook payouts {in_book} != received payouts {received}" def test_settled_settlements_sheet_reconciles(synth_file): """The excluded settlements are listed and their rows + the included rows = every row.""" result = process([synth_file], month_end=date(2026, 1, 31), clearing_lag_days=2) out = synth_file.replace(".xlsx", "-settled.xlsx") export_workbook(result, [synth_file], out) ws = openpyxl.load_workbook(out)["Settled Settlements"] labels = {ws.cell(row=r, column=1).value: r for r in range(1, ws.max_row + 1)} excluded = ws.cell(row=labels["Excluded (settled) order rows"], column=6).value included = ws.cell(row=labels["Included (open) order rows — in the marketplace tabs"], column=6).value total = ws.cell(row=labels["Total order rows in the source files"], column=6).value assert excluded + included == total engine_total = sum(st.row_count - st.transfer_count for st in result.aggregation.settlements.values()) assert total == engine_total, f"sheet says {total} order rows, engine has {engine_total}" # Every settled settlement is named, so the omission is documented rather than silent. listed = {ws.cell(row=r, column=3).value for r in range(5, ws.max_row + 1)} settled = {sid for (m, a, sid), st in result.aggregation.settlements.items() if st.status != "receivable" and (st.row_count - st.transfer_count) > 0} assert settled <= listed, f"settled settlements missing from the sheet: {settled - listed}"