fsanyoto commited on
Commit
62d663e
·
verified ·
1 Parent(s): 64b8d76

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
api/main.py CHANGED
@@ -61,6 +61,7 @@ import routes_automation # noqa: E402 (wave 18 C4-AUTO — SESSION D's router,
61
  import routes_customers # noqa: E402
62
  import routes_grid # noqa: E402
63
  import routes_keychain # noqa: E402 (wave 18 C7 — keychain + connectors admin surfaces)
 
64
  import routes_nav # noqa: E402
65
  import routes_pages # noqa: E402
66
  import routes_platform_admin # noqa: E402 (wave 19 R3/R4 — the Loopable cross-tenant plane)
@@ -179,6 +180,7 @@ app.include_router(routes_pages.router)
179
  app.include_router(routes_admin.router)
180
  app.include_router(routes_automation.router)
181
  app.include_router(routes_keychain.router)
 
182
  # Wave 19 (owner item 13, R3): the LOOPABLE admin plane — the platform's own cross-tenant view.
183
  # Its own prefix (`/api/v1/platform-admin`), never nested under `/admin`, so there is no path
184
  # ambiguity with `routes_admin`'s `{username}` params and no chance of a tenant-admin route and a
 
61
  import routes_customers # noqa: E402
62
  import routes_grid # noqa: E402
63
  import routes_keychain # noqa: E402 (wave 18 C7 — keychain + connectors admin surfaces)
64
+ import routes_statements # noqa: E402 (EXIT-6 — the statement sender, off Streamlit)
65
  import routes_nav # noqa: E402
66
  import routes_pages # noqa: E402
67
  import routes_platform_admin # noqa: E402 (wave 19 R3/R4 — the Loopable cross-tenant plane)
 
180
  app.include_router(routes_admin.router)
181
  app.include_router(routes_automation.router)
182
  app.include_router(routes_keychain.router)
183
+ app.include_router(routes_statements.router)
184
  # Wave 19 (owner item 13, R3): the LOOPABLE admin plane — the platform's own cross-tenant view.
185
  # Its own prefix (`/api/v1/platform-admin`), never nested under `/admin`, so there is no path
186
  # ambiguity with `routes_admin`'s `{username}` params and no chance of a tenant-admin route and a
