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:
parent
b267599ba5
commit
8265b4a97f
|
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
### 🔌 API response typing cleanup (Track C) — promoting page shapes into `@/types`, endpoint by endpoint
|
### 🔌 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.
|
- **[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"
|
### 🧮 Cross-surface money reconciliation harness (Track B) — pins "same number, same truth"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import type {
|
import type {
|
||||||
Bill, Category, Payment, TrackerResponse,
|
Bill, Category, Payment, TrackerResponse,
|
||||||
BankLedger, BankLedgerTransaction, AutoCategorizePreview,
|
BankLedger, BankLedgerTransaction, AutoCategorizePreview,
|
||||||
|
SpendingSummary, SpendingTransactionsResponse, SpendingIncomeResponse,
|
||||||
|
SpendingRule, CopyBudgetsResponse, CategoryGroup,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
// Fetch CSRF token from the server once and cache in memory.
|
// Fetch CSRF token from the server once and cache in memory.
|
||||||
|
|
@ -114,14 +116,14 @@ export const api = {
|
||||||
acknowledgeVersion: () => post('/auth/acknowledge-version'),
|
acknowledgeVersion: () => post('/auth/acknowledge-version'),
|
||||||
loginHistory: () => get('/auth/login-history'),
|
loginHistory: () => get('/auth/login-history'),
|
||||||
// Spending
|
// Spending
|
||||||
spendingSummary: (p?: QueryParams) => get('/spending/summary', p),
|
spendingSummary: (p?: QueryParams) => get<SpendingSummary>('/spending/summary', p),
|
||||||
spendingTransactions:(p?: QueryParams) => get('/spending/transactions', p),
|
spendingTransactions:(p?: QueryParams) => get<SpendingTransactionsResponse>('/spending/transactions', p),
|
||||||
categorizeTransaction: (id: Id, d: Body) => patch(`/spending/transactions/${id}/category`, d),
|
categorizeTransaction: (id: Id, d: Body) => patch(`/spending/transactions/${id}/category`, d),
|
||||||
spendingBudgets: (p?: QueryParams) => get('/spending/budgets', p),
|
spendingBudgets: (p?: QueryParams) => get('/spending/budgets', p),
|
||||||
setSpendingBudget: (d: Body) => put('/spending/budgets', d),
|
setSpendingBudget: (d: Body) => put('/spending/budgets', d),
|
||||||
copySpendingBudgets: (d: Body) => post('/spending/budgets/copy', d),
|
copySpendingBudgets: (d: Body) => post<CopyBudgetsResponse>('/spending/budgets/copy', d),
|
||||||
spendingIncome: (p?: QueryParams) => get('/spending/income', p),
|
spendingIncome: (p?: QueryParams) => get<SpendingIncomeResponse>('/spending/income', p),
|
||||||
spendingCategoryRules: () => get('/spending/category-rules'),
|
spendingCategoryRules: () => get<{ rules?: SpendingRule[] }>('/spending/category-rules'),
|
||||||
addSpendingRule: (d: Body) => post('/spending/category-rules', d),
|
addSpendingRule: (d: Body) => post('/spending/category-rules', d),
|
||||||
deleteSpendingRule: (id: Id) => del(`/spending/category-rules/${id}`),
|
deleteSpendingRule: (id: Id) => del(`/spending/category-rules/${id}`),
|
||||||
|
|
||||||
|
|
@ -344,7 +346,7 @@ export const api = {
|
||||||
restoreCategory: (id: Id) => post(`/categories/${id}/restore`),
|
restoreCategory: (id: Id) => post(`/categories/${id}/restore`),
|
||||||
|
|
||||||
// Category groups
|
// Category groups
|
||||||
categoryGroups: () => get('/categories/groups'),
|
categoryGroups: () => get<CategoryGroup[]>('/categories/groups'),
|
||||||
createCategoryGroup: (data: Body) => post('/categories/groups', data),
|
createCategoryGroup: (data: Body) => post('/categories/groups', data),
|
||||||
updateCategoryGroup: (id: Id, data: Body) => put(`/categories/groups/${id}`, data),
|
updateCategoryGroup: (id: Id, data: Body) => put(`/categories/groups/${id}`, data),
|
||||||
deleteCategoryGroup: (id: Id) => del(`/categories/groups/${id}`),
|
deleteCategoryGroup: (id: Id) => del(`/categories/groups/${id}`),
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,16 @@ import {
|
||||||
import { CategoryPicker } from '@/components/transactions/CategoryPicker';
|
import { CategoryPicker } from '@/components/transactions/CategoryPicker';
|
||||||
import { formatUSD, asDollars } from '@/lib/money';
|
import { formatUSD, asDollars } from '@/lib/money';
|
||||||
import { errMessage } from '@/lib/utils';
|
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'];
|
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';
|
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 };
|
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>;
|
type Budgets = Record<string, number | null>;
|
||||||
|
|
||||||
// pctBar() returns a progress-bar percent plus a YNAB-style status level:
|
// 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) => {
|
const load = useCallback(async (p = 1) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const d = await api.spendingIncome({ year, month, page: p }) as IncomeResponse;
|
const d = await api.spendingIncome({ year, month, page: p });
|
||||||
setRows(d.transactions || []);
|
setRows(d.transactions || []);
|
||||||
setPages(d.pages || 1);
|
setPages(d.pages || 1);
|
||||||
setPage(p);
|
setPage(p);
|
||||||
|
|
@ -315,7 +289,7 @@ function RulesManager({ categories }: { categories: Category[] }) {
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try { setRules(((await api.spendingCategoryRules()) as { rules?: SpendingRule[] }).rules || []); }
|
try { setRules((await api.spendingCategoryRules()).rules || []); }
|
||||||
catch { toast.error('Failed to load rules'); }
|
catch { toast.error('Failed to load rules'); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -693,15 +667,15 @@ export default function SpendingPage() {
|
||||||
const [txPage, setTxPage] = useState(1);
|
const [txPage, setTxPage] = useState(1);
|
||||||
const [activeCat, setActiveCat] = useState<number | null | undefined>(undefined); // undefined = all
|
const [activeCat, setActiveCat] = useState<number | null | undefined>(undefined); // undefined = all
|
||||||
const { data: summaryRaw = null, isPending: loading, error: summaryErrObj } = useSpendingSummary(year, month);
|
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 { 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 transactions = useMemo(() => txData?.transactions || [], [txData]);
|
||||||
const txTotal = txData?.total || 0;
|
const txTotal = txData?.total || 0;
|
||||||
const txPages = txData?.pages || 1;
|
const txPages = txData?.pages || 1;
|
||||||
const { data: categories = [], error: catErrObj } = useSpendingCategories();
|
const { data: categories = [], error: catErrObj } = useSpendingCategories();
|
||||||
const { data: categoryGroupsRaw = [] } = useCategoryGroups();
|
const { data: categoryGroupsRaw = [] } = useCategoryGroups();
|
||||||
const categoryGroups = categoryGroupsRaw as CategoryGroup[];
|
const categoryGroups = categoryGroupsRaw;
|
||||||
const summaryError = summaryErrObj ? (summaryErrObj.message || 'Failed to load spending summary') : null;
|
const summaryError = summaryErrObj ? (summaryErrObj.message || 'Failed to load spending summary') : null;
|
||||||
const txError = txErrObj ? (txErrObj.message || 'Failed to load transactions') : null;
|
const txError = txErrObj ? (txErrObj.message || 'Failed to load transactions') : null;
|
||||||
const catError = catErrObj ? (catErrObj.message || 'Failed to load categories') : null;
|
const catError = catErrObj ? (catErrObj.message || 'Failed to load categories') : null;
|
||||||
|
|
@ -769,7 +743,7 @@ export default function SpendingPage() {
|
||||||
const handleCopyBudgets = async () => {
|
const handleCopyBudgets = async () => {
|
||||||
setCopying(true);
|
setCopying(true);
|
||||||
try {
|
try {
|
||||||
const d = await api.copySpendingBudgets({ year, month }) as CopyBudgetsResponse;
|
const d = await api.copySpendingBudgets({ year, month });
|
||||||
if (d.copied === 0) {
|
if (d.copied === 0) {
|
||||||
toast.info('No budgets found in the previous month to copy.');
|
toast.info('No budgets found in the previous month to copy.');
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,40 @@ export interface AutoCategorizePreview {
|
||||||
categories?: { name: string; count?: number }[];
|
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.
|
// A bill's month status. Mirrors the server's statusService.
|
||||||
export type TrackerStatus =
|
export type TrackerStatus =
|
||||||
| 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';
|
| 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue