mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 10:28:47 +02:00
Analytics gains a 90-day USD/CRC buy/sell chart from the existing history endpoint (wishlist: rate history). Pensiones gains a Calculadora de Aporte: fund + target amount + target age → required monthly contribution via the annuity future-value formula, using the fund's live balance and configured rate (wishlist: contribution calculator). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
347 lines
10 KiB
TypeScript
347 lines
10 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}`);
|
|
|
|
// --- 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 } });
|