Files
jorgecuadros-platform/apps/web/src/lib/api.ts
T
rmancinasandClaude Opus 5 d645ba51d3
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m39s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m21s
feat(policy-ocr): read A.N.A. Seguros' two policy faces
A.N.A. is the Rosarito office's tourist auto book and the second carrier
the policy OCR pipeline reads. It ships two unrelated faces, and the split
is different from GMX's: GMX ships two documents about one policy, A.N.A.
ships two products.

  AUTOMOBILE (SPECIAL POLICY FOR TOURISTS)  insures a car; vehicle table,
                                            9 numbered sections, one
                                            LIMIT OF LIABILITY column
  DRIVER'S POLICY (the office: "licencia")  insures up to 5 named drivers;
                                            no vehicle at all, 6 unnumbered
                                            sections in a different order,
                                            SUM INSURED + PREMIUM columns

The four automobile products the office sells (amplia / responsabilidad
civil, annual / by-the-day) are the same layout with different numbers, so
they get one parser rather than four.

These are born-digital portal PDFs, so pdftotext -layout returns exact
columns and the driver's-policy parser uses that: its two value columns
print the same shape (100,000.00 usd. / 18.70 usd.) with no per-row label,
so horizontal position is the only thing separating them. The split comes
from the header's own offsets, not a constant, because they shift between
products; when it can't be read every amount is reported as a sum insured
and the reviewer is told, rather than half the premiums being filed as
coverage limits.

Three things the layout will punish a naive read for:

- Each PDF prints its face two or three times (ORIGINAL, AGENT COPY, then
  a receipt and three travel cards) and the pipeline concatenates every
  page before parsing. The coverage walk is bounded to the first copy and
  the driver list to the first POLICY HOLDER block. Unbounded, the licencia
  returns the same person three times, which reads as a three-driver policy
  rather than as a bug.
- The money row is read positionally off its header. An unused DISCOUNT
  prints as a bare "-", so "find the six amounts" shifts every value one
  column left on a discounted policy.
- Two five-digit numbers sit in the header band and only one is the agent
  clave; the agent's street address is "BENITO JUAREZ 25 No.50 INT 38",
  three lines above the No. cell holding the policy number.

Sections 6-8 print a PREMIUM where the others print a limit, so
ParsedCoverage gains an optional `premium` (GMX never fills it) and the
review table a column: $40 is what legal aid cost, not a $40 liability
limit. Exclusions follow the GMX rule and go in the risk label with a null
amount -- which matters more here, since a responsabilidad-civil policy
prints 0.00 for material damage and the two are identical on the page.

Also in this change:

- coveragePeriodDays is parsed and written. A.N.A. sells 3- and 4-day
  policies; Policy.coveragePeriodDays defaults to 365, so a weekend policy
  left at the default sits in the renewals window a year out. Derived from
  the dates, cross-checked against the printed DAYS cell, disagreement
  noted not resolved.
- Vehicles and named drivers are parsed, shown read-only in review, and
  written as Vehicle / InsuredDriver rows on confirm, skipping any already
  on the policy (VIN then plate; licence then name). The case that forces
  the skip is confirming a renewal onto an existing policy. Nothing is ever
  updated or deleted -- a changed plate lands as a second row for a human.
- Batch.provider is set from what the parsers actually claimed instead of
  being hardcoded "GMX", so a mixed upload is labelled as mixed and the
  header can never contradict its own documents. PolicyDocument.documentType
  follows the same rule (was hardcoded GMX_POLICY).
- matchNote becomes TEXT. It was VARCHAR(191) and the note trail was sliced
  to 190 chars, which cut the tail notes -- the "could not read X" ones.
- The policy detail page renders an array coveragesJson as a table. Both
  shapes have always been possible there, but the object renderer was the
  only one, so an OCR-confirmed policy showed a row per array index labelled
  "0", "1", "2" with [object Object] as the value. ANA makes that routine.

GMX is untouched behaviourally; its two parsers now spread a shared empty
base instead of listing every null field. 29 new parser cases against
verbatim pdftotext output of three real ANA PDFs, 53 in the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:09:16 -07:00

1532 lines
44 KiB
TypeScript

