Compare commits

..

9 Commits

Author SHA1 Message Date
null 03d15bd061 docs(reference): document v0.41.0 Track A/B/C/D in engineering manual
Eight in-flight commits targeting v0.41.0 (Track A money integrity,
Track B reconciliation tests, Track C API response typing across
auth/snowball/subscriptions/spending/bank-ledger, Track D 500
handler code:'INTERNAL_ERROR') are not yet pushed. Documenting them
in the manual so it stays current with the code that will land next.

Section updates:
- Header: keep package version 0.40.0, note v0.41.0 is the in-flight
  target; 'Last Updated' 2026-07-05.
- Notable changes: 4 new bullets summarizing Track A/B/C/D.
- API client (7): list Track C response typing and Track D error shape
  with cross-references to the sections where each is detailed.
- Domain types (7): expand to include User, SnowballProjection,
  SnowballSettings, Subscription*, Spending*, BankLedger*, CategoryGroup,
  CopyBudgetsResponse. Note single-consumer non-money casts left in
  place as a deliberate Track C stop.
- Auth state (7): User moved to @/types, re-exported from useAuth.
- client/lib/ (7): add paymentActions.ts (createPaymentOrConfirm).
- Response conventions (5): add 'INTERNAL_ERROR' for 500s; note
  409 DUPLICATE_SUSPECTED on POST /payments.
- Payments (5.5): Track A notes — transactional balance mutations,
  409 allow_duplicate flag, 409 DUPLICATE_SUSPECTED response shape,
  new tests/paymentsRoute.test.js and tests/reconciliation.test.js.
- Verification checklist (11): list 8 unpushed Track commits with
  one-line summaries and 19-file diff stat (813+/415-).
2026-07-05 12:32:02 -05:00
null 1df4b1bf87 refactor(ts): move User to @/types + type auth endpoints; add code to 500 (Track C/D)
Track C (auth): User lived in @/hooks/useAuth; moved it to @/types
(re-exported from useAuth for the 7 importers) and typed me/login/
totpChallenge/hasUsers, dropping those Admin/Login casts. Left the
single-consumer non-money casts (adminUsers, version/about/privacy/
health docs) and the optimistic-UI rewrite as deliberate low-value/
high-churn stops.

Track D: global 500 handler now emits code:'INTERNAL_ERROR' so an
unexpected error carries the same {error,code} field as structured 4xx.

typecheck/lint(0 err)/build/server-193 all green; probe 17/17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:26:11 -05:00
null 65a477cab1 refactor(ts): type snowball projection + settings responses (Track C)
Promoted SnowballProjection (+details) and SnowballSettings into @/types;
wired snowball (Bill[]), snowballSettings/saveSnowballSettings, and
snowballProjection with get<T>/_fetch<T>. Removed the projection/settings/
bills casts. Plan-lifecycle SnowballPlan casts kept (component-owned type).
typecheck/lint/build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:16:03 -05:00
null 6bb8c63d39 refactor(ts): type the subscriptions API responses (Track C)
Promoted SubscriptionsPage's Subscription/SubData/Recommendation/
CatalogMatch/SubTx (+ deps) into @/types and wired subscriptions/
recommendations/transaction-matches/create/match-bill with get<T>/
post<T>. Removed the (r: any) in useSubscriptionRecommendations, the 5
page casts, and an unused CatalogMatch import. typecheck/lint/build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:11:36 -05:00
null 8265b4a97f 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>
2026-07-05 12:06:40 -05:00
null b267599ba5 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>
2026-07-05 12:02:28 -05:00
null c223f62408 test(money): cross-surface reconciliation harness (Track B)
Pins that Tracker, Analytics, Bills, and Summary agree on a month's
expected/paid totals — the 'same number, different truth' bug class
(QA-B5/B9). Hand-seeds an off-month annual bill, an occurring quarterly
bill, and monthly bills w/ full+partial payments; asserts in integer
cents that Analytics.expected / Bills-gated-sum / Summary.expense_total
all == Tracker.total_expected, Analytics.actual == Tracker.total_paid,
and the off-month annual inflates no surface. All reconcile today; the
harness makes future gating drift fail loudly. Suite 193 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:55:52 -05:00
null dd5bf92fc1 fix(money): manual payment path flags suspected dupes (409) w/o losing legit ones (Track A)
The deliberate manual POST /payments had no dedupe guard (double-submit
made a second payment), but silently deduping it like the auto paths
would lose a legitimately-identical same-day payment — a money bug. So
the server returns 409 DUPLICATE_SUSPECTED (existing payment attached),
and a shared client helper (client/lib/paymentActions.ts, used by the
tracker inline cell, partial-payment dialog, and bill modal) turns it
into an 'add anyway?' toast that resends with allow_duplicate. Automated
one-click paths keep deduping silently (a repeat there is a retry).