api/routes_statements.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """routes_statements.py — the statement-of-account sender, ported off Streamlit (EXIT-6).
2
+
3
+ ⛔ THIS IS THE ONE SANCTIONED ODOO WRITER IN THE ENTIRE SYSTEM. Everything else in AIOS is
4
+ read-only on Odoo by hard block. `modules/collections_send.py` owns a narrow client that whitelists
5
+ exactly `mail.mail create` (queueing an outbound email) and nothing else; these routes call it and
6
+ add no write of their own.
7
+
8
+ WHY IT EXISTS SEPARATELY FROM THE COLLECTIONS PAGE. Owner ruling wave-17 item 15 retired the
9
+ Collections *dashboard* — the worklist is the shared "Collections" view on the Customer grid, from
10
+ the same reconciled blocks. What that ruling explicitly left "untouched" is this send workflow, so
11
+ when `app.py` is deleted it is the ONLY live Streamlit-only feature, and it moves here rather than
12
+ dying with the host. Ported faithfully: same tiers, same filters, same template placeholders, same
13
+ preview, same test-send, same two-step confirm.
14
+
15
+ THE THREE GUARDRAILS, and where each is enforced:
16
+ 1. SAFE_MODE (default ON) — in the DATA LAYER (`collections_send.queue_statement`), so no route,
17
+ payload or UI can bypass it. These routes only REPORT it; they never re-implement the check.
18
+ 2. Admin only — `admin_gate` (role, fail-closed), mirroring the Streamlit `if not is_admin()`.
19
+ 3. ⭐ TENANT — NEW HERE, and it did not exist in Streamlit because it could not. `cs_mod.Odoo()`
20
+ reads Odoo credentials from the ENVIRONMENT, which after the keychain cutover belongs to
21
+ TENANT #0 ALONE. On the multi-tenant API an unguarded route would let a nurilab or gtmlab
22
+ admin queue mail from Royal Imports' Odoo, as Royal Imports. `_royal_only` closes that; the
23
+ single-tenant Streamlit host never had the exposure, so this is a port that must ADD a wall
24
+ rather than copy one.
25
+
26
+ NOTHING SENDS ON A GET. The send route requires an explicit customer list in the body; there is no
27
+ "send all" parameter, deliberately — the confirm step is a product requirement, not a formality.
28
+ """
29
+ from fastapi import APIRouter, Body, Depends
30
+
31
+ from deps import Session, err
32
+ from routes_admin import admin_gate
33
+
34
+ router = APIRouter(prefix="/api/v1")
35
+
36
+ #: Cache the Odoo follow-up pull briefly. The Streamlit page used `@st.cache_data(ttl=1800)`; the
37
+ #: list moves slowly (it is a dunning worklist, not a live feed) and the pull is a multi-model read.
38
+ _TTL = 1800
39
+ _cache = {"at": 0.0, "rows": None}
40
+
41
+
42
+ def _cs():
43
+ import modules.collections_send as cs
44
+ return cs
45
+
46
+
47
+ def _royal_only(session: Session) -> Session:
48
+ """⛔ See the module docstring, guardrail 3. The send client is env-credentialed, so it is
49
+ tenant #0's and only tenant #0's. Refuse for anyone else rather than send as the wrong company.
50
+
51
+ Keyed on the runtime, never on a request field: a tenant is a property of the SESSION."""
52
+ if getattr(session.runtime, "key", None) != "royal-imports":
53
+ raise err(404, "not_found", "statements are not configured for this workspace")
54
+ return session
55
+
56
+
57
+ def _gate(session: Session = Depends(admin_gate)) -> Session:
58
+ return _royal_only(session)
59
+
60
+
61
+ def _rows(force=False):
62
+ import time
63
+ cs = _cs()
64
+ if force or _cache["rows"] is None or (time.time() - _cache["at"]) > _TTL:
65
+ _cache["rows"] = cs.load_collection_list(cs.Odoo())
66
+ _cache["at"] = time.time()
67
+ return _cache["rows"], time.strftime("%Y-%m-%d %H:%M", time.localtime(_cache["at"]))
68
+
69
+
70
+ def _public(row):
71
+ """Strip the internals the Streamlit grid also hid (`_`-prefixed + partner_id is kept, because
72
+ the client needs a stable row identity that is not the display name)."""
73
+ return {k: v for k, v in row.items() if not str(k).startswith("_")}
74
+
75
+
76
+ @router.get("/admin/statements")
77
+ def statements(refresh: int = 0, session: Session = Depends(_gate)):
78
+ """The worklist + everything the sender UI needs to render itself honestly."""
79
+ cs = _cs()
80
+ try:
81
+ rows, loaded_at = _rows(force=bool(refresh))
82
+ except Exception as e:
83
+ raise err(502, "odoo_unavailable", f"could not load the collection list: {str(e)[:200]}")
84
+ return {
85
+ "rows": [_public(r) for r in rows],
86
+ "loadedAt": loaded_at,
87
+ # The guardrail is reported, never decided, here — the data layer owns it.
88
+ "safeMode": bool(cs.SAFE_MODE),
89
+ "safeRecipients": sorted(cs.SAFE_RECIPIENTS),
90
+ "sender": {"name": cs.SENDER_NAME, "email": cs.SENDER_EMAIL,
91
+ "replyTo": cs.REPLY_TO, "company": cs.COMPANY},
92
+ "templates": {"subject": cs.DEFAULT_SUBJECT, "intro": cs.DEFAULT_INTRO,
93
+ "footer": cs.DEFAULT_FOOTER},
94
+ "tiers": ["A-Urgent", "B-Active", "C-Light", "Monitor"],
95
+ }
96
+
97
+
98
+ def _find(rows, customer):
99
+ return next((r for r in rows if r.get("Customer") == customer), None)
100
+
101
+
102
+ @router.post("/admin/statements/preview")
103
+ def preview(body: dict = Body(default=None), session: Session = Depends(_gate)):
104
+ """Render ONE customer's statement exactly as the send path would."""
105
+ cs = _cs()
106
+ body = body or {}
107
+ rows, _ = _rows()
108
+ row = _find(rows, body.get("customer"))
109
+ if row is None:
110
+ raise err(404, "not_found", "no such customer on the collection list")
111
+ t = body.get("templates") or {}
112
+ import datetime as dt
113
+ month = dt.date.today().strftime("%B %Y")
114
+ subject = (t.get("subject") or cs.DEFAULT_SUBJECT)
115
+ try:
116
+ subject = subject.format(customer=row["Customer"], company=cs.COMPANY, month=month)
117
+ except (KeyError, IndexError):
118
+ # An unknown placeholder is the user's typo, not a 500. Show the template verbatim so they
119
+ # can see what they typed rather than getting an opaque error.
120
+ pass
121
+ return {
122
+ "html": cs.render_statement_html(row, t.get("intro") or cs.DEFAULT_INTRO,
123
+ t.get("footer") or cs.DEFAULT_FOOTER),
124
+ "to": row.get("Email") or "",
125
+ "subject": subject,
126
+ }
127
+
128
+
129
+ @router.post("/admin/statements/send")
130
+ def send(body: dict = Body(default=None), session: Session = Depends(_gate)):
131
+ """Queue statements. Returns per-customer outcomes — NEVER a bare count.
132
+
133
+ `overrideTo` is the test-send path: one customer, one address. SAFE_MODE still applies (the
134
+ data layer refuses an address outside the allow-list), which is why the route does not check it.
135
+ """
136
+ cs = _cs()
137
+ body = body or {}
138
+ names = [str(n) for n in (body.get("customers") or []) if str(n).strip()]
139
+ if not names:
140
+ raise err(400, "bad_request", "name at least one customer")
141
+ override = (body.get("overrideTo") or "").strip() or None
142
+ if override and len(names) != 1:
143
+ raise err(400, "bad_request", "a test send takes exactly one customer")
144
+ t = body.get("templates") or {}
145
+ rows, _ = _rows()
146
+
147
+ sent, failed, skipped = [], [], []
148
+ for name in names:
149
+ row = _find(rows, name)
150
+ if row is None:
151
+ failed.append({"customer": name, "error": "not on the current collection list"})
152
+ continue
153
+ if not override and not row.get("Email"):
154
+ # The Streamlit page warned and skipped these. Reporting them SEPARATELY from failures
155
+ # keeps "we could not" distinct from "there was nowhere to send".
156
+ skipped.append({"customer": name, "reason": "no email address on the customer record"})
157
+ continue
158
+ try:
159
+ mid = cs.queue_statement(cs.Odoo(), row, t.get("subject") or cs.DEFAULT_SUBJECT,
160
+ t.get("intro") or cs.DEFAULT_INTRO,
161
+ t.get("footer") or cs.DEFAULT_FOOTER, override_to=override)
162
+ sent.append({"customer": name, "to": override or row.get("Email"), "mailId": mid})
163
+ except Exception as e:
164
+ # SafeModeBlocked lands here too, and that is correct: to the caller a guardrail refusal
165
+ # and an Odoo error are both "this one did not go", each with its own honest message.
166
+ failed.append({"customer": name, "error": str(e)[:200]})
167
+ return {"sent": sent, "failed": failed, "skipped": skipped,
168
+ "safeMode": bool(cs.SAFE_MODE), "test": bool(override)}
platform/aios_grid.py CHANGED
The diff for this file is too large to render. See raw diff
 
