loopable / web /src /settings /statementsApi.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
62d663e verified
Raw
History Blame Contribute Delete
3.44 kB
// ---------------------------------------------------------------------------
// 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<T> = { ok: true; data: T } | { ok: false; message: string };
async function call<T>(path: string, init?: RequestInit): Promise<Res<T>> {
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<StatementsPayload>(`/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<SendOutcome>("/admin/statements/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customers, templates, overrideTo: overrideTo || undefined }),
});
}