// --------------------------------------------------------------------------- // settings / statementsApi.ts — the statement sender's wire (EXIT-6). // // ⛔ THE ONLY WRITE PATH IN THIS PRODUCT THAT LEAVES THE BUILDING. Everything // else AIOS does to Odoo is read-only by hard block; `/admin/statements/send` // queues real email to real customers. Three consequences for this file: // // 1. **No convenience wrapper sends anything.** There is no `sendAll()`, no // default argument that could become a send. The caller names every // customer explicitly, every time. // 2. **SAFE_MODE is REPORTED here, never decided here.** The server's data // layer refuses an out-of-allow-list address. If this file ever grows a // `if (safeMode) return` it would look like the guardrail while the real // one silently rotted — a hidden button is a courtesy, never the check // ([[aios-permissioning]]). // 3. **Outcomes are per-customer.** `sent` / `failed` / `skipped` come back as // lists, not counts, because "37 queued" is unverifiable and this is mail. // --------------------------------------------------------------------------- import { API_V1, CREDENTIALS } from "../apiContract"; export interface StatementRow { Customer: string; Email: string; Tier: string; Overdue: number; Receivable: number; [k: string]: unknown; } export interface StatementTemplates { subject: string; intro: string; footer: string; } export interface StatementsPayload { rows: StatementRow[]; loadedAt: string; safeMode: boolean; safeRecipients: string[]; sender: { name: string; email: string; replyTo: string; company: string }; templates: StatementTemplates; tiers: string[]; } export interface SendOutcome { sent: Array<{ customer: string; to: string; mailId: number }>; failed: Array<{ customer: string; error: string }>; skipped: Array<{ customer: string; reason: string }>; safeMode: boolean; test: boolean; } type Res = { ok: true; data: T } | { ok: false; message: string }; async function call(path: string, init?: RequestInit): Promise> { try { const r = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init }); const body = await r.json().catch(() => null); if (!r.ok) { const msg = body?.error?.message || `request failed (${r.status})`; return { ok: false, message: msg }; } return { ok: true, data: body as T }; } catch (e) { return { ok: false, message: e instanceof Error ? e.message : "network error" }; } } export function loadStatements(refresh = false) { return call(`/admin/statements${refresh ? "?refresh=1" : ""}`); } export function previewStatement(customer: string, templates: StatementTemplates) { return call<{ html: string; to: string; subject: string }>("/admin/statements/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ customer, templates }), }); } /** Queue statements. `overrideTo` is the TEST path and takes exactly one customer. */ export function sendStatements( customers: string[], templates: StatementTemplates, overrideTo?: string, ) { return call("/admin/statements/send", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ customers, templates, overrideTo: overrideTo || undefined }), }); }