// 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, BankCleared, BankDirection, BankFacets, BankListResponse, BankSort, BankStats, BankSummary, BillingFacets, BillingStats, BusinessLine, CreateBankMovementInput, CreateMovementInput, CustomerDetail, CustomerInput, CustomerListResponse, CustomerStats, LedgerCurrency, LedgerDirection, MovementListResponse, MovementSort, PolicyDetail, PolicyFacets, PolicyInput, PolicyListResponse, PolicySort, PolicyStats, PolicyStatus, LookupsResponse, OpsJob, OpsJobKind, IngestFile, BackupFile, PropertyDetail, PropertyFacets, PropertyInput, PropertyListResponse, PropertySort, PropertyStats, 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"; } } async function apiFetch( path: string, init?: RequestInit, ): Promise { 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 { return apiFetch("/auth/login", { method: "POST", body: JSON.stringify({ email, password }), }); } export function me(): Promise { return apiFetch("/auth/me"); } export function logout(): Promise<{ success: boolean }> { return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" }); } export function getStats(): Promise { return apiFetch("/customers/stats"); } export interface CustomerQuery { query?: string; page?: number; pageSize?: number; line?: BusinessLine; } export function listCustomers( q: CustomerQuery, ): Promise { 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(`/customers${qs ? `?${qs}` : ""}`); } export function getCustomer(id: string): Promise { return apiFetch(`/customers/${id}`); } export function createCustomer(input: CustomerInput): Promise { return apiFetch("/customers", { method: "POST", body: JSON.stringify(input), }); } export function updateCustomer( id: string, input: Partial, ): Promise { return apiFetch(`/customers/${id}`, { method: "PATCH", body: JSON.stringify(input), }); } export function archiveCustomer(id: string): Promise { return apiFetch(`/customers/${id}`, { method: "DELETE" }); } export function restoreCustomer(id: string): Promise { return apiFetch(`/customers/${id}/restore`, { 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 { 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(`/policies${qs ? `?${qs}` : ""}`); } export function getPolicyStats( days: number = EXPIRY_WINDOW_DAYS, ): Promise { return apiFetch(`/policies/stats?days=${days}`); } export function getPolicyFacets(): Promise { return apiFetch("/policies/facets"); } export function getPolicy( id: string, days: number = EXPIRY_WINDOW_DAYS, ): Promise { return apiFetch(`/policies/${id}?days=${days}`); } export function createPolicy(input: PolicyInput): Promise { return apiFetch("/policies", { method: "POST", body: JSON.stringify(input), }); } export function updatePolicy( id: string, input: Partial, ): Promise { return apiFetch(`/policies/${id}`, { method: "PATCH", body: JSON.stringify(input), }); } export function archivePolicy(id: string): Promise { return apiFetch(`/policies/${id}`, { method: "DELETE" }); } export function restorePolicy(id: string): Promise { return apiFetch(`/policies/${id}/restore`, { method: "POST" }); } // Generic policy-child CRUD. `kind` is the URL segment // (installments|vehicles|drivers|beneficiaries|claims). export function addPolicyChild( policyId: string, kind: string, input: T, ): Promise { return apiFetch(`/policies/${policyId}/${kind}`, { method: "POST", body: JSON.stringify(input), }); } export function updatePolicyChild( policyId: string, kind: string, childId: string, input: T, ): Promise { return apiFetch(`/policies/${policyId}/${kind}/${childId}`, { method: "PATCH", body: JSON.stringify(input), }); } export function removePolicyChild( policyId: string, kind: string, childId: string, ): Promise { return apiFetch(`/policies/${policyId}/${kind}/${childId}`, { method: "DELETE", }); } /* ------------------------------------------------- Lookups (insurance ref) */ export function getLookups(): Promise { return apiFetch("/lookups"); } export function createLookup(kind: string, input: unknown): Promise { return apiFetch(`/lookups/${kind}`, { method: "POST", body: JSON.stringify(input) }); } export function updateLookup( kind: string, id: string, input: unknown, ): Promise { return apiFetch(`/lookups/${kind}/${id}`, { method: "PATCH", body: JSON.stringify(input), }); } export function removeLookup(kind: string, id: string): Promise { 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 { 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(`/properties${qs ? `?${qs}` : ""}`); } export function getPropertyStats( days: number = EXPIRY_WINDOW_DAYS, ): Promise { return apiFetch(`/properties/stats?days=${days}`); } export function getPropertyFacets(): Promise { return apiFetch("/properties/facets"); } export function getProperty( id: string, days: number = EXPIRY_WINDOW_DAYS, ): Promise { return apiFetch(`/properties/${id}?days=${days}`); } export function createProperty(input: PropertyInput): Promise { return apiFetch("/properties", { method: "POST", body: JSON.stringify(input), }); } export function updateProperty( id: string, input: Partial, ): Promise { return apiFetch(`/properties/${id}`, { method: "PATCH", body: JSON.stringify(input), }); } export function archiveProperty(id: string): Promise { return apiFetch(`/properties/${id}`, { method: "DELETE" }); } export function restoreProperty(id: string): Promise { return apiFetch(`/properties/${id}/restore`, { method: "POST" }); } // Service child CRUD. export function addService(propertyId: string, input: ServiceInput): Promise { return apiFetch(`/properties/${propertyId}/services`, { method: "POST", body: JSON.stringify(input), }); } export function updateService( propertyId: string, serviceId: string, input: Partial, ): Promise { return apiFetch(`/properties/${propertyId}/services/${serviceId}`, { method: "PATCH", body: JSON.stringify(input), }); } export function removeService(propertyId: string, serviceId: string): Promise { return apiFetch(`/properties/${propertyId}/services/${serviceId}`, { method: "DELETE", }); } // Trust account (1:1 upsert). export function upsertTrust(propertyId: string, input: TrustInput): Promise { return apiFetch(`/properties/${propertyId}/trust`, { method: "PUT", body: JSON.stringify(input), }); } export function removeTrust(propertyId: string): Promise { return apiFetch(`/properties/${propertyId}/trust`, { method: "DELETE" }); } export function removePropertyDocument( propertyId: string, documentId: string, ): Promise { 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 { const q = type ? `?type=${encodeURIComponent(type)}` : ""; return uploadFile(`/properties/${propertyId}/documents${q}`, file); } export function removePolicyDocument( policyId: string, documentId: string, ): Promise { 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 { 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; /** `YYYY-MM-DD`, inclusive on both ends. */ from?: string; to?: string; sort?: MovementSort; } export function listMovements(q: MovementQuery): Promise { 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.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(`/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 { 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(`/billing/balances${qs ? `?${qs}` : ""}`); } export function getBillingStats(): Promise { return apiFetch("/billing/stats"); } export function getBillingFacets(): Promise { return apiFetch("/billing/facets"); } export function getStatement(customerId: string): Promise { return apiFetch(`/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 { return apiFetch("/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 { return apiFetch(`/billing/${id}/void`, { method: "POST" }); } /* ------------------------------------------------- Bank register (chequera) */ export interface BankQuery { 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 { 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.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); const qs = params.toString(); return apiFetch(`/bank${qs ? `?${qs}` : ""}`); } export function getBankStats(): Promise { return apiFetch("/bank/stats"); } export function getBankFacets(): Promise { return apiFetch("/bank/facets"); } export function getBankSummary(year?: number): Promise { return apiFetch(`/bank/summary${year ? `?year=${year}` : ""}`); } /** Append a new chequera movement. Booked rows are never edited — fix mistakes * with voidBankMovement + a fresh capture. */ export function createBankMovement( input: CreateBankMovementInput, ): Promise { 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 { return apiFetch(`/bank/${id}/void`, { method: "POST" }); } /* ------------------------------------------------- Users / administration */ export function listUsers(): Promise { return apiFetch("/users"); } export interface CreateUserInput { name: string; email: string; password: string; role: Role; active?: boolean; } export function createUser(input: CreateUserInput): Promise { return apiFetch("/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 { return apiFetch(`/users/${id}`, { method: "PATCH", body: JSON.stringify(input), }); } export function resetUserPassword(id: string, password: string): Promise { return apiFetch(`/users/${id}/reset-password`, { method: "POST", body: JSON.stringify({ password }), }); } export function deleteUser(id: string): Promise { return apiFetch(`/users/${id}`, { method: "DELETE" }); } /* ------------------------------------------- DB operations (admin only) */ export function listIngest(): Promise { return apiFetch("/ops/ingest"); } /** * 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. */ export async function uploadFile( path: string, file: File, filename?: string, ): Promise { const body = new FormData(); body.append("file", file, filename ?? file.name); const res = await fetch(`${API_ORIGIN}${path}`, { 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 { /* ignore */ } throw new ApiError(res.status, message); } return res.status === 204 ? undefined : res.json().catch(() => undefined); } export function uploadIngest(name: string, file: File): Promise { return uploadFile(`/ops/ingest/${encodeURIComponent(name)}`, file, name); } export function deleteIngest(name: string): Promise { return apiFetch(`/ops/ingest/${encodeURIComponent(name)}`, { method: "DELETE" }); } export function listBackups(): Promise { return apiFetch("/ops/backups"); } export function backupDownloadUrl(name: string): string { return `${API_ORIGIN}/ops/backups/${encodeURIComponent(name)}/download`; } export function deleteBackup(name: string): Promise { return apiFetch(`/ops/backups/${encodeURIComponent(name)}`, { method: "DELETE" }); } export function listOpsJobs(): Promise { return apiFetch("/ops/jobs"); } export function getOpsJob(id: string): Promise { return apiFetch(`/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 { return apiFetch("/ops/jobs", { method: "POST", body: JSON.stringify({ kind, file }), }); }