import type { components } from "./api-types.gen"; /** Schemas generated from the backend OpenAPI spec (pnpm gen:api). Aliasing * the app's read types to them makes backend schema drift a typecheck error * instead of a runtime surprise (review ARCH-16 / FE-14 / plan 3.4). */ type Schema = components["schemas"][K]; const BASE_URL = "/api/v1"; class ApiError extends Error { response: { status: number; data: unknown }; constructor(status: number, data: unknown) { super(`Request failed with status ${status}`); this.response = { status, data }; } } interface RequestConfig { params?: Record; /** Caller cancellation (TanStack Query passes this); combined with the * default 30s timeout. */ signal?: AbortSignal; } const REQUEST_TIMEOUT_MS = 30_000; async function request( method: string, url: string, body?: unknown, config?: RequestConfig, ): Promise<{ data: T }> { let fullUrl = `${BASE_URL}${url}`; if (config?.params) { const search = new URLSearchParams(); for (const [k, v] of Object.entries(config.params)) { if (v !== undefined) search.set(k, String(v)); } const qs = search.toString(); if (qs) fullUrl += `?${qs}`; } const headers: Record = {}; let fetchBody: BodyInit | undefined; if (body instanceof FormData || body instanceof URLSearchParams) { fetchBody = body; } else if (body !== undefined) { headers["Content-Type"] = "application/json"; fetchBody = JSON.stringify(body); } const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); const signal = config?.signal ? AbortSignal.any([config.signal, timeout]) : timeout; const res = await fetch(fullUrl, { method, headers, body: fetchBody, credentials: "same-origin", signal, }); if (res.status === 401) { // Don't logout/redirect from the login page itself — that would loop. if ( typeof window !== "undefined" && !window.location.pathname.startsWith("/login") ) { await fetch("/api/auth/logout", { method: "POST" }).catch(() => {}); window.location.replace("/login"); } throw new ApiError(401, null); } if (!res.ok) { let data: unknown = null; try { data = await res.json(); } catch {} throw new ApiError(res.status, data); } if (res.status === 204) return { data: null as T }; const data = await res.json(); return { data }; } const api = { get(url: string, config?: RequestConfig) { return request("GET", url, undefined, config); }, post(url: string, body?: unknown, config?: RequestConfig) { return request("POST", url, body, config); }, patch(url: string, body?: unknown, config?: RequestConfig) { return request("PATCH", url, body, config); }, put(url: string, body?: unknown, config?: RequestConfig) { return request("PUT", url, body, config); }, delete(url: string, config?: RequestConfig) { return request("DELETE", url, undefined, config); }, }; export default api; export async function login(username: string, password: string) { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), credentials: "same-origin", }); if (!res.ok) { let data: unknown = null; try { data = await res.json(); } catch {} throw new ApiError(res.status, data); } return res.json(); } export async function logout() { await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" }); } // ─── Types ────────────────────────────────────────────────────────────────── export type Account = Schema<'AccountRead'>; export type Category = Schema<'CategoryRead'>; export type SkippedDuplicate = Schema<'SkippedDuplicate'>; export type ImportResult = Schema<'PasteImportResult'>; export type Transaction = Schema<'TransactionRead'>; // --- Budget / Recurring Items --- export type RecurringItemType = Schema<"RecurringItemType">; export type RecurringFrequency = Schema<"RecurringFrequency">; export type RecurringItem = Schema<'RecurringItemRead'>; export interface RecurringItemCreate { name: string; amount: number; currency?: string; item_type: RecurringItemType; frequency?: RecurringFrequency; day_of_month?: number | null; month_of_year?: number | null; override_amounts?: Record | null; category_id?: number | null; is_active?: boolean; notes?: string | null; } export interface RecurringItemUpdate { name?: string; amount?: number; currency?: string; item_type?: RecurringItemType; frequency?: RecurringFrequency; day_of_month?: number | null; month_of_year?: number | null; override_amounts?: Record | null; category_id?: number | null; is_active?: boolean; notes?: string | null; } export type RecurringItemDetail = Schema<'RecurringItemDetail'>; export type ActualsBySource = Schema<'ActualsBySource'>; export type MonthlyProjection = Schema<'MonthlyProjectionResponse'>; export type YearlyProjection = Schema<'YearlyProjectionResponse'>; export type MonthlyDetail = Schema<'MonthlyDetailResponse'>; // --- Savings Accrual --- export type SavingsAccrual = Schema<'SavingsAccrualRead'>; export interface SavingsAccrualCreate { year: number; month: number; memp_amount?: number; mpat_amount?: number; trigger_transaction_id?: number | null; notes?: string | null; } export interface SavingsAccrualUpdate { memp_amount?: number; mpat_amount?: number; notes?: string | null; } export const getSavingsAccruals = () => api.get("/savings-accrual/"); export const createSavingsAccrual = (data: SavingsAccrualCreate) => api.post("/savings-accrual/", data); export const updateSavingsAccrual = (id: number, data: SavingsAccrualUpdate) => api.patch(`/savings-accrual/${id}`, data); export const deleteSavingsAccrual = (id: number) => api.delete(`/savings-accrual/${id}`); // --- Budget --- export const getRecurringItems = (params?: { item_type?: string; is_active?: boolean; }) => api.get("/budget/recurring", { params }); export const createRecurringItem = (data: RecurringItemCreate) => api.post("/budget/recurring", data); export const updateRecurringItem = (id: number, data: RecurringItemUpdate) => api.patch(`/budget/recurring/${id}`, data); export const deleteRecurringItem = (id: number) => api.delete(`/budget/recurring/${id}`); export const getYearlyProjection = (year: number) => api.get(`/budget/projection/${year}`); export const getMonthlyDetail = (year: number, month: number) => api.get(`/budget/month/${year}/${month}`); export const upsertBalanceOverride = ( year: number, month: number, override_balance: number, ) => api.put(`/budget/balance-override/${year}/${month}`, { override_balance }); export const deleteBalanceOverride = (year: number, month: number) => api.delete(`/budget/balance-override/${year}/${month}`); // --- Salarios --- export type SalariosSummary = Schema<'SalariosSummary'>; export const getSalarios = (params?: { limit?: number; offset?: number }) => api.get("/salarios/", { params }); export const getSalariosSummary = () => api.get("/salarios/summary"); // --- Pensions --- export type PensionSnapshot = Schema<'PensionSnapshotRead'>; export type PensionUploadResult = Schema<'PensionUploadResult'>; export interface PensionManualEntry { fund: string; period_start: string; period_end: string; saldo_anterior: number; aportes: number; rendimientos: number; retiros: number; traslados: number; comision: number; correccion: number; bonificacion: number; saldo_final: number; } export const uploadPensionPDFs = (files: File[]) => { const form = new FormData(); files.forEach((f) => form.append("files", f)); return api.post("/pensions/upload", form); }; export const getPensionSnapshots = () => api.get("/pensions/snapshots"); export const getPensionFundSummary = () => api.get("/pensions/fund-summary"); export const submitPensionManualEntries = (entries: PensionManualEntry[]) => api.post("/pensions/manual", { entries }); // --- Municipal Receipts --- export interface MunicipalCharge { detail: string; amount: number; } export type WaterMeterReading = Schema<'WaterMeterReadingRead'>; export type MunicipalReceipt = Omit< Schema<'MunicipalReceiptRead'>, 'raw_charges' > & { raw_charges: MunicipalCharge[] }; export type MunicipalReceiptDetail = Omit< Schema<'MunicipalReceiptDetailRead'>, 'raw_charges' > & { raw_charges: MunicipalCharge[] }; export type MunicipalReceiptUploadResult = Schema<'MunicipalReceiptUploadResult'>; export const uploadMunicipalReceipt = (file: File) => { const form = new FormData(); form.append("file", file); return api.post( "/municipal-receipts/upload", form, ); }; export const getMunicipalReceipts = () => api.get("/municipal-receipts/"); export const getMunicipalReceiptDetail = (id: number) => api.get(`/municipal-receipts/${id}`); export const getWaterConsumption = (months?: number) => api.get("/municipal-receipts/water-consumption", { params: months ? { months } : undefined, }); // --- Sync Status --- export type SyncSource = Schema<'SyncSourceStatus'>; export type SyncStatusResponse = Schema<'SyncStatusResponse'>; export const getSyncStatus = () => api.get('/sync-status/'); // --- User Settings --- export type UserSettingsData = Record; export type UserSettingsRead = Schema<'UserSettingsRead'>; export const getUserSettings = () => api.get('/settings/'); // --- Exchange Rate --- export type ExchangeRatePoint = Schema<'ExchangeRateRead'>; export const getRateHistory = (days = 90) => api.get('/exchange-rate/history', { params: { days } });