platform/core/grid_events.py CHANGED
@@ -13,13 +13,17 @@ Streamlit couplings the contract names:
13
  is_admin() (the bare call inside doc_delete, and _cl_table_workspace's admin=None default)
14
  -> ctx.admin
15
 
16
- There is NO `import streamlit` in this file and there must never be one — `verify_seam.py`
17
  enforces that with a `sys.meta_path` block, which is the only proof that survives refactoring.
18
-
19
- TWO ADAPTERS, ONE HANDLER:
20
- * the Streamlit adapter — app.py keeps `_cl_handle_grid_event(event, uname, allowed_pids, …)`
21
- with its old name and signature, builds an EventCtx whose `seen_ids` and `fallback_ws` are
22
- session-state-backed dicts, and applies the Result to session state.
 
 
 
 
23
  * the HTTP adapter — `aios-web/api` builds an EventCtx with `fallback_ws=None`. A None
24
  fallback with the store unavailable is a **503**, never a silent in-memory workspace: an
25
  API request has no durable session dict to fall back to, so pretending to persist would
 
13
  is_admin() (the bare call inside doc_delete, and _cl_table_workspace's admin=None default)
14
  -> ctx.admin
15
 
16
+ There is NO `import streamlit` in this file and there must never be one — `verify_no_streamlit.py`
17
  enforces that with a `sys.meta_path` block, which is the only proof that survives refactoring.
18
+ (That gate was `verify_seam.py` until EXIT-6 retired it with `app.py`; the block, its negative
19
+ control and the AST walk moved over intact.)
20
+
21
+ ONE ADAPTER, ONE HANDLER it was TWO until 2026-08-04:
22
+ * the Streamlit adapter is GONE. `app.py::_cl_handle_grid_event` built an EventCtx whose
23
+ `seen_ids` and `fallback_ws` were session-state-backed dicts and applied the Result to session
24
+ state. EXIT-6 deleted it. The host-neutrality this file was written for is what let that
25
+ happen without touching a line of the handler — which is the argument for the design, recorded
26
+ at the moment it paid out.
27
  * the HTTP adapter — `aios-web/api` builds an EventCtx with `fallback_ws=None`. A None
28
  fallback with the store unavailable is a **503**, never a silent in-memory workspace: an
29
  API request has no durable session dict to fall back to, so pretending to persist would
requirements.txt CHANGED
@@ -1,16 +1,26 @@
1
- # AIOS web API — the FastAPI backend + the platform data-layer deps it reuses.
2
- # (streamlit is included only because the shared modules/core may import it at load; it is never
3
- # run here. No altair/plotly/openpyxl/reportlabthose are Streamlit-UI/export only.)
4
- fastapi>=0.139
5
- uvicorn[standard]>=0.30
6
- python-dotenv>=1.0
7
- pandas>=2.0
8
- requests>=2.28
9
- huggingface_hub>=0.20
10
- duckdb>=1.0
11
- pyyaml>=6.0
12
- streamlit==1.58.0
13
- pillow>=10.0
14
- beautifulsoup4>=4.12
15
- lxml>=5.0
16
- cryptography>=42.0
 
 
 
 
 
 
 
 
 
 
 
1
+ # AIOS web API — the FastAPI backend + the platform data-layer deps it reuses.
2
+ #
3
+ # STREAMLIT IS GONE (EXIT-6, 2026-08-04) do not add it back. The line here read
4
+ # `streamlit==1.58.0` with the comment "included only because the shared modules/core may import
5
+ # it at load; it is never served". That stopped being true when the shared layer was cleaned, and
6
+ # it stayed in THIS file — the one the Dockerfile actually installs — while `api/requirements.txt`
7
+ # had already dropped it and a gate reported the omission "verified". ~250 MB of image for a
8
+ # package nothing imported.
9
+ #
10
+ # The lesson is the file, not the package: the PINNED intent and the SHIPPED manifest are two
11
+ # different documents, and checking only the one you wrote is how the other rots.
12
+ # `api/verify_no_streamlit.py` now checks BOTH, and proves the API imports with streamlit,
13
+ # plotly, altair, openpyxl, reportlab and jinja2 all blocked at sys.meta_path.
14
+ # No altair/plotly/openpyxl/reportlab either — those were Streamlit UI/export only.
15
+ fastapi>=0.139
16
+ uvicorn[standard]>=0.30
17
+ python-dotenv>=1.0
18
+ pandas>=2.0
19
+ requests>=2.28
20
+ huggingface_hub>=0.20
21
+ duckdb>=1.0
22
+ pyyaml>=6.0
23
+ pillow>=10.0
24
+ beautifulsoup4>=4.12
25
+ lxml>=5.0
26
+ cryptography>=42.0
web/src/index.css CHANGED
@@ -8199,3 +8199,106 @@ a.cg-map-ctl-b { text-decoration: none; }
8199
  }
8200
  }
8201
  /* == /W19-D == */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8199
  }
8200
  }
8201
  /* == /W19-D == */
