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>
1502 lines
43 KiB
TypeScript
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`;
|
|
}
|