Finance-Accounts/ar-aging-app/backend/tests/test_session_lifecycle.py

255 lines
12 KiB
Python

"""
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