OpenAPI type codegen with CI drift gate (plan 3.4, ARCH-16, FE-14)

Every dict-returning route the SPA consumes now declares a response
model (auth, bulk, sync-status, notifications). api-types.gen.ts is
generated from app.openapi() via openapi-typescript, and the 22 hand-
written read interfaces in api.ts are now aliases onto the generated
schemas — backend schema drift becomes a typecheck failure, and CI
regenerates the file and fails the deploy if it's stale. The aliasing
immediately caught two real drifts: RecurringItemType was missing
SAVINGS, and raw_charges (untyped JSON column) is now explicitly
shaped at the boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-12 07:53:00 -06:00
parent 3ea1ef0fa0
commit 7dfe2da2a9
11 changed files with 4347 additions and 251 deletions

View File

@@ -1,3 +1,10 @@
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 {
@@ -125,91 +132,22 @@ export async function logout() {
// ─── Types ──────────────────────────────────────────────────────────────────
export interface Account {
id: number;
bank: string;
currency: string;
label: string;
balance: number;
account_type: string;
next_payment: number | null;
updated_at: string;
}
export type Account = Schema<'AccountRead'>;
export interface Category {
id: number;
name: string;
icon: string;
auto_match_patterns: string | null;
}
export type Category = Schema<'CategoryRead'>;
export interface SkippedDuplicate {
line: number;
merchant: string;
date: string;
amount: number;
currency: string;
existing_id: number;
existing_merchant: string;
existing_date: string;
existing_amount: number;
existing_source: string;
}
export type SkippedDuplicate = Schema<'SkippedDuplicate'>;
export interface ImportResult {
imported: number;
duplicates: number;
errors: string[];
skipped: SkippedDuplicate[];
}
export type ImportResult = Schema<'PasteImportResult'>;
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;
}
export type Transaction = Schema<'TransactionRead'>;
// --- Budget / Recurring Items ---
export type RecurringItemType = "INCOME" | "EXPENSE";
export type RecurringFrequency =
| "WEEKLY"
| "MONTHLY"
| "QUARTERLY"
| "BIANNUAL"
| "YEARLY";
export type RecurringItemType = Schema<"RecurringItemType">;
export type RecurringFrequency = Schema<"RecurringFrequency">;
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 type RecurringItem = Schema<'RecurringItemRead'>;
export interface RecurringItemCreate {
name: string;
@@ -239,76 +177,19 @@ export interface RecurringItemUpdate {
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 type RecurringItemDetail = Schema<'RecurringItemDetail'>;
export interface ActualsBySource {
source: string;
total_compra: number;
total_devolucion: number;
net: number;
count: number;
}
export type ActualsBySource = Schema<'ActualsBySource'>;
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 type MonthlyProjection = Schema<'MonthlyProjectionResponse'>;
export interface YearlyProjection {
year: number;
months: MonthlyProjection[];
annual_income: number;
annual_expenses: number;
annual_net: number;
}
export type YearlyProjection = Schema<'YearlyProjectionResponse'>;
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 }[];
}
export type MonthlyDetail = Schema<'MonthlyDetailResponse'>;
// --- 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 type SavingsAccrual = Schema<'SavingsAccrualRead'>;
export interface SavingsAccrualCreate {
year: number;
@@ -361,11 +242,7 @@ export const deleteBalanceOverride = (year: number, month: number) =>
// --- Salarios ---
export interface SalariosSummary {
count: number;
total_amount: number;
latest_date: string | null;
}
export type SalariosSummary = Schema<'SalariosSummary'>;
export const getSalarios = (params?: { limit?: number; offset?: number }) =>
api.get<Transaction[]>("/salarios/", { params });
@@ -374,32 +251,9 @@ export const getSalariosSummary = () =>
// --- 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 type PensionSnapshot = Schema<'PensionSnapshotRead'>;
export interface PensionUploadResult {
imported: number;
updated: number;
duplicates: number;
errors: string[];
snapshots: PensionSnapshot[];
}
export type PensionUploadResult = Schema<'PensionUploadResult'>;
export interface PensionManualEntry {
fund: string;
@@ -436,51 +290,19 @@ export interface MunicipalCharge {
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 type WaterMeterReading = Schema<'WaterMeterReadingRead'>;
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 type MunicipalReceipt = Omit<
Schema<'MunicipalReceiptRead'>,
'raw_charges'
> & { raw_charges: MunicipalCharge[] };
export interface MunicipalReceiptDetail extends MunicipalReceipt {
water_readings: WaterMeterReading[];
}
export type MunicipalReceiptDetail = Omit<
Schema<'MunicipalReceiptDetailRead'>,
'raw_charges'
> & { raw_charges: MunicipalCharge[] };
export interface MunicipalReceiptUploadResult {
imported: number;
updated: number;
errors: string[];
receipt: MunicipalReceipt | null;
}
export type MunicipalReceiptUploadResult = Schema<'MunicipalReceiptUploadResult'>;
export const uploadMunicipalReceipt = (file: File) => {
const form = new FormData();
@@ -502,20 +324,9 @@ export const getWaterConsumption = (months?: number) =>
// --- Sync Status ---
export interface SyncSource {
key: string;
label: string;
description: string;
last_received: string | null;
age_days: number | null;
warn_after_days: number;
status: 'ok' | 'warning' | 'never';
}
export type SyncSource = Schema<'SyncSourceStatus'>;
export interface SyncStatusResponse {
sources: SyncSource[];
warnings: number;
}
export type SyncStatusResponse = Schema<'SyncStatusResponse'>;
export const getSyncStatus = () => api.get<SyncStatusResponse>('/sync-status/');
@@ -523,10 +334,6 @@ export const getSyncStatus = () => api.get<SyncStatusResponse>('/sync-status/');
export type UserSettingsData = Record<string, unknown>;
export interface UserSettingsRead {
key: string;
data: UserSettingsData;
updated_at: string;
}
export type UserSettingsRead = Schema<'UserSettingsRead'>;
export const getUserSettings = () => api.get<UserSettingsRead>('/settings/');