refactor(ts): type the bank-ledger API responses (Track C)

Promoted BankTransactionsPage's local Tx/Account/Ledger/... interfaces
into @/types (BankLedgerTransaction extends BankTransaction so amounts
stay branded Cents) and wired bankTransactionsLedger/match/unmatch/
ignore/unignore/apply-merchant-match/auto-categorize with get<T>/post<T>.
useBankLedger data is now typed; all 9 as {...} casts in the page removed.
Dropped a dead Cents import. typecheck/lint/build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 12:02:28 -05:00
parent c223f62408
commit b267599ba5
4 changed files with 97 additions and 86 deletions

View File

@ -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<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"
- **[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.

View File

@ -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<BankLedger>(`/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<BankLedgerTransaction>(`/transactions/${id}/ignore`),
unignoreTransaction: (id: Id) => post<BankLedgerTransaction>(`/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<AutoCategorizePreview>('/transactions/auto-categorize', opts),
// Match suggestions
matchSuggestions: (params: QueryParams = {}) => get(`/matches/suggestions${queryString(params)}`),

View File

@ -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<Ledger>(
['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<Tx>;
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<Tx>;
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<Tx>);
updateTransaction(targets[i]!.id, r.value);
applied++;
}
});

View File

@ -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';