Files
WealthySmart/frontend/src/lib/api.ts
Carlos Escalante c563618957 Tasa Cero: regenerate API types + installment-plan client helpers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:13:18 -06:00

376 lines
11 KiB
TypeScript

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<K extends keyof components["schemas"]> = 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<string, string | number | boolean | undefined>;
/** Caller cancellation (TanStack Query passes this); combined with the
* default 30s timeout. */
signal?: AbortSignal;
}
const REQUEST_TIMEOUT_MS = 30_000;
async function request<T>(
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<string, string> = {};
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<T = unknown>(url: string, config?: RequestConfig) {
return request<T>("GET", url, undefined, config);
},
post<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>("POST", url, body, config);
},
patch<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>("PATCH", url, body, config);
},
put<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>("PUT", url, body, config);
},
delete<T = unknown>(url: string, config?: RequestConfig) {
return request<T>("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<string, number> | 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<string, number> | 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<SavingsAccrual[]>("/savings-accrual/");
export const createSavingsAccrual = (data: SavingsAccrualCreate) =>
api.post<SavingsAccrual>("/savings-accrual/", data);
export const updateSavingsAccrual = (id: number, data: SavingsAccrualUpdate) =>
api.patch<SavingsAccrual>(`/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<RecurringItem[]>("/budget/recurring", { params });
export const createRecurringItem = (data: RecurringItemCreate) =>
api.post<RecurringItem>("/budget/recurring", data);
export const updateRecurringItem = (id: number, data: RecurringItemUpdate) =>
api.patch<RecurringItem>(`/budget/recurring/${id}`, data);
export const deleteRecurringItem = (id: number) =>
api.delete(`/budget/recurring/${id}`);
export const getYearlyProjection = (year: number) =>
api.get<YearlyProjection>(`/budget/projection/${year}`);
export const getMonthlyDetail = (year: number, month: number) =>
api.get<MonthlyDetail>(`/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}`);
// --- Installment Plans (Tasa Cero) ---
export type InstallmentPlan = Schema<'InstallmentPlanRead'>;
export type InstallmentPlanDetail = Schema<'InstallmentPlanDetail'>;
export type InstallmentPlanListResponse = Schema<'InstallmentPlanListResponse'>;
export interface InstallmentPlanCreate {
transaction_id: number;
num_installments?: number;
first_installment_date?: string | null;
}
export interface InstallmentPlanUpdate {
num_installments?: number;
first_installment_date?: string | null;
notes?: string | null;
}
export const getInstallmentPlans = () =>
api.get<InstallmentPlanListResponse>("/installment-plans/");
export const getInstallmentPlanDetail = (id: number) =>
api.get<InstallmentPlanDetail>(`/installment-plans/${id}`);
export const createInstallmentPlan = (data: InstallmentPlanCreate) =>
api.post<InstallmentPlan>("/installment-plans/", data);
export const updateInstallmentPlan = (id: number, data: InstallmentPlanUpdate) =>
api.patch<InstallmentPlan>(`/installment-plans/${id}`, data);
export const deleteInstallmentPlan = (id: number) =>
api.delete(`/installment-plans/${id}`);
// --- Salarios ---
export type SalariosSummary = Schema<'SalariosSummary'>;
export const getSalarios = (params?: { limit?: number; offset?: number }) =>
api.get<Transaction[]>("/salarios/", { params });
export const getSalariosSummary = () =>
api.get<SalariosSummary>("/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<PensionUploadResult>("/pensions/upload", form);
};
export const getPensionSnapshots = () =>
api.get<PensionSnapshot[]>("/pensions/snapshots");
export const getPensionFundSummary = () =>
api.get<PensionSnapshot[]>("/pensions/fund-summary");
export const submitPensionManualEntries = (entries: PensionManualEntry[]) =>
api.post<PensionUploadResult>("/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<MunicipalReceiptUploadResult>(
"/municipal-receipts/upload",
form,
);
};
export const getMunicipalReceipts = () =>
api.get<MunicipalReceipt[]>("/municipal-receipts/");
export const getMunicipalReceiptDetail = (id: number) =>
api.get<MunicipalReceiptDetail>(`/municipal-receipts/${id}`);
export const getWaterConsumption = (months?: number) =>
api.get<WaterMeterReading[]>("/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<SyncStatusResponse>('/sync-status/');
// --- User Settings ---
export type UserSettingsData = Record<string, unknown>;
export type UserSettingsRead = Schema<'UserSettingsRead'>;
export const getUserSettings = () => api.get<UserSettingsRead>('/settings/');
// --- Exchange Rate ---
export type ExchangeRatePoint = Schema<'ExchangeRateRead'>;
export const getRateHistory = (days = 90) =>
api.get<ExchangeRatePoint[]>('/exchange-rate/history', { params: { days } });