Files
jorgecuadros-platform/apps/web/src/lib/api.ts
T
rmancinasandClaude Opus 5 6a97242fc3
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m59s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m13s
feat(customers): allocate portal NUMids, with an audit for reusable ones
Customers created in the staff UI had no NUMid and so could not log in to
my.jorgecuadros.com at all: the id is a CustomerLegacyRef row, not a column,
and create() deliberately writes none.

Allocation is a staff action (POST /customers/:id/portal-access, MANAGER)
rather than part of create, because insurance is expected to move to the
platform before utilities and an insurance-only customer has no reason to
spend a utilities id.

The audit that decides which ids are reusable took three passes. "Owns no
rows" matches nobody -- migration gave all 1,171 NUMids a property and a
transaction. "No transaction in N years" also matches nobody -- every customer
carries a synthetic Jan-1 opening-balance row, so everyone looks active this
year. Subtracting that row is what makes dormancy measurable, and it leaves 4
never-used ids and 10 dormant ones on dev. Two further traps are encoded in the
queries: insurance/DATGRAL is a separate id space that reuses the sourceTable
name and runs past 4,000, and ACCOUNT CANCELED is a transaction line type, not
an account state -- all 8 customers carrying it have current-year activity.

Recycling ships switched off (numid.recycleEmpty, default false). Every
reusable id still exists in Access DATGRAL, and a --sync run reassigns refs
with ON DUPLICATE KEY UPDATE customerId, so an id recycled before the utilities
cutover is silently handed back to its Access owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:04:13 -07:00

1502 lines
43 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,
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. In the browser it comes
// from window.__API_ORIGIN__, injected server-side by the root layout from the
// deploy .env (API_ORIGIN) — so one built image serves any deployment. On the
// server (SSR) read process.env directly. NEXT_PUBLIC_API_ORIGIN stays as the
// dev/build fallback.
function resolveApiOrigin(): string {
if (typeof window !== "undefined") {
const injected = (window as { __API_ORIGIN__?: string }).__API_ORIGIN__;
if (injected) return injected;
}
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");
}
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. */
export function startOpsJob(kind: OpsJobKind, file?: string): Promise<OpsJob> {
return apiFetch<OpsJob>("/ops/jobs", {
method: "POST",
body: JSON.stringify({ kind, file }),
});
}
/* ----------------------------------------------------------- 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) */
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`;
}