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
|
# Bill Tracker — Changelog
|
||||||
## v0.41.0
|
## 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"
|
### 🧮 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.
|
- **[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.
|
// Fetch CSRF token from the server once and cache in memory.
|
||||||
// The cookie is httpOnly so document.cookie cannot access it directly.
|
// The cookie is httpOnly so document.cookie cannot access it directly.
|
||||||
|
|
@ -461,18 +464,18 @@ export const api = {
|
||||||
|
|
||||||
// Transactions
|
// Transactions
|
||||||
transactions: (params: QueryParams = {}) => get(`/transactions${queryString(params)}`),
|
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),
|
createManualTransaction: (data: Body) => post('/transactions/manual', data),
|
||||||
updateTransaction: (id: Id, data: Body) => put(`/transactions/${id}`, data),
|
updateTransaction: (id: Id, data: Body) => put(`/transactions/${id}`, data),
|
||||||
deleteTransaction: (id: Id) => del(`/transactions/${id}`),
|
deleteTransaction: (id: Id) => del(`/transactions/${id}`),
|
||||||
matchTransaction: (id: Id, billId: Id) => post(`/transactions/${id}/match`, { billId }),
|
matchTransaction: (id: Id, billId: Id) => post<{ transaction: BankLedgerTransaction }>(`/transactions/${id}/match`, { billId }),
|
||||||
unmatchTransaction: (id: Id) => post(`/transactions/${id}/unmatch`),
|
unmatchTransaction: (id: Id) => post<{ transaction: BankLedgerTransaction }>(`/transactions/${id}/unmatch`),
|
||||||
unmatchTransactionBulk: (matches: Body) => post('/transactions/unmatch-bulk', { matches }),
|
unmatchTransactionBulk: (matches: Body) => post('/transactions/unmatch-bulk', { matches }),
|
||||||
ignoreTransaction: (id: Id) => post(`/transactions/${id}/ignore`),
|
ignoreTransaction: (id: Id) => post<BankLedgerTransaction>(`/transactions/${id}/ignore`),
|
||||||
unignoreTransaction: (id: Id) => post(`/transactions/${id}/unignore`),
|
unignoreTransaction: (id: Id) => post<BankLedgerTransaction>(`/transactions/${id}/unignore`),
|
||||||
transactionMerchantMatch: (id: Id) => get(`/transactions/${id}/merchant-match`),
|
transactionMerchantMatch: (id: Id) => get(`/transactions/${id}/merchant-match`),
|
||||||
applyTransactionMerchantMatch: (id: Id) => post(`/transactions/${id}/apply-merchant-match`),
|
applyTransactionMerchantMatch: (id: Id) => post<{ matched?: boolean; category?: { id: number; name: string } }>(`/transactions/${id}/apply-merchant-match`),
|
||||||
autoCategorizeTransactions: (opts: Body = {}) => post('/transactions/auto-categorize', opts),
|
autoCategorizeTransactions: (opts: Body = {}) => post<AutoCategorizePreview>('/transactions/auto-categorize', opts),
|
||||||
|
|
||||||
// Match suggestions
|
// Match suggestions
|
||||||
matchSuggestions: (params: QueryParams = {}) => get(`/matches/suggestions${queryString(params)}`),
|
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 { MatchBillDialog } from '@/components/transactions/MatchBillDialog';
|
||||||
import BillModal from '@/components/BillModal';
|
import BillModal from '@/components/BillModal';
|
||||||
import { cn, fmtDate, categoryColor, localDateString, errMessage } from '@/lib/utils';
|
import { cn, fmtDate, categoryColor, localDateString, errMessage } from '@/lib/utils';
|
||||||
import { formatUSD, asDollars, type Cents } from '@/lib/money';
|
import { formatUSD, asDollars } from '@/lib/money';
|
||||||
import type { Bill, Category, BankTransaction } from '@/types';
|
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 {
|
function fmt(v: number | null | undefined): string {
|
||||||
return formatUSD(asDollars(Number(v) || 0));
|
return formatUSD(asDollars(Number(v) || 0));
|
||||||
|
|
@ -40,71 +49,6 @@ function fmt(v: number | null | undefined): string {
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
const TABLE_COLUMN_COUNT = 8;
|
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 = [
|
const flowOptions = [
|
||||||
{ value: 'all', label: 'All' },
|
{ value: 'all', label: 'All' },
|
||||||
{ value: 'in', label: 'Money in' },
|
{ value: 'in', label: 'Money in' },
|
||||||
|
|
@ -384,7 +328,7 @@ export default function BankTransactionsPage() {
|
||||||
// React Query keys the ledger on the filters/page.
|
// React Query keys the ledger on the filters/page.
|
||||||
const { data: ledgerRaw = null, isPending: loading, isFetching, error: ledgerErr, refetch: loadLedger } =
|
const { data: ledgerRaw = null, isPending: loading, isFetching, error: ledgerErr, refetch: loadLedger } =
|
||||||
useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize: PAGE_SIZE });
|
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 error = ledgerErr ? (ledgerErr.message || 'Unable to load bank transactions') : '';
|
||||||
const setLedger = useCallback((u: (prev: Ledger | undefined) => Ledger | undefined) => queryClient.setQueryData<Ledger>(
|
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]);
|
['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) => {
|
const handleApplySuggestion = useCallback(async (tx: Tx) => {
|
||||||
setApplyingSuggestionId(tx.id);
|
setApplyingSuggestionId(tx.id);
|
||||||
try {
|
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) {
|
if (result.matched && result.category) {
|
||||||
updateTransaction(tx.id, {
|
updateTransaction(tx.id, {
|
||||||
spending_category_id: result.category.id,
|
spending_category_id: result.category.id,
|
||||||
|
|
@ -452,7 +396,7 @@ export default function BankTransactionsPage() {
|
||||||
const handleAutoCategorize = useCallback(async () => {
|
const handleAutoCategorize = useCallback(async () => {
|
||||||
setAutoCategorizing(true);
|
setAutoCategorizing(true);
|
||||||
try {
|
try {
|
||||||
const preview = await api.autoCategorizeTransactions({ dry_run: true }) as AutoCatPreview;
|
const preview = await api.autoCategorizeTransactions({ dry_run: true });
|
||||||
if (!preview?.changes?.length) {
|
if (!preview?.changes?.length) {
|
||||||
toast.success('No new matches found');
|
toast.success('No new matches found');
|
||||||
return;
|
return;
|
||||||
|
|
@ -479,7 +423,7 @@ export default function BankTransactionsPage() {
|
||||||
const handleConfirmAutoCategorize = useCallback(async () => {
|
const handleConfirmAutoCategorize = useCallback(async () => {
|
||||||
setAutoCategorizing(true);
|
setAutoCategorizing(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.autoCategorizeTransactions() as { changes?: AutoCatChange[] };
|
const result = await api.autoCategorizeTransactions();
|
||||||
const changes = result?.changes || [];
|
const changes = result?.changes || [];
|
||||||
setAutoCategorizePreview(null);
|
setAutoCategorizePreview(null);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|
@ -501,7 +445,7 @@ export default function BankTransactionsPage() {
|
||||||
if (!matchTarget) return;
|
if (!matchTarget) return;
|
||||||
setMatchSubmitting(true);
|
setMatchSubmitting(true);
|
||||||
try {
|
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);
|
updateTransaction(matchTarget.id, result.transaction);
|
||||||
setMatchTarget(null);
|
setMatchTarget(null);
|
||||||
toast.success('Transaction matched to bill');
|
toast.success('Transaction matched to bill');
|
||||||
|
|
@ -538,7 +482,7 @@ export default function BankTransactionsPage() {
|
||||||
setMatchTarget(null);
|
setMatchTarget(null);
|
||||||
if (tx && newBill?.id) {
|
if (tx && newBill?.id) {
|
||||||
try {
|
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);
|
updateTransaction(tx.id, result.transaction);
|
||||||
toast.success('Bill created and matched to transaction');
|
toast.success('Bill created and matched to transaction');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -550,7 +494,7 @@ export default function BankTransactionsPage() {
|
||||||
|
|
||||||
const handleUnmatch = useCallback(async (tx: Tx) => {
|
const handleUnmatch = useCallback(async (tx: Tx) => {
|
||||||
try {
|
try {
|
||||||
const result = await api.unmatchTransaction(tx.id) as { transaction: Tx };
|
const result = await api.unmatchTransaction(tx.id);
|
||||||
updateTransaction(tx.id, result.transaction);
|
updateTransaction(tx.id, result.transaction);
|
||||||
toast.success('Transaction unmatched');
|
toast.success('Transaction unmatched');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -561,7 +505,7 @@ export default function BankTransactionsPage() {
|
||||||
|
|
||||||
const handleIgnore = useCallback(async (tx: Tx) => {
|
const handleIgnore = useCallback(async (tx: Tx) => {
|
||||||
try {
|
try {
|
||||||
const transaction = await api.ignoreTransaction(tx.id) as Partial<Tx>;
|
const transaction = await api.ignoreTransaction(tx.id);
|
||||||
updateTransaction(tx.id, transaction);
|
updateTransaction(tx.id, transaction);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(errMessage(err, 'Failed to ignore transaction'));
|
toast.error(errMessage(err, 'Failed to ignore transaction'));
|
||||||
|
|
@ -571,7 +515,7 @@ export default function BankTransactionsPage() {
|
||||||
|
|
||||||
const handleUnignore = useCallback(async (tx: Tx) => {
|
const handleUnignore = useCallback(async (tx: Tx) => {
|
||||||
try {
|
try {
|
||||||
const transaction = await api.unignoreTransaction(tx.id) as Partial<Tx>;
|
const transaction = await api.unignoreTransaction(tx.id);
|
||||||
updateTransaction(tx.id, transaction);
|
updateTransaction(tx.id, transaction);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(errMessage(err, 'Failed to unignore transaction'));
|
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)));
|
const results = await Promise.allSettled(targets.map(tx => api.applyTransactionMerchantMatch(tx.id)));
|
||||||
let applied = 0;
|
let applied = 0;
|
||||||
results.forEach((r, i) => {
|
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) {
|
if (val?.matched && val.category) {
|
||||||
updateTransaction(targets[i]!.id, {
|
updateTransaction(targets[i]!.id, {
|
||||||
spending_category_id: val.category.id,
|
spending_category_id: val.category.id,
|
||||||
|
|
@ -679,7 +623,7 @@ export default function BankTransactionsPage() {
|
||||||
let applied = 0;
|
let applied = 0;
|
||||||
results.forEach((r, i) => {
|
results.forEach((r, i) => {
|
||||||
if (r.status === 'fulfilled') {
|
if (r.status === 'fulfilled') {
|
||||||
updateTransaction(targets[i]!.id, r.value as Partial<Tx>);
|
updateTransaction(targets[i]!.id, r.value);
|
||||||
applied++;
|
applied++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,66 @@ export interface BankTransaction {
|
||||||
[key: string]: unknown;
|
[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.
|
// 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