8202
+
8203
+ /* == EXIT-6 STATEMENTS == */
8204
+ /* `settings/StatementsPane.tsx` — the statement-of-account sender, ported off
8205
+ `app.py::_collections_statements` when Streamlit was deleted.
8206
+
8207
+ ⛔ WHY THE TABLE RULES ARE `stmt-` AND NOT `set-table`. The accounts table was
8208
+ removed in wave 17 (R7) and its rules deliberately went with it, with a note
8209
+ in this file saying a dead rule with a plausible name is worse than no rule
8210
+ because the next thing needing a table adopts decisions nobody made for it.
8211
+ This IS that next thing, so it declares its own, named for its one purpose.
8212
+
8213
+ Quiet by intent: this pane ends in a control that emails real customers, so
8214
+ nothing here competes with the confirm block for attention. */
8215
+ .stmt-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 12px 0 0; }
8216
+ .stmt-row .set-input { width: auto; min-width: 180px; flex: 1 1 180px; }
8217
+ .stmt-small { margin-top: 6px; }
8218
+ .stmt-stack { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
8219
+ .stmt-wide { width: 100%; }
8220
+ .stmt-link {
8221
+ margin-top: 14px;
8222
+ padding: 0;
8223
+ border: 0;
8224
+ background: none;
8225
+ color: var(--lp-blue-deep);
8226
+ font: inherit;
8227
+ font-size: var(--lp-fs-2xs);
8228
+ font-weight: 600;
8229
+ cursor: pointer;
8230
+ text-align: left;
8231
+ }
8232
+ .stmt-textarea {
8233
+ width: 100%;
8234
+ padding: 8px 10px;
8235
+ border: 1px solid var(--lp-line);
8236
+ border-radius: var(--lp-r-sm);
8237
+ background: var(--lp-surface);
8238
+ color: var(--lp-ink);
8239
+ font: inherit;
8240
+ font-size: var(--lp-fs-xs);
8241
+ line-height: var(--lp-lh);
8242
+ resize: vertical;
8243
+ }
8244
+ /* The worklist scrolls INSIDE its own box. A dunning list is hundreds of rows and
8245
+ the settings modal must not grow a second scrollbar for it. */
8246
+ .stmt-tablewrap {
8247
+ margin-top: 12px;
8248
+ max-height: 340px;
8249
+ overflow: auto;
8250
+ border: 1px solid var(--lp-line);
8251
+ border-radius: var(--lp-r-sm);
8252
+ }
8253
+ .stmt-tablewrap--short { max-height: 200px; }
8254
+ .stmt-table { width: 100%; border-collapse: collapse; font-size: var(--lp-fs-2xs); }
8255
+ .stmt-table th,
8256
+ .stmt-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--lp-line); }
8257
+ /* Sticky head: scrolling 400 dunning rows without column labels is unreadable. */
8258
+ .stmt-table thead th {
8259
+ position: sticky;
8260
+ top: 0;
8261
+ z-index: 1;
8262
+ background: var(--lp-surface-2);
8263
+ font-weight: 620;
8264
+ }
8265
+ .stmt-table tbody tr:last-child td { border-bottom: 0; }
8266
+ .stmt-table tbody tr.is-picked { background: var(--lp-blue-tint); }
8267
+ /* Money aligns right, everywhere in this product. */
8268
+ .stmt-num { text-align: right; font-variant-numeric: tabular-nums; }
8269
+ .stmt-table th.stmt-num { text-align: right; }
8270
+ /* ⚠ AMBER, NOT `.set-notice` (green) AND NOT `.set-error` (red). The SAFE_MODE
8271
+ banner is neither success nor failure — it is a live restriction, and dressing
8272
+ a guardrail in green is how somebody reads "ON" as "you are clear to send". */
8273
+ .stmt-warn {
8274
+ margin: 0 0 12px;
8275
+ padding: 9px 12px;
8276
+ border-radius: var(--lp-r-sm);
8277
+ background: var(--lp-yellow-tint);
8278
+ color: var(--lp-yellow-deep);
8279
+ font-size: var(--lp-fs-2xs);
8280
+ line-height: var(--lp-lh);
8281
+ }
8282
+ .stmt-details { margin-top: 10px; font-size: var(--lp-fs-2xs); }
8283
+ .stmt-details summary { cursor: pointer; font-weight: 600; }
8284
+ /* The rendered statement is OUR html from OUR template, but it is still foreign
8285
+ markup inside a pane: box it so its own font sizes cannot reflow the modal. */
8286
+ .stmt-preview {
8287
+ margin-top: 10px;
8288
+ padding: 12px;
8289
+ max-height: 320px;
8290
+ overflow: auto;
8291
+ border: 1px solid var(--lp-line);
8292
+ border-radius: var(--lp-r-sm);
8293
+ background: var(--lp-surface);
8294
+ }
8295
+ .stmt-preview img { max-width: 100%; }
8296
+ /* The confirm block is the loudest thing on the pane, on purpose. */
8297
+ .stmt-confirm {
8298
+ margin-top: 16px;
8299
+ padding: 12px;
8300
+ border: 1px solid var(--lp-line);
8301
+ border-radius: var(--lp-r-sm);
8302
+ background: var(--lp-surface-2);
8303
+ }
8304
+ .stmt-confirm .stmt-row { margin-top: 12px; }
web/src/settings/SettingsModal.tsx CHANGED
@@ -56,6 +56,7 @@ import type { ConnectorRow, KeyEntry, UnsyncedInfo } from "./settingsApi";
56
  import { PermsEditor } from "./PermsEditor";
57
  // Wave 19 contract C2 — session D's self-contained plane; see the mount below.
58
  import { AdminPane } from "./AdminPane";
 
59
  import { moduleSummary, parseEntry, reachableSection } from "./permsModel";
60
 
61
  // The vocabulary lives beside the rule that gates it (permsModel), and is
@@ -139,6 +140,14 @@ const IconKey = (
139
  </RailIcon>
140
  );
141
  /** Connectors — two links joined: where the data comes from. */
 
 
 
 
 
 
 
 
142
  const IconPlug = (
143
  <RailIcon>
144
  <path d="M6.6 9.4 4.5 11.5a2.6 2.6 0 0 1-3.7-3.7l2.1-2.1" />
@@ -717,6 +726,10 @@ export function SettingsModal({
717
  // Wave 18 (C7): the tenant's credential store + data-source status board.
718
  ["keychains", "Keychains", IconKey],
719
  ["connectors", "Connectors", IconPlug],
 
 
 
 
720
  ] as Array<[SettingsSection, string, ReactNode]>)
721
  : []),
722
  // Wave 19 (R3 / C2): the Loopable admin plane. Gated on the SERVER's
