refactor(ts): type the spending API responses (Track C)

Promoted SpendingPage's local shapes into @/types (SpendingCategoryEntry,
SpendingSummary, SpendingTransaction(s), SpendingIncome*, SpendingRule,
CategoryGroup, CopyBudgetsResponse) and wired the /spending/* + category-
groups endpoints with get<T>/post<T>. Hooks return typed data; 6 casts
removed; 2 orphaned type aliases dropped. typecheck/lint/build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 12:06:40 -05:00
parent b267599ba5
commit 8265b4a97f
4 changed files with 59 additions and 48 deletions

View File

@ -3,6 +3,7 @@
### 🔌 API response typing cleanup (Track C) — promoting page shapes into `@/types`, endpoint by endpoint
- **[Client] Spending domain typed.** Promoted `SpendingPage`'s local response interfaces into `@/types` (`SpendingCategoryEntry`, `SpendingSummary`, `SpendingTransaction`/`…Response`, `SpendingIncomeTx`/`…Response`, `SpendingRule`, `CategoryGroup`, `CopyBudgetsResponse`) and wired `spendingSummary`/`spendingTransactions`/`spendingIncome`/`spendingCategoryRules`/`copySpendingBudgets`/`categoryGroups` with `get<T>`/`post<T>`. `useSpendingSummary`/`useSpendingTransactions`/`useCategoryGroups` now return typed data; the 6 spending `as {…}` casts are gone. Removed 2 now-unused type aliases the cast-removal orphaned (Track E).
- **[Client] Bank-ledger domain typed end-to-end.** Promoted the `BankTransactionsPage` local interfaces (`Tx`/`Account`/`Ledger`/`LedgerSummary`/`CategoryBreakdown`/auto-categorize preview) into `@/types` as `BankLedgerTransaction` (extends the existing `BankTransaction`, so raw amounts stay branded `Cents`), `BankAccount`, `BankLedger`, `BankLedgerSummary`, `BankCategoryBreakdown`, `AutoCategorizePreview`. Wired the endpoints in `api.ts` (`bankTransactionsLedger` → `get<BankLedger>`, `matchTransaction`/`unmatchTransaction` → `{ transaction }`, `ignoreTransaction`/`unignoreTransaction` → the transaction, `applyTransactionMerchantMatch`/`autoCategorizeTransactions` → their result shapes), so `useBankLedger`'s `data` is now typed and **all 9 `as {…}` casts in the page are gone** — a server field rename now fails in `@/types`, not silently at 9 call sites. Dropped a now-dead `Cents` import (Track E). typecheck/lint/build clean.
### 🧮 Cross-surface money reconciliation harness (Track B) — pins "same number, same truth"

View File

@ -1,6 +1,8 @@
import type {
Bill, Category, Payment, TrackerResponse,
BankLedger, BankLedgerTransaction, AutoCategorizePreview,
SpendingSummary, SpendingTransactionsResponse, SpendingIncomeResponse,
SpendingRule, CopyBudgetsResponse, CategoryGroup,
} from '@/types';
// Fetch CSRF token from the server once and cache in memory.
@ -114,14 +116,14 @@ export const api = {
acknowledgeVersion: () => post('/auth/acknowledge-version'),
loginHistory: () => get('/auth/login-history'),
// Spending
spendingSummary: (p?: QueryParams) => get('/spending/summary', p),
spendingTransactions:(p?: QueryParams) => get('/spending/transactions', p),
spendingSummary: (p?: QueryParams) => get<SpendingSummary>('/spending/summary', p),
spendingTransactions:(p?: QueryParams) => get<SpendingTransactionsResponse>('/spending/transactions', p),
categorizeTransaction: (id: Id, d: Body) => patch(`/spending/transactions/${id}/category`, d),
spendingBudgets: (p?: QueryParams) => get('/spending/budgets', p),
setSpendingBudget: (d: Body) => put('/spending/budgets', d),
copySpendingBudgets: (d: Body) => post('/spending/budgets/copy', d),
spendingIncome: (p?: QueryParams) => get('/spending/income', p),
spendingCategoryRules: () => get('/spending/category-rules'),
copySpendingBudgets: (d: Body) => post<CopyBudgetsResponse>('/spending/budgets/copy', d),
spendingIncome: (p?: QueryParams) => get<SpendingIncomeResponse>('/spending/income', p),
spendingCategoryRules: () => get<{ rules?: SpendingRule[] }>('/spending/category-rules'),
addSpendingRule: (d: Body) => post('/spending/category-rules', d),
deleteSpendingRule: (id: Id) => del(`/spending/category-rules/${id}`),
@ -344,7 +346,7 @@ export const api = {
restoreCategory: (id: Id) => post(`/categories/${id}/restore`),
// Category groups
categoryGroups: () => get('/categories/groups'),
categoryGroups: () => get<CategoryGroup[]>('/categories/groups'),
createCategoryGroup: (data: Body) => post('/categories/groups', data),
updateCategoryGroup: (id: Id, data: Body) => put(`/categories/groups/${id}`, data),
deleteCategoryGroup: (id: Id) => del(`/categories/groups/${id}`),

View File

@ -20,7 +20,16 @@ import {
import { CategoryPicker } from '@/components/transactions/CategoryPicker';
import { formatUSD, asDollars } from '@/lib/money';
import { errMessage } from '@/lib/utils';
import type { Category } from '@/types';
import type {
Category,
SpendingCategoryEntry as CatEntry,
SpendingSummary,
SpendingTransaction as SpendingTx,
SpendingTransactionsResponse as SpendingTxResponse,
SpendingIncomeTx as IncomeTx,
SpendingRule,
CategoryGroup,
} from '@/types';
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
@ -35,43 +44,8 @@ function settingEnabled(value: unknown, fallback = true): boolean {
type Level = 'over' | 'warn' | 'ok';
interface CatEntry {
category_id: number | 'other' | null;
category_name?: string;
amount: number;
budget?: number | null;
tx_count?: number;
avg_3mo?: number;
group_id?: number | null;
}
type RealCatEntry = CatEntry & { category_id: number };
interface SpendingSummary {
by_category?: CatEntry[];
total_spending?: number;
income?: number;
uncategorized_amount?: number;
uncategorized_count?: number;
}
interface SpendingTx {
id: number;
payee?: string | null;
date?: string | null;
amount: number;
spending_category_id?: number | string | null;
spending_category_name?: string | null;
[key: string]: unknown;
}
interface SpendingTxResponse { transactions?: SpendingTx[]; total?: number; pages?: number }
interface IncomeTx { id: number; payee?: string | null; date?: string | null; amount: number }
interface IncomeResponse { transactions?: IncomeTx[]; total?: number; pages?: number }
interface SpendingRule { id: number; merchant: string; category_name?: string; category_id?: number }
interface CategoryGroup { id: number; name: string }
interface CopyBudgetsResponse { copied: number; budgets?: { category_id: number; amount: number }[] }
type Budgets = Record<string, number | null>;
// pctBar() returns a progress-bar percent plus a YNAB-style status level:
@ -232,7 +206,7 @@ function IncomeSection({ year, month, totalIncome }: { year: number; month: numb
const load = useCallback(async (p = 1) => {
setLoading(true);
try {
const d = await api.spendingIncome({ year, month, page: p }) as IncomeResponse;
const d = await api.spendingIncome({ year, month, page: p });
setRows(d.transactions || []);
setPages(d.pages || 1);
setPage(p);
@ -315,7 +289,7 @@ function RulesManager({ categories }: { categories: Category[] }) {
const load = useCallback(async () => {
setLoading(true);
try { setRules(((await api.spendingCategoryRules()) as { rules?: SpendingRule[] }).rules || []); }
try { setRules((await api.spendingCategoryRules()).rules || []); }
catch { toast.error('Failed to load rules'); }
finally { setLoading(false); }
}, []);
@ -693,15 +667,15 @@ export default function SpendingPage() {
const [txPage, setTxPage] = useState(1);
const [activeCat, setActiveCat] = useState<number | null | undefined>(undefined); // undefined = all
const { data: summaryRaw = null, isPending: loading, error: summaryErrObj } = useSpendingSummary(year, month);
const summary = summaryRaw as SpendingSummary | null;
const summary = summaryRaw;
const { data: txDataRaw, isFetching: txLoading, error: txErrObj } = useSpendingTransactions({ year, month, activeCat, page: txPage });
const txData = txDataRaw as SpendingTxResponse | undefined;
const txData = txDataRaw;
const transactions = useMemo(() => txData?.transactions || [], [txData]);
const txTotal = txData?.total || 0;
const txPages = txData?.pages || 1;
const { data: categories = [], error: catErrObj } = useSpendingCategories();
const { data: categoryGroupsRaw = [] } = useCategoryGroups();
const categoryGroups = categoryGroupsRaw as CategoryGroup[];
const categoryGroups = categoryGroupsRaw;
const summaryError = summaryErrObj ? (summaryErrObj.message || 'Failed to load spending summary') : null;
const txError = txErrObj ? (txErrObj.message || 'Failed to load transactions') : null;
const catError = catErrObj ? (catErrObj.message || 'Failed to load categories') : null;
@ -769,7 +743,7 @@ export default function SpendingPage() {
const handleCopyBudgets = async () => {
setCopying(true);
try {
const d = await api.copySpendingBudgets({ year, month }) as CopyBudgetsResponse;
const d = await api.copySpendingBudgets({ year, month });
if (d.copied === 0) {
toast.info('No budgets found in the previous month to copy.');
} else {

View File

@ -88,6 +88,40 @@ export interface AutoCategorizePreview {
categories?: { name: string; count?: number }[];
}
// The spending page (GET /spending/*). Amounts are dollars (server fromCents);
// kept as plain `number` because the page works in numbers via a local formatter.
export interface SpendingCategoryEntry {
category_id: number | 'other' | null;
category_name?: string;
amount: number;
budget?: number | null;
tx_count?: number;
avg_3mo?: number;
group_id?: number | null;
}
export interface SpendingSummary {
by_category?: SpendingCategoryEntry[];
total_spending?: number;
income?: number;
uncategorized_amount?: number;
uncategorized_count?: number;
}
export interface SpendingTransaction {
id: number;
payee?: string | null;
date?: string | null;
amount: number;
spending_category_id?: number | string | null;
spending_category_name?: string | null;
[key: string]: unknown;
}
export interface SpendingTransactionsResponse { transactions?: SpendingTransaction[]; total?: number; pages?: number }
export interface SpendingIncomeTx { id: number; payee?: string | null; date?: string | null; amount: number }
export interface SpendingIncomeResponse { transactions?: SpendingIncomeTx[]; total?: number; pages?: number }
export interface SpendingRule { id: number; merchant: string; category_name?: string; category_id?: number }
export interface CategoryGroup { id: number; name: string }
export interface CopyBudgetsResponse { copied: number; budgets?: { category_id: number; amount: number }[] }
// A bill's month status. Mirrors the server's statusService.
export type TrackerStatus =
| 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';