diff --git a/HISTORY.md b/HISTORY.md index 46c23f1..450d53f 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,10 @@ # Bill Tracker — Changelog ## v0.41.0 +### 🔌 API response typing cleanup (Track C) — promoting page shapes into `@/types`, endpoint by endpoint + +- **[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`, `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" - **[Money integrity] Added `tests/reconciliation.test.js` — the totals every surface shows for a month must agree.** The app has repeatedly shipped "same number, different truth" drift (QA-B5/B9) where the Tracker, Summary, Analytics, Bills, and bank ledger each gate a bill's monthly occurrence slightly differently — so an annual/off-month bill inflates one surface's "expected" but not another's. The harness hand-seeds the exact shapes that historically broke gating (a $900 **off-month annual** bill, a **quarterly** bill that *does* fall in the month, plus plain monthly bills with a full + a partial payment) and pins the invariants, **comparing in integer cents** (never post-`fromCents` floats, which drift on rounding): `Analytics.expected` == `Tracker.total_expected`, `Σ resolveDueDate-gated Bills` == `Tracker.total_expected`, `Summary.expense_total` (via the real `GET /summary` handler) == `Tracker.total_expected`, `Analytics.actual` == `Tracker.total_paid`, and — the linchpin — the off-month annual bill inflates **no** surface. All four money surfaces reconcile today (the QA-B5 fixes hold); the harness makes a future regression fail loudly. (Bank-ledger occurrence gating is already pinned by `trackerService.test.js`.) Full suite 193 green. diff --git a/client/api.ts b/client/api.ts index d35a734..ece1f80 100644 --- a/client/api.ts +++ b/client/api.ts @@ -1,4 +1,7 @@ -import type { Bill, Category, Payment, TrackerResponse } from '@/types'; +import type { + Bill, Category, Payment, TrackerResponse, + BankLedger, BankLedgerTransaction, AutoCategorizePreview, +} from '@/types'; // Fetch CSRF token from the server once and cache in memory. // The cookie is httpOnly so document.cookie cannot access it directly. @@ -461,18 +464,18 @@ export const api = { // Transactions transactions: (params: QueryParams = {}) => get(`/transactions${queryString(params)}`), - bankTransactionsLedger: (params: QueryParams = {}) => get(`/transactions/bank-ledger${queryString(params)}`), + bankTransactionsLedger: (params: QueryParams = {}) => get(`/transactions/bank-ledger${queryString(params)}`), createManualTransaction: (data: Body) => post('/transactions/manual', data), updateTransaction: (id: Id, data: Body) => put(`/transactions/${id}`, data), deleteTransaction: (id: Id) => del(`/transactions/${id}`), - matchTransaction: (id: Id, billId: Id) => post(`/transactions/${id}/match`, { billId }), - unmatchTransaction: (id: Id) => post(`/transactions/${id}/unmatch`), + matchTransaction: (id: Id, billId: Id) => post<{ transaction: BankLedgerTransaction }>(`/transactions/${id}/match`, { billId }), + unmatchTransaction: (id: Id) => post<{ transaction: BankLedgerTransaction }>(`/transactions/${id}/unmatch`), unmatchTransactionBulk: (matches: Body) => post('/transactions/unmatch-bulk', { matches }), - ignoreTransaction: (id: Id) => post(`/transactions/${id}/ignore`), - unignoreTransaction: (id: Id) => post(`/transactions/${id}/unignore`), + ignoreTransaction: (id: Id) => post(`/transactions/${id}/ignore`), + unignoreTransaction: (id: Id) => post(`/transactions/${id}/unignore`), transactionMerchantMatch: (id: Id) => get(`/transactions/${id}/merchant-match`), - applyTransactionMerchantMatch: (id: Id) => post(`/transactions/${id}/apply-merchant-match`), - autoCategorizeTransactions: (opts: Body = {}) => post('/transactions/auto-categorize', opts), + applyTransactionMerchantMatch: (id: Id) => post<{ matched?: boolean; category?: { id: number; name: string } }>(`/transactions/${id}/apply-merchant-match`), + autoCategorizeTransactions: (opts: Body = {}) => post('/transactions/auto-categorize', opts), // Match suggestions matchSuggestions: (params: QueryParams = {}) => get(`/matches/suggestions${queryString(params)}`), diff --git a/client/pages/BankTransactionsPage.tsx b/client/pages/BankTransactionsPage.tsx index cec1127..344be06 100644 --- a/client/pages/BankTransactionsPage.tsx +++ b/client/pages/BankTransactionsPage.tsx @@ -30,8 +30,17 @@ import { CategoryPicker } from '@/components/transactions/CategoryPicker'; import { MatchBillDialog } from '@/components/transactions/MatchBillDialog'; import BillModal from '@/components/BillModal'; import { cn, fmtDate, categoryColor, localDateString, errMessage } from '@/lib/utils'; -import { formatUSD, asDollars, type Cents } from '@/lib/money'; -import type { Bill, Category, BankTransaction } from '@/types'; +import { formatUSD, asDollars } from '@/lib/money'; +import type { + Bill, Category, BankTransaction, + BankLedgerTransaction as Tx, + BankAccount as Account, + BankLedger as Ledger, + BankLedgerSummary as LedgerSummary, + BankCategoryBreakdown as CategoryBreakdown, + AutoCategorizeChange as AutoCatChange, + AutoCategorizePreview as AutoCatPreview, +} from '@/types'; function fmt(v: number | null | undefined): string { return formatUSD(asDollars(Number(v) || 0)); @@ -40,71 +49,6 @@ function fmt(v: number | null | undefined): string { const PAGE_SIZE = 50; const TABLE_COLUMN_COUNT = 8; -interface Tx { - id: number; - amount: Cents; - payee?: string | null; - description?: string | null; - memo?: string | null; - category?: string | null; - posted_date?: string | null; - transacted_at?: string | null; - pending?: boolean; - ignored?: boolean; - match_status?: string | null; - matched_bill_name?: string | null; - spending_category_id?: number | null; - spending_category_name?: string | null; - suggested_match?: { display_name?: string; category?: string } | null; - account_org_name?: string | null; - account_name?: string | null; - account_type?: string | null; - currency?: string | null; - [key: string]: unknown; -} - -interface Account { - id: number | string; - org_name?: string; - name?: string; - account_type?: string; - currency?: string; - balance?: number; - available_balance?: number | null; - transaction_count?: number; - last_transaction_date?: string; -} - -interface CategoryBreakdown { name: string; total?: number; count?: number } - -interface LedgerSummary { - inflow?: number; - outflow?: number; - net?: number; - matched?: number; - unmatched?: number; - pending?: number; - total?: number; - latest_date?: string; - category_breakdown?: CategoryBreakdown[]; -} - -interface Ledger { - accounts?: Account[]; - transactions?: Tx[]; - summary?: LedgerSummary; - total?: number; - sources?: { last_sync_at?: string | null }[]; - enabled?: boolean; - has_connections?: boolean; -} - -interface AutoCatChange { transaction_id: number } -interface AutoCatPreview { - changes?: AutoCatChange[]; - categories?: { name: string; count?: number }[]; -} - const flowOptions = [ { value: 'all', label: 'All' }, { value: 'in', label: 'Money in' }, @@ -384,7 +328,7 @@ export default function BankTransactionsPage() { // React Query keys the ledger on the filters/page. const { data: ledgerRaw = null, isPending: loading, isFetching, error: ledgerErr, refetch: loadLedger } = useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize: PAGE_SIZE }); - const ledger = ledgerRaw as Ledger | null; + const ledger = ledgerRaw; const error = ledgerErr ? (ledgerErr.message || 'Unable to load bank transactions') : ''; const setLedger = useCallback((u: (prev: Ledger | undefined) => Ledger | undefined) => queryClient.setQueryData( ['bank-ledger', accountId, flow, page, query, sortBy, sortDir], u), [queryClient, accountId, flow, page, query, sortBy, sortDir]); @@ -433,7 +377,7 @@ export default function BankTransactionsPage() { const handleApplySuggestion = useCallback(async (tx: Tx) => { setApplyingSuggestionId(tx.id); try { - const result = await api.applyTransactionMerchantMatch(tx.id) as { matched?: boolean; category?: { id: number; name: string } }; + const result = await api.applyTransactionMerchantMatch(tx.id); if (result.matched && result.category) { updateTransaction(tx.id, { spending_category_id: result.category.id, @@ -452,7 +396,7 @@ export default function BankTransactionsPage() { const handleAutoCategorize = useCallback(async () => { setAutoCategorizing(true); try { - const preview = await api.autoCategorizeTransactions({ dry_run: true }) as AutoCatPreview; + const preview = await api.autoCategorizeTransactions({ dry_run: true }); if (!preview?.changes?.length) { toast.success('No new matches found'); return; @@ -479,7 +423,7 @@ export default function BankTransactionsPage() { const handleConfirmAutoCategorize = useCallback(async () => { setAutoCategorizing(true); try { - const result = await api.autoCategorizeTransactions() as { changes?: AutoCatChange[] }; + const result = await api.autoCategorizeTransactions(); const changes = result?.changes || []; setAutoCategorizePreview(null); await Promise.all([ @@ -501,7 +445,7 @@ export default function BankTransactionsPage() { if (!matchTarget) return; setMatchSubmitting(true); try { - const result = await api.matchTransaction(matchTarget.id, billId) as { transaction: Tx }; + const result = await api.matchTransaction(matchTarget.id, billId); updateTransaction(matchTarget.id, result.transaction); setMatchTarget(null); toast.success('Transaction matched to bill'); @@ -538,7 +482,7 @@ export default function BankTransactionsPage() { setMatchTarget(null); if (tx && newBill?.id) { try { - const result = await api.matchTransaction(tx.id, newBill.id) as { transaction: Tx }; + const result = await api.matchTransaction(tx.id, newBill.id); updateTransaction(tx.id, result.transaction); toast.success('Bill created and matched to transaction'); } catch (err) { @@ -550,7 +494,7 @@ export default function BankTransactionsPage() { const handleUnmatch = useCallback(async (tx: Tx) => { try { - const result = await api.unmatchTransaction(tx.id) as { transaction: Tx }; + const result = await api.unmatchTransaction(tx.id); updateTransaction(tx.id, result.transaction); toast.success('Transaction unmatched'); } catch (err) { @@ -561,7 +505,7 @@ export default function BankTransactionsPage() { const handleIgnore = useCallback(async (tx: Tx) => { try { - const transaction = await api.ignoreTransaction(tx.id) as Partial; + const transaction = await api.ignoreTransaction(tx.id); updateTransaction(tx.id, transaction); } catch (err) { toast.error(errMessage(err, 'Failed to ignore transaction')); @@ -571,7 +515,7 @@ export default function BankTransactionsPage() { const handleUnignore = useCallback(async (tx: Tx) => { try { - const transaction = await api.unignoreTransaction(tx.id) as Partial; + const transaction = await api.unignoreTransaction(tx.id); updateTransaction(tx.id, transaction); } catch (err) { toast.error(errMessage(err, 'Failed to unignore transaction')); @@ -626,7 +570,7 @@ export default function BankTransactionsPage() { const results = await Promise.allSettled(targets.map(tx => api.applyTransactionMerchantMatch(tx.id))); let applied = 0; results.forEach((r, i) => { - const val = r.status === 'fulfilled' ? r.value as { matched?: boolean; category?: { id: number; name: string } } : null; + const val = r.status === 'fulfilled' ? r.value : null; if (val?.matched && val.category) { updateTransaction(targets[i]!.id, { spending_category_id: val.category.id, @@ -679,7 +623,7 @@ export default function BankTransactionsPage() { let applied = 0; results.forEach((r, i) => { if (r.status === 'fulfilled') { - updateTransaction(targets[i]!.id, r.value as Partial); + updateTransaction(targets[i]!.id, r.value); applied++; } }); diff --git a/client/types.ts b/client/types.ts index 3a3916d..e770bcf 100644 --- a/client/types.ts +++ b/client/types.ts @@ -28,6 +28,66 @@ export interface BankTransaction { [key: string]: unknown; } +// The bank ledger (GET /transactions/bank-ledger): accounts, the transaction +// occurrence with spending/match metadata, and the month summary. Raw +// transaction/account amounts stay in CENTS on this endpoint (the page formats +// them with a local formatCents), so they're plain `number` cents, not Dollars. +export interface BankLedgerTransaction extends BankTransaction { + transacted_at?: string | null; // narrower than BankTransaction's for date slicing + category?: string | null; + pending?: boolean; + ignored?: boolean; + match_status?: string | null; + matched_bill_name?: string | null; + spending_category_id?: number | null; + spending_category_name?: string | null; + suggested_match?: { display_name?: string; category?: string } | null; + account_org_name?: string | null; + account_type?: string | null; +} + +export interface BankAccount { + id: number | string; + org_name?: string; + name?: string; + account_type?: string; + currency?: string; + balance?: number; + available_balance?: number | null; + transaction_count?: number; + last_transaction_date?: string; +} + +export interface BankCategoryBreakdown { name: string; total?: number; count?: number } + +export interface BankLedgerSummary { + inflow?: number; + outflow?: number; + net?: number; + matched?: number; + unmatched?: number; + pending?: number; + total?: number; + latest_date?: string; + category_breakdown?: BankCategoryBreakdown[]; +} + +export interface BankLedger { + accounts?: BankAccount[]; + transactions?: BankLedgerTransaction[]; + summary?: BankLedgerSummary; + total?: number; + sources?: { last_sync_at?: string | null }[]; + enabled?: boolean; + has_connections?: boolean; +} + +export interface AutoCategorizeChange { transaction_id: number } +export interface AutoCategorizePreview { + changes?: AutoCategorizeChange[]; + categories?: { name: string; count?: number }[]; +} + // A bill's month status. Mirrors the server's statusService. export type TrackerStatus = | 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';