@@ -781,6 +794,7 @@ export function SettingsModal({
781
 
782
  {active === "keychains" ? <KeychainPane /> : null}
783
  {active === "connectors" ? <ConnectorsPane /> : null}
 
784
  {/* Wave 19 contract C2 — the wiring record, verified at the MOUNT SITE
785
  (the wave-14 lesson: an occurrence-grep proves an import, not a
786
  render): `settings/AdminPane.tsx → SettingsModal.tsx pane switch →
 
56
  import { PermsEditor } from "./PermsEditor";
57
  // Wave 19 contract C2 — session D's self-contained plane; see the mount below.
58
  import { AdminPane } from "./AdminPane";
59
+ import { StatementsPane } from "./StatementsPane";
60
  import { moduleSummary, parseEntry, reachableSection } from "./permsModel";
61
 
62
  // The vocabulary lives beside the rule that gates it (permsModel), and is
 
140
  </RailIcon>
141
  );
142
  /** Connectors — two links joined: where the data comes from. */
143
+ /** Statements — an envelope. The one room in here that sends something OUT of
144
+ * the building, so it wears the only outbound-shaped mark on the rail. */
145
+ const IconMail = (
146
+ <RailIcon>
147
+ <path d="M2 4.5h12v7H2z" />
148
+ <path d="M2.4 5 8 9l5.6-4" />
149
+ </RailIcon>
150
+ );
151
  const IconPlug = (
152
  <RailIcon>
153
  <path d="M6.6 9.4 4.5 11.5a2.6 2.6 0 0 1-3.7-3.7l2.1-2.1" />
 
726
  // Wave 18 (C7): the tenant's credential store + data-source status board.
727
  ["keychains", "Keychains", IconKey],
728
  ["connectors", "Connectors", IconPlug],
729
+ // EXIT-6: the statement sender, ported off app.py. Admin-only, and the
730
+ // server ALSO refuses it to any tenant but Royal (the send client is
731
+ // env-credentialed) — this entry is the courtesy, not the wall.
732
+ ["statements", "Statements", IconMail],
733
  ] as Array<[SettingsSection, string, ReactNode]>)
734
  : []),
735
  // Wave 19 (R3 / C2): the Loopable admin plane. Gated on the SERVER's
 
794
 
795
  {active === "keychains" ? <KeychainPane /> : null}
796
  {active === "connectors" ? <ConnectorsPane /> : null}
797
+ {active === "statements" ? <StatementsPane /> : null}
798
  {/* Wave 19 contract C2 — the wiring record, verified at the MOUNT SITE
799
  (the wave-14 lesson: an occurrence-grep proves an import, not a
800
  render): `settings/AdminPane.tsx → SettingsModal.tsx pane switch →
web/src/settings/StatementsPane.tsx ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // settings / StatementsPane.tsx — the statement-of-account sender (EXIT-6).
3
+ //
4
+ // Ported off `app.py::_collections_statements` when Streamlit was deleted. Owner
5
+ // ruling wave-17 item 15 retired the Collections DASHBOARD (it is the shared
6
+ // "Collections" view on the Customer grid) but explicitly left this workflow
7
+ // untouched — so it is the last live surface that had nowhere else to go.
8
+ //
9
+ // ⛔ THIS IS THE ONE SANCTIONED ODOO WRITER. Queueing these emails is the only
10
+ // write the product performs, so the original's shape is preserved deliberately
11
+ // rather than modernised: review → tick → preview → test → CONFIRM → send. The
12
+ // confirm step is a product requirement, not a formality, and the send button is
13
+ // disabled while SAFE_MODE is on exactly as the Streamlit page had it.
14
+ //
15
+ // ⚠ THE GUARDRAIL IS THE SERVER'S. Everything this file does with `safeMode` is
16
+ // presentation: disabling a control and explaining why. `collections_send`
17
+ // refuses an out-of-allow-list recipient in the data layer, which is what makes
18
+ // the guarantee real. Never read the disabled button as the enforcement.
19
+ // ---------------------------------------------------------------------------
20
+ import { useCallback, useEffect, useMemo, useState } from "react";
21
+
22
+ import {
23
+ loadStatements,
24
+ previewStatement,
25
+ sendStatements,
26
+ type SendOutcome,
27
+ type StatementRow,
28
+ type StatementTemplates,
29
+ type StatementsPayload,
30
+ } from "./statementsApi";
31
+
32
+ const money = (n: number) =>
33
+ `$${Math.round(Number(n) || 0).toLocaleString("en-US")}`;
34
+
35
+ /** The tiers the original defaulted ON. "Monitor" = open receivable but nothing
36
+ * overdue, and the Streamlit page excluded it by default for that reason. */
37
+ const DEFAULT_TIERS = ["A-Urgent", "B-Active", "C-Light"];
38
+
39
+ export function StatementsPane() {
40
+ const [data, setData] = useState<StatementsPayload | null>(null);
41
+ const [err, setErr] = useState("");
42
+ const [busy, setBusy] = useState(false);
43
+ const [tiers, setTiers] = useState<string[]>(DEFAULT_TIERS);
44
+ const [emailOnly, setEmailOnly] = useState(true);
45
+ const [search, setSearch] = useState("");
46
+ const [picked, setPicked] = useState<Set<string>>(new Set());
47
+ const [tpl, setTpl] = useState<StatementTemplates | null>(null);
48
+ const [showTpl, setShowTpl] = useState(false);
49
+ const [previewOf, setPreviewOf] = useState("");
50
+ const [preview, setPreview] = useState<{ html: string; to: string; subject: string } | null>(null);
51
+ const [testTo, setTestTo] = useState("");
52
+ const [confirming, setConfirming] = useState(false);
53
+ const [result, setResult] = useState<SendOutcome | null>(null);
54
+
55
+ const reload = useCallback(async (refresh = false) => {
56
+ setBusy(true);
57
+ const r = await loadStatements(refresh);
58
+ setBusy(false);
59
+ if (!r.ok) { setErr(r.message); return; }
60
+ setErr("");
61
+ setData(r.data);
62
+ setTpl((t) => t ?? r.data.templates);
63
+ setTestTo((v) => v || (r.data.safeMode ? (r.data.safeRecipients[0] ?? "") : ""));
64
+ }, []);
65
+
66
+ useEffect(() => { void reload(false); }, [reload]);
67
+
68
+ const view = useMemo(() => {
69
+ if (!data) return [] as StatementRow[];
70
+ const s = search.trim().toLowerCase();
71
+ return data.rows.filter(
72
+ (r) =>
73
+ tiers.includes(r.Tier) &&
74
+ (!emailOnly || !!r.Email) &&
75
+ (!s || r.Customer.toLowerCase().includes(s)),
76
+ );
77
+ }, [data, tiers, emailOnly, search]);
78
+
79
+ // The selection follows the FILTER: a customer ticked and then filtered out
80
+ // must not ride along invisibly into a send. Intersecting here (rather than
81
+ // pruning on every filter change) keeps that true without fighting the user's
82
+ // ticks when they widen the filter again.
83
+ const selected = useMemo(
84
+ () => view.filter((r) => picked.has(r.Customer)),
85
+ [view, picked],
86
+ );
87
+ const sendable = useMemo(() => selected.filter((r) => r.Email), [selected]);
88
+ const noEmail = useMemo(() => selected.filter((r) => !r.Email), [selected]);
89
+
90
+ const previewRow = previewOf || selected[0]?.Customer || view[0]?.Customer || "";
91
+
92
+ useEffect(() => {
93
+ if (!previewRow || !tpl) { setPreview(null); return; }
94
+ let live = true;
95
+ void previewStatement(previewRow, tpl).then((r) => {
96
+ if (live) setPreview(r.ok ? r.data : null);
97
+ });
98
+ return () => { live = false; };
99
+ }, [previewRow, tpl]);
100
+
101
+ const toggle = (name: string) =>
102
+ setPicked((p) => {
103
+ const n = new Set(p);
104
+ if (n.has(name)) n.delete(name); else n.add(name);
105
+ return n;
106
+ });
107
+
108
+ const doSend = async (customers: string[], overrideTo?: string) => {
109
+ if (!tpl || !customers.length) return;
110
+ setBusy(true);
111
+ const r = await sendStatements(customers, tpl, overrideTo);
112
+ setBusy(false);
113
+ setConfirming(false);
114
+ if (!r.ok) { setErr(r.message); return; }
115
+ setErr("");
116
+ setResult(r.data);
117
+ if (!overrideTo) setPicked(new Set());
118
+ };
119
+
120
+ if (!data && !err) return <p className="set-help">Loading the collection list…</p>;
121
+
122
+ return (
123
+ <div className="set-pane">
124
+ <h3 className="set-h">Statements</h3>
125
+ <p className="set-pane-intro set-help">
126
+ Queue per-customer statement-of-account emails. The list is the Odoo follow-up filter
127
+ (Reminders = Automatic, receivable over $1, GIFTWARE excluded), tiered by urgency.
128
+ Statements send as <b>{data?.sender.name}</b> through the Odoo mail queue (Office 365
129
+ relay, ~15 min); every send is logged on the customer chatter in Odoo. Queueing these
130
+ emails is the only write this app can perform, and nothing sends without the confirm step.
131
+ </p>
132
+
133
+ {data?.safeMode ? (
134
+ <div className="stmt-warn">
135
+ <b>Testing guardrail is ON</b> — email can only go to{" "}
136
+ {data.safeRecipients.join(", ")}. Bulk send to customers is disabled. This is enforced
137
+ in the data layer, not by this screen; set the Space secret <code>SAFE_MODE=0</code> to
138
+ go live.
139
+ </div>
140
+ ) : null}
141
+ {err ? <div className="set-error">{err}</div> : null}
142
+
143
+ {data ? (
144
+ <>
145
+ <div className="stmt-row">
146
+ {data.tiers.map((t) => (
147
+ <label key={t} className="set-chip">
148
+ <input
149
+ type="checkbox"
150
+ checked={tiers.includes(t)}
151
+ onChange={() =>
152
+ setTiers((cur) =>
153
+ cur.includes(t) ? cur.filter((x) => x !== t) : [...cur, t],
154
+ )
155
+ }
156
+ />
157
+ {t}
158
+ </label>
159
+ ))}
160
+ <label className="set-chip">
161
+ <input
162
+ type="checkbox"
163
+ checked={emailOnly}
164
+ onChange={(e) => setEmailOnly(e.currentTarget.checked)}
165
+ />
166
+ Has email only
167
+ </label>
168
+ <input
169
+ className="set-input"
170
+ placeholder="Search customer"
171
+ value={search}
172
+ onChange={(e) => setSearch(e.currentTarget.value)}
173
+ />
174
+ <button className="set-secondary" disabled={busy} onClick={() => void reload(true)}>
175
+ Refresh from Odoo
176
+ </button>
177
+ </div>
178
+ <p className="set-help stmt-small">
179
+ Data as of {data.loadedAt}. “Monitor” customers have open receivables but nothing
180
+ overdue — usually excluded from monthly statements.
181
+ </p>
182
+
183
+ <button className="stmt-link" onClick={() => setShowTpl((v) => !v)}>
184
+ {showTpl ? "Hide" : "Show"} statement template (applies to every send below)
185
+ </button>
186
+ {showTpl && tpl ? (
187
+ <div className="stmt-stack">
188
+ <label className="set-label">
189
+ Subject <span className="set-help">— placeholders: {"{customer} {company} {month}"}</span>
190
+ <input
191
+ className="set-input stmt-wide"
192
+ value={tpl.subject}
193
+ onChange={(e) => setTpl({ ...tpl, subject: e.currentTarget.value })}
194
+ />
195
+ </label>
196
+ <label className="set-label">
197
+ Intro (HTML ok)
198
+ <textarea
199
+ className="stmt-textarea"
200
+ rows={4}
201
+ value={tpl.intro}
202
+ onChange={(e) => setTpl({ ...tpl, intro: e.currentTarget.value })}
203
+ />
204
+ </label>
205
+ <label className="set-label">
206
+ Footer (HTML ok)
207
+ <textarea
208
+ className="stmt-textarea"
209
+ rows={4}
210
+ value={tpl.footer}
211
+ onChange={(e) => setTpl({ ...tpl, footer: e.currentTarget.value })}
212
+ />
213
+ </label>
214
+ </div>
215
+ ) : null}
216
+
217
+ <div className="stmt-tablewrap">
218
+ <table className="stmt-table">
219
+ <thead>
220
+ <tr>
221
+ <th style={{ width: 44 }}>Send</th>
222
+ <th>Customer</th>
223
+ <th>Email</th>
224
+ <th>Tier</th>
225
+ <th className="stmt-num">Overdue</th>
226
+ <th className="stmt-num">Receivable</th>
227
+ </tr>
228
+ </thead>
229
+ <tbody>
230
+ {view.map((r) => (
231
+ <tr key={r.Customer} className={picked.has(r.Customer) ? "is-picked" : undefined}>
232
+ <td>
233
+ <input
234
+ type="checkbox"
235
+ aria-label={`Send to ${r.Customer}`}
236
+ checked={picked.has(r.Customer)}
237
+ onChange={() => toggle(r.Customer)}
238
+ />
239
+ </td>
240
+ <td>{r.Customer}</td>
241
+ <td className={r.Email ? undefined : "set-muted"}>{r.Email || "(no email)"}</td>
242
+ <td>{r.Tier}</td>
243
+ <td className="stmt-num">{money(r.Overdue)}</td>
244
+ <td className="stmt-num">{money(r.Receivable)}</td>
245
+ </tr>
246
+ ))}
247
+ </tbody>
248
+ </table>
249
+ {/* Owner rule [[no-unverifiable-aggregates]]: state the denominator, always. */}
250
+ <p className="set-help stmt-small">
251
+ Showing {view.length} of {data.rows.length} customers · {selected.length} selected ·
252
+ combined overdue {money(selected.reduce((a, r) => a + (Number(r.Overdue) || 0), 0))}.
253
+ </p>
254
+ </div>
255
+
256
+ {view.length ? (
257
+ <div className="stmt-stack">
258
+ <label className="set-label">
259
+ Preview customer
260
+ <select
261
+ className="set-input"
262
+ value={previewRow}
263
+ onChange={(e) => setPreviewOf(e.currentTarget.value)}
264
+ >
265
+ {(selected.length ? selected : view).map((r) => (
266
+ <option key={r.Customer} value={r.Customer}>{r.Customer}</option>
267
+ ))}
268
+ </select>
269
+ </label>
270
+ {preview ? (
271
+ <>
272
+ <p className="set-help stmt-small">
273
+ From: {data.sender.name} &lt;{data.sender.email}&gt; · reply-to{" "}
274
+ {data.sender.replyTo} · To: {preview.to || "(no email)"} · Subject:{" "}
275
+ {preview.subject}
276
+ </p>
277
+ <details className="stmt-details">
278
+ <summary>Preview statement</summary>
279
+ {/* Server-rendered from our own template + our own Odoo rows — the same
280
+ HTML the send path posts. */}
281
+ <div
282
+ className="stmt-preview"
283
+ dangerouslySetInnerHTML={{ __html: preview.html }}
284
+ />
285
+ </details>
286
+ </>
287
+ ) : null}
288
+
289
+ <div className="stmt-row">
290
+ <label className="set-label">
291
+ Test address (sends the preview customer’s statement here)
292
+ <input
293
+ className="set-input stmt-wide"
294
+ value={testTo}
295
+ disabled={data.safeMode}
296
+ title={data.safeMode ? "Locked to the guardrail address while testing." : undefined}
297
+ onChange={(e) => setTestTo(e.currentTarget.value)}
298
+ />
299
+ </label>
300
+ <button
301
+ className="set-secondary"
302
+ disabled={busy || !previewRow || !testTo.includes("@")}
303
+ onClick={() => void doSend([previewRow], testTo)}
304
+ >
305
+ Send test email
306
+ </button>
307
+ </div>
308
+ </div>
309
+ ) : (
310
+ <p className="set-help">No customers match the current filters.</p>
311
+ )}
312
+
313
+ {noEmail.length ? (
314
+ <div className="stmt-warn">
315
+ {noEmail.length} selected {noEmail.length === 1 ? "customer has" : "customers have"} no
316
+ email and will be skipped: {noEmail.slice(0, 5).map((r) => r.Customer).join(", ")}
317
+ {noEmail.length > 5 ? "…" : ""}
318
+ </div>
319
+ ) : null}
320
+
321
+ {result ? (
322
+ <div className={result.failed.length ? "set-warn" : "set-ok"}>
323
+ <b>
324
+ {result.sent.length} statement{result.sent.length === 1 ? "" : "s"} queued
325
+ {result.test ? " (test)" : ""} in Odoo
326
+ </b>{" "}
327
+ — delivery within ~15 min via the Office 365 relay; each send is logged on the
328
+ customer record.
329
+ {result.skipped.length ? (
330
+ <div>Skipped: {result.skipped.map((s) => `${s.customer} (${s.reason})`).join(" · ")}</div>
331
+ ) : null}
332
+ {result.failed.length ? (
333
+ <div>Failed: {result.failed.map((f) => `${f.customer}: ${f.error}`).join(" | ")}</div>
334
+ ) : null}
335
+ </div>
336
+ ) : null}
337
+
338
+ {/* ── the confirm flow ─────────────────────────────────────────── */}
339
+ {!confirming ? (
340
+ <button
341
+ className="set-primary"
342
+ disabled={busy || !sendable.length || data.safeMode}
343
+ title={data.safeMode ? "Disabled while the testing guardrail is on." : undefined}
344
+ onClick={() => { setResult(null); setConfirming(true); }}
345
+ >
346
+ Send {sendable.length} statement{sendable.length === 1 ? "" : "s"}…
347
+ </button>
348
+ ) : (
349
+ <div className="stmt-confirm">
350
+ <div className="stmt-warn">
351
+ About to queue <b>{sendable.length}</b> statements as {data.sender.name} (
352
+ {data.sender.email}), replies to {data.sender.replyTo} — combined overdue{" "}
353
+ {money(sendable.reduce((a, r) => a + (Number(r.Overdue) || 0), 0))}.{" "}
354
+ <b>This cannot be undone from here.</b>
355
+ </div>
356
+ <div className="stmt-tablewrap stmt-tablewrap--short">
357
+ <table className="stmt-table">
358
+ <thead>
359
+ <tr><th>Customer</th><th>Email</th><th className="stmt-num">Overdue</th></tr>
360
+ </thead>
361
+ <tbody>
362
+ {sendable.map((r) => (
363
+ <tr key={r.Customer}>
364
+ <td>{r.Customer}</td>
365
+ <td>{r.Email}</td>
366
+ <td className="stmt-num">{money(r.Overdue)}</td>
367
+ </tr>
368
+ ))}
369
+ </tbody>
370
+ </table>
371
+ </div>
372
+ <div className="stmt-row">
373
+ <button
374
+ className="set-primary"
375
+ disabled={busy}
376
+ onClick={() => void doSend(sendable.map((r) => r.Customer))}
377
+ >
378
+ {busy ? "Queueing…" : "Confirm and send"}
379
+ </button>
380
+ <button className="set-secondary" disabled={busy} onClick={() => setConfirming(false)}>
381
+ Cancel
382
+ </button>
383
+ </div>
384
+ </div>
385
+ )}
386
+ </>
387
+ ) : null}
388
+ </div>
389
+ );
390
+ }
391
+
392
+ export default StatementsPane;
web/src/settings/permsModel.ts CHANGED
@@ -44,6 +44,10 @@ export type SettingsSection =
44
  // Wave 18 (C7): the tenant's credential store and its data-source status board.
45
  | "keychains"
46
  | "connectors"
 
 
 
 
47
  /**
48
  * Wave 19 (R3 / contract C2): the Loopable admin plane — the cross-TENANT
49
  * console, visible only to a `platform_admin` account.
@@ -75,7 +79,13 @@ export type SettingsSection =
75
  export function reachableSection(section: SettingsSection, admin: boolean): SettingsSection {
76
  // Wave 18 (C7): keychains + connectors are admin rooms for the same reason users is —
77
  // every control inside them would 403 a member.
78
- const adminOnly = section === "users" || section === "keychains" || section === "connectors";
 
 
 
 
 
 
79
  return adminOnly && !admin ? "account" : section;
80
  }
81
 
 
44
  // Wave 18 (C7): the tenant's credential store and its data-source status board.
45
  | "keychains"
46
  | "connectors"
47
+ /** EXIT-6: the statement-of-account sender, ported off `app.py` when Streamlit
48
+ * was deleted. Admin-only for the same reason the Streamlit section was —
49
+ * it is THE one sanctioned Odoo writer in the product. */
50
+ | "statements"
51
  /**
52
  * Wave 19 (R3 / contract C2): the Loopable admin plane — the cross-TENANT
53
  * console, visible only to a `platform_admin` account.
 
79
  export function reachableSection(section: SettingsSection, admin: boolean): SettingsSection {
80
  // Wave 18 (C7): keychains + connectors are admin rooms for the same reason users is —
81
  // every control inside them would 403 a member.
82
+ const adminOnly =
83
+ section === "users" ||
84
+ section === "keychains" ||
85
+ section === "connectors" ||
86
+ // EXIT-6: every control in the statements room would 403 a member, and the
87
+ // one at the bottom sends customer email. It belongs in this list twice over.
88
+ section === "statements";
89
  return adminOnly && !admin ? "account" : section;
90
  }
91
 
web/src/settings/statementsApi.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // settings / statementsApi.ts — the statement sender's wire (EXIT-6).
3
+ //
4
+ // ⛔ THE ONLY WRITE PATH IN THIS PRODUCT THAT LEAVES THE BUILDING. Everything
5
+ // else AIOS does to Odoo is read-only by hard block; `/admin/statements/send`
6
+ // queues real email to real customers. Three consequences for this file:
7
+ //
8
+ // 1. **No convenience wrapper sends anything.** There is no `sendAll()`, no
9
+ // default argument that could become a send. The caller names every
10
+ // customer explicitly, every time.
11
+ // 2. **SAFE_MODE is REPORTED here, never decided here.** The server's data
12
+ // layer refuses an out-of-allow-list address. If this file ever grows a
13
+ // `if (safeMode) return` it would look like the guardrail while the real
14
+ // one silently rotted — a hidden button is a courtesy, never the check
15
+ // ([[aios-permissioning]]).
16
+ // 3. **Outcomes are per-customer.** `sent` / `failed` / `skipped` come back as
17
+ // lists, not counts, because "37 queued" is unverifiable and this is mail.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ import { API_V1, CREDENTIALS } from "../apiContract";
21
+
22
+ export interface StatementRow {
23
+ Customer: string;
24
+ Email: string;
25
+ Tier: string;
26
+ Overdue: number;
27
+ Receivable: number;
28
+ [k: string]: unknown;
29
+ }
30
+
31
+ export interface StatementTemplates {
32
+ subject: string;
33
+ intro: string;
34
+ footer: string;
35
+ }
36
+
37
+ export interface StatementsPayload {
38
+ rows: StatementRow[];
39
+ loadedAt: string;
40
+ safeMode: boolean;
41
+ safeRecipients: string[];
42
+ sender: { name: string; email: string; replyTo: string; company: string };
43
+ templates: StatementTemplates;
44
+ tiers: string[];
45
+ }
46
+
47
+ export interface SendOutcome {
48
+ sent: Array<{ customer: string; to: string; mailId: number }>;
49
+ failed: Array<{ customer: string; error: string }>;
50
+ skipped: Array<{ customer: string; reason: string }>;
51
+ safeMode: boolean;
52
+ test: boolean;
53
+ }
54
+
55
+ type Res<T> = { ok: true; data: T } | { ok: false; message: string };
56
+
57
+ async function call<T>(path: string, init?: RequestInit): Promise<Res<T>> {
58
+ try {
59
+ const r = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init });
60
+ const body = await r.json().catch(() => null);
61
+ if (!r.ok) {
62
+ const msg = body?.error?.message || `request failed (${r.status})`;
63
+ return { ok: false, message: msg };
64
+ }
65
+ return { ok: true, data: body as T };
66
+ } catch (e) {
67
+ return { ok: false, message: e instanceof Error ? e.message : "network error" };
68
+ }
69
+ }
70
+
71
+ export function loadStatements(refresh = false) {
72
+ return call<StatementsPayload>(`/admin/statements${refresh ? "?refresh=1" : ""}`);
73
+ }
74
+
75
+ export function previewStatement(customer: string, templates: StatementTemplates) {
76
+ return call<{ html: string; to: string; subject: string }>("/admin/statements/preview", {
77
+ method: "POST",
78
+ headers: { "Content-Type": "application/json" },
79
+ body: JSON.stringify({ customer, templates }),
80
+ });
81
+ }
82
+
83
+ /** Queue statements. `overrideTo` is the TEST path and takes exactly one customer. */
84
+ export function sendStatements(
85
+ customers: string[],
86
+ templates: StatementTemplates,
87
+ overrideTo?: string,
88
+ ) {
89
+ return call<SendOutcome>("/admin/statements/send", {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/json" },
92
+ body: JSON.stringify({ customers, templates, overrideTo: overrideTo || undefined }),
93
+ });
94
+ }