255 lines
12 KiB
Python
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
|