Dropped a now-unused api import in PaymentLedgerDialog (Track E sweep).
Server suite 188 green; typecheck/lint/build clean; probe 17/17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:51:06 -05:00
null 60848964b6 fix(money): make every payment balance-mutation atomic (Track A)
Quick/bulk-pay were already atomic (X1); the audit found the row write +
applyBalanceDelta split across two un-transactional statements on manual
POST /payments, autopay-confirm, DELETE (unpay), restore, and PUT (edit,
where the bill-balance write and the payment UPDATE weren't atomic). A
failure between the two could leave a payment without its balance
adjustment (or a balance reversed without the row deleted). Each is now a
single db.transaction(). Behavior-preserving.

tests/paymentsRoute.test.js pins the create→delete→restore→edit balance
round-trip + double-restore no-op. Full suite 186 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:44:45 -05:00
20 changed files with 857 additions and 420 deletions

View File

@ -1,6 +1,24 @@
# Bill Tracker — Changelog
## v0.41.0
### 🔌 API response typing cleanup (Track C) — promoting page shapes into `@/types`, endpoint by endpoint
- **[Client] Auth: `User` moved to its proper home + auth endpoints typed.** `User` lived oddly in `@/hooks/useAuth`; moved it to `@/types` (re-exported from `useAuth` so the 7 importers keep working) and typed `me`/`login`/`totpChallenge`/`hasUsers` with `get<T>`/`post<T>`, dropping those casts on the Admin + Login pages. *(Deliberate stop: the remaining single-consumer, non-money casts — `adminUsers` with its page-local `AdminUser`, and the one-off `version`/`about`/`privacy`/`health`/`importHistory` doc shapes — are left as-is; central types add ~nothing for a single reader, and typing them is the low-value tail. Likewise the **optimistic-UI/`toast.promise` rewrite** (planned Track D) is intentionally not done — it's behavior-visible churn on already-working handlers, the worst risk/reward of the remaining work.)*
- **[Server] Error-shape consistency (Track D).** The global 500 handler ([server.js](server.js)) returned `{ error: 'Internal server error' }` with no `code`; added `code: 'INTERNAL_ERROR'` so an unexpected server error carries the same `{error, code}` field the client can switch on as every structured 4xx.
- **[Client] Snowball projection + settings typed.** Promoted `SnowballProjection` (+ `SnowballProjectionDetail`/`AvalancheProjectionDetail`/`SnowballDebtProjection`) and `SnowballSettings` into `@/types`, and wired `snowball` (→ `Bill[]`), `snowballSettings`/`saveSnowballSettings` (→ `SnowballSettings`), and `snowballProjection` (→ `SnowballProjection`). Removed the projection/settings/bills casts. (The plan-lifecycle endpoints still cast to `SnowballPlan`, which is a component-owned type in `PlanHistoryPanel` — left as-is to avoid a cross-file type move for marginal gain.)
- **[Client] Subscriptions domain typed.** Promoted the `SubscriptionsPage` shapes into `@/types` (`Subscription`, `SubscriptionSummary`/`…TopType`, `SubscriptionsResponse`, `Recommendation` + `RecommendationEvidence`/`…Transaction`/`ExistingBillMatch`/`CatalogMatch`, `SubscriptionTx`) and wired `subscriptions`/`subscriptionRecommendations`/`subscriptionTransactionMatches`/`createSubscriptionFromRecommendation`/`matchRecommendationToBill` with `get<T>`/`post<T>`. Dropped the `(r: any)` in `useSubscriptionRecommendations` (now typed) and the 5 page casts; removed an unused `CatalogMatch` import (Track E).
- **[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.
### 🧮 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.
### 💰 Payment atomicity audit (Track A) — every balance mutation is now transactional
- **[Money integrity] Wrapped the four remaining non-atomic payment paths in `db.transaction()`.** Quick-pay and bulk-pay were already atomic (X1), but the audit found the INSERT/UPDATE + `applyBalanceDelta` pair split across two un-transactional statements on **manual `POST /payments`**, **autopay-suggestion confirm**, **`DELETE` (unpay)**, and **`POST /:id/restore`** — plus **`PUT` (edit)**, where the bill-balance write and the payment `UPDATE` weren't atomic. A crash/constraint failure between the two statements could leave a payment without its balance adjustment (or a balance reversed without the row deleted). Each is now a single transaction: the row write and the balance mutation land together or neither. Behavior-preserving — `tests/paymentsRoute.test.js` pins the create → delete → restore → edit balance round-trip and the double-restore no-op.
- **[Money integrity] Manual `POST /payments` now flags a suspected duplicate instead of blindly creating one — without silently dropping a legitimate one.** The deliberate manual-add path had *no* dedupe guard, so a double-submit created a second payment. But unlike the automated one-click paths (`/quick`, `/bulk`, autopay-confirm — which dedupe *silently* because a repeat there is a retry), the manual path may legitimately record two identical payments on the same day, so silently deduping would *lose a real payment* — itself a money bug. Resolution: the server returns **`409 DUPLICATE_SUSPECTED`** (with the existing payment attached) on a same `bill+date+amount`; a shared client helper (`client/lib/paymentActions.ts`, used by the tracker inline cell, the partial-payment ledger dialog, and the bill modal) turns that into an **"add anyway?"** toast that resends with `allow_duplicate`. Best of both: retry-safety on the auto paths, no lost-write on the deliberate one. (A DB `UNIQUE` index was rejected — it would forbid legitimately-identical payments; an idempotency-key header is the documented upgrade if this ever goes multi-process.) Full server suite 188 green; e2e probe 17/17. (Track A)
### 🔷 TypeScript foundation + branded money types
- **[Client] Adopted TypeScript incrementally, starting with the money boundary** — the move off plain JS. Upgraded `jsconfig``tsconfig` with full `strict` + `noUncheckedIndexedAccess`, but `allowJs` + `checkJs:false` so every existing `.js`/`.jsx` keeps resolving and running untouched — only `.ts`/`.tsx` are type-checked, so it's a file-by-file migration with nothing breaking in between. Added `npm run typecheck` (`tsc --noEmit`) and wired it into `npm run ci`. Installed TypeScript 6 + `@types/react`/`react-dom` 19.

View File

@ -1,4 +1,11 @@
import type { Bill, Category, Payment, TrackerResponse } from '@/types';
import type {
Bill, Category, Payment, TrackerResponse, User,
BankLedger, BankLedgerTransaction, AutoCategorizePreview,
SpendingSummary, SpendingTransactionsResponse, SpendingIncomeResponse,
SpendingRule, CopyBudgetsResponse, CategoryGroup,
SubscriptionsResponse, Recommendation, SubscriptionTx,
SnowballProjection, SnowballSettings,
} from '@/types';
// Fetch CSRF token from the server once and cache in memory.
// The cookie is httpOnly so document.cookie cannot access it directly.
@ -101,9 +108,9 @@ function filenameFromDisposition(value: string | null): string | null {
export const api = {
// Auth
me: () => get('/auth/me'),
me: () => get<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'),
authMode: () => get('/auth/mode'),
login: (data: Body) => post('/auth/login', data),
login: (data: Body) => post<{ requires_totp?: boolean; challenge_token?: string; user?: User }>('/auth/login', data),
logout: () => post('/auth/logout'),
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
changePassword: (data: Body) => post('/auth/change-password', data),
@ -111,14 +118,14 @@ export const api = {
acknowledgeVersion: () => post('/auth/acknowledge-version'),
loginHistory: () => get('/auth/login-history'),
// Spending
spendingSummary: (p?: QueryParams) => get('/spending/summary', p),
spendingTransactions:(p?: QueryParams) => get('/spending/transactions', p),
spendingSummary: (p?: QueryParams) => get<SpendingSummary>('/spending/summary', p),
spendingTransactions:(p?: QueryParams) => get<SpendingTransactionsResponse>('/spending/transactions', p),
categorizeTransaction: (id: Id, d: Body) => patch(`/spending/transactions/${id}/category`, d),
spendingBudgets: (p?: QueryParams) => get('/spending/budgets', p),
setSpendingBudget: (d: Body) => put('/spending/budgets', d),
copySpendingBudgets: (d: Body) => post('/spending/budgets/copy', d),
spendingIncome: (p?: QueryParams) => get('/spending/income', p),
spendingCategoryRules: () => get('/spending/category-rules'),
copySpendingBudgets: (d: Body) => post<CopyBudgetsResponse>('/spending/budgets/copy', d),
spendingIncome: (p?: QueryParams) => get<SpendingIncomeResponse>('/spending/income', p),
spendingCategoryRules: () => get<{ rules?: SpendingRule[] }>('/spending/category-rules'),
addSpendingRule: (d: Body) => post('/spending/category-rules', d),
deleteSpendingRule: (id: Id) => del(`/spending/category-rules/${id}`),
@ -126,7 +133,7 @@ export const api = {
totpSetup: () => get('/auth/totp/setup'),
totpEnable: (data: Body) => post('/auth/totp/enable', data),
totpDisable: (data: Body) => post('/auth/totp/disable', data),
totpChallenge: (data: Body) => post('/auth/totp/challenge', data),
totpChallenge: (data: Body) => post<{ user?: User }>('/auth/totp/challenge', data),
webauthnStatus: () => get('/auth/webauthn/status'),
webauthnSetup: () => get('/auth/webauthn/setup'),
@ -137,7 +144,7 @@ export const api = {
webauthnChallenge: (data: Body) => post('/auth/webauthn/challenge', data),
// Admin
hasUsers: () => get('/admin/has-users'),
hasUsers: () => get<{ has_users?: boolean }>('/admin/has-users'),
adminUsers: () => get('/admin/users'),
createUser: (data: Body) => post('/admin/users', data),
resetPassword: (id: Id, data: Body) => put(`/admin/users/${id}/password`, data),
@ -280,19 +287,19 @@ export const api = {
deleteBillTemplate: (id: Id) => del(`/bills/templates/${id}`),
// Subscriptions
subscriptions: () => get('/subscriptions'),
subscriptions: () => get<SubscriptionsResponse>('/subscriptions'),
confirmTransactionMatch: (transactionId: Id, billId: Id) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }),
matchRecommendationToBill: (transactionIds: unknown, billId: Id, merchant: unknown, catalogId: unknown, confidence: unknown) => post('/subscriptions/recommendations/match-bill', {
matchRecommendationToBill: (transactionIds: unknown, billId: Id, merchant: unknown, catalogId: unknown, confidence: unknown) => post<{ matched_count?: number; bill_name?: string }>('/subscriptions/recommendations/match-bill', {
transaction_ids: transactionIds,
bill_id: billId,
merchant,
catalog_id: catalogId,
confidence,
}),
subscriptionRecommendations: () => get('/subscriptions/recommendations'),
subscriptionTransactionMatches: (params: QueryParams = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`),
subscriptionRecommendations: () => get<{ recommendations?: Recommendation[] }>('/subscriptions/recommendations'),
subscriptionTransactionMatches: (params: QueryParams = {}) => get<SubscriptionTx[] | { transactions?: SubscriptionTx[] }>(`/subscriptions/transaction-matches${queryString(params)}`),
updateSubscription: (id: Id, data: Body) => _fetch('PATCH', `/subscriptions/${id}`, data),
createSubscriptionFromRecommendation: (data: Body) => post('/subscriptions/recommendations/create', data),
createSubscriptionFromRecommendation: (data: Body) => post<{ id?: number; name?: string }>('/subscriptions/recommendations/create', data),
declineRecommendation: (recommendation: any) => post('/subscriptions/recommendations/decline', {
decline_key: recommendation?.decline_key || recommendation,
catalog_id: recommendation?.catalog_match?.id,
@ -317,11 +324,11 @@ export const api = {
undoAutoMatch: (id: Id) => post(`/payments/${id}/undo-auto`),
// Snowball
snowball: () => get('/snowball'),
snowballSettings: () => get('/snowball/settings'),
saveSnowballSettings: (data: Body) => _fetch('PATCH', '/snowball/settings', data),
snowball: () => get<Bill[]>('/snowball'),
snowballSettings: () => get<SnowballSettings>('/snowball/settings'),
saveSnowballSettings: (data: Body) => _fetch<SnowballSettings>('PATCH', '/snowball/settings', data),
saveSnowballOrder: (items: Body) => _fetch('PATCH', '/snowball/order', items),
snowballProjection: (params: QueryParams = {}) => get(`/snowball/projection${queryString(params)}`),
snowballProjection: (params: QueryParams = {}) => get<SnowballProjection>(`/snowball/projection${queryString(params)}`),
snowballPlans: () => get('/snowball/plans'),
snowballActivePlan: () => get('/snowball/plans/active'),
startSnowballPlan: (data: Body) => post('/snowball/plans', data),
@ -341,7 +348,7 @@ export const api = {
restoreCategory: (id: Id) => post(`/categories/${id}/restore`),
// Category groups
categoryGroups: () => get('/categories/groups'),
categoryGroups: () => get<CategoryGroup[]>('/categories/groups'),
createCategoryGroup: (data: Body) => post('/categories/groups', data),
updateCategoryGroup: (id: Id, data: Body) => put(`/categories/groups/${id}`, data),
deleteCategoryGroup: (id: Id) => del(`/categories/groups/${id}`),
@ -461,18 +468,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

@ -16,6 +16,7 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { api } from '@/api';
import { createPaymentOrConfirm } from '@/lib/paymentActions';
import { cn, todayStr, errMessage } from '@/lib/utils';
import DebtDetailsSection from '@/components/bill-modal/DebtDetailsSection';
import AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator';
@ -368,17 +369,22 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
notes: paymentNotes || null,
payment_source: 'manual',
};
if (editingPayment) {
await api.updatePayment(editingPayment.id, payload);
toast.success('Payment updated');
} else {
await api.createPayment({ ...payload, bill_id: bill?.id });
toast.success('Payment added');
}
const finishPaymentSave = async (successMessage: string) => {
toast.success(successMessage);
resetPaymentForm();
setPaymentFormOpen(false);
await loadPayments();
onSave?.();
};
if (editingPayment) {
await api.updatePayment(editingPayment.id, payload);
await finishPaymentSave('Payment updated');
} else {
await createPaymentOrConfirm(
{ ...payload, bill_id: bill?.id },
() => finishPaymentSave('Payment added'),
);
}
} catch (err) {
toast.error(errMessage(err, 'Payment could not be saved.'));
} finally {

View File

@ -1,6 +1,7 @@
import { useState, useRef } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
import { createPaymentOrConfirm } from '@/lib/paymentActions';
import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import type { Dollars } from '@/lib/money';
@ -47,15 +48,18 @@ export function EditableCell({ row, field, threshold, defaultPaymentDate, refres
if (field === 'amount') update.amount = parseFloat(val);
if (field === 'date') update.paid_date = val;
await api.updatePayment(row.payments[0]!.id, update);
toast.success('Saved');
refresh();
} else {
await api.createPayment({
await createPaymentOrConfirm(
{
bill_id: row.id,
amount: field === 'amount' ? parseFloat(val) : threshold,
paid_date: field === 'date' ? val : defaultPaymentDate,
});
},
() => { toast.success('Saved'); refresh(); },
);
}
toast.success('Saved');
refresh();
} catch (err) {
toast.error(errMessage(err));
}

View File

@ -1,7 +1,7 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { createPaymentOrConfirm } from '@/lib/paymentActions';
import { fmt, fmtDate, errMessage } from '@/lib/utils';
import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
import { Button } from '@/components/ui/button';
@ -53,16 +53,16 @@ export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymen
setBusy(true);
try {
await api.createPayment({
await createPaymentOrConfirm(
{
bill_id: row.id,
amount: parsedAmount,
paid_date: date,
method: method === METHOD_NONE ? null : method,
notes: notes || null,
});
toast.success('Partial payment added');
onSaved?.();
onClose?.();
},
() => { toast.success('Partial payment added'); onSaved?.(); onClose?.(); },
);
} catch (err) {
toast.error(errMessage(err, 'Failed to add payment'));
} finally {

View File

@ -1,16 +1,8 @@
import { createContext, useContext, useEffect, useState, type Dispatch, type SetStateAction, type ReactNode } from 'react';
import { api } from '@/api';
import type { User } from '@/types';
export interface User {
id: number;
username?: string;
role?: string;
display_name?: string;
displayName?: string;
name?: string;
is_default_admin?: boolean;
[key: string]: unknown;
}
export type { User }; // re-export so existing `@/hooks/useAuth` importers keep working
interface MeResponse {
user?: User | null;

View File

@ -234,7 +234,7 @@ export function useSubscriptions() {
export function useSubscriptionRecommendations() {
return useQuery({
queryKey: ['subscription-recommendations'],
queryFn: () => api.subscriptionRecommendations().then((r: any) => r.recommendations || []),
queryFn: () => api.subscriptionRecommendations().then(r => r.recommendations || []),
staleTime: 1000 * 60 * 5,
});
}

View File

@ -0,0 +1,43 @@
import { api, type ApiError } from '@/api';
import { toast } from 'sonner';
import { errMessage } from '@/lib/utils';
/**
* Create a manual payment, treating the server's "duplicate suspected" (409) as a
* question rather than an error. The deliberate manual-add path must never
* *silently* drop a legitimately-identical second payment (same bill, date,
* amount) losing a real payment is itself a money-integrity bug. So the server
* returns `409 DUPLICATE_SUSPECTED` instead of silently deduping, and here we
* surface an "add anyway?" toast; confirming resends with `allow_duplicate`.
*
* `onSuccess` runs after any *real* creation immediate OR confirmed-retry so
* callers put their toast/refresh/close there. Any non-duplicate error re-throws
* for the caller's existing `catch`.
*/
export async function createPaymentOrConfirm(
payload: Record<string, unknown>,
onSuccess: () => void | Promise<void>,
): Promise<void> {
const create = async (allowDuplicate: boolean) => {
await api.createPayment(allowDuplicate ? { ...payload, allow_duplicate: true } : payload);
await onSuccess();
};
try {
await create(false);
} catch (err) {
const e = err as ApiError;
if (e?.status === 409 && e?.code === 'DUPLICATE_SUSPECTED') {
toast.warning('A matching payment already exists for this bill, date, and amount.', {
description: 'Add it anyway?',
action: {
label: 'Add anyway',
onClick: () => {
create(true).catch(retryErr => toast.error(errMessage(retryErr, 'Failed to add payment')));
},
},
});
return; // handled — not an error the caller should re-toast
}
throw err;
}
}

View File

@ -26,7 +26,7 @@ export default function AdminPage() {
const loadMe = useCallback(async () => {
try {
const d = await api.me() as { user?: User };
const d = await api.me();
setMe(d.user ?? null);
} catch {
navigate('/login', { replace: true });
@ -42,7 +42,7 @@ export default function AdminPage() {
const loadHasUsers = useCallback(async () => {
try {
const d = await api.hasUsers() as { has_users?: boolean };
const d = await api.hasUsers();
setHasUsers(!!d.has_users);
if (d.has_users) loadUsers();
} catch (err) {

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

@ -86,7 +86,7 @@ export default function LoginPage() {
setError('');
setLoading(true);
try {
const data = await api.login({ username, password }) as { requires_totp?: boolean; challenge_token?: string; user: User };
const data = await api.login({ username, password });
if (data.requires_totp) {
setTotpChallenge(data.challenge_token ?? null);
setTotpCode('');
@ -94,7 +94,7 @@ export default function LoginPage() {
setUseRecovery(false);
return;
}
handlePostLogin(data.user);
handlePostLogin(data.user!);
} catch (err) {
setError(errMessage(err, 'Login failed.'));
} finally {
@ -110,8 +110,8 @@ export default function LoginPage() {
const payload: { challenge_token: string | null; recovery_code?: string; code?: string } = { challenge_token: totpChallenge };
if (useRecovery) payload.recovery_code = totpCode;
else payload.code = totpCode;
const data = await api.totpChallenge(payload) as { user: User };
handlePostLogin(data.user);
const data = await api.totpChallenge(payload);
handlePostLogin(data.user!);
} catch (err) {
setTotpError(errMessage(err, 'Invalid code.'));
setTotpCode('');

View File

@ -19,7 +19,13 @@ import PlanStatusBanner, { type ActivePlan } from '@/components/snowball/PlanSta
import PlanHistoryPanel, { type SnowballPlan } from '@/components/snowball/PlanHistoryPanel';
import * as AlertDialog from '@radix-ui/react-alert-dialog';
import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money';
import type { Bill, Category } from '@/types';
import type {
Bill, Category,
SnowballProjection as Projection,
SnowballProjectionDetail as SnowballProj,
AvalancheProjectionDetail as AvalancheProj,
SnowballSettings,
} from '@/types';
// ── formatters ────────────────────────────────────────────────────────────────
function fmt(val: number | null | undefined): string {
@ -51,38 +57,6 @@ function isRamseyOrdered(debts: Bill[]): boolean {
return debts.every((debt, index) => debt.id === sorted[index]?.id);
}
interface DebtProj {
id: number;
name: string;
payoff_display?: string;
months?: number;
total_interest?: number;
current_balance?: number;
}
interface SnowballProj {
debts: DebtProj[];
skipped: { name: string }[];
payoff_display?: string;
months_to_freedom?: number;
total_interest_paid?: number;
capped?: boolean;
}
interface AvalancheProj {
months_to_freedom?: number;
total_interest_paid?: number;
payoff_display?: string;
}
interface Projection {
snowball?: SnowballProj;
avalanche?: AvalancheProj;
}
interface SnowballSettings {
extra_payment?: number;
ramsey_mode?: boolean;
ready_current_on_bills?: boolean;
ready_emergency_fund?: boolean;
}
interface ReadinessItem {
id: string;
@ -347,11 +321,11 @@ function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, dis
export default function SnowballPage() {
const queryClient = useQueryClient();
const { data: billsRaw = [], isPending: billsPending, error: snowballError } = useSnowball();
const bills = (billsRaw ?? []) as Bill[];
const bills = billsRaw ?? [];
const { data: categoriesRaw = [] } = useCategories();
const categories = (categoriesRaw ?? []) as Category[];
const { data: settingsRaw } = useSnowballSettings();
const settings = settingsRaw as SnowballSettings | undefined;
const settings = settingsRaw;
const { data: activePlanRaw = null } = useSnowballActivePlan();
const activePlan = (activePlanRaw ?? null) as SnowballPlan | null;
const { data: allPlansRaw = [] } = useSnowballPlans();
@ -395,7 +369,7 @@ export default function SnowballPage() {
try {
const extra = parseFloat(typedExtraRef.current);
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
setProjection(await api.snowballProjection(params) as Projection);
setProjection(await api.snowballProjection(params));
setProjectionError(false);
} catch {
setProjectionError(true);
@ -491,7 +465,7 @@ export default function SnowballPage() {
if (val === extraPaymentRef.current) return;
setSavingSettings(true);
try {
const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) }) as SnowballSettings;
const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) });
const saved = Number(result.extra_payment) > 0 ? String(result.extra_payment) : '';
extraPaymentRef.current = saved;
setExtraPayment(saved);
@ -504,7 +478,7 @@ export default function SnowballPage() {
const handleRamseyModeChange = async (checked: boolean) => {
setSavingSettings(true);
try {
const result = await api.saveSnowballSettings({ ramsey_mode: checked }) as SnowballSettings;
const result = await api.saveSnowballSettings({ ramsey_mode: checked });
const nextMode = result.ramsey_mode !== false;
setRamseyMode(nextMode);
setDirty(false);
@ -530,7 +504,7 @@ export default function SnowballPage() {
setSavingSettings(true);
try {
const result = await api.saveSnowballSettings(payload) as SnowballSettings;
const result = await api.saveSnowballSettings(payload);
setReadyCurrentOnBills(!!result.ready_current_on_bills);
setReadyEmergencyFund(!!result.ready_emergency_fund);
} catch (err) {
@ -583,7 +557,7 @@ export default function SnowballPage() {
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
try {
setProjectionLoading(true);
const result = await api.snowballProjection(params) as Projection;
const result = await api.snowballProjection(params);
setProjection(result);
setProjectionError(false);
} catch {

View File

@ -20,7 +20,16 @@ import {
import { CategoryPicker } from '@/components/transactions/CategoryPicker';
import { formatUSD, asDollars } from '@/lib/money';
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'];
@ -35,43 +44,8 @@ function settingEnabled(value: unknown, fallback = true): boolean {
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 };
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>;
// 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) => {
setLoading(true);
try {
const d = await api.spendingIncome({ year, month, page: p }) as IncomeResponse;
const d = await api.spendingIncome({ year, month, page: p });
setRows(d.transactions || []);
setPages(d.pages || 1);
setPage(p);
@ -315,7 +289,7 @@ function RulesManager({ categories }: { categories: Category[] }) {
const load = useCallback(async () => {
setLoading(true);
try { setRules(((await api.spendingCategoryRules()) as { rules?: SpendingRule[] }).rules || []); }
try { setRules((await api.spendingCategoryRules()).rules || []); }
catch { toast.error('Failed to load rules'); }
finally { setLoading(false); }
}, []);
@ -693,15 +667,15 @@ export default function SpendingPage() {
const [txPage, setTxPage] = useState(1);
const [activeCat, setActiveCat] = useState<number | null | undefined>(undefined); // undefined = all
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 txData = txDataRaw as SpendingTxResponse | undefined;
const txData = txDataRaw;
const transactions = useMemo(() => txData?.transactions || [], [txData]);
const txTotal = txData?.total || 0;
const txPages = txData?.pages || 1;
const { data: categories = [], error: catErrObj } = useSpendingCategories();
const { data: categoryGroupsRaw = [] } = useCategoryGroups();
const categoryGroups = categoryGroupsRaw as CategoryGroup[];
const categoryGroups = categoryGroupsRaw;
const summaryError = summaryErrObj ? (summaryErrObj.message || 'Failed to load spending summary') : null;
const txError = txErrObj ? (txErrObj.message || 'Failed to load transactions') : null;
const catError = catErrObj ? (catErrObj.message || 'Failed to load categories') : null;
@ -769,7 +743,7 @@ export default function SpendingPage() {
const handleCopyBudgets = async () => {
setCopying(true);
try {
const d = await api.copySpendingBudgets({ year, month }) as CopyBudgetsResponse;
const d = await api.copySpendingBudgets({ year, month });
if (d.copied === 0) {
toast.info('No budgets found in the previous month to copy.');
} else {

View File

@ -53,7 +53,15 @@ import { getLinkImportPref } from '@/pages/SettingsPage';
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import { useVirtualizer } from '@tanstack/react-virtual';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
import type { Bill, Category } from '@/types';
import type {
Bill, Category,
Subscription,
SubscriptionsResponse as SubData,
SubscriptionSummary as SubSummary,
ExistingBillMatch,
Recommendation,
SubscriptionTx as SubTx,
} from '@/types';
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
function fmt(v: number | null | undefined): string {
@ -93,102 +101,6 @@ interface CadenceLike {
[key: string]: unknown;
}
interface Subscription {
id: number;
name: string;
active?: boolean | number;
is_subscription?: boolean | number;
category_id?: number | null;
category_name?: string | null;
expected_amount?: number | null;
monthly_equivalent?: number | null;
yearly_equivalent?: number | null;
next_due_date?: string | null;
subscription_type?: string;
due_day?: number | null;
cycle_type?: string | null;
billing_cycle?: string | null;
cycle_day?: number | string | null;
autopay_enabled?: unknown;
has_2fa?: unknown;
has_merchant_rule?: unknown;
has_linked_transactions?: unknown;
reminder_days_before?: number | null;
[key: string]: unknown;
}
interface TopType { type: string; monthly_total: number }
interface SubSummary {
active_count?: number;
paused_count?: number;
monthly_total?: number;
yearly_total?: number;
top_type?: TopType | null;
}
interface SubData { summary?: SubSummary; subscriptions?: Subscription[] }
interface CatalogMatch {
id?: number | string;
name: string;
subscription_type?: string;
website?: string;
starting_monthly_usd?: number | null;
starting_annual_usd?: number | null;
}
interface ExistingBillMatch {
id: number;
name: string;
expected_amount?: number | null;
due_day?: number | null;
reasons?: string[];
}
interface RecEvidence {
identity?: { label?: string };
amount?: { label?: string; match?: string };
cadence?: { label?: string; recurring?: boolean };
amount_range?: { min: number; max: number };
ambiguity?: { ambiguous?: boolean; label?: string; reasons?: string[] };
}
interface RecTransaction { id: number; payee?: string; description?: string; memo?: string; date?: string; account?: string; amount?: number }
interface Recommendation {
id: number | string;
name: string;
subscription_type?: string;
occurrence_count?: number;
last_seen_date?: string;
expected_amount?: number | null;
monthly_equivalent?: number | null;
confidence?: number;
cycle_type?: string;
merchant?: string;
decline_key?: string;
transaction_ids?: (number | string)[];
accounts?: string[];
reasons?: string[];
evidence?: RecEvidence;
existing_bill_match?: ExistingBillMatch | null;
catalog_match?: CatalogMatch | null;
transactions?: RecTransaction[];
[key: string]: unknown;
}
interface SubTx {
id: number;
amount: number;
payee?: string;
description?: string;
memo?: string;
account_name?: string;
data_source_name?: string;
account?: string;
match_status?: string;
matched_bill_name?: string;
catalog_match?: CatalogMatch | null;
posted_date?: string;
date?: string;
[key: string]: unknown;
}
interface SubDraft {
name?: string;
category_id?: number | null;
@ -1200,9 +1112,9 @@ type FlatItem =
export default function SubscriptionsPage() {
const queryClient = useQueryClient();
const { data: subData, isPending: loading } = useSubscriptions();
const data: SubData = (subData as SubData | undefined) || { summary: {}, subscriptions: [] };
const data: SubData = subData || { summary: {}, subscriptions: [] };
const { data: recommendationsRaw = [], isPending: recommendationsLoading } = useSubscriptionRecommendations();
const recommendations = recommendationsRaw as Recommendation[];
const recommendations = recommendationsRaw;
const { data: categories = [] } = useCategories();
const { data: bills = [] } = useBills();
// Optimistic updates write through the query cache so every existing call site
@ -1270,7 +1182,7 @@ export default function SubscriptionsPage() {
txDebounce.current = setTimeout(async () => {
setTxSearching(true);
try {
const result = await api.subscriptionTransactionMatches({ q, limit: 50 }) as SubTx[] | { transactions?: SubTx[] };
const result = await api.subscriptionTransactionMatches({ q, limit: 50 });
setTxResults(Array.isArray(result) ? result : (result?.transactions ?? []));
} catch (err) {
setTxResults([]);
@ -1300,7 +1212,7 @@ export default function SubscriptionsPage() {
async function acceptRecommendation(recommendation: Recommendation) {
setBusyId(`rec-${recommendation.id}`);
try {
const created = await api.createSubscriptionFromRecommendation(recommendation) as { id?: number; name?: string } | null;
const created = await api.createSubscriptionFromRecommendation(recommendation);
toast.success(`${recommendation.name} is now tracked.`);
await refreshAll();
if (getLinkImportPref() && recommendation.merchant && created?.id) {
@ -1336,7 +1248,7 @@ export default function SubscriptionsPage() {
recommendation.merchant,
recommendation.catalog_match?.id,
recommendation.confidence,
) as { matched_count?: number; bill_name?: string };
);
toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`);
setMatchTarget(null);
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));

View File

@ -10,6 +10,19 @@
// sparkline) are left `unknown` and refined when a consumer needs them.
import type { Cents, Dollars } from '@/lib/money';
// The authenticated user (GET /auth/me → `user`). Re-exported from
// `@/hooks/useAuth` for the many components that import it from there.
export interface User {
id: number;
username?: string;
role?: string;
display_name?: string;
displayName?: string;
name?: string;
is_default_admin?: boolean;
[key: string]: unknown;
}
// A bank transaction as serialized by the server. Unlike bill/payment amounts,
// raw bank-transaction amounts stay in CENTS on the wire — hence branded `Cents`
// (format them with formatCentsUSD, never formatUSD).
@ -28,6 +41,233 @@ 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 }[];
}
// 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 }[] }
// The subscriptions page (GET /subscriptions, /subscriptions/recommendations,
// /subscriptions/transaction-matches). A Subscription is a decorated Bill;
// amounts here are dollars kept as plain `number` (the page uses a local
// formatter), except SubTx.amount which is a raw bank amount in CENTS.
export interface Subscription {
id: number;
name: string;
active?: boolean | number;
is_subscription?: boolean | number;
category_id?: number | null;
category_name?: string | null;
expected_amount?: number | null;
monthly_equivalent?: number | null;
yearly_equivalent?: number | null;
next_due_date?: string | null;
subscription_type?: string;
due_day?: number | null;
cycle_type?: string | null;
billing_cycle?: string | null;
cycle_day?: number | string | null;
autopay_enabled?: unknown;
has_2fa?: unknown;
has_merchant_rule?: unknown;
has_linked_transactions?: unknown;
reminder_days_before?: number | null;
[key: string]: unknown;
}
export interface SubscriptionTopType { type: string; monthly_total: number }
export interface SubscriptionSummary {
active_count?: number;
paused_count?: number;
monthly_total?: number;
yearly_total?: number;
top_type?: SubscriptionTopType | null;
}
export interface SubscriptionsResponse { summary?: SubscriptionSummary; subscriptions?: Subscription[] }
export interface CatalogMatch {
id?: number | string;
name: string;
subscription_type?: string;
website?: string;
starting_monthly_usd?: number | null;
starting_annual_usd?: number | null;
}
export interface ExistingBillMatch {
id: number;
name: string;
expected_amount?: number | null;
due_day?: number | null;
reasons?: string[];
}
export interface RecommendationEvidence {
identity?: { label?: string };
amount?: { label?: string; match?: string };
cadence?: { label?: string; recurring?: boolean };
amount_range?: { min: number; max: number };
ambiguity?: { ambiguous?: boolean; label?: string; reasons?: string[] };
}
export interface RecommendationTransaction { id: number; payee?: string; description?: string; memo?: string; date?: string; account?: string; amount?: number }
export interface Recommendation {
id: number | string;
name: string;
subscription_type?: string;
occurrence_count?: number;
last_seen_date?: string;
expected_amount?: number | null;
monthly_equivalent?: number | null;
confidence?: number;
cycle_type?: string;
merchant?: string;
decline_key?: string;
transaction_ids?: (number | string)[];
accounts?: string[];
reasons?: string[];
evidence?: RecommendationEvidence;
existing_bill_match?: ExistingBillMatch | null;
catalog_match?: CatalogMatch | null;
transactions?: RecommendationTransaction[];
[key: string]: unknown;
}
// The snowball projection (GET /snowball/projection) and settings
// (PATCH /snowball/settings). Amounts are dollars kept as plain `number`.
export interface SnowballDebtProjection {
id: number;
name: string;
payoff_display?: string;
months?: number;
total_interest?: number;
current_balance?: number;
}
export interface SnowballProjectionDetail {
debts: SnowballDebtProjection[];
skipped: { name: string }[];
payoff_display?: string;
months_to_freedom?: number;
total_interest_paid?: number;
capped?: boolean;
}
export interface AvalancheProjectionDetail {
months_to_freedom?: number;
total_interest_paid?: number;
payoff_display?: string;
}
export interface SnowballProjection {
snowball?: SnowballProjectionDetail;
avalanche?: AvalancheProjectionDetail;
}
export interface SnowballSettings {
extra_payment?: number;
ramsey_mode?: boolean;
ready_current_on_bills?: boolean;
ready_emergency_fund?: boolean;
}
// A raw bank transaction on the subscription-match search — amount in CENTS.
export interface SubscriptionTx {
id: number;
amount: number;
payee?: string;
description?: string;
memo?: string;
account_name?: string;
data_source_name?: string;
account?: string;
match_status?: string;
matched_bill_name?: string;
catalog_match?: CatalogMatch | null;
posted_date?: string;
date?: string;
[key: string]: unknown;
}
// A bill's month status. Mirrors the server's statusService.
export type TrackerStatus =
| 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';

View File

@ -2,7 +2,7 @@
**Status:** Current code reference
**Last Updated:** 2026-07-05
**Version:** 0.40.0
**Version:** 0.40.0 (package version; the in-flight Track A/B/C/D commits target **v0.41.0** — see HISTORY.md and the `Last Updated` note below)
**Primary stack:** Node.js 22 + Express 4 (CommonJS), React 19 + Vite 5 (TypeScript, `strict` + `noUncheckedIndexedAccess`), Tailwind CSS 3 + shadcn/ui, TanStack Query 5, Sonner, SQLite via `better-sqlite3` 12, `@simplewebauthn/*` 13, `otplib` 13, `openid-client` 5
This manual reflects the current application code in `server.js`, `routes/`, `services/`, `middleware/`, `db/`, `client/`, `package.json`, `Dockerfile`, `docker-compose.yml`, `tsconfig.json`, `eslint.config.mjs`, and `vite.config.mjs`. It is written as a current-state reference, not a changelog. The previous version of this manual (v0.28.1) is fully superseded.
@ -16,6 +16,13 @@ Notable changes since the previous manual:
- **New features:** SimpleFIN bank sync, WebAuthn + TOTP 2FA, calendar feed (`/api/calendar/feed.ics`), subscription catalog + recommendations, spending categories / budgets, snowball plans (lifecycle), push notifications (ntfy / Gotify / Discord / Telegram), push notification column on users, encrypted secrets at rest with HKDF-derived key, money-cents migration (in progress), category groups, banker-rule auto-attribute.
- **Schema:** migrations `v0.2``v1.06`. Rollback map covers `v0.44``v1.04`.
Since the v0.40.0 manual shipped, **8 in-flight commits** targeting v0.41.0 have been added locally and are documented in this manual ahead of push:
- **Track A (money integrity):** every payment balance-mutation now runs in a single transaction. `POST /payments` returns `409 { code: 'DUPLICATE_SUSPECTED' }` instead of silently deduping when a live payment matches `(bill_id, paid_date, amount)`; the client surfaces a sonner "Add anyway?" toast via `client/lib/paymentActions.ts → createPaymentOrConfirm` and resends with `allow_duplicate: true` to confirm.
- **Track B (tests):** `tests/paymentsRoute.test.js` (route + transactional behavior) and `tests/reconciliation.test.js` (cross-surface reconciliation harness).
- **Track C (API response typing):** high-traffic domains (`auth`, `snowball`, `subscriptions`, `spending`, `bank-ledger`) promoted page-local shapes into `client/types.ts` and wired through `get<T>` / `post<T>`. `User` moved from `@/hooks/useAuth` to `@/types` (re-exported for 7 importers). The single-consumer non-money casts (`adminUsers` and the one-off `version` / `about` / `privacy` / `health` / `importHistory` doc shapes) are deliberately left as-is.
- **Track D (server error-shape consistency):** the global 500 handler in `server.js` now emits `{ error, code: 'INTERNAL_ERROR' }` so an unexpected server error carries the same `{error, code}` field the client can switch on as every structured 4xx.
---
## 1. System Overview
@ -403,8 +410,9 @@ Response conventions:
- Unauthenticated: `401`.
- Forbidden: `403`.
- Missing resource: `404`.
- Conflict: `409`.
- Conflict: `409` (also used for `DUPLICATE_SUSPECTED` on `POST /payments` — see §5.5).
- Rate-limited: `429`.
- **Server error (Track D):** the global 500 handler in `server.js` emits `{ error: 'Internal server error', code: 'INTERNAL_ERROR' }` so an unexpected server error carries the same `{error, code}` field the client can switch on as every structured 4xx.
### 5.1 Auth (`/api/auth`)
@ -467,6 +475,12 @@ User/admin tracker access. Soft delete via `deleted_at`. `payment_source` ∈ `{
Endpoints: `GET /payments?bill_id=&year=&month=`, `GET /payments/:id`, `POST /payments`, `POST /payments/quick`, `POST /payments/bulk` (max 50), `PUT /payments/:id`, `DELETE /payments/:id`, `POST /payments/:id/restore`.
**Track A money-integrity notes:**
- `POST /payments` body now accepts an optional `allow_duplicate: true` flag. When omitted and a live payment already matches the same `(bill_id, paid_date, amount)`, the route returns `409 { error, code: 'DUPLICATE_SUSPECTED' }` instead of silently deduping — silently dropping a legitimately-identical second payment would be a money-integrity bug. The client uses `client/lib/paymentActions.ts → createPaymentOrConfirm` to surface a sonner "Add anyway?" toast; confirming resends with `allow_duplicate: true`.
- Every balance-mutation runs in a single transaction so a partial failure cannot leave `bills.current_balance` half-updated relative to the new payment.
- New tests: `tests/paymentsRoute.test.js` (route + transactional behavior) and `tests/reconciliation.test.js` (cross-surface reconciliation harness).
### 5.6 Categories (`/api/categories`)
User/admin tracker access. Categories have a `spending_enabled` flag (`v0.88`) and a `sort_order` (`v0.75`). Categories can be grouped via `category_groups` (`v1.06`).
@ -1293,27 +1307,38 @@ Routes (all rendered through `Routes` with `ErrorBoundary`, lazy + `Suspense` fo
- Reads CSRF token from the `bt_csrf_token` cookie **or** fetches once from `/api/auth/csrf-token` and caches the token in memory; sends `x-csrf-token` on `POST`/`PUT`/`PATCH`/`DELETE`.
- Non-OK responses throw an `Error` whose `status`/`data`/`details`/`code` match the server's structured `errorFormatter`.
- `ApiError` interface carries those fields; `TogglePaidResult` and `PaymentRecord` provide typed payment responses; `QueryParams` types the query-string builder.
- Exposes grouped functions for `auth`, `admin`, `notifications`, `profile`, `tracker`, `calendar`, `summary`, `bills`, `payments`, `categories`, `settings`, `analytics`, `status`, `version`/`about`, `import`, `export`, `data-sources`, `transactions`, `matches`, `snowball`, `subscriptions`, `spending`, `monthlyStartingAmounts`, `csvImport`, `matchSuggestions`.
- Exposes grouped functions for `auth`, `admin`, `notifications`, `profile`, `tracker`, `calendar`, `summary`, `bills`, `payments`, `categories`, `settings`, `analytics`, `status`, `version`/`about`, `import`, `export`, `data-sources`, `transactions`, `matches`, `snowball`, `subscriptions`, `spending`, `monthlyStartingAmounts`, `csvImport`, `matchSuggestions`, `bankTransactionsLedger`, `matchTransaction`, `unmatchTransaction`, `ignoreTransaction`, `unignoreTransaction`, `applyTransactionMerchantMatch`, `autoCategorizeTransactions`, `categoryGroups`, `copySpendingBudgets`, `spendingIncome`, `spendingCategoryRules`, `createPaymentOrConfirm` (via `client/lib/paymentActions.ts`).
- File download/upload endpoints use raw `fetch` because responses/bodies are blobs or octet streams.
- **All 16 import sites** that referenced `@/api.js` were normalized to `@/api` so Vite/TS resolve the `.ts`.
- **Track C response typing** — see §7 *Domain types* for the full list. In short, the high-traffic domains (`auth`, `bills`, `payments`, `categories`, `tracker`, `snowball`, `subscriptions`, `spending`, `bank-ledger`) all return typed responses via `get<T>` / `post<T>`; the remaining single-consumer non-money casts (adminUsers, version/about/privacy/health/importHistory) are deliberately left as-is.
- **Server error-shape consistency (Track D).** The global 500 handler in `server.js` emits `{ error, code: 'INTERNAL_ERROR' }` so an unexpected server error carries the same `{error, code}` field the client can switch on as every structured 4xx. See §5 *Response conventions*.
### Domain types — `client/types.ts`
- Branded `Dollars` and `Cents` money types (from `client/lib/money.ts`).
- `User` (moved from `@/hooks/useAuth` in Track C; re-exported from `useAuth` for the 7 importers).
- `Bill`, `Payment`, `Category`, `TrackerResponse` (with `summary` / `bank_tracking` / `cashflow` / `rows`).
- `DriftBill`, `AutopaySuggestion`, `AmountSuggestion`, `AutopayStats`.
- `BankTransaction` (integer cents on the wire).
- `BankLedgerTransaction extends BankTransaction` (Track C: bank-ledger domain types) — preserves the `Cents` brand.
- `BankAccount`, `BankLedger`, `BankLedgerSummary`, `BankCategoryBreakdown`, `AutoCategorizePreview`, `AutoCategorizeChange`.
- `SnowballProjection` (Track C) + `SnowballProjectionDetail` / `AvalancheProjectionDetail` / `SnowballDebtProjection`.
- `SnowballSettings` (Track C).
- `Subscription`, `SubscriptionTopType`, `SubscriptionSummary`, `SubscriptionsResponse`, `CatalogMatch`, `ExistingBillMatch`, `Recommendation` (Track C: subscriptions domain types) + `RecommendationEvidence` / `RecommendationTransaction` / `SubscriptionTx`.
- `SpendingCategoryEntry`, `SpendingSummary`, `SpendingTransaction`, `SpendingTransactionsResponse`, `SpendingIncomeTx`, `SpendingIncomeResponse`, `SpendingRule`, `CategoryGroup`, `CopyBudgetsResponse` (Track C: spending domain types).
- `TimelineBill` for the Safe-to-Spend SVG timeline.
- Money fields are typed as `Dollars` for bill/payment/tracker/summary amounts and as `Cents` for raw bank transactions — so the unit mixup bug class is unrepresentable in typed code.
- A `money.type-test.ts` never-imported guard uses `@ts-expect-error` to assert each unit mixup is a real compile error, so `tsc --noEmit` fails loudly if the branding ever regresses.
- **Single-consumer non-money casts left in place** (deliberate Track C stop): `adminUsers` with its page-local `AdminUser`, and the one-off `version` / `about` / `privacy` / `health` / `importHistory` doc shapes. Central types add ~nothing for a single reader; typing them is the low-value tail.
### Auth state
`client/hooks/useAuth.tsx`:
- `AuthContext` typed (a `createContext(null)` would have made `useContext` return `never`).
- `User` interface lives in `client/types.ts` (moved there in Track C; `useAuth` re-exports it for the 7 importers that previously imported it from `@/hooks/useAuth`).
- Maintains `user`, `singleUserMode`, `loading`.
- Calls `api.authMode()` and `api.me()` on startup.
- Calls `api.authMode()` and `api.me()` on startup; `api.me()` is now `get<User>` (typed).
- Exposes `logout()`, `logoutAll()`, `refresh()`.
- `singleUserMode` is sourced from `/auth/mode`; `RequireAuth` consults it.
@ -1373,7 +1398,7 @@ Routes (all rendered through `Routes` with `ErrorBoundary`, lazy + `Suspense` fo
### `client/lib/`
- `money.ts` — branded `Cents`/`Dollars` types, `asCents`/`asDollars`/`centsToDollars`/`dollarsToCents`, formatters. A `formatUSD(dollars)` and `formatCentsUSD(cents)` split mirrors the server's `formatUSD`/`formatCentsUSD` split. Inputs are coerced defensively so null / `''` / undefined / NaN never render as `$NaN`.
- `utils.ts``cn`, `fmt`, date/format helpers. `fmt` inherits `formatUSD`'s branded-dollars input.
- `utils.ts``cn`, `fmt`, date/format helpers, `errMessage(unknown, fallback)` for narrowing `unknown` caught errors into strings. `fmt` inherits `formatUSD`'s branded-dollars input.
- `trackerUtils.ts` — row/status/sort helpers, `TrackerRow` domain interface, `TrackerStatus` union.
- `billDrafts.ts``SourceBill` / `Template` / `Category` shapes for `makeBillDraft`.
- `billingSchedule.ts``Schedule` union and helpers.
@ -1381,6 +1406,7 @@ Routes (all rendered through `Routes` with `ErrorBoundary`, lazy + `Suspense` fo
- `trackerTableColumns.ts` — typed column descriptors.
- `reorder.ts` — generic `moveInArray<T>`.
- `version.ts` — typed release-notes shapes; the Vite-injected `__APP_VERSION__` is declared inline.
- `paymentActions.ts``createPaymentOrConfirm(payload, onSuccess)` (Track A). The deliberate manual-add path treats the server's `409 DUPLICATE_SUSPECTED` as a question rather than an error: losing a legitimately-identical second payment (same bill, date, amount) is itself a money-integrity bug, so the server returns 409 instead of silently deduping and the helper surfaces a sonner "Add anyway?" toast whose confirm action resends with `allow_duplicate: true`. `onSuccess` runs after any real creation (immediate or confirmed retry). Non-duplicate errors re-throw for the caller's existing `catch`.
### Frontend build & lint
@ -1688,4 +1714,17 @@ End-to-end checks run against the codebase:
- `npm run typecheck` was green at the time of the v0.41.0 entry in `HISTORY.md` (0 errors); 48 client unit tests pass; 17/17 e2e probe (every page renders, all API paths respond).
- `node --check` on every server JS file under `server.js`, `db`, `middleware`, `routes`, `services`, `utils` (`npm run check:server`).
Track A/B/C/D additions (targeting v0.41.0, un-pushed as of this update):
- `6084896` (Track A) — `fix(money): make every payment balance-mutation atomic`
- `dd5bf92` (Track A) — `fix(money): manual payment path flags suspected dupes (409) w/o losing legit ones`
- `c223f62` (Track B) — `test(money): cross-surface reconciliation harness`
- `b267599` (Track C) — `refactor(ts): type the bank-ledger API responses`
- `8265b4a` (Track C) — `refactor(ts): type the spending API responses`
- `6bb8c63` (Track C) — `refactor(ts): type the subscriptions API responses`
- `65a477c` (Track C) — `refactor(ts): type snowball projection + settings responses`
- `1df4b1b` (Track C/D) — `refactor(ts): move User to @/types + type auth endpoints; add code to 500`
Diff stats across the 8 commits: 19 files changed, 813 insertions(+), 415 deletions(-). Notable: `client/types.ts` grew by 240 lines; new file `client/lib/paymentActions.ts` (~40 lines, `createPaymentOrConfirm`); `client/api.ts` re-exports 9 new typed methods; `routes/payments.js` grew by ~150 lines for the transactional + duplicate-detection logic; `server.js` gained a single line (`code: 'INTERNAL_ERROR'`).
The previous manual (v0.28.1) contained stale route/page descriptions and missed major features added between v0.28 and v0.40 (subscriptions, spending, snowball plans, SimpleFIN, WebAuthn, TOTP, push notifications, category groups, calendar feed, money-cents migration in progress, React 18 → 19 + TypeScript migration, branded money types). This version replaces that with a current-state engineering reference.

View File

@ -190,7 +190,7 @@ router.post('/:id/undo-auto', (req, res) => {
// POST /api/payments — create single payment
router.post('/', (req, res) => {
const db = getDb();
const { bill_id, amount, paid_date, method, notes, payment_source, autopay_failure } = req.body;
const { bill_id, amount, paid_date, method, notes, payment_source, autopay_failure, allow_duplicate } = req.body;
const validation = validatePaymentInput({ bill_id, amount, paid_date, payment_source: payment_source ?? 'manual' });
if (validation.error) {
@ -202,15 +202,41 @@ router.post('/', (req, res) => {
if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
// The deliberate manual add must not *silently* drop a same-key payment — a user
// may legitimately record two identical payments on the same day, and dropping
// one is itself a money-integrity bug. Surface a 409 the client turns into an
// "add anyway?" confirm; a resend with allow_duplicate bypasses this. (The
// automated one-click paths — /quick, /bulk, autopay-confirm — dedupe silently
// instead, because a repeat there is a retry, not a second intent.)
if (!allow_duplicate) {
const existingDuplicate = db.prepare(
`SELECT p.* FROM payments p
WHERE p.bill_id = ? AND p.paid_date = ? AND p.amount = ? AND p.${SQL_NOT_DELETED}
ORDER BY p.id DESC LIMIT 1`
).get(bill.id, payment.paid_date, payment.amount);
if (existingDuplicate) {
return res.status(409).json({
...standardizeError('A matching payment already exists for this bill, date, and amount', 'DUPLICATE_SUSPECTED', 'amount'),
existing: serializePayment(existingDuplicate),
});
}
}
const balCalc = computeBalanceDelta(bill, payment.amount);
const failureFlag = autopay_failure ? 1 : 0;
const result = db.prepare(
// Atomic: the INSERT and the balance update land together or neither, so a
// mid-way failure never leaves a payment without its balance adjustment.
const insertManualPayment = db.transaction(() => {
const r = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source, autopay_failure) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment.payment_source, failureFlag);
applyBalanceDelta(db, bill.id, balCalc);
return r.lastInsertRowid;
});
const paymentId = insertManualPayment();
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)));
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(paymentId)));
});
// POST /api/payments/quick — pay a bill (expected amount, today)
@ -315,7 +341,11 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
}
const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);
const result = db.prepare(`
// Atomic: the payment INSERT, its balance update, and clearing the dismissal
// must all land or none, so a mid-way failure can't leave a half-recorded pay.
const confirmAutopay = db.transaction(() => {
const r = db.prepare(`
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
@ -328,12 +358,14 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
balCalc?.interest_delta ?? null,
'manual',
);
applyBalanceDelta(db, bill.id, balCalc);
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
.run(req.user.id, bill.id, ym.year, ym.month);
return r.lastInsertRowid;
});
const paymentId = confirmAutopay();
res.status(201).json({ created: true, payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)) });
res.status(201).json({ created: true, payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(paymentId)) });
});
// POST /api/payments/autopay-suggestions/:billId/dismiss
@ -467,13 +499,18 @@ router.put('/:id', (req, res) => {
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(existing.bill_id, req.user.id);
let nextInterestDelta = existing.interest_delta ?? null;
// Compute the balance mutation without writing yet — the write happens inside
// the transaction below so it lands atomically with the payment UPDATE.
let balCalc = null;
let paymentPortion = null;
let restoredBalance = null;
if (bill) {
// Reverse only the *payment* portion of the stored delta (not the interest component)
// so that interest already charged this month is not double-counted. For legacy rows
// where interest_delta is NULL, fall back to reversing the full delta as before.
const interestPortion = existing.interest_delta ?? 0;
const paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null;
let restoredBalance = bill.current_balance;
paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null;
restoredBalance = bill.current_balance;
if (paymentPortion != null && bill.current_balance != null) {
restoredBalance = Math.max(0, bill.current_balance - paymentPortion);
}
@ -481,9 +518,18 @@ router.put('/:id', (req, res) => {
// interest_accrued_month is still set to this month (if interest was charged) so
// computeBalanceDelta will skip interest when the payment is within the same month,
// and charge a fresh month of interest if editing into a new calendar month.
const balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount);
balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount);
nextBalanceDelta = balCalc?.balance_delta ?? null;
nextInterestDelta = balCalc?.interest_delta ?? null;
}
const { autopay_failure } = req.body;
const nextAutopayFailure = autopay_failure !== undefined ? (autopay_failure ? 1 : 0) : existing.autopay_failure;
// Atomic: the bill-balance write and the payment UPDATE land together or neither,
// so an edit never leaves the balance reversed without the row updated (or vice versa).
const applyEdit = db.transaction(() => {
if (bill) {
if (balCalc) {
applyBalanceDelta(db, existing.bill_id, balCalc);
} else if (paymentPortion != null && restoredBalance != null) {
@ -491,10 +537,6 @@ router.put('/:id', (req, res) => {
.run(restoredBalance, existing.bill_id);
}
}
const { autopay_failure } = req.body;
const nextAutopayFailure = autopay_failure !== undefined ? (autopay_failure ? 1 : 0) : existing.autopay_failure;
db.prepare(`
UPDATE payments SET
amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, interest_delta = ?,
@ -513,6 +555,8 @@ router.put('/:id', (req, res) => {
req.params.id,
req.user.id,
);
});
applyEdit();
res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id)));
});
@ -524,9 +568,11 @@ router.delete('/:id', (req, res) => {
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
// Reverse any balance delta that was stored when this payment was created.
// If this payment was the one that charged interest this month, clear
// Atomic: reverse the balance delta and soft-delete the payment together, so a
// failure can't leave the balance restored while the payment is still active
// (or vice versa). If this payment charged interest this month, clear
// interest_accrued_month so the next payment can re-accrue correctly.
const softDeletePayment = db.transaction(() => {
if (!payment.accounting_excluded && payment.balance_delta != null) {
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
if (bill?.current_balance != null) {
@ -540,8 +586,9 @@ router.delete('/:id', (req, res) => {
`).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
}
}
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)").run(req.params.id, req.user.id);
});
softDeletePayment();
res.json({ success: true });
});
@ -552,9 +599,12 @@ router.post('/:id/restore', (req, res) => {
if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
// Re-apply the balance delta (undo the reversal done on delete).
// If this payment originally charged interest, restore interest_accrued_month
// to the month of the payment so future same-month payments skip interest.
// Atomic: re-apply the balance delta and un-delete the payment together, so a
// failure can't leave the balance re-applied while the payment stays deleted
// (or vice versa). If this payment originally charged interest, restore
// interest_accrued_month to the payment's month so future same-month payments
// skip interest.
const restorePayment = db.transaction(() => {
if (!payment.accounting_excluded && payment.balance_delta != null) {
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
if (bill?.current_balance != null) {
@ -569,8 +619,9 @@ router.post('/:id/restore', (req, res) => {
`).run(reapplied, interestMonth, interestMonth, payment.bill_id);
}
}
db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)').run(req.params.id, req.user.id);
});
restorePayment();
res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id)));
});

View File

@ -196,6 +196,7 @@ app.use((err, req, res, next) => {
res.status(err.status || 500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR',
});
});

123
tests/paymentsRoute.test.js Normal file
View File

@ -0,0 +1,123 @@
'use strict';
// Track A — the manual create / delete / restore / edit payment paths must apply
// their balance mutation atomically with the row write (each is now wrapped in a
// single db.transaction()). These tests pin the *observable* invariant the wrap
// guarantees: the bill balance and the payment state stay consistent across a
// create → delete → restore → edit round-trip, and a double-restore is a no-op.
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const dbPath = path.join(os.tmpdir(), `bill-tracker-payments-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const router = require('../routes/payments');
function handler(method, routePath) {
const layer = router.stack.find(l => l.route?.path === routePath && l.route.methods[method]);
assert.ok(layer, `${method.toUpperCase()} ${routePath} exists`);
return layer.route.stack[layer.route.stack.length - 1].handle;
}
function call(method, routePath, { userId, params = {}, body = {} } = {}) {
const h = handler(method, routePath);
return new Promise((resolve) => {
const req = { params, body, query: {}, user: { id: userId, role: 'user' } };
const res = {
statusCode: 200,
status(c) { this.statusCode = c; return this; },
json(d) { resolve({ status: this.statusCode, data: d }); },
};
h(req, res);
});
}
function balanceOf(billId) {
return getDb().prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance;
}
let userId, billId;
test.before(() => {
const db = getDb();
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('pay-user','x','user',1)").run().lastInsertRowid;
// $100 expected, $1,000 balance (cents), 0% interest so deltas are pure principal.
billId = db.prepare(
"INSERT INTO bills (user_id, name, due_day, expected_amount, current_balance, minimum_payment, interest_rate, active) VALUES (?, 'Loan', 1, 10000, 100000, 5000, 0, 1)",
).run(userId).lastInsertRowid;
});
test.after(() => {
closeDb();
for (const s of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + s); } catch {} }
});
let paymentId;
test('manual POST /payments creates the payment (201) and drops the balance once', async () => {
const { status, data } = await call('post', '/', {
userId, body: { bill_id: billId, amount: 100, paid_date: '2026-07-01' },
});
assert.equal(status, 201);
assert.equal(data.amount, 100, 'serialized amount in dollars');
paymentId = data.id;
assert.equal(balanceOf(billId), 90000, '1000.00 100.00 = 900.00 (cents)');
const count = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
assert.equal(count, 1);
});
test('DELETE reverses the balance and soft-deletes atomically', async () => {
const { status, data } = await call('delete', '/:id', { userId, params: { id: paymentId } });
assert.equal(status, 200);
assert.equal(data.success, true);
assert.equal(balanceOf(billId), 100000, 'balance restored to 1000.00');
const active = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
assert.equal(active, 0, 'payment is soft-deleted');
});
test('restore re-applies the balance drop atomically', async () => {
const { status } = await call('post', '/:id/restore', { userId, params: { id: paymentId } });
assert.equal(status, 200);
assert.equal(balanceOf(billId), 90000, 'balance dropped to 900.00 again');
const active = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
assert.equal(active, 1, 'payment is active again');
});
test('double-restore is a no-op (404, balance unchanged)', async () => {
const before = balanceOf(billId);
const { status } = await call('post', '/:id/restore', { userId, params: { id: paymentId } });
assert.equal(status, 404, 'already-active payment cannot be restored again');
assert.equal(balanceOf(billId), before, 'balance not double-applied');
});
test('PUT edit reverses the old amount and applies the new one atomically', async () => {
const { status, data } = await call('put', '/:id', {
userId, params: { id: paymentId }, body: { amount: 50 },
});
assert.equal(status, 200);
assert.equal(data.amount, 50, 'amount updated to $50');
// reverse $100 (→ 1000.00) then apply $50 (→ 950.00)
assert.equal(balanceOf(billId), 95000, '1000.00 50.00 = 950.00 (cents)');
});
test('manual POST of a same bill+date+amount is a suspected duplicate (409, no new row, balance unchanged)', async () => {
const before = balanceOf(billId);
const { status, data } = await call('post', '/', {
userId, body: { bill_id: billId, amount: 50, paid_date: '2026-07-01' },
});
assert.equal(status, 409);
assert.equal(data.code, 'DUPLICATE_SUSPECTED');
assert.ok(data.existing?.id, 'returns the existing payment so the client can show it');
assert.equal(balanceOf(billId), before, 'balance not touched by a rejected duplicate');
const active = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
assert.equal(active, 1, 'still one payment');
});
test('manual POST with allow_duplicate bypasses the guard and records the second payment', async () => {
const { status } = await call('post', '/', {
userId, body: { bill_id: billId, amount: 50, paid_date: '2026-07-01', allow_duplicate: true },
});
assert.equal(status, 201);
assert.equal(balanceOf(billId), 90000, '950.00 50.00 = 900.00 (cents)');
const active = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
assert.equal(active, 2, 'a legitimately-identical second payment is recorded');
});

View File

@ -0,0 +1,109 @@
'use strict';
// Track B — cross-surface money reconciliation. The Tracker, Analytics, and the
// raw Bills list must agree on the same month's expected/paid totals — the app
// has repeatedly had "same number, different truth" drift (QA-B5/B9) where one
// surface gates a bill's occurrence differently from another. This harness seeds
// the exact shapes that historically broke gating (an off-month annual bill, a
// quarterly bill that DOES occur, plus plain monthly bills) and pins the
// invariants that must hold, comparing in INTEGER CENTS (never post-fromCents
// floats, which drift on rounding).
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const dbPath = path.join(os.tmpdir(), `bill-tracker-recon-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const { getTracker } = require('../services/trackerService');
const { getAnalyticsSummary } = require('../services/analyticsService');
const { resolveDueDate } = require('../services/statusService');
const summaryRouter = require('../routes/summary');
// Fixed "now" so occurrence gating is deterministic: June 20, 2026.
const NOW = new Date(2026, 5, 20, 12, 0, 0);
const YEAR = 2026, MONTH = 6, KEY = '2026-06';
const cents = (dollars) => Math.round(dollars * 100);
let db, userId;
test.before(() => {
db = getDb();
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('recon','x','user',1)").run().lastInsertRowid;
const ins = db.prepare(`
INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, cycle_day, expected_amount, active)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
`);
const monthlyA = ins.run(userId, 'Monthly A', 15, 'monthly', 'monthly', null, 10000).lastInsertRowid; // $100, occurs
const monthlyB = ins.run(userId, 'Monthly B', 1, 'monthly', 'monthly', null, 20000).lastInsertRowid; // $200, occurs
ins.run(userId, 'Quarterly (Mar anchor)', 10, 'quarterly', 'quarterly', '3', 30000); // $300, Mar/Jun/Sep/Dec → occurs in June
ins.run(userId, 'Annual (Jan)', 1, 'annually', 'annual', '1', 90000); // $900, NOT due in June
const pay = db.prepare("INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, ?, ?, 'manual')");
pay.run(monthlyA, 10000, '2026-06-15'); // pay A in full ($100)
pay.run(monthlyB, 5000, '2026-06-10'); // partial toward B ($50)
});
test.after(() => {
closeDb();
for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
});
function analyticsMonthRow() {
const summary = getAnalyticsSummary(userId, { year: YEAR, month: MONTH, months: 12 });
return (summary.expected_vs_actual || []).find(r => r.month === KEY) || { expected: 0, actual: 0 };
}
function billsGatedExpectedCents() {
// The raw Bills surface: sum the occurrence-gated active bills' expected_amount.
const bills = db.prepare('SELECT * FROM bills WHERE user_id = ? AND active = 1 AND deleted_at IS NULL').all(userId);
return bills
.filter(b => resolveDueDate(b, YEAR, MONTH))
.reduce((sum, b) => sum + (b.expected_amount || 0), 0);
}
function summaryExpenseTotalCents() {
// The Summary surface: invoke GET /summary and read its gated expense_total.
const layer = summaryRouter.stack.find(l => l.route?.path === '/' && l.route.methods.get);
const handle = layer.route.stack[layer.route.stack.length - 1].handle;
let captured;
const req = { query: { year: String(YEAR), month: String(MONTH) }, user: { id: userId } };
const res = { status() { return this; }, json(d) { captured = d; } };
handle(req, res);
return cents(captured.summary.expense_total);
}
test('Analytics.expected reconciles with Tracker.total_expected for the month (integer cents)', () => {
const tracker = getTracker(userId, { year: YEAR, month: MONTH }, NOW);
const analytics = analyticsMonthRow();
// Gated June bills: $100 + $200 + $300 (quarterly) = $600; the $900 annual is excluded.
assert.equal(cents(tracker.summary.total_expected), 60000, 'tracker total_expected gated to $600');
assert.equal(cents(analytics.expected), cents(tracker.summary.total_expected), 'analytics.expected == tracker.total_expected');
});
test('Bills gated sum reconciles with Tracker.total_expected (integer cents)', () => {
const tracker = getTracker(userId, { year: YEAR, month: MONTH }, NOW);
assert.equal(billsGatedExpectedCents(), cents(tracker.summary.total_expected), 'Σ resolveDueDate-gated bills == tracker total_expected');
});
test('Summary.expense_total reconciles with Tracker.total_expected for the month (integer cents)', () => {
const tracker = getTracker(userId, { year: YEAR, month: MONTH }, NOW);
assert.equal(summaryExpenseTotalCents(), cents(tracker.summary.total_expected), 'summary expense_total == tracker total_expected');
});
test('Analytics.actual reconciles with Tracker.total_paid for the month (integer cents)', () => {
const tracker = getTracker(userId, { year: YEAR, month: MONTH }, NOW);
const analytics = analyticsMonthRow();
// Payments in June: $100 (A) + $50 (B partial) = $150.
assert.equal(cents(tracker.summary.total_paid), 15000, 'tracker total_paid = $150');
assert.equal(cents(analytics.actual), cents(tracker.summary.total_paid), 'analytics.actual == tracker.total_paid');
});
test('the off-month annual bill inflates NO surface (gating is consistent everywhere)', () => {
const tracker = getTracker(userId, { year: YEAR, month: MONTH }, NOW);
const analytics = analyticsMonthRow();
assert.equal(tracker.rows.filter(r => r.name === 'Annual (Jan)').length, 0, 'annual bill absent from tracker rows');
// If the annual $900 leaked into any surface, the totals would be $1,500 not $600.
assert.equal(cents(tracker.summary.total_expected), 60000);
assert.equal(cents(analytics.expected), 60000);
assert.equal(billsGatedExpectedCents(), 60000);
});