// Cross-origin API client. All requests send the session cookie (connect.sid)
// via credentials: "include". The API origin comes from NEXT_PUBLIC_API_ORIGIN.
import type {
AuthUser,
BalanceFilter,
BalanceListResponse,
BalanceSort,
BankAccount,
BankCleared,
BankDirection,
BankFacets,
BankInstitution,
BankListResponse,
BankSort,
BankStats,
BankSummary,
BatchCreateInput,
ConfirmBatchInput,
ConfirmBatchResult,
BatchCreateResponse,
BillingFacets,
BillingStats,
BusinessLine,
ByCheckResponse,
CreateBankAccountInput,
CreateBankInput,
CreateBankMovementInput,
CreateMovementInput,
DiscardBatchResult,
UpdateBankAccountInput,
ResolveOutstandingInput,
ReviewDocumentInput,
StatementBatch,
StatementBatchDetail,
StatementDocument,
StatementDocumentStatus,
CustomerDetail,
CustomerInput,
CustomerListResponse,
CustomerStats,
LedgerCurrency,
LedgerDirection,
MovementListResponse,
MovementSort,
PolicyDetail,
PolicyFacets,
PolicyInput,
PolicyListResponse,
PolicySort,
PolicyStats,
PolicyStatus,
PolicyOcrBatch,
PolicyOcrBatchDetail,
PolicyOcrDocument,
PolicyOcrReviewInput,
PolicyOcrConfirmInput,
PolicyOcrConfirmResult,
LookupsResponse,
OpsJob,
OpsJobKind,
ReplicationStatus,
VerifyResult,
IngestFile,
BackupFile,
PropertyDetail,
PropertyFacets,
PropertyInput,
PropertyListResponse,
PropertySort,
PropertyStats,
ReportCatalog,
ReportRunResult,
ServiceInput,
TrustInput,
Role,
ServiceKind,
Statement,
Transaction,
TransactionDomain,
TrustFilter,
UserRow,
} from "./types";
// Resolve the API origin at runtime, not build time — so one built image serves
// any deployment and the app follows the box when it moves (tailnet today,
// 192.168.1.x office LAN later) with no config change.
//
// In the browser, derive the origin from the page's own location, the way a PHP
// app would. An explicit API_ORIGIN (injected as window.__API_ORIGIN__ by the
// root layout) still wins when a deployment genuinely splits the two hosts.
// On the server (SSR) read process.env directly — a derived origin is
// browser-only, and "/api" is not fetchable server-side.
function resolveApiOrigin(): string {
if (typeof window !== "undefined") {
const injected = (window as { __API_ORIGIN__?: string }).__API_ORIGIN__;
if (injected) return injected;
const { protocol, hostname } = window.location;
// Over TLS the API must share the page's origin or the browser blocks the
// call as mixed active content. The reverse proxy maps /api to the API.
if (protocol === "https:") return "/api";
// Plain HTTP: same host, API port. 3001 is the port the API container
// publishes everywhere (deploy/galactus/jorgecuadros-app.compose.yml).
return `http://${hostname}:3001`;
}
return (
process.env.API_ORIGIN ??
process.env.NEXT_PUBLIC_API_ORIGIN ??
"http://localhost:3001"
);
}
export const API_ORIGIN = resolveApiOrigin();
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "ApiError";
}
}
export async function apiFetch<T>(
path: string,
init?: RequestInit,
): Promise<T> {
let res: Response;
try {
res = await fetch(`${API_ORIGIN}${path}`, {
credentials: "include",
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
...init,
});
} catch (e) {
throw new ApiError(
0,
"No se pudo conectar con el servidor. Verifica tu conexión.",
);
}
if (!res.ok) {
let message = `Error ${res.status}`;
try {
const body = await res.json();
if (body?.message) message = body.message;
} catch {
/* ignore non-JSON error bodies */
}
throw new ApiError(res.status, message);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export function login(email: string, password: string): Promise<AuthUser> {
return apiFetch<AuthUser>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
}
export function me(): Promise<AuthUser> {
return apiFetch<AuthUser>("/auth/me");
}
/** Persist the caller's own text-size preference on their account. */
export function updateUiScale(uiScale: number): Promise<AuthUser> {
return apiFetch<AuthUser>("/auth/preferences", {
method: "PATCH",
body: JSON.stringify({ uiScale }),
});
}
export function logout(): Promise<{ success: boolean }> {
return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" });
}
export interface ServiceVersion {
service: string;
version: string;
gitSha: string;
buildDate: string;
}
/** What the API container reports it is running. Unauthenticated by design. */
export function getApiVersion(): Promise<ServiceVersion> {
return apiFetch<ServiceVersion>("/version");
}
export function getStats(): Promise<CustomerStats> {
return apiFetch<CustomerStats>("/customers/stats");
}
export interface CustomerQuery {
query?: string;
page?: number;
pageSize?: number;
line?: BusinessLine;
}
export function listCustomers(
q: CustomerQuery,
): Promise<CustomerListResponse> {
const params = new URLSearchParams();
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
if (q.pageSize) params.set("pageSize", String(q.pageSize));
if (q.line) params.set("line", q.line);
const qs = params.toString();
return apiFetch<CustomerListResponse>(`/customers${qs ? `?${qs}` : ""}`);
}
export function getCustomer(id: string): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}`);
}
export function createCustomer(input: CustomerInput): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>("/customers", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateCustomer(
id: string,
input: Partial<CustomerInput>,
): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function archiveCustomer(id: string): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}`, { method: "DELETE" });
}
export function restoreCustomer(id: string): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}/restore`, { method: "POST" });
}
export interface NumidAllocation {
numid: string;
/** "existing" when the customer already had one — the call is idempotent. */
origin: "existing" | "new" | "recycled";
previousCustomerId?: string;
}
/** Give a customer the portal NUMid they log in to my.jorgecuadros.com with. */
export function grantPortalAccess(id: string): Promise<NumidAllocation> {
return apiFetch<NumidAllocation>(`/customers/${id}/portal-access`, {
method: "POST",
});
}
/* ------------------------------------------------------ Policies module */
/** Renewal horizon in days, shared by the list, stats and detail calls so the
* "por vencer" bucket means the same thing everywhere. */
export const EXPIRY_WINDOW_DAYS = 30;
export interface PolicyQuery {
query?: string;
page?: number;
pageSize?: number;
status?: PolicyStatus;
days?: number;
typeId?: string;
providerId?: string;
liquidated?: boolean;
sort?: PolicySort;
}
export function listPolicies(q: PolicyQuery): Promise<PolicyListResponse> {
const params = new URLSearchParams();
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
if (q.pageSize) params.set("pageSize", String(q.pageSize));
if (q.status) params.set("status", q.status);
if (q.days) params.set("days", String(q.days));
if (q.typeId) params.set("typeId", q.typeId);
if (q.providerId) params.set("providerId", q.providerId);
if (q.liquidated !== undefined) params.set("liquidated", String(q.liquidated));
if (q.sort) params.set("sort", q.sort);
const qs = params.toString();
return apiFetch<PolicyListResponse>(`/policies${qs ? `?${qs}` : ""}`);
}
export function getPolicyStats(
days: number = EXPIRY_WINDOW_DAYS,
): Promise<PolicyStats> {
return apiFetch<PolicyStats>(`/policies/stats?days=${days}`);
}
export function getPolicyFacets(): Promise<PolicyFacets> {
return apiFetch<PolicyFacets>("/policies/facets");
}
export function getPolicy(
id: string,
days: number = EXPIRY_WINDOW_DAYS,
): Promise<PolicyDetail> {
return apiFetch<PolicyDetail>(`/policies/${id}?days=${days}`);
}
export function createPolicy(input: PolicyInput): Promise<PolicyDetail> {
return apiFetch<PolicyDetail>("/policies", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updatePolicy(
id: string,
input: Partial<PolicyInput>,
): Promise<PolicyDetail> {
return apiFetch<PolicyDetail>(`/policies/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function archivePolicy(id: string): Promise<PolicyDetail> {
return apiFetch<PolicyDetail>(`/policies/${id}`, { method: "DELETE" });
}
export function restorePolicy(id: string): Promise<PolicyDetail> {
return apiFetch<PolicyDetail>(`/policies/${id}/restore`, { method: "POST" });
}
// Generic policy-child CRUD. `kind` is the URL segment
// (installments|vehicles|drivers|beneficiaries|claims).
export function addPolicyChild<T>(
policyId: string,
kind: string,
input: T,
): Promise<unknown> {
return apiFetch(`/policies/${policyId}/${kind}`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function updatePolicyChild<T>(
policyId: string,
kind: string,
childId: string,
input: T,
): Promise<unknown> {
return apiFetch(`/policies/${policyId}/${kind}/${childId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function removePolicyChild(
policyId: string,
kind: string,
childId: string,
): Promise<unknown> {
return apiFetch(`/policies/${policyId}/${kind}/${childId}`, {
method: "DELETE",
});
}
/* ------------------------------------------------- Lookups (insurance ref) */
export function getLookups(): Promise<LookupsResponse> {
return apiFetch<LookupsResponse>("/lookups");
}
export function createLookup(kind: string, input: unknown): Promise<unknown> {
return apiFetch(`/lookups/${kind}`, { method: "POST", body: JSON.stringify(input) });
}
export function updateLookup(
kind: string,
id: string,
input: unknown,
): Promise<unknown> {
return apiFetch(`/lookups/${kind}/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function removeLookup(kind: string, id: string): Promise<unknown> {
return apiFetch(`/lookups/${kind}/${id}`, { method: "DELETE" });
}
/* ----------------------------------------------------- Utilities module */
export interface PropertyQuery {
query?: string;
page?: number;
pageSize?: number;
serviceKind?: ServiceKind;
municipality?: string;
bank?: string;
trust?: TrustFilter;
hasServices?: boolean;
customerId?: string;
days?: number;
sort?: PropertySort;
}
export function listProperties(q: PropertyQuery): Promise<PropertyListResponse> {
const params = new URLSearchParams();
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
if (q.pageSize) params.set("pageSize", String(q.pageSize));
if (q.serviceKind) params.set("serviceKind", q.serviceKind);
if (q.municipality) params.set("municipality", q.municipality);
if (q.bank) params.set("bank", q.bank);
if (q.trust) params.set("trust", q.trust);
if (q.hasServices !== undefined)
params.set("hasServices", String(q.hasServices));
if (q.customerId) params.set("customerId", q.customerId);
if (q.days) params.set("days", String(q.days));
if (q.sort) params.set("sort", q.sort);
const qs = params.toString();
return apiFetch<PropertyListResponse>(`/properties${qs ? `?${qs}` : ""}`);
}
export function getPropertyStats(
days: number = EXPIRY_WINDOW_DAYS,
): Promise<PropertyStats> {
return apiFetch<PropertyStats>(`/properties/stats?days=${days}`);
}
export function getPropertyFacets(): Promise<PropertyFacets> {
return apiFetch<PropertyFacets>("/properties/facets");
}
export function getProperty(
id: string,
days: number = EXPIRY_WINDOW_DAYS,
): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}?days=${days}`);
}
export function createProperty(input: PropertyInput): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>("/properties", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateProperty(
id: string,
input: Partial<PropertyInput>,
): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function archiveProperty(id: string): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}`, { method: "DELETE" });
}
export function restoreProperty(id: string): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}/restore`, { method: "POST" });
}
// Service child CRUD.
export function addService(propertyId: string, input: ServiceInput): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/services`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateService(
propertyId: string,
serviceId: string,
input: Partial<ServiceInput>,
): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function removeService(propertyId: string, serviceId: string): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
method: "DELETE",
});
}
// Trust account (1:1 upsert).
export function upsertTrust(propertyId: string, input: TrustInput): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/trust`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export function removeTrust(propertyId: string): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/trust`, { method: "DELETE" });
}
export function removePropertyDocument(
propertyId: string,
documentId: string,
): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/documents/${documentId}`, {
method: "DELETE",
});
}
export function propertyDocumentDownloadUrl(
propertyId: string,
documentId: string,
): string {
return `${API_ORIGIN}/properties/${propertyId}/documents/${documentId}/download`;
}
export function uploadPropertyDocument(
propertyId: string,
file: File,
type?: string,
): Promise<unknown> {
const q = type ? `?type=${encodeURIComponent(type)}` : "";
return uploadFile(`/properties/${propertyId}/documents${q}`, file);
}
export function removePolicyDocument(
policyId: string,
documentId: string,
): Promise<unknown> {
return apiFetch(`/policies/${policyId}/documents/${documentId}`, {
method: "DELETE",
});
}
export function policyDocumentDownloadUrl(
policyId: string,
documentId: string,
): string {
return `${API_ORIGIN}/policies/${policyId}/documents/${documentId}/download`;
}
export function uploadPolicyDocument(
policyId: string,
file: File,
type?: string,
): Promise<unknown> {
const q = type ? `?type=${encodeURIComponent(type)}` : "";
return uploadFile(`/policies/${policyId}/documents${q}`, file);
}
/* ------------------------------------------- Billing / statements module */
export interface MovementQuery {
query?: string;
page?: number;
pageSize?: number;
domain?: TransactionDomain;
currency?: LedgerCurrency;
direction?: LedgerDirection;
typeId?: string;
source?: string;
customerId?: string;
/** Restrict to captured-but-unpaid rows (the NOPAGO worklist). */
outstanding?: boolean;
/** Exact check number — the by-check reconciliation lookup. */
checkNumber?: string;
/** `YYYY-MM-DD`, inclusive on both ends. */
from?: string;
to?: string;
sort?: MovementSort;
}
export function listMovements(q: MovementQuery): Promise<MovementListResponse> {
const params = new URLSearchParams();
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
if (q.pageSize) params.set("pageSize", String(q.pageSize));
if (q.domain) params.set("domain", q.domain);
if (q.currency) params.set("currency", q.currency);
if (q.direction) params.set("direction", q.direction);
if (q.typeId) params.set("typeId", q.typeId);
if (q.source) params.set("source", q.source);
if (q.customerId) params.set("customerId", q.customerId);
if (q.outstanding !== undefined) params.set("outstanding", String(q.outstanding));
if (q.checkNumber) params.set("checkNumber", q.checkNumber);
if (q.from) params.set("from", q.from);
if (q.to) params.set("to", q.to);
if (q.sort) params.set("sort", q.sort);
const qs = params.toString();
return apiFetch<MovementListResponse>(`/billing${qs ? `?${qs}` : ""}`);
}
export interface BalanceQuery {
query?: string;
page?: number;
pageSize?: number;
currency?: LedgerCurrency;
balance?: BalanceFilter;
domain?: TransactionDomain;
sort?: BalanceSort;
}
export function listBalances(q: BalanceQuery): Promise<BalanceListResponse> {
const params = new URLSearchParams();
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
if (q.pageSize) params.set("pageSize", String(q.pageSize));
if (q.currency) params.set("currency", q.currency);
if (q.balance) params.set("balance", q.balance);
if (q.domain) params.set("domain", q.domain);
if (q.sort) params.set("sort", q.sort);
const qs = params.toString();
return apiFetch<BalanceListResponse>(`/billing/balances${qs ? `?${qs}` : ""}`);
}
export function getBillingStats(): Promise<BillingStats> {
return apiFetch<BillingStats>("/billing/stats");
}
export function getBillingFacets(): Promise<BillingFacets> {
return apiFetch<BillingFacets>("/billing/facets");
}
export function getStatement(customerId: string): Promise<Statement> {
return apiFetch<Statement>(`/billing/customers/${customerId}`);
}
/** Append a new ledger movement. Booked movements are never edited — fix
* mistakes with voidMovement + a fresh capture. */
export function createMovement(
input: CreateMovementInput,
): Promise<Transaction> {
return apiFetch<Transaction>("/billing", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Reverse a movement by marking it voided; totals drop it. 400 if already void. */
export function voidMovement(id: string): Promise<Transaction> {
return apiFetch<Transaction>(`/billing/${id}/void`, { method: "POST" });
}
/** Capture many customers' receipts against one check, in one transaction. The
* returned `items` are positionally parallel to `input.lines`. */
export function createMovementBatch(
input: BatchCreateInput,
): Promise<BatchCreateResponse> {
return apiFetch<BatchCreateResponse>("/billing/batch", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Clear an outstanding (NOPAGO) row: stamps the check number + resolution date
* and starts counting it toward the balance. 400 if not outstanding or voided. */
export function resolveOutstanding(
id: string,
input: ResolveOutstandingInput,
): Promise<Transaction> {
return apiFetch<Transaction>(`/billing/${id}/resolve-outstanding`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** Everything captured against one check, with its reconciliation total. */
export function getByCheck(checkNumber: string): Promise<ByCheckResponse> {
return apiFetch<ByCheckResponse>(
`/billing/by-check?checkNumber=${encodeURIComponent(checkNumber)}`,
);
}
/* ------------------------------------------------- Bank register (chequera) */
/**
* Every read below is scoped to one chequera. `bankAccountId` is required, not
* defaulted to "all accounts": the office's registers are in different
* currencies, and a combined total would be a figure that never existed.
*/
export interface BankQuery {
bankAccountId: string;
query?: string;
page?: number;
pageSize?: number;
direction?: BankDirection;
cleared?: BankCleared;
/** `YYYY-MM-DD`, inclusive on both ends. */
from?: string;
to?: string;
sort?: BankSort;
}
export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
const params = new URLSearchParams({ bankAccountId: q.bankAccountId });
if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page));
if (q.pageSize) params.set("pageSize", String(q.pageSize));
if (q.direction) params.set("direction", q.direction);
if (q.cleared) params.set("cleared", q.cleared);
if (q.from) params.set("from", q.from);
if (q.to) params.set("to", q.to);
if (q.sort) params.set("sort", q.sort);
return apiFetch<BankListResponse>(`/bank?${params.toString()}`);
}
export function getBankStats(bankAccountId: string): Promise<BankStats> {
return apiFetch<BankStats>(
`/bank/stats?bankAccountId=${encodeURIComponent(bankAccountId)}`,
);
}
export function getBankFacets(bankAccountId: string): Promise<BankFacets> {
return apiFetch<BankFacets>(
`/bank/facets?bankAccountId=${encodeURIComponent(bankAccountId)}`,
);
}
export function getBankSummary(
bankAccountId: string,
year?: number,
): Promise<BankSummary> {
const params = new URLSearchParams({ bankAccountId });
if (year) params.set("year", String(year));
return apiFetch<BankSummary>(`/bank/summary?${params.toString()}`);
}
/* --------------------------------------------- Chequera accounts (catalog) */
/** The account picker's source. Includes closed accounts, which stay readable. */
export function listBankAccounts(): Promise<BankAccount[]> {
return apiFetch<BankAccount[]>("/bank/accounts");
}
export function listBankInstitutions(): Promise<BankInstitution[]> {
return apiFetch<BankInstitution[]>("/bank/banks");
}
export function createBankInstitution(
input: CreateBankInput,
): Promise<BankInstitution> {
return apiFetch<BankInstitution>("/bank/banks", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateBankInstitution(
id: string,
input: Partial<CreateBankInput>,
): Promise<BankInstitution> {
return apiFetch<BankInstitution>(`/bank/banks/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function createBankAccount(
input: CreateBankAccountInput,
): Promise<unknown> {
return apiFetch("/bank/accounts", {
method: "POST",
body: JSON.stringify(input),
});
}
/** No `currency` — an account's booked movements are denominated in it. */
export function updateBankAccount(
id: string,
input: UpdateBankAccountInput,
): Promise<unknown> {
return apiFetch(`/bank/accounts/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
/** Append a new chequera movement. Booked rows are never edited — fix mistakes
* with voidBankMovement + a fresh capture. */
export function createBankMovement(
input: CreateBankMovementInput,
): Promise<unknown> {
return apiFetch("/bank", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Reverse a chequera movement by marking it voided; totals drop it. */
export function voidBankMovement(id: string): Promise<unknown> {
return apiFetch(`/bank/${id}/void`, { method: "POST" });
}
/* ------------------------------------------------- Users / administration */
export function listUsers(): Promise<UserRow[]> {
return apiFetch<UserRow[]>("/users");
}
export interface CreateUserInput {
name: string;
email: string;
password: string;
role: Role;
active?: boolean;
}
export function createUser(input: CreateUserInput): Promise<UserRow> {
return apiFetch<UserRow>("/users", {
method: "POST",
body: JSON.stringify(input),
});
}
export interface UpdateUserInput {
name?: string;
email?: string;
role?: Role;
active?: boolean;
}
export function updateUser(id: string, input: UpdateUserInput): Promise<UserRow> {
return apiFetch<UserRow>(`/users/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function resetUserPassword(id: string, password: string): Promise<UserRow> {
return apiFetch<UserRow>(`/users/${id}/reset-password`, {
method: "POST",
body: JSON.stringify({ password }),
});
}
export function deleteUser(id: string): Promise<void> {
return apiFetch<void>(`/users/${id}`, { method: "DELETE" });
}
/* ------------------------------------------- DB operations (admin only) */
export function listIngest(): Promise<IngestFile[]> {
return apiFetch<IngestFile[]>("/ops/ingest");
}
/** Live upload stats reported to `uploadFile`'s `onProgress` callback. */
export type UploadProgress = {
loaded: number;
/** 0 when the browser can't compute the request size. */
total: number;
/** 0..1, or null when `total` is unknown. */
fraction: number | null;
/** Smoothed transfer rate. */
bytesPerSecond: number;
/** null until a rate and a total are both known. */
secondsRemaining: number | null;
/** True once the bytes are sent and we're waiting on the server's reply. */
finishing: boolean;
};
/**
* Multipart upload — not JSON, so it bypasses apiFetch's Content-Type. `path`
* is API-relative (may include a query string); `filename` overrides the part
* name sent to the server. Uses XHR rather than fetch because fetch has no way
* to report request-body progress.
*/
export function uploadFile(
path: string,
file: File,
filename?: string,
onProgress?: (p: UploadProgress) => void,
): Promise<unknown> {
const body = new FormData();
body.append("file", file, filename ?? file.name);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${API_ORIGIN}${path}`);
xhr.withCredentials = true;
if (onProgress) {
// Exponentially smoothed rate — raw per-chunk deltas jump around too
// much to read.
let lastAt = performance.now();
let lastLoaded = 0;
let rate = 0;
xhr.upload.onprogress = (e) => {
const now = performance.now();
const dt = (now - lastAt) / 1000;
if (dt >= 0.15) {
const sample = (e.loaded - lastLoaded) / dt;
rate = rate === 0 ? sample : rate * 0.7 + sample * 0.3;
lastAt = now;
lastLoaded = e.loaded;
}
const total = e.lengthComputable ? e.total : 0;
onProgress({
loaded: e.loaded,
total,
fraction: total ? e.loaded / total : null,
bytesPerSecond: rate,
secondsRemaining:
total && rate > 0 ? (total - e.loaded) / rate : null,
finishing: false,
});
};
// Bytes are out the door; the server still has to write the file.
xhr.upload.onload = () => {
onProgress({
loaded: file.size,
total: file.size,
fraction: 1,
bytesPerSecond: rate,
secondsRemaining: 0,
finishing: true,
});
};
}
xhr.onload = () => {
let parsed: unknown;
try {
parsed = xhr.responseText ? JSON.parse(xhr.responseText) : undefined;
} catch {
parsed = undefined;
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(parsed);
return;
}
const message =
(parsed as { message?: string } | undefined)?.message ??
`Error ${xhr.status}`;
reject(new ApiError(xhr.status, message));
};
xhr.onerror = () =>
reject(new ApiError(0, "Fallo de red durante la carga."));
xhr.onabort = () => reject(new ApiError(0, "Carga cancelada."));
xhr.ontimeout = () => reject(new ApiError(0, "Tiempo de carga agotado."));
xhr.send(body);
});
}
export function uploadIngest(
name: string,
file: File,
onProgress?: (p: UploadProgress) => void,
): Promise<unknown> {
return uploadFile(
`/ops/ingest/${encodeURIComponent(name)}`,
file,
name,
onProgress,
);
}
export function deleteIngest(name: string): Promise<unknown> {
return apiFetch(`/ops/ingest/${encodeURIComponent(name)}`, { method: "DELETE" });
}
export function listBackups(): Promise<BackupFile[]> {
return apiFetch<BackupFile[]>("/ops/backups");
}
export function backupDownloadUrl(name: string): string {
return `${API_ORIGIN}/ops/backups/${encodeURIComponent(name)}/download`;
}
export function deleteBackup(name: string): Promise<unknown> {
return apiFetch(`/ops/backups/${encodeURIComponent(name)}`, { method: "DELETE" });
}
/**
* Health of the read replica my.jorgecuadros.com serves customers from.
*
* A stopped replica does not error — it answers with stale balances — so this
* is the only place the failure is visible.
*/
export function getReplicationStatus(): Promise<ReplicationStatus> {
return apiFetch<ReplicationStatus>("/ops/replication");
}
/**
* Compare every customer-visible table against the master, row by row.
*
* Slow by nature — it is a full scan of both servers — so it is a button, not
* part of the poll. Answers the question replication status cannot: GTIDs prove
* the replica applied everything the master sent, not that nothing else changed
* the rows here.
*/
export function verifyReplication(): Promise<VerifyResult> {
return apiFetch<VerifyResult>("/ops/replication/verify", { method: "POST" });
}
export function listOpsJobs(): Promise<OpsJob[]> {
return apiFetch<OpsJob[]>("/ops/jobs");
}
export function getOpsJob(id: string): Promise<OpsJob> {
return apiFetch<OpsJob>(`/ops/jobs/${id}`);
}
/** Start a mutating op. `file` is required for RESTORE. 409 if one is running. */
/** `forceFull` applies to REIMPORT only: proceed even though the rebuild
* deletes rows that exist only in the platform. */
export function startOpsJob(
kind: OpsJobKind,
file?: string,
forceFull?: boolean,
): Promise<OpsJob> {
return apiFetch<OpsJob>("/ops/jobs", {
method: "POST",
body: JSON.stringify({ kind, file, forceFull }),
});
}
/* ----------------------------------------------------------- Reports module */
export function getReportCatalog(): Promise<ReportCatalog> {
return apiFetch<ReportCatalog>("/reports");
}
export function runReport(
slug: string,
params: Record<string, string | undefined>,
): Promise<ReportRunResult> {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== "") qs.set(k, v);
}
const tail = qs.toString();
return apiFetch<ReportRunResult>(`/reports/${slug}${tail ? `?${tail}` : ""}`);
}
/* ------------------------------------------------- Mass email notifications */
export type NotificationType =
| "OUTSTANDING_PAYMENT"
| "PAYMENT_CONFIRMATION"
| "ACCOUNT_STATUS"
| "TRUST_PAYMENT_CONFIRMATION"
| "RENEWAL_NOTICE";
export type NotificationServicio = "CUSTOMERS" | "TRUST" | "POLICIES";
/** Which servicios each /notificaciones tab reads out of the shared log. */
export const SERVICIOS_LOG_SCOPE: NotificationServicio[] = ["CUSTOMERS", "TRUST"];
export const POLIZAS_LOG_SCOPE: NotificationServicio[] = ["POLICIES"];
export type NotificationStatus =
| "SENT"
| "FAILED"
| "SKIPPED_NO_EMAIL"
| "SKIPPED_GATE";
export interface NotificationLogRow {
id: string;
sendDate: string;
notificationType: NotificationType;
level: number | null;
servicio: NotificationServicio;
customerId: string | null;
customerName: string;
customerEmail: string;
subject: string;
debug: boolean;
status: NotificationStatus;
providerMessageId: string | null;
error: string | null;
}
export interface NotificationLogPage {
items: NotificationLogRow[];
total: number;
page: number;
pageSize: number;
pageCount: number;
}
export interface NotificationStats {
byType: { notificationType: NotificationType; status: NotificationStatus; _count: { _all: number } }[];
byStatus: { status: NotificationStatus; _count: { _all: number } }[];
byServicio: { servicio: NotificationServicio; status: NotificationStatus; _count: { _all: number } }[];
lastRun: { sendDate: string; notificationType: NotificationType } | null;
transport: { available: boolean; devFallback: boolean };
}
export type NotificationFlags = {
debug?: boolean;
ignoreDayRestriction?: boolean;
useEmailLimit?: boolean;
};
/** Job 1 (Outstanding) response — legacy `result` field. */
export interface OutstandingResponse {
result: "success";
notificationType: "sendPaymentConfirmation";
reason: string;
statusCode: 200;
sent: number;
skipped: number;
failed: number;
debug: boolean;
type: "OUTSTANDING_PAYMENT";
}
/** Job 2 (Payment Confirmation) response. */
export interface PaymentConfirmResponse {
request: "success";
notificationType: "sendPaymentConfirmation";
confirmationSent: string;
statusCode: 200;
sent: number;
skipped: number;
failed: number;
debug: boolean;
type: "PAYMENT_CONFIRMATION";
}
/** Job 3 (Account Status) response. */
export interface AccountStatusResponse {
request: "success";
notificationType: "sendAccountStatus";
statusSent: string;
statusReport: string;
statusCode: 200;
red: number;
yellow: number;
total: number;
sent: number;
skipped: number;
failed: number;
debug: boolean;
type: "ACCOUNT_STATUS";
}
/** Job 4 (Trust Confirmation) response. */
export interface TrustConfirmResponse {
request: "success";
notificationType: "sendTrustPaymentConfirmation";
confirmationSent: string;
statusCode: 200;
sent: number;
skipped: number;
failed: number;
debug: boolean;
type: "TRUST_PAYMENT_CONFIRMATION";
}
export type NotificationJobResponse =
| OutstandingResponse
| PaymentConfirmResponse
| AccountStatusResponse
| TrustConfirmResponse;
export function runOutstandingPayments(
flags: NotificationFlags = {},
): Promise<OutstandingResponse> {
return apiFetch<OutstandingResponse>("/notifications/outstanding-payments", {
method: "POST",
body: JSON.stringify(flags),
});
}
export function runPaymentConfirmation(
flags: NotificationFlags = {},
): Promise<PaymentConfirmResponse> {
return apiFetch<PaymentConfirmResponse>("/notifications/payment-confirmation", {
method: "POST",
body: JSON.stringify(flags),
});
}
export function runAccountStatus(
flags: NotificationFlags = {},
): Promise<AccountStatusResponse> {
return apiFetch<AccountStatusResponse>("/notifications/account-status", {
method: "POST",
body: JSON.stringify(flags),
});
}
export function runTrustConfirmation(
flags: NotificationFlags = {},
): Promise<TrustConfirmResponse> {
return apiFetch<TrustConfirmResponse>("/notifications/trust-payment-confirmation", {
method: "POST",
body: JSON.stringify(flags),
});
}
export type NotificationJobKind = "outstanding" | "payment" | "account" | "trust";
export interface NotificationRunAllJobResult {
kind: NotificationJobKind;
ok: boolean;
result?: NotificationJobResponse;
error?: string;
}
/** Aggregate response of the "Ejecutar todos" sweep. */
export interface NotificationRunAllResponse {
request: "success";
notificationType: "runAllNotifications";
statusCode: 200;
debug: boolean;
sent: number;
skipped: number;
failed: number;
errors: number;
jobs: NotificationRunAllJobResult[];
type: "RUN_ALL";
}
export function runAllNotifications(
flags: NotificationFlags = {},
): Promise<NotificationRunAllResponse> {
return apiFetch<NotificationRunAllResponse>("/notifications/run-all", {
method: "POST",
body: JSON.stringify(flags),
});
}
export interface NotificationLogQuery {
page?: number;
pageSize?: number;
type?: NotificationType;
/** One or more servicios; omitted = the whole log. */
servicio?: NotificationServicio[];
status?: NotificationStatus;
view?: "sent" | "failed" | "skipped" | "all";
}
export function listNotificationLog(
q: NotificationLogQuery = {},
): Promise<NotificationLogPage> {
const qs = new URLSearchParams();
if (q.page) qs.set("page", String(q.page));
if (q.pageSize) qs.set("pageSize", String(q.pageSize));
if (q.type) qs.set("type", q.type);
if (q.servicio?.length) qs.set("servicio", q.servicio.join(","));
if (q.status) qs.set("status", q.status);
if (q.view) qs.set("view", q.view);
const tail = qs.toString();
return apiFetch<NotificationLogPage>(`/notifications/log${tail ? `?${tail}` : ""}`);
}
/** Where a setting's current value came from — shown so an operator can tell
* "nobody has set this, you are seeing the deploy's value" from "somebody
* set this on purpose". */
export type SettingSource = "db" | "env" | "default";
export interface NotificationAdminEmails {
value: string[];
source: SettingSource;
updatedAt: string | null;
updatedById: string | null;
}
export function getNotificationAdminEmails(): Promise<NotificationAdminEmails> {
return apiFetch<NotificationAdminEmails>("/notifications/settings/admin-emails");
}
export function setNotificationAdminEmails(
emails: string[],
): Promise<NotificationAdminEmails> {
return apiFetch<NotificationAdminEmails>("/notifications/settings/admin-emails", {
method: "PUT",
body: JSON.stringify({ emails }),
});
}
/* ----------------------------------------------------- envío scheduling */
/** The two automatic envíos, one per /notificaciones tab. */
export type ScheduleKind = "servicios" | "polizas";
export interface NotificationSchedule {
enabled: boolean;
/** Local hour/minute in America/Tijuana. */
hour: number;
minute: number;
/** 0 = domingo … 6 = sábado. Vacío = todos los días. */
weekdays: number[];
}
export interface ResolvedSchedule {
value: NotificationSchedule;
source: SettingSource;
updatedAt: string | null;
updatedById: string | null;
/** Expression the value compiles to, shown verbatim in the UI. */
cron: string;
nextRun: string | null;
}
export type NotificationSchedules = Record<ScheduleKind, ResolvedSchedule>;
export function getNotificationSchedules(): Promise<NotificationSchedules> {
return apiFetch<NotificationSchedules>("/notifications/settings/schedule");
}
export function setNotificationSchedule(
kind: ScheduleKind,
schedule: NotificationSchedule,
): Promise<ResolvedSchedule> {
return apiFetch<ResolvedSchedule>(`/notifications/settings/schedule/${kind}`, {
method: "PUT",
body: JSON.stringify(schedule),
});
}
export function getNotificationStats(
servicio?: NotificationServicio[],
): Promise<NotificationStats> {
const tail = servicio?.length
? `?servicio=${encodeURIComponent(servicio.join(","))}`
: "";
return apiFetch<NotificationStats>(`/notifications/stats${tail}`);
}
/** Build a download URL for a report's file output. The session cookie
* travels with the browser's same-origin navigation, so a plain `href`
* is enough — no fetch-with-credentials dance. */
export function reportDownloadUrl(
slug: string,
format: "csv" | "xlsx" | "pdf" | "print",
params: Record<string, string | undefined>,
): string {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== "") qs.set(k, v);
}
const tail = qs.toString();
return `${API_ORIGIN}/reports/${slug}/${format}${tail ? `?${tail}` : ""}`;
}
/* ------------------------------------- Statement OCR intake (recibos) */
/**
* Whether this deployment can ingest scans — automatic capture is hidden
* without it. OCR reads the page, object storage keeps it; both are required.
*/
export function getStatementStatus(): Promise<{
ocrAvailable: boolean;
storageAvailable: boolean;
}> {
return apiFetch("/statements/status");
}
export function listStatementBatches(
page = 1,
pageSize = 25,
): Promise<{
items: StatementBatch[];
total: number;
page: number;
pageSize: number;
pageCount: number;
}> {
return apiFetch(`/statements/batches?page=${page}&pageSize=${pageSize}`);
}
export function getStatementBatch(id: string): Promise<StatementBatchDetail> {
return apiFetch(`/statements/batches/${id}`);
}
export function listStatementDocuments(
batchId: string,
status?: StatementDocumentStatus,
): Promise<StatementDocument[]> {
const q = status ? `?status=${status}` : "";
return apiFetch(`/statements/batches/${batchId}/documents${q}`);
}
/** Multi-file upload — one batch is usually several multi-page scans. */
export async function uploadStatementBatch(
files: File[],
serviceKind: ServiceKind,
label?: string,
): Promise<StatementBatch> {
const body = new FormData();
for (const f of files) body.append("files", f, f.name);
const qs = new URLSearchParams({ serviceKind });
if (label) qs.set("label", label);
const res = await fetch(`${API_ORIGIN}/statements/batches?${qs}`, {
method: "POST",
credentials: "include",
body,
});
if (!res.ok) {
let message = `Error ${res.status}`;
try {
const b = await res.json();
if (b?.message) message = b.message;
} catch {
/* non-JSON error body */
}
throw new Error(message);
}
return res.json();
}
export function reviewStatementDocument(
id: string,
input: ReviewDocumentInput,
): Promise<StatementDocument> {
return apiFetch(`/statements/documents/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function rejectStatementDocument(id: string): Promise<StatementDocument> {
return apiFetch(`/statements/documents/${id}/reject`, { method: "POST" });
}
export function confirmStatementBatch(
batchId: string,
input: ConfirmBatchInput,
): Promise<ConfirmBatchResult> {
return apiFetch(`/statements/batches/${batchId}/confirm`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** Abandon a batch pending review; rejects every page that is not posted. */
export function discardStatementBatch(batchId: string): Promise<DiscardBatchResult> {
return apiFetch(`/statements/batches/${batchId}/discard`, { method: "POST" });
}
/** The rendered page image. A plain <img src> — the cookie rides along. */
export function statementPageUrl(documentId: string): string {
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
}
/* ----------------------------------------------- Policy OCR (GMX / ANA) */
export function getPolicyOcrStatus(): Promise<{
ocrAvailable: boolean;
storageAvailable: boolean;
}> {
return apiFetch("/policy-ocr/status");
}
export function listPolicyOcrBatches(
page = 1,
pageSize = 25,
): Promise<{
items: PolicyOcrBatch[];
total: number;
page: number;
pageSize: number;
pageCount: number;
}> {
return apiFetch(`/policy-ocr/batches?page=${page}&pageSize=${pageSize}`);
}
export function getPolicyOcrBatch(id: string): Promise<PolicyOcrBatchDetail> {
return apiFetch(`/policy-ocr/batches/${id}`);
}
export function listPolicyOcrDocuments(batchId: string): Promise<PolicyOcrDocument[]> {
return apiFetch(`/policy-ocr/batches/${batchId}/documents`);
}
export async function uploadPolicyOcrBatch(
files: File[],
label?: string,
): Promise<PolicyOcrBatch> {
const body = new FormData();
for (const f of files) body.append("files", f, f.name);
const qs = new URLSearchParams();
if (label) qs.set("label", label);
const res = await fetch(
`${API_ORIGIN}/policy-ocr/batches${qs.toString() ? `?${qs}` : ""}`,
{
method: "POST",
credentials: "include",
body,
},
);
if (!res.ok) {
let message = `Error ${res.status}`;
try {
const b = await res.json();
if (b?.message) message = b.message;
} catch {
/* non-JSON error body */
}
throw new Error(message);
}
return res.json();
}
export function reviewPolicyOcrDocument(
id: string,
input: PolicyOcrReviewInput,
): Promise<PolicyOcrDocument> {
return apiFetch(`/policy-ocr/documents/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function rejectPolicyOcrDocument(id: string): Promise<PolicyOcrDocument> {
return apiFetch(`/policy-ocr/documents/${id}/reject`, { method: "POST" });
}
export function confirmPolicyOcrBatch(
batchId: string,
input: PolicyOcrConfirmInput,
): Promise<PolicyOcrConfirmResult> {
return apiFetch(`/policy-ocr/batches/${batchId}/confirm`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** Abandon a batch pending review; rejects every page that is not applied. */
export function discardPolicyOcrBatch(batchId: string): Promise<DiscardBatchResult> {
return apiFetch(`/policy-ocr/batches/${batchId}/discard`, { method: "POST" });
}
/**
* URL for the source PDF of a parsed policy document. The endpoint returns
* the original upload (one PDF = one parsed policy), not a rendered page
* image, so the review screen embeds it in an iframe.
*/
export function policyOcrDocumentUrl(documentId: string): string {
return `${API_ORIGIN}/policy-ocr/documents/${documentId}/page`;
}