""" Session deletion (cascade) and the three opening-balance modes. Both were user-reported issues: * deleting a closing failed with a FOREIGN KEY error because child rows blocked it * the opening balance auto-seeded, with no way to choose zero / carry-forward / manual """ from __future__ import annotations import os import tempfile # Fixture .xlsx files only — data/DB isolation is guaranteed centrally by conftest.py, which # must stay the single owner of AR_DATA_DIR (overriding it here trips the isolation guard). _TMP = tempfile.mkdtemp(prefix="ar_lifecycle_test_") from fastapi.testclient import TestClient # noqa: E402 from app.api.main import app # noqa: E402 from app.db import models # noqa: E402 from app.db.database import SessionLocal, init_db # noqa: E402 from tests.test_excel_export import make_amazon_xlsx # noqa: E402 def _new(c, name: str, month_end: str, **kw) -> int: body = {"name": name, "month_end_date": month_end, "clearing_lag_days": 2, **kw} r = c.post("/api/sessions", json=body) assert r.status_code == 200, r.text return r.json()["id"] def _processed_session(c, name: str, month_end: str, **kw) -> int: sid = _new(c, name, month_end, **kw) path = os.path.join(_TMP, f"USA {name}.xlsx") make_amazon_xlsx(path, order_rows=6) with open(path, "rb") as fh: assert c.post(f"/api/sessions/{sid}/files", files={"files": (os.path.basename(path), fh)}).status_code == 200 assert c.post(f"/api/sessions/{sid}/process").status_code == 200 assert c.get(f"/api/sessions/{sid}/status").json()["status"] == "processed" return sid # --------------------------------------------------------------------- deletion def test_delete_removes_session_and_all_children(): init_db() with TestClient(app) as c: sid = _processed_session(c, "to delete", "2026-01-31") db = SessionLocal() assert db.query(models.Transaction).filter_by(session_id=sid).count() > 0 db.close() r = c.delete(f"/api/sessions/{sid}") assert r.status_code == 200, r.text assert r.json()["deleted"] == sid assert r.json()["rows_deleted"] # reported what it removed assert c.get(f"/api/sessions/{sid}").status_code == 404 db = SessionLocal() for model in (models.Transaction, models.Settlement, models.ReceivableResultRow, models.ReconciliationRow, models.MarketPayout, models.Exception_, models.OpeningBalance, models.FxRate, models.Reserve, models.JournalEntry, models.SessionFile): assert db.query(model).filter_by(session_id=sid).count() == 0, model.__tablename__ db.close() def test_delete_blocked_while_processing(): init_db() with TestClient(app) as c: sid = _new(c, "busy", "2026-01-31") db = SessionLocal() db.get(models.Session, sid).status = "processing" db.commit() db.close() assert c.delete(f"/api/sessions/{sid}").status_code == 409 db = SessionLocal() # tidy up db.get(models.Session, sid).status = "draft" db.commit() db.close() assert c.delete(f"/api/sessions/{sid}").status_code == 200 # --------------------------------------------------------- opening-balance modes def test_opening_mode_zero_is_the_default(): init_db() with TestClient(app) as c: prior = _processed_session(c, "prior for zero", "2026-01-31") c.put(f"/api/sessions/{prior}/opening-balances", json=[{"marketplace": "USA", "amount": 5000.0, "reason": "x", "source": "manual"}]) sid = _new(c, "zero month", "2026-02-28") # no opening_mode passed assert c.get(f"/api/sessions/{sid}").json()["opening_mode"] == "zero" assert all(o["amount"] == 0.0 for o in c.get(f"/api/sessions/{sid}/opening-balances").json()) def test_opening_mode_carry_forward_at_creation(): init_db() with TestClient(app) as c: prior = _processed_session(c, "prior carry", "2026-03-31") closing = c.get(f"/api/sessions/{prior}/ar-movement").json()["closing"] assert closing != 0 sid = _new(c, "carry month", "2026-04-30", opening_mode="carry_forward", opening_source_session_id=prior) s = c.get(f"/api/sessions/{sid}").json() assert s["opening_mode"] == "carry_forward" usa = [o for o in c.get(f"/api/sessions/{sid}/opening-balances").json() if o["marketplace"] == "USA"][0] assert usa["amount"] == closing assert usa["source"] == "carried_forward" def test_carry_forward_and_reset_endpoints(): init_db() with TestClient(app) as c: prior = _processed_session(c, "prior endpoints", "2026-05-31") closing = c.get(f"/api/sessions/{prior}/ar-movement").json()["closing"] sid = _processed_session(c, "target endpoints", "2026-06-30") # starts at zero assert c.get(f"/api/sessions/{sid}/ar-movement").json()["opening"] == 0.0 cands = c.get(f"/api/sessions/{sid}/opening-candidates").json() assert any(x["session_id"] == prior for x in cands["candidates"]) r = c.post(f"/api/sessions/{sid}/opening-balances/carry-forward", json={"from_session_id": prior}) assert r.status_code == 200, r.text assert r.json()["applied"] >= 1 mv = c.get(f"/api/sessions/{sid}/ar-movement").json() assert mv["opening"] == closing # the roll-forward must move with the new opening assert round(mv["closing"], 2) == round( closing + mv["net_revenue"] + mv["received_payouts"], 2) assert c.post(f"/api/sessions/{sid}/opening-balances/reset").status_code == 200 assert c.get(f"/api/sessions/{sid}/ar-movement").json()["opening"] == 0.0 assert c.get(f"/api/sessions/{sid}").json()["opening_mode"] == "zero" def test_carry_forward_with_no_prior_falls_back_to_zero(): """A first-ever closing that asks to carry forward must still be creatable.""" init_db() with TestClient(app) as c: # a month earlier than every processed closing -> no candidates sid = _new(c, "earliest", "2020-01-31", opening_mode="carry_forward") s = c.get(f"/api/sessions/{sid}").json() assert s["opening_mode"] == "zero" assert c.delete(f"/api/sessions/{sid}").status_code == 200 # --------------------------------------------------------------- opening worksheet def test_opening_worksheet_shows_variance_and_saving_revalidates(): """ The all-markets worksheet: every marketplace's opening in one call, with the variance the roll-forward produces against the settlement method, and the implied opening that would close it. Saving an opening must re-run the month-end controls (C4 feeds off it). """ init_db() with TestClient(app) as c: sid = _processed_session(c, "worksheet", "2026-07-31") ws = c.get(f"/api/sessions/{sid}/opening-balances/worksheet").json() assert ws["available"] and ws["all_zero"] row = next(r for r in ws["rows"] if r["marketplace"] == "USA") # roll-forward = opening + net revenue + payouts(negative); with opening 0 the # variance against the settlement closing is closed exactly by `implied_opening`. assert row["opening"] == 0.0 assert row["implied_opening"] == round( row["settlement_closing"] - row["movement"], 2) # Save an opening -> mode flips to manual, movement shifts by exactly that amount, # and the controls have been re-evaluated (C4 present with a fresh verdict). r = c.put(f"/api/sessions/{sid}/opening-balances", json=[{"marketplace": "USA", "amount": 1234.56, "reason": "prior close", "source": "manual"}]) assert r.status_code == 200 ws2 = c.get(f"/api/sessions/{sid}/opening-balances/worksheet").json() row2 = next(r for r in ws2["rows"] if r["marketplace"] == "USA") assert row2["opening"] == 1234.56 assert not ws2["all_zero"] assert ws2["mode"] == "manual" assert round(row2["roll_forward_closing"] - row["roll_forward_closing"], 2) == 1234.56 ctrl = c.get(f"/api/sessions/{sid}/controls").json() assert any(x["key"] == "C4" for x in ctrl["controls"]) def test_carry_forward_fills_next_month_opening_automatically(): """User requirement: with carry-forward, last month's closing IS next month's opening — no manual entry.""" init_db() with TestClient(app) as c: prior = _processed_session(c, "june close", "2026-06-30") prior_ws = c.get(f"/api/sessions/{prior}/opening-balances/worksheet").json() prior_closing = next(r for r in prior_ws["rows"] if r["marketplace"] == "USA")["roll_forward_closing"] nxt = _processed_session(c, "july close", "2026-07-31", opening_mode="carry_forward", opening_source_session_id=prior) ws = c.get(f"/api/sessions/{nxt}/opening-balances/worksheet").json() row = next(r for r in ws["rows"] if r["marketplace"] == "USA") assert row["source"] == "carried_forward" assert row["opening"] == round(prior_closing, 2) assert ws["mode"] == "carry_forward" # --------------------------------------------------------------- journal sign-off def test_journal_review_approve_publishes_to_accounts_summary(): """Review → approve is the publish step; re-processing withdraws the sign-off.""" init_db() with TestClient(app) as c: sid = _processed_session(c, "signoff", "2026-07-31") j = c.get(f"/api/sessions/{sid}/journal").json() assert j["available"] and j["approved_by"] == "" # Advertising line exists and Transfer is still in the payload (movement needs it). keys = [ln["key"] for ln in j["lines"]] assert "Advertising Cost" in keys and "Transfer" in keys # The accrual balancing figure equals -(sum of non-Transfer lines) = net revenue. non_transfer = sum(ln["total"] for ln in j["lines"] if ln["key"] != "Transfer") assert j["receivable_accrual"]["total"] == round(-non_transfer, 2) # GL accounts carry the journal's own marketplace, not a hardcoded USA. assert any("Amazon USA" in ln["gl_account"] for ln in j["lines"]) # single-market USA # Approval without review is refused. assert c.post(f"/api/sessions/{sid}/journal/approve", json={"name": "boss"}).status_code == 400 # Nothing is published yet. assert c.get("/api/accounts-summary").json()["available"] is False assert c.post(f"/api/sessions/{sid}/journal/review", json={"name": "A. Accountant"}).status_code == 200 approved = c.post(f"/api/sessions/{sid}/journal/approve", json={"name": "B. Controller"}).json() assert approved["approved_by"] == "B. Controller" summ = c.get("/api/accounts-summary").json() assert summ["available"] assert summ["months"][0]["approved_by"] == "B. Controller" assert "Transfer" not in summ["line_keys"] cell = summ["cells"][0] assert cell["marketplace"] == "USA" assert cell["receivable"] == j["receivable_accrual"]["total"] # Re-processing changes the numbers -> the sign-off clears and the month unpublishes. assert c.post(f"/api/sessions/{sid}/process").status_code == 200 assert c.get(f"/api/sessions/{sid}/status").json()["status"] == "processed" j2 = c.get(f"/api/sessions/{sid}/journal").json() assert j2["approved_by"] == "" and j2["reviewed_by"] == "" assert c.get("/api/accounts-summary").json()["available"] is False