Files
WealthySmart/frontend/src/api.ts
Carlos Escalante d929ed6573
All checks were successful
Deploy to VPS / deploy (push) Successful in 23s
Remove Ahorro from budget UI, add SALARY type and savings auto-accrual
Ahorro was already deducted from gross salary so displaying it in
budget projections was misleading. This removes the Ahorro card,
summary line, Proyecciones column, and Ahorro Anual card from the UI,
and strips all savings fields from budget API responses.

Adds SALARY TransactionType so salary deposits can be distinguished
from generic DEPOSITO transfers. When a SALARY transaction arrives,
the system auto-increments MEMP and MPAT savings account balances
(+200K CRC each) once per month via an idempotent accrual log.

New CRUD endpoints at /api/v1/savings-accrual/ allow manual correction
of the accrual history. Feb+Mar 2026 are seeded as historical baseline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:13:29 -06:00

465 lines
12 KiB
TypeScript

const BASE_URL = import.meta.env.VITE_API_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>;
}
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> = {};
const token = localStorage.getItem('token');
if (token) headers['Authorization'] = `Bearer ${token}`;
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 res = await fetch(fullUrl, { method, headers, body: fetchBody });
if (res.status === 401) {
localStorage.removeItem('token');
window.location.href = '/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 = any>(url: string, config?: RequestConfig) {
return request<T>('GET', url, undefined, config);
},
post<T = any>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>('POST', url, body, config);
},
patch<T = any>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>('PATCH', url, body, config);
},
put<T = any>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>('PUT', url, body, config);
},
delete<T = any>(url: string, config?: RequestConfig) {
return request<T>('DELETE', url, undefined, config);
},
};
export default api;
export async function login(username: string, password: string) {
const form = new URLSearchParams();
form.append('username', username);
form.append('password', password);
const { data } = await api.post('/auth/login', form);
localStorage.setItem('token', data.access_token);
return data;
}
export interface Account {
id: number;
bank: string;
currency: string;
label: string;
balance: number;
account_type: string;
next_payment: number | null;
updated_at: string;
}
export interface Category {
id: number;
name: string;
icon: string;
auto_match_patterns: string | null;
}
export interface ImportResult {
imported: number;
duplicates: number;
errors: string[];
}
// --- User Settings ---
export interface SectionSettings {
label: string;
color: string;
cardColor: string;
visible: boolean;
order: number;
expanded: boolean;
}
export interface DashboardSettings {
sections: Record<string, SectionSettings>;
}
export interface UserSettingsData {
dashboard: DashboardSettings;
}
export interface UserSettingsResponse {
key: string;
data: UserSettingsData;
updated_at: string;
}
export const getSettings = () => api.get<UserSettingsResponse>('/settings/');
export const updateSettings = (data: UserSettingsData) => api.patch<UserSettingsResponse>('/settings/', { data });
export interface Transaction {
id: number;
amount: number;
currency: string;
merchant: string;
city: string | null;
date: string;
card_type: string | null;
card_last4: string | null;
authorization_code: string | null;
reference: string | null;
transaction_type: string;
source: string;
bank: string;
notes: string | null;
category_id: number | null;
category: Category | null;
deferred_to_next_cycle: boolean;
created_at: string;
}
// --- Budget / Recurring Items ---
export type RecurringItemType = 'INCOME' | 'EXPENSE';
export type RecurringFrequency = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'BIANNUAL' | 'YEARLY';
export interface RecurringItem {
id: number;
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;
created_at: string;
category: Category | null;
}
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 interface RecurringItemDetail {
id: number;
name: string;
amount: number;
projected_amount: number | null;
used_actual: boolean;
item_type: string;
frequency: string;
category_name: string | null;
category_id: number | null;
}
export interface ActualsBySource {
source: string;
total_compra: number;
total_devolucion: number;
net: number;
count: number;
}
export interface MonthlyProjection {
month: number;
year: number;
projected_income: number;
projected_fixed_expenses: number;
actual_credit_card: number;
actual_cash: number;
actual_transfers: number;
uncovered_actual: number;
gran_total_egresos: number;
net_balance: number;
carryover_balance: number;
cumulative_balance: number;
balance_overridden: boolean;
}
export interface YearlyProjection {
year: number;
months: MonthlyProjection[];
annual_income: number;
annual_expenses: number;
annual_net: number;
}
export interface MonthlyDetail {
year: number;
month: number;
income_items: RecurringItemDetail[];
expense_items: RecurringItemDetail[];
actuals_by_source: ActualsBySource[];
total_projected_income: number;
total_projected_expenses: number;
uncovered_actual: number;
gran_total_egresos: number;
net_balance: number;
cc_by_category: { category_name: string; amount: number }[];
}
// --- Savings Accrual ---
export interface SavingsAccrual {
id: number;
year: number;
month: number;
memp_amount: number;
mpat_amount: number;
trigger_transaction_id: number | null;
applied_at: string;
notes: string | null;
}
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 API functions
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 interface SalariosSummary {
count: number;
total_amount: number;
latest_date: string | null;
}
export const getSalarios = (params?: { limit?: number; offset?: number }) =>
api.get<Transaction[]>('/salarios/', { params });
export const getSalariosSummary = () =>
api.get<SalariosSummary>('/salarios/summary');
// --- Pensions ---
export interface PensionSnapshot {
id: number;
fund: string;
contract_number: 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;
source_filename: string;
created_at: string;
}
export interface PensionUploadResult {
imported: number;
updated: number;
duplicates: number;
errors: string[];
snapshots: PensionSnapshot[];
}
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 interface WaterMeterReading {
id: number;
meter_id: string;
period: string;
reading_previous: number;
reading_current: number;
consumption_m3: number;
agua_potable: number;
serv_ambientales: number;
alcant_sanitario: number;
iva: number;
is_historical: boolean;
receipt_id: number | null;
created_at: string;
}
export interface MunicipalReceipt {
id: number;
receipt_date: string;
due_date: string;
period: string;
account: string;
finca: string;
holder_name: string;
holder_cedula: string;
holder_address: string;
subtotal: number;
interests: number;
iva: number;
total: number;
raw_charges: MunicipalCharge[];
source_filename: string;
created_at: string;
}
export interface MunicipalReceiptDetail extends MunicipalReceipt {
water_readings: WaterMeterReading[];
}
export interface MunicipalReceiptUploadResult {
imported: number;
updated: number;
errors: string[];
receipt: MunicipalReceipt | null;
}
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,
});