"""routes_statements.py — the statement-of-account sender, ported off Streamlit (EXIT-6). ⛔ THIS IS THE ONE SANCTIONED ODOO WRITER IN THE ENTIRE SYSTEM. Everything else in AIOS is read-only on Odoo by hard block. `modules/collections_send.py` owns a narrow client that whitelists exactly `mail.mail create` (queueing an outbound email) and nothing else; these routes call it and add no write of their own. WHY IT EXISTS SEPARATELY FROM THE COLLECTIONS PAGE. Owner ruling wave-17 item 15 retired the Collections *dashboard* — the worklist is the shared "Collections" view on the Customer grid, from the same reconciled blocks. What that ruling explicitly left "untouched" is this send workflow, so when `app.py` is deleted it is the ONLY live Streamlit-only feature, and it moves here rather than dying with the host. Ported faithfully: same tiers, same filters, same template placeholders, same preview, same test-send, same two-step confirm. THE THREE GUARDRAILS, and where each is enforced: 1. SAFE_MODE (default ON) — in the DATA LAYER (`collections_send.queue_statement`), so no route, payload or UI can bypass it. These routes only REPORT it; they never re-implement the check. 2. Admin only — `admin_gate` (role, fail-closed), mirroring the Streamlit `if not is_admin()`. 3. ⭐ TENANT — NEW HERE, and it did not exist in Streamlit because it could not. `cs_mod.Odoo()` reads Odoo credentials from the ENVIRONMENT, which after the keychain cutover belongs to TENANT #0 ALONE. On the multi-tenant API an unguarded route would let a nurilab or gtmlab admin queue mail from Royal Imports' Odoo, as Royal Imports. `_royal_only` closes that; the single-tenant Streamlit host never had the exposure, so this is a port that must ADD a wall rather than copy one. NOTHING SENDS ON A GET. The send route requires an explicit customer list in the body; there is no "send all" parameter, deliberately — the confirm step is a product requirement, not a formality. """ from fastapi import APIRouter, Body, Depends from deps import Session, err from routes_admin import admin_gate router = APIRouter(prefix="/api/v1") #: Cache the Odoo follow-up pull briefly. The Streamlit page used `@st.cache_data(ttl=1800)`; the #: list moves slowly (it is a dunning worklist, not a live feed) and the pull is a multi-model read. _TTL = 1800 _cache = {"at": 0.0, "rows": None} def _cs(): import modules.collections_send as cs return cs def _royal_only(session: Session) -> Session: """⛔ See the module docstring, guardrail 3. The send client is env-credentialed, so it is tenant #0's and only tenant #0's. Refuse for anyone else rather than send as the wrong company. Keyed on the runtime, never on a request field: a tenant is a property of the SESSION.""" if getattr(session.runtime, "key", None) != "royal-imports": raise err(404, "not_found", "statements are not configured for this workspace") return session def _gate(session: Session = Depends(admin_gate)) -> Session: return _royal_only(session) def _rows(force=False): import time cs = _cs() if force or _cache["rows"] is None or (time.time() - _cache["at"]) > _TTL: _cache["rows"] = cs.load_collection_list(cs.Odoo()) _cache["at"] = time.time() return _cache["rows"], time.strftime("%Y-%m-%d %H:%M", time.localtime(_cache["at"])) def _public(row): """Strip the internals the Streamlit grid also hid (`_`-prefixed + partner_id is kept, because the client needs a stable row identity that is not the display name).""" return {k: v for k, v in row.items() if not str(k).startswith("_")} @router.get("/admin/statements") def statements(refresh: int = 0, session: Session = Depends(_gate)): """The worklist + everything the sender UI needs to render itself honestly.""" cs = _cs() try: rows, loaded_at = _rows(force=bool(refresh)) except Exception as e: raise err(502, "odoo_unavailable", f"could not load the collection list: {str(e)[:200]}") return { "rows": [_public(r) for r in rows], "loadedAt": loaded_at, # The guardrail is reported, never decided, here — the data layer owns it. "safeMode": bool(cs.SAFE_MODE), "safeRecipients": sorted(cs.SAFE_RECIPIENTS), "sender": {"name": cs.SENDER_NAME, "email": cs.SENDER_EMAIL, "replyTo": cs.REPLY_TO, "company": cs.COMPANY}, "templates": {"subject": cs.DEFAULT_SUBJECT, "intro": cs.DEFAULT_INTRO, "footer": cs.DEFAULT_FOOTER}, "tiers": ["A-Urgent", "B-Active", "C-Light", "Monitor"], } def _find(rows, customer): return next((r for r in rows if r.get("Customer") == customer), None) @router.post("/admin/statements/preview") def preview(body: dict = Body(default=None), session: Session = Depends(_gate)): """Render ONE customer's statement exactly as the send path would.""" cs = _cs() body = body or {} rows, _ = _rows() row = _find(rows, body.get("customer")) if row is None: raise err(404, "not_found", "no such customer on the collection list") t = body.get("templates") or {} import datetime as dt month = dt.date.today().strftime("%B %Y") subject = (t.get("subject") or cs.DEFAULT_SUBJECT) try: subject = subject.format(customer=row["Customer"], company=cs.COMPANY, month=month) except (KeyError, IndexError): # An unknown placeholder is the user's typo, not a 500. Show the template verbatim so they # can see what they typed rather than getting an opaque error. pass return { "html": cs.render_statement_html(row, t.get("intro") or cs.DEFAULT_INTRO, t.get("footer") or cs.DEFAULT_FOOTER), "to": row.get("Email") or "", "subject": subject, } @router.post("/admin/statements/send") def send(body: dict = Body(default=None), session: Session = Depends(_gate)): """Queue statements. Returns per-customer outcomes — NEVER a bare count. `overrideTo` is the test-send path: one customer, one address. SAFE_MODE still applies (the data layer refuses an address outside the allow-list), which is why the route does not check it. """ cs = _cs() body = body or {} names = [str(n) for n in (body.get("customers") or []) if str(n).strip()] if not names: raise err(400, "bad_request", "name at least one customer") override = (body.get("overrideTo") or "").strip() or None if override and len(names) != 1: raise err(400, "bad_request", "a test send takes exactly one customer") t = body.get("templates") or {} rows, _ = _rows() sent, failed, skipped = [], [], [] for name in names: row = _find(rows, name) if row is None: failed.append({"customer": name, "error": "not on the current collection list"}) continue if not override and not row.get("Email"): # The Streamlit page warned and skipped these. Reporting them SEPARATELY from failures # keeps "we could not" distinct from "there was nowhere to send". skipped.append({"customer": name, "reason": "no email address on the customer record"}) continue try: mid = cs.queue_statement(cs.Odoo(), row, t.get("subject") or cs.DEFAULT_SUBJECT, t.get("intro") or cs.DEFAULT_INTRO, t.get("footer") or cs.DEFAULT_FOOTER, override_to=override) sent.append({"customer": name, "to": override or row.get("Email"), "mailId": mid}) except Exception as e: # SafeModeBlocked lands here too, and that is correct: to the caller a guardrail refusal # and an Odoo error are both "this one did not go", each with its own honest message. failed.append({"customer": name, "error": str(e)[:200]}) return {"sent": sent, "failed": failed, "skipped": skipped, "safeMode": bool(cs.SAFE_MODE), "test": bool(override)}