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:
parent
c223f62408
commit
b267599ba5
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)}`),
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue