Compare commits

..

No commits in common. "03d15bd0616acc5b8825c4a8081db127b2bbbd0a" and "6a2db3780f66acce10e574434220ff72a79106fb" have entirely different histories.

20 changed files with 420 additions and 857 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,17 +30,8 @@ import { CategoryPicker } from '@/components/transactions/CategoryPicker';
import { MatchBillDialog } from '@/components/transactions/MatchBillDialog'; import { MatchBillDialog } from '@/components/transactions/MatchBillDialog';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { cn, fmtDate, categoryColor, localDateString, errMessage } from '@/lib/utils'; import { cn, fmtDate, categoryColor, localDateString, errMessage } from '@/lib/utils';
import { formatUSD, asDollars } from '@/lib/money'; import { formatUSD, asDollars, type Cents } from '@/lib/money';
import type { import type { Bill, Category, BankTransaction } from '@/types';
Bill, Category, BankTransaction,
BankLedgerTransaction as Tx,
BankAccount as Account,
BankLedger as Ledger,
BankLedgerSummary as LedgerSummary,
BankCategoryBreakdown as CategoryBreakdown,
AutoCategorizeChange as AutoCatChange,
AutoCategorizePreview as AutoCatPreview,
} from '@/types';
function fmt(v: number | null | undefined): string { function fmt(v: number | null | undefined): string {
return formatUSD(asDollars(Number(v) || 0)); return formatUSD(asDollars(Number(v) || 0));
@ -49,6 +40,71 @@ function fmt(v: number | null | undefined): string {
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const TABLE_COLUMN_COUNT = 8; const TABLE_COLUMN_COUNT = 8;
interface Tx {
id: number;
amount: Cents;
payee?: string | null;
description?: string | null;
memo?: string | null;
category?: string | null;
posted_date?: string | null;
transacted_at?: string | null;
pending?: boolean;
ignored?: boolean;
match_status?: string | null;
matched_bill_name?: string | null;
spending_category_id?: number | null;
spending_category_name?: string | null;
suggested_match?: { display_name?: string; category?: string } | null;
account_org_name?: string | null;
account_name?: string | null;
account_type?: string | null;
currency?: string | null;
[key: string]: unknown;
}
interface Account {
id: number | string;
org_name?: string;
name?: string;
account_type?: string;
currency?: string;
balance?: number;
available_balance?: number | null;
transaction_count?: number;
last_transaction_date?: string;
}
interface CategoryBreakdown { name: string; total?: number; count?: number }
interface LedgerSummary {
inflow?: number;
outflow?: number;
net?: number;
matched?: number;
unmatched?: number;
pending?: number;
total?: number;
latest_date?: string;
category_breakdown?: CategoryBreakdown[];
}
interface Ledger {
accounts?: Account[];
transactions?: Tx[];
summary?: LedgerSummary;
total?: number;
sources?: { last_sync_at?: string | null }[];
enabled?: boolean;
has_connections?: boolean;
}
interface AutoCatChange { transaction_id: number }
interface AutoCatPreview {
changes?: AutoCatChange[];
categories?: { name: string; count?: number }[];
}
const flowOptions = [ const flowOptions = [
{ value: 'all', label: 'All' }, { value: 'all', label: 'All' },
{ value: 'in', label: 'Money in' }, { value: 'in', label: 'Money in' },
@ -328,7 +384,7 @@ export default function BankTransactionsPage() {
// React Query keys the ledger on the filters/page. // React Query keys the ledger on the filters/page.
const { data: ledgerRaw = null, isPending: loading, isFetching, error: ledgerErr, refetch: loadLedger } = const { data: ledgerRaw = null, isPending: loading, isFetching, error: ledgerErr, refetch: loadLedger } =
useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize: PAGE_SIZE }); useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize: PAGE_SIZE });
const ledger = ledgerRaw; const ledger = ledgerRaw as Ledger | null;
const error = ledgerErr ? (ledgerErr.message || 'Unable to load bank transactions') : ''; const error = ledgerErr ? (ledgerErr.message || 'Unable to load bank transactions') : '';
const setLedger = useCallback((u: (prev: Ledger | undefined) => Ledger | undefined) => queryClient.setQueryData<Ledger>( const setLedger = useCallback((u: (prev: Ledger | undefined) => Ledger | undefined) => queryClient.setQueryData<Ledger>(
['bank-ledger', accountId, flow, page, query, sortBy, sortDir], u), [queryClient, accountId, flow, page, query, sortBy, sortDir]); ['bank-ledger', accountId, flow, page, query, sortBy, sortDir], u), [queryClient, accountId, flow, page, query, sortBy, sortDir]);
@ -377,7 +433,7 @@ export default function BankTransactionsPage() {
const handleApplySuggestion = useCallback(async (tx: Tx) => { const handleApplySuggestion = useCallback(async (tx: Tx) => {
setApplyingSuggestionId(tx.id); setApplyingSuggestionId(tx.id);
try { try {
const result = await api.applyTransactionMerchantMatch(tx.id); const result = await api.applyTransactionMerchantMatch(tx.id) as { matched?: boolean; category?: { id: number; name: string } };
if (result.matched && result.category) { if (result.matched && result.category) {
updateTransaction(tx.id, { updateTransaction(tx.id, {
spending_category_id: result.category.id, spending_category_id: result.category.id,
@ -396,7 +452,7 @@ export default function BankTransactionsPage() {
const handleAutoCategorize = useCallback(async () => { const handleAutoCategorize = useCallback(async () => {
setAutoCategorizing(true); setAutoCategorizing(true);
try { try {
const preview = await api.autoCategorizeTransactions({ dry_run: true }); const preview = await api.autoCategorizeTransactions({ dry_run: true }) as AutoCatPreview;
if (!preview?.changes?.length) { if (!preview?.changes?.length) {
toast.success('No new matches found'); toast.success('No new matches found');
return; return;
@ -423,7 +479,7 @@ export default function BankTransactionsPage() {
const handleConfirmAutoCategorize = useCallback(async () => { const handleConfirmAutoCategorize = useCallback(async () => {
setAutoCategorizing(true); setAutoCategorizing(true);
try { try {
const result = await api.autoCategorizeTransactions(); const result = await api.autoCategorizeTransactions() as { changes?: AutoCatChange[] };
const changes = result?.changes || []; const changes = result?.changes || [];
setAutoCategorizePreview(null); setAutoCategorizePreview(null);
await Promise.all([ await Promise.all([
@ -445,7 +501,7 @@ export default function BankTransactionsPage() {
if (!matchTarget) return; if (!matchTarget) return;
setMatchSubmitting(true); setMatchSubmitting(true);
try { try {
const result = await api.matchTransaction(matchTarget.id, billId); const result = await api.matchTransaction(matchTarget.id, billId) as { transaction: Tx };
updateTransaction(matchTarget.id, result.transaction); updateTransaction(matchTarget.id, result.transaction);
setMatchTarget(null); setMatchTarget(null);
toast.success('Transaction matched to bill'); toast.success('Transaction matched to bill');
@ -482,7 +538,7 @@ export default function BankTransactionsPage() {
setMatchTarget(null); setMatchTarget(null);
if (tx && newBill?.id) { if (tx && newBill?.id) {
try { try {
const result = await api.matchTransaction(tx.id, newBill.id); const result = await api.matchTransaction(tx.id, newBill.id) as { transaction: Tx };
updateTransaction(tx.id, result.transaction); updateTransaction(tx.id, result.transaction);
toast.success('Bill created and matched to transaction'); toast.success('Bill created and matched to transaction');
} catch (err) { } catch (err) {
@ -494,7 +550,7 @@ export default function BankTransactionsPage() {
const handleUnmatch = useCallback(async (tx: Tx) => { const handleUnmatch = useCallback(async (tx: Tx) => {
try { try {
const result = await api.unmatchTransaction(tx.id); const result = await api.unmatchTransaction(tx.id) as { transaction: Tx };
updateTransaction(tx.id, result.transaction); updateTransaction(tx.id, result.transaction);
toast.success('Transaction unmatched'); toast.success('Transaction unmatched');
} catch (err) { } catch (err) {
@ -505,7 +561,7 @@ export default function BankTransactionsPage() {
const handleIgnore = useCallback(async (tx: Tx) => { const handleIgnore = useCallback(async (tx: Tx) => {
try { try {
const transaction = await api.ignoreTransaction(tx.id); const transaction = await api.ignoreTransaction(tx.id) as Partial<Tx>;
updateTransaction(tx.id, transaction); updateTransaction(tx.id, transaction);
} catch (err) { } catch (err) {
toast.error(errMessage(err, 'Failed to ignore transaction')); toast.error(errMessage(err, 'Failed to ignore transaction'));
@ -515,7 +571,7 @@ export default function BankTransactionsPage() {
const handleUnignore = useCallback(async (tx: Tx) => { const handleUnignore = useCallback(async (tx: Tx) => {
try { try {
const transaction = await api.unignoreTransaction(tx.id); const transaction = await api.unignoreTransaction(tx.id) as Partial<Tx>;
updateTransaction(tx.id, transaction); updateTransaction(tx.id, transaction);
} catch (err) { } catch (err) {
toast.error(errMessage(err, 'Failed to unignore transaction')); toast.error(errMessage(err, 'Failed to unignore transaction'));
@ -570,7 +626,7 @@ export default function BankTransactionsPage() {
const results = await Promise.allSettled(targets.map(tx => api.applyTransactionMerchantMatch(tx.id))); const results = await Promise.allSettled(targets.map(tx => api.applyTransactionMerchantMatch(tx.id)));
let applied = 0; let applied = 0;
results.forEach((r, i) => { results.forEach((r, i) => {
const val = r.status === 'fulfilled' ? r.value : null; const val = r.status === 'fulfilled' ? r.value as { matched?: boolean; category?: { id: number; name: string } } : null;
if (val?.matched && val.category) { if (val?.matched && val.category) {
updateTransaction(targets[i]!.id, { updateTransaction(targets[i]!.id, {
spending_category_id: val.category.id, spending_category_id: val.category.id,
@ -623,7 +679,7 @@ export default function BankTransactionsPage() {
let applied = 0; let applied = 0;
results.forEach((r, i) => { results.forEach((r, i) => {
if (r.status === 'fulfilled') { if (r.status === 'fulfilled') {
updateTransaction(targets[i]!.id, r.value); updateTransaction(targets[i]!.id, r.value as Partial<Tx>);
applied++; applied++;
} }
}); });

View File

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

View File

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

View File

@ -20,16 +20,7 @@ import {
import { CategoryPicker } from '@/components/transactions/CategoryPicker'; import { CategoryPicker } from '@/components/transactions/CategoryPicker';
import { formatUSD, asDollars } from '@/lib/money'; import { formatUSD, asDollars } from '@/lib/money';
import { errMessage } from '@/lib/utils'; import { errMessage } from '@/lib/utils';
import type { import type { Category } from '@/types';
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']; const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
@ -44,8 +35,43 @@ function settingEnabled(value: unknown, fallback = true): boolean {
type Level = 'over' | 'warn' | 'ok'; 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 }; 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>; type Budgets = Record<string, number | null>;
// pctBar() returns a progress-bar percent plus a YNAB-style status level: // pctBar() returns a progress-bar percent plus a YNAB-style status level:
@ -206,7 +232,7 @@ function IncomeSection({ year, month, totalIncome }: { year: number; month: numb
const load = useCallback(async (p = 1) => { const load = useCallback(async (p = 1) => {
setLoading(true); setLoading(true);
try { try {
const d = await api.spendingIncome({ year, month, page: p }); const d = await api.spendingIncome({ year, month, page: p }) as IncomeResponse;
setRows(d.transactions || []); setRows(d.transactions || []);
setPages(d.pages || 1); setPages(d.pages || 1);
setPage(p); setPage(p);
@ -289,7 +315,7 @@ function RulesManager({ categories }: { categories: Category[] }) {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { setRules((await api.spendingCategoryRules()).rules || []); } try { setRules(((await api.spendingCategoryRules()) as { rules?: SpendingRule[] }).rules || []); }
catch { toast.error('Failed to load rules'); } catch { toast.error('Failed to load rules'); }
finally { setLoading(false); } finally { setLoading(false); }
}, []); }, []);
@ -667,15 +693,15 @@ export default function SpendingPage() {
const [txPage, setTxPage] = useState(1); const [txPage, setTxPage] = useState(1);
const [activeCat, setActiveCat] = useState<number | null | undefined>(undefined); // undefined = all const [activeCat, setActiveCat] = useState<number | null | undefined>(undefined); // undefined = all
const { data: summaryRaw = null, isPending: loading, error: summaryErrObj } = useSpendingSummary(year, month); const { data: summaryRaw = null, isPending: loading, error: summaryErrObj } = useSpendingSummary(year, month);
const summary = summaryRaw; const summary = summaryRaw as SpendingSummary | null;
const { data: txDataRaw, isFetching: txLoading, error: txErrObj } = useSpendingTransactions({ year, month, activeCat, page: txPage }); const { data: txDataRaw, isFetching: txLoading, error: txErrObj } = useSpendingTransactions({ year, month, activeCat, page: txPage });
const txData = txDataRaw; const txData = txDataRaw as SpendingTxResponse | undefined;
const transactions = useMemo(() => txData?.transactions || [], [txData]); const transactions = useMemo(() => txData?.transactions || [], [txData]);
const txTotal = txData?.total || 0; const txTotal = txData?.total || 0;
const txPages = txData?.pages || 1; const txPages = txData?.pages || 1;
const { data: categories = [], error: catErrObj } = useSpendingCategories(); const { data: categories = [], error: catErrObj } = useSpendingCategories();
const { data: categoryGroupsRaw = [] } = useCategoryGroups(); const { data: categoryGroupsRaw = [] } = useCategoryGroups();
const categoryGroups = categoryGroupsRaw; const categoryGroups = categoryGroupsRaw as CategoryGroup[];
const summaryError = summaryErrObj ? (summaryErrObj.message || 'Failed to load spending summary') : null; const summaryError = summaryErrObj ? (summaryErrObj.message || 'Failed to load spending summary') : null;
const txError = txErrObj ? (txErrObj.message || 'Failed to load transactions') : null; const txError = txErrObj ? (txErrObj.message || 'Failed to load transactions') : null;
const catError = catErrObj ? (catErrObj.message || 'Failed to load categories') : null; const catError = catErrObj ? (catErrObj.message || 'Failed to load categories') : null;
@ -743,7 +769,7 @@ export default function SpendingPage() {
const handleCopyBudgets = async () => { const handleCopyBudgets = async () => {
setCopying(true); setCopying(true);
try { try {
const d = await api.copySpendingBudgets({ year, month }); const d = await api.copySpendingBudgets({ year, month }) as CopyBudgetsResponse;
if (d.copied === 0) { if (d.copied === 0) {
toast.info('No budgets found in the previous month to copy.'); toast.info('No budgets found in the previous month to copy.');
} else { } else {

View File

@ -53,15 +53,7 @@ import { getLinkImportPref } from '@/pages/SettingsPage';
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference'; import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
import type { import type { Bill, Category } from '@/types';
Bill, Category,
Subscription,
SubscriptionsResponse as SubData,
SubscriptionSummary as SubSummary,
ExistingBillMatch,
Recommendation,
SubscriptionTx as SubTx,
} from '@/types';
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow'; import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
function fmt(v: number | null | undefined): string { function fmt(v: number | null | undefined): string {
@ -101,6 +93,102 @@ interface CadenceLike {
[key: string]: unknown; [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 { interface SubDraft {
name?: string; name?: string;
category_id?: number | null; category_id?: number | null;
@ -1112,9 +1200,9 @@ type FlatItem =
export default function SubscriptionsPage() { export default function SubscriptionsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: subData, isPending: loading } = useSubscriptions(); const { data: subData, isPending: loading } = useSubscriptions();
const data: SubData = subData || { summary: {}, subscriptions: [] }; const data: SubData = (subData as SubData | undefined) || { summary: {}, subscriptions: [] };
const { data: recommendationsRaw = [], isPending: recommendationsLoading } = useSubscriptionRecommendations(); const { data: recommendationsRaw = [], isPending: recommendationsLoading } = useSubscriptionRecommendations();
const recommendations = recommendationsRaw; const recommendations = recommendationsRaw as Recommendation[];
const { data: categories = [] } = useCategories(); const { data: categories = [] } = useCategories();
const { data: bills = [] } = useBills(); const { data: bills = [] } = useBills();
// Optimistic updates write through the query cache so every existing call site // Optimistic updates write through the query cache so every existing call site
@ -1182,7 +1270,7 @@ export default function SubscriptionsPage() {
txDebounce.current = setTimeout(async () => { txDebounce.current = setTimeout(async () => {
setTxSearching(true); setTxSearching(true);
try { try {
const result = await api.subscriptionTransactionMatches({ q, limit: 50 }); const result = await api.subscriptionTransactionMatches({ q, limit: 50 }) as SubTx[] | { transactions?: SubTx[] };
setTxResults(Array.isArray(result) ? result : (result?.transactions ?? [])); setTxResults(Array.isArray(result) ? result : (result?.transactions ?? []));
} catch (err) { } catch (err) {
setTxResults([]); setTxResults([]);
@ -1212,7 +1300,7 @@ export default function SubscriptionsPage() {
async function acceptRecommendation(recommendation: Recommendation) { async function acceptRecommendation(recommendation: Recommendation) {
setBusyId(`rec-${recommendation.id}`); setBusyId(`rec-${recommendation.id}`);
try { try {
const created = await api.createSubscriptionFromRecommendation(recommendation); const created = await api.createSubscriptionFromRecommendation(recommendation) as { id?: number; name?: string } | null;
toast.success(`${recommendation.name} is now tracked.`); toast.success(`${recommendation.name} is now tracked.`);
await refreshAll(); await refreshAll();
if (getLinkImportPref() && recommendation.merchant && created?.id) { if (getLinkImportPref() && recommendation.merchant && created?.id) {
@ -1248,7 +1336,7 @@ export default function SubscriptionsPage() {
recommendation.merchant, recommendation.merchant,
recommendation.catalog_match?.id, recommendation.catalog_match?.id,
recommendation.confidence, 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}".`); toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`);
setMatchTarget(null); setMatchTarget(null);
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id)); setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));

View File

@ -10,19 +10,6 @@
// sparkline) are left `unknown` and refined when a consumer needs them. // sparkline) are left `unknown` and refined when a consumer needs them.
import type { Cents, Dollars } from '@/lib/money'; 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, // 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` // raw bank-transaction amounts stay in CENTS on the wire — hence branded `Cents`
// (format them with formatCentsUSD, never formatUSD). // (format them with formatCentsUSD, never formatUSD).
@ -41,233 +28,6 @@ export interface BankTransaction {
[key: string]: unknown; [key: string]: unknown;
} }
// The bank ledger (GET /transactions/bank-ledger): accounts, the transaction
// occurrence with spending/match metadata, and the month summary. Raw
// transaction/account amounts stay in CENTS on this endpoint (the page formats
// them with a local formatCents), so they're plain `number` cents, not Dollars.
export interface BankLedgerTransaction extends BankTransaction {
transacted_at?: string | null; // narrower than BankTransaction's for date slicing
category?: string | null;
pending?: boolean;
ignored?: boolean;
match_status?: string | null;
matched_bill_name?: string | null;
spending_category_id?: number | null;
spending_category_name?: string | null;
suggested_match?: { display_name?: string; category?: string } | null;
account_org_name?: string | null;
account_type?: string | null;
}
export interface BankAccount {
id: number | string;
org_name?: string;
name?: string;
account_type?: string;
currency?: string;
balance?: number;
available_balance?: number | null;
transaction_count?: number;
last_transaction_date?: string;
}
export interface BankCategoryBreakdown { name: string; total?: number; count?: number }
export interface BankLedgerSummary {
inflow?: number;
outflow?: number;
net?: number;
matched?: number;
unmatched?: number;
pending?: number;
total?: number;
latest_date?: string;
category_breakdown?: BankCategoryBreakdown[];
}
export interface BankLedger {
accounts?: BankAccount[];
transactions?: BankLedgerTransaction[];
summary?: BankLedgerSummary;
total?: number;
sources?: { last_sync_at?: string | null }[];
enabled?: boolean;
has_connections?: boolean;
}
export interface AutoCategorizeChange { transaction_id: number }
export interface AutoCategorizePreview {
changes?: AutoCategorizeChange[];
categories?: { name: string; count?: number }[];
}
// 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. // A bill's month status. Mirrors the server's statusService.
export type TrackerStatus = export type TrackerStatus =
| 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped'; | 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';

View File

@ -2,7 +2,7 @@
**Status:** Current code reference **Status:** Current code reference
**Last Updated:** 2026-07-05 **Last Updated:** 2026-07-05
**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) **Version:** 0.40.0
**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 **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. 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,13 +16,6 @@ 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. - **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`. - **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 ## 1. System Overview
@ -410,9 +403,8 @@ Response conventions:
- Unauthenticated: `401`. - Unauthenticated: `401`.
- Forbidden: `403`. - Forbidden: `403`.
- Missing resource: `404`. - Missing resource: `404`.
- Conflict: `409` (also used for `DUPLICATE_SUSPECTED` on `POST /payments` — see §5.5). - Conflict: `409`.
- Rate-limited: `429`. - 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`) ### 5.1 Auth (`/api/auth`)
@ -475,12 +467,6 @@ 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`. 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`) ### 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`). 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`).
@ -1307,38 +1293,27 @@ 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`. - 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`. - 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. - `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`, `bankTransactionsLedger`, `matchTransaction`, `unmatchTransaction`, `ignoreTransaction`, `unignoreTransaction`, `applyTransactionMerchantMatch`, `autoCategorizeTransactions`, `categoryGroups`, `copySpendingBudgets`, `spendingIncome`, `spendingCategoryRules`, `createPaymentOrConfirm` (via `client/lib/paymentActions.ts`). - 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`.
- File download/upload endpoints use raw `fetch` because responses/bodies are blobs or octet streams. - 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`. - **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` ### Domain types — `client/types.ts`
- Branded `Dollars` and `Cents` money types (from `client/lib/money.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`). - `Bill`, `Payment`, `Category`, `TrackerResponse` (with `summary` / `bank_tracking` / `cashflow` / `rows`).
- `DriftBill`, `AutopaySuggestion`, `AmountSuggestion`, `AutopayStats`. - `DriftBill`, `AutopaySuggestion`, `AmountSuggestion`, `AutopayStats`.
- `BankTransaction` (integer cents on the wire). - `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. - `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. - 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. - 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 ### Auth state
`client/hooks/useAuth.tsx`: `client/hooks/useAuth.tsx`:
- `AuthContext` typed (a `createContext(null)` would have made `useContext` return `never`). - `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`. - Maintains `user`, `singleUserMode`, `loading`.
- Calls `api.authMode()` and `api.me()` on startup; `api.me()` is now `get<User>` (typed). - Calls `api.authMode()` and `api.me()` on startup.
- Exposes `logout()`, `logoutAll()`, `refresh()`. - Exposes `logout()`, `logoutAll()`, `refresh()`.
- `singleUserMode` is sourced from `/auth/mode`; `RequireAuth` consults it. - `singleUserMode` is sourced from `/auth/mode`; `RequireAuth` consults it.
@ -1398,7 +1373,7 @@ Routes (all rendered through `Routes` with `ErrorBoundary`, lazy + `Suspense` fo
### `client/lib/` ### `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`. - `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, `errMessage(unknown, fallback)` for narrowing `unknown` caught errors into strings. `fmt` inherits `formatUSD`'s branded-dollars input. - `utils.ts``cn`, `fmt`, date/format helpers. `fmt` inherits `formatUSD`'s branded-dollars input.
- `trackerUtils.ts` — row/status/sort helpers, `TrackerRow` domain interface, `TrackerStatus` union. - `trackerUtils.ts` — row/status/sort helpers, `TrackerRow` domain interface, `TrackerStatus` union.
- `billDrafts.ts``SourceBill` / `Template` / `Category` shapes for `makeBillDraft`. - `billDrafts.ts``SourceBill` / `Template` / `Category` shapes for `makeBillDraft`.
- `billingSchedule.ts``Schedule` union and helpers. - `billingSchedule.ts``Schedule` union and helpers.
@ -1406,7 +1381,6 @@ Routes (all rendered through `Routes` with `ErrorBoundary`, lazy + `Suspense` fo
- `trackerTableColumns.ts` — typed column descriptors. - `trackerTableColumns.ts` — typed column descriptors.
- `reorder.ts` — generic `moveInArray<T>`. - `reorder.ts` — generic `moveInArray<T>`.
- `version.ts` — typed release-notes shapes; the Vite-injected `__APP_VERSION__` is declared inline. - `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 ### Frontend build & lint
@ -1714,17 +1688,4 @@ 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). - `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`). - `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. 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 // POST /api/payments — create single payment
router.post('/', (req, res) => { router.post('/', (req, res) => {
const db = getDb(); const db = getDb();
const { bill_id, amount, paid_date, method, notes, payment_source, autopay_failure, allow_duplicate } = req.body; const { bill_id, amount, paid_date, method, notes, payment_source, autopay_failure } = req.body;
const validation = validatePaymentInput({ bill_id, amount, paid_date, payment_source: payment_source ?? 'manual' }); const validation = validatePaymentInput({ bill_id, amount, paid_date, payment_source: payment_source ?? 'manual' });
if (validation.error) { if (validation.error) {
@ -202,41 +202,15 @@ router.post('/', (req, res) => {
if (!bill) if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); 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 balCalc = computeBalanceDelta(bill, payment.amount);
const failureFlag = autopay_failure ? 1 : 0; 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)' '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); ).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(paymentId))); applyBalanceDelta(db, bill.id, balCalc);
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)));
}); });
// POST /api/payments/quick — pay a bill (expected amount, today) // POST /api/payments/quick — pay a bill (expected amount, today)
@ -341,11 +315,7 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
} }
const balCalc = computeBalanceDelta(bill, suggestedPayment.amount); 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) INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
@ -358,14 +328,12 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
balCalc?.interest_delta ?? null, balCalc?.interest_delta ?? null,
'manual', 'manual',
); );
applyBalanceDelta(db, bill.id, balCalc); applyBalanceDelta(db, bill.id, balCalc);
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?') 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); .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(paymentId)) }); res.status(201).json({ created: true, payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)) });
}); });
// POST /api/payments/autopay-suggestions/:billId/dismiss // POST /api/payments/autopay-suggestions/:billId/dismiss
@ -499,18 +467,13 @@ 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); 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; 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) { if (bill) {
// Reverse only the *payment* portion of the stored delta (not the interest component) // 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 // 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. // where interest_delta is NULL, fall back to reversing the full delta as before.
const interestPortion = existing.interest_delta ?? 0; const interestPortion = existing.interest_delta ?? 0;
paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null; const paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null;
restoredBalance = bill.current_balance; let restoredBalance = bill.current_balance;
if (paymentPortion != null && bill.current_balance != null) { if (paymentPortion != null && bill.current_balance != null) {
restoredBalance = Math.max(0, bill.current_balance - paymentPortion); restoredBalance = Math.max(0, bill.current_balance - paymentPortion);
} }
@ -518,18 +481,9 @@ router.put('/:id', (req, res) => {
// interest_accrued_month is still set to this month (if interest was charged) so // 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, // 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. // and charge a fresh month of interest if editing into a new calendar month.
balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount); const balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount);
nextBalanceDelta = balCalc?.balance_delta ?? null; nextBalanceDelta = balCalc?.balance_delta ?? null;
nextInterestDelta = balCalc?.interest_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) { if (balCalc) {
applyBalanceDelta(db, existing.bill_id, balCalc); applyBalanceDelta(db, existing.bill_id, balCalc);
} else if (paymentPortion != null && restoredBalance != null) { } else if (paymentPortion != null && restoredBalance != null) {
@ -537,6 +491,10 @@ router.put('/:id', (req, res) => {
.run(restoredBalance, existing.bill_id); .run(restoredBalance, existing.bill_id);
} }
} }
const { autopay_failure } = req.body;
const nextAutopayFailure = autopay_failure !== undefined ? (autopay_failure ? 1 : 0) : existing.autopay_failure;
db.prepare(` db.prepare(`
UPDATE payments SET UPDATE payments SET
amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, interest_delta = ?, amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, interest_delta = ?,
@ -555,8 +513,6 @@ router.put('/:id', (req, res) => {
req.params.id, req.params.id,
req.user.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))); 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)));
}); });
@ -568,11 +524,9 @@ router.delete('/:id', (req, res) => {
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id')); if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res); if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
// Atomic: reverse the balance delta and soft-delete the payment together, so a // Reverse any balance delta that was stored when this payment was created.
// failure can't leave the balance restored while the payment is still active // If this payment was the one that charged interest this month, clear
// (or vice versa). If this payment charged interest this month, clear
// interest_accrued_month so the next payment can re-accrue correctly. // interest_accrued_month so the next payment can re-accrue correctly.
const softDeletePayment = db.transaction(() => {
if (!payment.accounting_excluded && payment.balance_delta != null) { 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); 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) { if (bill?.current_balance != null) {
@ -586,9 +540,8 @@ router.delete('/:id', (req, res) => {
`).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id); `).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); 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 }); res.json({ success: true });
}); });
@ -599,12 +552,9 @@ router.post('/:id/restore', (req, res) => {
if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id')); if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res); if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
// Atomic: re-apply the balance delta and un-delete the payment together, so a // Re-apply the balance delta (undo the reversal done on delete).
// failure can't leave the balance re-applied while the payment stays deleted // If this payment originally charged interest, restore interest_accrued_month
// (or vice versa). If this payment originally charged interest, restore // to the month of the payment so future same-month payments skip interest.
// 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) { 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); 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) { if (bill?.current_balance != null) {
@ -619,9 +569,8 @@ router.post('/:id/restore', (req, res) => {
`).run(reapplied, interestMonth, interestMonth, payment.bill_id); `).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); 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))); 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,7 +196,6 @@ app.use((err, req, res, next) => {
res.status(err.status || 500).json({ res.status(err.status || 500).json({
error: 'Internal server error', error: 'Internal server error',
code: 'INTERNAL_ERROR',
}); });
}); });

View File

@ -1,123 +0,0 @@
'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

@ -1,109 +0,0 @@
'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);
});