Compare commits
No commits in common. "03d15bd0616acc5b8825c4a8081db127b2bbbd0a" and "6a2db3780f66acce10e574434220ff72a79106fb" have entirely different histories.
03d15bd061
...
6a2db3780f
18
HISTORY.md
18
HISTORY.md
|
|
@ -1,24 +1,6 @@
|
|||
# Bill Tracker — Changelog
|
||||
## v0.41.0
|
||||
|
||||
### 🔌 API response typing cleanup (Track C) — promoting page shapes into `@/types`, endpoint by endpoint
|
||||
|
||||
- **[Client] Auth: `User` moved to its proper home + auth endpoints typed.** `User` lived oddly in `@/hooks/useAuth`; moved it to `@/types` (re-exported from `useAuth` so the 7 importers keep working) and typed `me`/`login`/`totpChallenge`/`hasUsers` with `get<T>`/`post<T>`, dropping those casts on the Admin + Login pages. *(Deliberate stop: the remaining single-consumer, non-money casts — `adminUsers` with its page-local `AdminUser`, and the one-off `version`/`about`/`privacy`/`health`/`importHistory` doc shapes — are left as-is; central types add ~nothing for a single reader, and typing them is the low-value tail. Likewise the **optimistic-UI/`toast.promise` rewrite** (planned Track D) is intentionally not done — it's behavior-visible churn on already-working handlers, the worst risk/reward of the remaining work.)*
|
||||
- **[Server] Error-shape consistency (Track D).** The global 500 handler ([server.js](server.js)) returned `{ error: 'Internal server error' }` with no `code`; added `code: 'INTERNAL_ERROR'` so an unexpected server error carries the same `{error, code}` field the client can switch on as every structured 4xx.
|
||||
- **[Client] Snowball projection + settings typed.** Promoted `SnowballProjection` (+ `SnowballProjectionDetail`/`AvalancheProjectionDetail`/`SnowballDebtProjection`) and `SnowballSettings` into `@/types`, and wired `snowball` (→ `Bill[]`), `snowballSettings`/`saveSnowballSettings` (→ `SnowballSettings`), and `snowballProjection` (→ `SnowballProjection`). Removed the projection/settings/bills casts. (The plan-lifecycle endpoints still cast to `SnowballPlan`, which is a component-owned type in `PlanHistoryPanel` — left as-is to avoid a cross-file type move for marginal gain.)
|
||||
- **[Client] Subscriptions domain typed.** Promoted the `SubscriptionsPage` shapes into `@/types` (`Subscription`, `SubscriptionSummary`/`…TopType`, `SubscriptionsResponse`, `Recommendation` + `RecommendationEvidence`/`…Transaction`/`ExistingBillMatch`/`CatalogMatch`, `SubscriptionTx`) and wired `subscriptions`/`subscriptionRecommendations`/`subscriptionTransactionMatches`/`createSubscriptionFromRecommendation`/`matchRecommendationToBill` with `get<T>`/`post<T>`. Dropped the `(r: any)` in `useSubscriptionRecommendations` (now typed) and the 5 page casts; removed an unused `CatalogMatch` import (Track E).
|
||||
- **[Client] Spending domain typed.** Promoted `SpendingPage`'s local response interfaces into `@/types` (`SpendingCategoryEntry`, `SpendingSummary`, `SpendingTransaction`/`…Response`, `SpendingIncomeTx`/`…Response`, `SpendingRule`, `CategoryGroup`, `CopyBudgetsResponse`) and wired `spendingSummary`/`spendingTransactions`/`spendingIncome`/`spendingCategoryRules`/`copySpendingBudgets`/`categoryGroups` with `get<T>`/`post<T>`. `useSpendingSummary`/`useSpendingTransactions`/`useCategoryGroups` now return typed data; the 6 spending `as {…}` casts are gone. Removed 2 now-unused type aliases the cast-removal orphaned (Track E).
|
||||
- **[Client] Bank-ledger domain typed end-to-end.** Promoted the `BankTransactionsPage` local interfaces (`Tx`/`Account`/`Ledger`/`LedgerSummary`/`CategoryBreakdown`/auto-categorize preview) into `@/types` as `BankLedgerTransaction` (extends the existing `BankTransaction`, so raw amounts stay branded `Cents`), `BankAccount`, `BankLedger`, `BankLedgerSummary`, `BankCategoryBreakdown`, `AutoCategorizePreview`. Wired the endpoints in `api.ts` (`bankTransactionsLedger` → `get<BankLedger>`, `matchTransaction`/`unmatchTransaction` → `{ transaction }`, `ignoreTransaction`/`unignoreTransaction` → the transaction, `applyTransactionMerchantMatch`/`autoCategorizeTransactions` → their result shapes), so `useBankLedger`'s `data` is now typed and **all 9 `as {…}` casts in the page are gone** — a server field rename now fails in `@/types`, not silently at 9 call sites. Dropped a now-dead `Cents` import (Track E). typecheck/lint/build clean.
|
||||
|
||||
### 🧮 Cross-surface money reconciliation harness (Track B) — pins "same number, same truth"
|
||||
|
||||
- **[Money integrity] Added `tests/reconciliation.test.js` — the totals every surface shows for a month must agree.** The app has repeatedly shipped "same number, different truth" drift (QA-B5/B9) where the Tracker, Summary, Analytics, Bills, and bank ledger each gate a bill's monthly occurrence slightly differently — so an annual/off-month bill inflates one surface's "expected" but not another's. The harness hand-seeds the exact shapes that historically broke gating (a $900 **off-month annual** bill, a **quarterly** bill that *does* fall in the month, plus plain monthly bills with a full + a partial payment) and pins the invariants, **comparing in integer cents** (never post-`fromCents` floats, which drift on rounding): `Analytics.expected` == `Tracker.total_expected`, `Σ resolveDueDate-gated Bills` == `Tracker.total_expected`, `Summary.expense_total` (via the real `GET /summary` handler) == `Tracker.total_expected`, `Analytics.actual` == `Tracker.total_paid`, and — the linchpin — the off-month annual bill inflates **no** surface. All four money surfaces reconcile today (the QA-B5 fixes hold); the harness makes a future regression fail loudly. (Bank-ledger occurrence gating is already pinned by `trackerService.test.js`.) Full suite 193 green.
|
||||
|
||||
### 💰 Payment atomicity audit (Track A) — every balance mutation is now transactional
|
||||
|
||||
- **[Money integrity] Wrapped the four remaining non-atomic payment paths in `db.transaction()`.** Quick-pay and bulk-pay were already atomic (X1), but the audit found the INSERT/UPDATE + `applyBalanceDelta` pair split across two un-transactional statements on **manual `POST /payments`**, **autopay-suggestion confirm**, **`DELETE` (unpay)**, and **`POST /:id/restore`** — plus **`PUT` (edit)**, where the bill-balance write and the payment `UPDATE` weren't atomic. A crash/constraint failure between the two statements could leave a payment without its balance adjustment (or a balance reversed without the row deleted). Each is now a single transaction: the row write and the balance mutation land together or neither. Behavior-preserving — `tests/paymentsRoute.test.js` pins the create → delete → restore → edit balance round-trip and the double-restore no-op.
|
||||
- **[Money integrity] Manual `POST /payments` now flags a suspected duplicate instead of blindly creating one — without silently dropping a legitimate one.** The deliberate manual-add path had *no* dedupe guard, so a double-submit created a second payment. But unlike the automated one-click paths (`/quick`, `/bulk`, autopay-confirm — which dedupe *silently* because a repeat there is a retry), the manual path may legitimately record two identical payments on the same day, so silently deduping would *lose a real payment* — itself a money bug. Resolution: the server returns **`409 DUPLICATE_SUSPECTED`** (with the existing payment attached) on a same `bill+date+amount`; a shared client helper (`client/lib/paymentActions.ts`, used by the tracker inline cell, the partial-payment ledger dialog, and the bill modal) turns that into an **"add anyway?"** toast that resends with `allow_duplicate`. Best of both: retry-safety on the auto paths, no lost-write on the deliberate one. (A DB `UNIQUE` index was rejected — it would forbid legitimately-identical payments; an idempotency-key header is the documented upgrade if this ever goes multi-process.) Full server suite 188 green; e2e probe 17/17. (Track A)
|
||||
|
||||
### 🔷 TypeScript foundation + branded money types
|
||||
|
||||
- **[Client] Adopted TypeScript incrementally, starting with the money boundary** — the move off plain JS. Upgraded `jsconfig` → `tsconfig` with full `strict` + `noUncheckedIndexedAccess`, but `allowJs` + `checkJs:false` so every existing `.js`/`.jsx` keeps resolving and running untouched — only `.ts`/`.tsx` are type-checked, so it's a file-by-file migration with nothing breaking in between. Added `npm run typecheck` (`tsc --noEmit`) and wired it into `npm run ci`. Installed TypeScript 6 + `@types/react`/`react-dom` 19.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,4 @@
|
|||
import type {
|
||||
Bill, Category, Payment, TrackerResponse, User,
|
||||
BankLedger, BankLedgerTransaction, AutoCategorizePreview,
|
||||
SpendingSummary, SpendingTransactionsResponse, SpendingIncomeResponse,
|
||||
SpendingRule, CopyBudgetsResponse, CategoryGroup,
|
||||
SubscriptionsResponse, Recommendation, SubscriptionTx,
|
||||
SnowballProjection, SnowballSettings,
|
||||
} from '@/types';
|
||||
import type { Bill, Category, Payment, TrackerResponse } from '@/types';
|
||||
|
||||
// Fetch CSRF token from the server once and cache in memory.
|
||||
// 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 = {
|
||||
// Auth
|
||||
me: () => get<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'),
|
||||
me: () => get('/auth/me'),
|
||||
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'),
|
||||
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
|
||||
changePassword: (data: Body) => post('/auth/change-password', data),
|
||||
|
|
@ -118,14 +111,14 @@ export const api = {
|
|||
acknowledgeVersion: () => post('/auth/acknowledge-version'),
|
||||
loginHistory: () => get('/auth/login-history'),
|
||||
// Spending
|
||||
spendingSummary: (p?: QueryParams) => get<SpendingSummary>('/spending/summary', p),
|
||||
spendingTransactions:(p?: QueryParams) => get<SpendingTransactionsResponse>('/spending/transactions', p),
|
||||
spendingSummary: (p?: QueryParams) => get('/spending/summary', p),
|
||||
spendingTransactions:(p?: QueryParams) => get('/spending/transactions', p),
|
||||
categorizeTransaction: (id: Id, d: Body) => patch(`/spending/transactions/${id}/category`, d),
|
||||
spendingBudgets: (p?: QueryParams) => get('/spending/budgets', p),
|
||||
setSpendingBudget: (d: Body) => put('/spending/budgets', d),
|
||||
copySpendingBudgets: (d: Body) => post<CopyBudgetsResponse>('/spending/budgets/copy', d),
|
||||
spendingIncome: (p?: QueryParams) => get<SpendingIncomeResponse>('/spending/income', p),
|
||||
spendingCategoryRules: () => get<{ rules?: SpendingRule[] }>('/spending/category-rules'),
|
||||
copySpendingBudgets: (d: Body) => post('/spending/budgets/copy', d),
|
||||
spendingIncome: (p?: QueryParams) => get('/spending/income', p),
|
||||
spendingCategoryRules: () => get('/spending/category-rules'),
|
||||
addSpendingRule: (d: Body) => post('/spending/category-rules', d),
|
||||
deleteSpendingRule: (id: Id) => del(`/spending/category-rules/${id}`),
|
||||
|
||||
|
|
@ -133,7 +126,7 @@ export const api = {
|
|||
totpSetup: () => get('/auth/totp/setup'),
|
||||
totpEnable: (data: Body) => post('/auth/totp/enable', data),
|
||||
totpDisable: (data: Body) => post('/auth/totp/disable', data),
|
||||
totpChallenge: (data: Body) => post<{ user?: User }>('/auth/totp/challenge', data),
|
||||
totpChallenge: (data: Body) => post('/auth/totp/challenge', data),
|
||||
|
||||
webauthnStatus: () => get('/auth/webauthn/status'),
|
||||
webauthnSetup: () => get('/auth/webauthn/setup'),
|
||||
|
|
@ -144,7 +137,7 @@ export const api = {
|
|||
webauthnChallenge: (data: Body) => post('/auth/webauthn/challenge', data),
|
||||
|
||||
// Admin
|
||||
hasUsers: () => get<{ has_users?: boolean }>('/admin/has-users'),
|
||||
hasUsers: () => get('/admin/has-users'),
|
||||
adminUsers: () => get('/admin/users'),
|
||||
createUser: (data: Body) => post('/admin/users', data),
|
||||
resetPassword: (id: Id, data: Body) => put(`/admin/users/${id}/password`, data),
|
||||
|
|
@ -287,19 +280,19 @@ export const api = {
|
|||
deleteBillTemplate: (id: Id) => del(`/bills/templates/${id}`),
|
||||
|
||||
// Subscriptions
|
||||
subscriptions: () => get<SubscriptionsResponse>('/subscriptions'),
|
||||
subscriptions: () => get('/subscriptions'),
|
||||
confirmTransactionMatch: (transactionId: Id, billId: Id) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }),
|
||||
matchRecommendationToBill: (transactionIds: unknown, billId: Id, merchant: unknown, catalogId: unknown, confidence: unknown) => post<{ 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,
|
||||
bill_id: billId,
|
||||
merchant,
|
||||
catalog_id: catalogId,
|
||||
confidence,
|
||||
}),
|
||||
subscriptionRecommendations: () => get<{ recommendations?: Recommendation[] }>('/subscriptions/recommendations'),
|
||||
subscriptionTransactionMatches: (params: QueryParams = {}) => get<SubscriptionTx[] | { transactions?: SubscriptionTx[] }>(`/subscriptions/transaction-matches${queryString(params)}`),
|
||||
subscriptionRecommendations: () => get('/subscriptions/recommendations'),
|
||||
subscriptionTransactionMatches: (params: QueryParams = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`),
|
||||
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', {
|
||||
decline_key: recommendation?.decline_key || recommendation,
|
||||
catalog_id: recommendation?.catalog_match?.id,
|
||||
|
|
@ -324,11 +317,11 @@ export const api = {
|
|||
undoAutoMatch: (id: Id) => post(`/payments/${id}/undo-auto`),
|
||||
|
||||
// Snowball
|
||||
snowball: () => get<Bill[]>('/snowball'),
|
||||
snowballSettings: () => get<SnowballSettings>('/snowball/settings'),
|
||||
saveSnowballSettings: (data: Body) => _fetch<SnowballSettings>('PATCH', '/snowball/settings', data),
|
||||
snowball: () => get('/snowball'),
|
||||
snowballSettings: () => get('/snowball/settings'),
|
||||
saveSnowballSettings: (data: Body) => _fetch('PATCH', '/snowball/settings', data),
|
||||
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'),
|
||||
snowballActivePlan: () => get('/snowball/plans/active'),
|
||||
startSnowballPlan: (data: Body) => post('/snowball/plans', data),
|
||||
|
|
@ -348,7 +341,7 @@ export const api = {
|
|||
restoreCategory: (id: Id) => post(`/categories/${id}/restore`),
|
||||
|
||||
// Category groups
|
||||
categoryGroups: () => get<CategoryGroup[]>('/categories/groups'),
|
||||
categoryGroups: () => get('/categories/groups'),
|
||||
createCategoryGroup: (data: Body) => post('/categories/groups', data),
|
||||
updateCategoryGroup: (id: Id, data: Body) => put(`/categories/groups/${id}`, data),
|
||||
deleteCategoryGroup: (id: Id) => del(`/categories/groups/${id}`),
|
||||
|
|
@ -468,18 +461,18 @@ export const api = {
|
|||
|
||||
// Transactions
|
||||
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),
|
||||
updateTransaction: (id: Id, data: Body) => put(`/transactions/${id}`, data),
|
||||
deleteTransaction: (id: Id) => del(`/transactions/${id}`),
|
||||
matchTransaction: (id: Id, billId: Id) => post<{ transaction: BankLedgerTransaction }>(`/transactions/${id}/match`, { billId }),
|
||||
unmatchTransaction: (id: Id) => post<{ transaction: BankLedgerTransaction }>(`/transactions/${id}/unmatch`),
|
||||
matchTransaction: (id: Id, billId: Id) => post(`/transactions/${id}/match`, { billId }),
|
||||
unmatchTransaction: (id: Id) => post(`/transactions/${id}/unmatch`),
|
||||
unmatchTransactionBulk: (matches: Body) => post('/transactions/unmatch-bulk', { matches }),
|
||||
ignoreTransaction: (id: Id) => post<BankLedgerTransaction>(`/transactions/${id}/ignore`),
|
||||
unignoreTransaction: (id: Id) => post<BankLedgerTransaction>(`/transactions/${id}/unignore`),
|
||||
ignoreTransaction: (id: Id) => post(`/transactions/${id}/ignore`),
|
||||
unignoreTransaction: (id: Id) => post(`/transactions/${id}/unignore`),
|
||||
transactionMerchantMatch: (id: Id) => get(`/transactions/${id}/merchant-match`),
|
||||
applyTransactionMerchantMatch: (id: Id) => post<{ matched?: boolean; category?: { id: number; name: string } }>(`/transactions/${id}/apply-merchant-match`),
|
||||
autoCategorizeTransactions: (opts: Body = {}) => post<AutoCategorizePreview>('/transactions/auto-categorize', opts),
|
||||
applyTransactionMerchantMatch: (id: Id) => post(`/transactions/${id}/apply-merchant-match`),
|
||||
autoCategorizeTransactions: (opts: Body = {}) => post('/transactions/auto-categorize', opts),
|
||||
|
||||
// Match suggestions
|
||||
matchSuggestions: (params: QueryParams = {}) => get(`/matches/suggestions${queryString(params)}`),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import {
|
|||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { api } from '@/api';
|
||||
import { createPaymentOrConfirm } from '@/lib/paymentActions';
|
||||
import { cn, todayStr, errMessage } from '@/lib/utils';
|
||||
import DebtDetailsSection from '@/components/bill-modal/DebtDetailsSection';
|
||||
import AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator';
|
||||
|
|
@ -369,22 +368,17 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
|||
notes: paymentNotes || null,
|
||||
payment_source: 'manual',
|
||||
};
|
||||
const finishPaymentSave = async (successMessage: string) => {
|
||||
toast.success(successMessage);
|
||||
if (editingPayment) {
|
||||
await api.updatePayment(editingPayment.id, payload);
|
||||
toast.success('Payment updated');
|
||||
} else {
|
||||
await api.createPayment({ ...payload, bill_id: bill?.id });
|
||||
toast.success('Payment added');
|
||||
}
|
||||
resetPaymentForm();
|
||||
setPaymentFormOpen(false);
|
||||
await loadPayments();
|
||||
onSave?.();
|
||||
};
|
||||
if (editingPayment) {
|
||||
await api.updatePayment(editingPayment.id, payload);
|
||||
await finishPaymentSave('Payment updated');
|
||||
} else {
|
||||
await createPaymentOrConfirm(
|
||||
{ ...payload, bill_id: bill?.id },
|
||||
() => finishPaymentSave('Payment added'),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(errMessage(err, 'Payment could not be saved.'));
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { createPaymentOrConfirm } from '@/lib/paymentActions';
|
||||
import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Dollars } from '@/lib/money';
|
||||
|
|
@ -48,18 +47,15 @@ export function EditableCell({ row, field, threshold, defaultPaymentDate, refres
|
|||
if (field === 'amount') update.amount = parseFloat(val);
|
||||
if (field === 'date') update.paid_date = val;
|
||||
await api.updatePayment(row.payments[0]!.id, update);
|
||||
toast.success('Saved');
|
||||
refresh();
|
||||
} else {
|
||||
await createPaymentOrConfirm(
|
||||
{
|
||||
await api.createPayment({
|
||||
bill_id: row.id,
|
||||
amount: field === 'amount' ? parseFloat(val) : threshold,
|
||||
paid_date: field === 'date' ? val : defaultPaymentDate,
|
||||
},
|
||||
() => { toast.success('Saved'); refresh(); },
|
||||
);
|
||||
});
|
||||
}
|
||||
toast.success('Saved');
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(errMessage(err));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { createPaymentOrConfirm } from '@/lib/paymentActions';
|
||||
import { api } from '@/api';
|
||||
import { fmt, fmtDate, errMessage } from '@/lib/utils';
|
||||
import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -53,16 +53,16 @@ export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymen
|
|||
|
||||
setBusy(true);
|
||||
try {
|
||||
await createPaymentOrConfirm(
|
||||
{
|
||||
await api.createPayment({
|
||||
bill_id: row.id,
|
||||
amount: parsedAmount,
|
||||
paid_date: date,
|
||||
method: method === METHOD_NONE ? null : method,
|
||||
notes: notes || null,
|
||||
},
|
||||
() => { toast.success('Partial payment added'); onSaved?.(); onClose?.(); },
|
||||
);
|
||||
});
|
||||
toast.success('Partial payment added');
|
||||
onSaved?.();
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
toast.error(errMessage(err, 'Failed to add payment'));
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
import { createContext, useContext, useEffect, useState, type Dispatch, type SetStateAction, type ReactNode } from 'react';
|
||||
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 {
|
||||
user?: User | null;
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ export function useSubscriptions() {
|
|||
export function useSubscriptionRecommendations() {
|
||||
return useQuery({
|
||||
queryKey: ['subscription-recommendations'],
|
||||
queryFn: () => api.subscriptionRecommendations().then(r => r.recommendations || []),
|
||||
queryFn: () => api.subscriptionRecommendations().then((r: any) => r.recommendations || []),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ export default function AdminPage() {
|
|||
|
||||
const loadMe = useCallback(async () => {
|
||||
try {
|
||||
const d = await api.me();
|
||||
const d = await api.me() as { user?: User };
|
||||
setMe(d.user ?? null);
|
||||
} catch {
|
||||
navigate('/login', { replace: true });
|
||||
|
|
@ -42,7 +42,7 @@ export default function AdminPage() {
|
|||
|
||||
const loadHasUsers = useCallback(async () => {
|
||||
try {
|
||||
const d = await api.hasUsers();
|
||||
const d = await api.hasUsers() as { has_users?: boolean };
|
||||
setHasUsers(!!d.has_users);
|
||||
if (d.has_users) loadUsers();
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -30,17 +30,8 @@ import { CategoryPicker } from '@/components/transactions/CategoryPicker';
|
|||
import { MatchBillDialog } from '@/components/transactions/MatchBillDialog';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import { cn, fmtDate, categoryColor, localDateString, errMessage } from '@/lib/utils';
|
||||
import { formatUSD, asDollars } from '@/lib/money';
|
||||
import type {
|
||||
Bill, Category, BankTransaction,
|
||||
BankLedgerTransaction as Tx,
|
||||
BankAccount as Account,
|
||||
BankLedger as Ledger,
|
||||
BankLedgerSummary as LedgerSummary,
|
||||
BankCategoryBreakdown as CategoryBreakdown,
|
||||
AutoCategorizeChange as AutoCatChange,
|
||||
AutoCategorizePreview as AutoCatPreview,
|
||||
} from '@/types';
|
||||
import { formatUSD, asDollars, type Cents } from '@/lib/money';
|
||||
import type { Bill, Category, BankTransaction } from '@/types';
|
||||
|
||||
function fmt(v: number | null | undefined): string {
|
||||
return formatUSD(asDollars(Number(v) || 0));
|
||||
|
|
@ -49,6 +40,71 @@ function fmt(v: number | null | undefined): string {
|
|||
const PAGE_SIZE = 50;
|
||||
const TABLE_COLUMN_COUNT = 8;
|
||||
|
||||
interface Tx {
|
||||
id: number;
|
||||
amount: Cents;
|
||||
payee?: string | null;
|
||||
description?: string | null;
|
||||
memo?: string | null;
|
||||
category?: string | null;
|
||||
posted_date?: string | null;
|
||||
transacted_at?: string | null;
|
||||
pending?: boolean;
|
||||
ignored?: boolean;
|
||||
match_status?: string | null;
|
||||
matched_bill_name?: string | null;
|
||||
spending_category_id?: number | null;
|
||||
spending_category_name?: string | null;
|
||||
suggested_match?: { display_name?: string; category?: string } | null;
|
||||
account_org_name?: string | null;
|
||||
account_name?: string | null;
|
||||
account_type?: string | null;
|
||||
currency?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Account {
|
||||
id: number | string;
|
||||
org_name?: string;
|
||||
name?: string;
|
||||
account_type?: string;
|
||||
currency?: string;
|
||||
balance?: number;
|
||||
available_balance?: number | null;
|
||||
transaction_count?: number;
|
||||
last_transaction_date?: string;
|
||||
}
|
||||
|
||||
interface CategoryBreakdown { name: string; total?: number; count?: number }
|
||||
|
||||
interface LedgerSummary {
|
||||
inflow?: number;
|
||||
outflow?: number;
|
||||
net?: number;
|
||||
matched?: number;
|
||||
unmatched?: number;
|
||||
pending?: number;
|
||||
total?: number;
|
||||
latest_date?: string;
|
||||
category_breakdown?: CategoryBreakdown[];
|
||||
}
|
||||
|
||||
interface Ledger {
|
||||
accounts?: Account[];
|
||||
transactions?: Tx[];
|
||||
summary?: LedgerSummary;
|
||||
total?: number;
|
||||
sources?: { last_sync_at?: string | null }[];
|
||||
enabled?: boolean;
|
||||
has_connections?: boolean;
|
||||
}
|
||||
|
||||
interface AutoCatChange { transaction_id: number }
|
||||
interface AutoCatPreview {
|
||||
changes?: AutoCatChange[];
|
||||
categories?: { name: string; count?: number }[];
|
||||
}
|
||||
|
||||
const flowOptions = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'in', label: 'Money in' },
|
||||
|
|
@ -328,7 +384,7 @@ export default function BankTransactionsPage() {
|
|||
// React Query keys the ledger on the filters/page.
|
||||
const { data: ledgerRaw = null, isPending: loading, isFetching, error: ledgerErr, refetch: loadLedger } =
|
||||
useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize: PAGE_SIZE });
|
||||
const ledger = ledgerRaw;
|
||||
const ledger = ledgerRaw as Ledger | null;
|
||||
const error = ledgerErr ? (ledgerErr.message || 'Unable to load bank transactions') : '';
|
||||
const setLedger = useCallback((u: (prev: Ledger | undefined) => Ledger | undefined) => queryClient.setQueryData<Ledger>(
|
||||
['bank-ledger', accountId, flow, page, query, sortBy, sortDir], u), [queryClient, accountId, flow, page, query, sortBy, sortDir]);
|
||||
|
|
@ -377,7 +433,7 @@ export default function BankTransactionsPage() {
|
|||
const handleApplySuggestion = useCallback(async (tx: Tx) => {
|
||||
setApplyingSuggestionId(tx.id);
|
||||
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) {
|
||||
updateTransaction(tx.id, {
|
||||
spending_category_id: result.category.id,
|
||||
|
|
@ -396,7 +452,7 @@ export default function BankTransactionsPage() {
|
|||
const handleAutoCategorize = useCallback(async () => {
|
||||
setAutoCategorizing(true);
|
||||
try {
|
||||
const preview = await api.autoCategorizeTransactions({ dry_run: true });
|
||||
const preview = await api.autoCategorizeTransactions({ dry_run: true }) as AutoCatPreview;
|
||||
if (!preview?.changes?.length) {
|
||||
toast.success('No new matches found');
|
||||
return;
|
||||
|
|
@ -423,7 +479,7 @@ export default function BankTransactionsPage() {
|
|||
const handleConfirmAutoCategorize = useCallback(async () => {
|
||||
setAutoCategorizing(true);
|
||||
try {
|
||||
const result = await api.autoCategorizeTransactions();
|
||||
const result = await api.autoCategorizeTransactions() as { changes?: AutoCatChange[] };
|
||||
const changes = result?.changes || [];
|
||||
setAutoCategorizePreview(null);
|
||||
await Promise.all([
|
||||
|
|
@ -445,7 +501,7 @@ export default function BankTransactionsPage() {
|
|||
if (!matchTarget) return;
|
||||
setMatchSubmitting(true);
|
||||
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);
|
||||
setMatchTarget(null);
|
||||
toast.success('Transaction matched to bill');
|
||||
|
|
@ -482,7 +538,7 @@ export default function BankTransactionsPage() {
|
|||
setMatchTarget(null);
|
||||
if (tx && newBill?.id) {
|
||||
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);
|
||||
toast.success('Bill created and matched to transaction');
|
||||
} catch (err) {
|
||||
|
|
@ -494,7 +550,7 @@ export default function BankTransactionsPage() {
|
|||
|
||||
const handleUnmatch = useCallback(async (tx: Tx) => {
|
||||
try {
|
||||
const result = await api.unmatchTransaction(tx.id);
|
||||
const result = await api.unmatchTransaction(tx.id) as { transaction: Tx };
|
||||
updateTransaction(tx.id, result.transaction);
|
||||
toast.success('Transaction unmatched');
|
||||
} catch (err) {
|
||||
|
|
@ -505,7 +561,7 @@ export default function BankTransactionsPage() {
|
|||
|
||||
const handleIgnore = useCallback(async (tx: Tx) => {
|
||||
try {
|
||||
const transaction = await api.ignoreTransaction(tx.id);
|
||||
const transaction = await api.ignoreTransaction(tx.id) as Partial<Tx>;
|
||||
updateTransaction(tx.id, transaction);
|
||||
} catch (err) {
|
||||
toast.error(errMessage(err, 'Failed to ignore transaction'));
|
||||
|
|
@ -515,7 +571,7 @@ export default function BankTransactionsPage() {
|
|||
|
||||
const handleUnignore = useCallback(async (tx: Tx) => {
|
||||
try {
|
||||
const transaction = await api.unignoreTransaction(tx.id);
|
||||
const transaction = await api.unignoreTransaction(tx.id) as Partial<Tx>;
|
||||
updateTransaction(tx.id, transaction);
|
||||
} catch (err) {
|
||||
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)));
|
||||
let applied = 0;
|
||||
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) {
|
||||
updateTransaction(targets[i]!.id, {
|
||||
spending_category_id: val.category.id,
|
||||
|
|
@ -623,7 +679,7 @@ export default function BankTransactionsPage() {
|
|||
let applied = 0;
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'fulfilled') {
|
||||
updateTransaction(targets[i]!.id, r.value);
|
||||
updateTransaction(targets[i]!.id, r.value as Partial<Tx>);
|
||||
applied++;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export default function LoginPage() {
|
|||
setError('');
|
||||
setLoading(true);
|
||||
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) {
|
||||
setTotpChallenge(data.challenge_token ?? null);
|
||||
setTotpCode('');
|
||||
|
|
@ -94,7 +94,7 @@ export default function LoginPage() {
|
|||
setUseRecovery(false);
|
||||
return;
|
||||
}
|
||||
handlePostLogin(data.user!);
|
||||
handlePostLogin(data.user);
|
||||
} catch (err) {
|
||||
setError(errMessage(err, 'Login failed.'));
|
||||
} finally {
|
||||
|
|
@ -110,8 +110,8 @@ export default function LoginPage() {
|
|||
const payload: { challenge_token: string | null; recovery_code?: string; code?: string } = { challenge_token: totpChallenge };
|
||||
if (useRecovery) payload.recovery_code = totpCode;
|
||||
else payload.code = totpCode;
|
||||
const data = await api.totpChallenge(payload);
|
||||
handlePostLogin(data.user!);
|
||||
const data = await api.totpChallenge(payload) as { user: User };
|
||||
handlePostLogin(data.user);
|
||||
} catch (err) {
|
||||
setTotpError(errMessage(err, 'Invalid code.'));
|
||||
setTotpCode('');
|
||||
|
|
|
|||
|
|
@ -19,13 +19,7 @@ import PlanStatusBanner, { type ActivePlan } from '@/components/snowball/PlanSta
|
|||
import PlanHistoryPanel, { type SnowballPlan } from '@/components/snowball/PlanHistoryPanel';
|
||||
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
||||
import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money';
|
||||
import type {
|
||||
Bill, Category,
|
||||
SnowballProjection as Projection,
|
||||
SnowballProjectionDetail as SnowballProj,
|
||||
AvalancheProjectionDetail as AvalancheProj,
|
||||
SnowballSettings,
|
||||
} from '@/types';
|
||||
import type { Bill, Category } from '@/types';
|
||||
|
||||
// ── formatters ────────────────────────────────────────────────────────────────
|
||||
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);
|
||||
}
|
||||
|
||||
interface DebtProj {
|
||||
id: number;
|
||||
name: string;
|
||||
payoff_display?: string;
|
||||
months?: number;
|
||||
total_interest?: number;
|
||||
current_balance?: number;
|
||||
}
|
||||
interface SnowballProj {
|
||||
debts: DebtProj[];
|
||||
skipped: { name: string }[];
|
||||
payoff_display?: string;
|
||||
months_to_freedom?: number;
|
||||
total_interest_paid?: number;
|
||||
capped?: boolean;
|
||||
}
|
||||
interface AvalancheProj {
|
||||
months_to_freedom?: number;
|
||||
total_interest_paid?: number;
|
||||
payoff_display?: string;
|
||||
}
|
||||
interface Projection {
|
||||
snowball?: SnowballProj;
|
||||
avalanche?: AvalancheProj;
|
||||
}
|
||||
|
||||
interface SnowballSettings {
|
||||
extra_payment?: number;
|
||||
ramsey_mode?: boolean;
|
||||
ready_current_on_bills?: boolean;
|
||||
ready_emergency_fund?: boolean;
|
||||
}
|
||||
|
||||
interface ReadinessItem {
|
||||
id: string;
|
||||
|
|
@ -321,11 +347,11 @@ function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, dis
|
|||
export default function SnowballPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: billsRaw = [], isPending: billsPending, error: snowballError } = useSnowball();
|
||||
const bills = billsRaw ?? [];
|
||||
const bills = (billsRaw ?? []) as Bill[];
|
||||
const { data: categoriesRaw = [] } = useCategories();
|
||||
const categories = (categoriesRaw ?? []) as Category[];
|
||||
const { data: settingsRaw } = useSnowballSettings();
|
||||
const settings = settingsRaw;
|
||||
const settings = settingsRaw as SnowballSettings | undefined;
|
||||
const { data: activePlanRaw = null } = useSnowballActivePlan();
|
||||
const activePlan = (activePlanRaw ?? null) as SnowballPlan | null;
|
||||
const { data: allPlansRaw = [] } = useSnowballPlans();
|
||||
|
|
@ -369,7 +395,7 @@ export default function SnowballPage() {
|
|||
try {
|
||||
const extra = parseFloat(typedExtraRef.current);
|
||||
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
|
||||
setProjection(await api.snowballProjection(params));
|
||||
setProjection(await api.snowballProjection(params) as Projection);
|
||||
setProjectionError(false);
|
||||
} catch {
|
||||
setProjectionError(true);
|
||||
|
|
@ -465,7 +491,7 @@ export default function SnowballPage() {
|
|||
if (val === extraPaymentRef.current) return;
|
||||
setSavingSettings(true);
|
||||
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) : '';
|
||||
extraPaymentRef.current = saved;
|
||||
setExtraPayment(saved);
|
||||
|
|
@ -478,7 +504,7 @@ export default function SnowballPage() {
|
|||
const handleRamseyModeChange = async (checked: boolean) => {
|
||||
setSavingSettings(true);
|
||||
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;
|
||||
setRamseyMode(nextMode);
|
||||
setDirty(false);
|
||||
|
|
@ -504,7 +530,7 @@ export default function SnowballPage() {
|
|||
|
||||
setSavingSettings(true);
|
||||
try {
|
||||
const result = await api.saveSnowballSettings(payload);
|
||||
const result = await api.saveSnowballSettings(payload) as SnowballSettings;
|
||||
setReadyCurrentOnBills(!!result.ready_current_on_bills);
|
||||
setReadyEmergencyFund(!!result.ready_emergency_fund);
|
||||
} catch (err) {
|
||||
|
|
@ -557,7 +583,7 @@ export default function SnowballPage() {
|
|||
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
|
||||
try {
|
||||
setProjectionLoading(true);
|
||||
const result = await api.snowballProjection(params);
|
||||
const result = await api.snowballProjection(params) as Projection;
|
||||
setProjection(result);
|
||||
setProjectionError(false);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -20,16 +20,7 @@ import {
|
|||
import { CategoryPicker } from '@/components/transactions/CategoryPicker';
|
||||
import { formatUSD, asDollars } from '@/lib/money';
|
||||
import { errMessage } from '@/lib/utils';
|
||||
import type {
|
||||
Category,
|
||||
SpendingCategoryEntry as CatEntry,
|
||||
SpendingSummary,
|
||||
SpendingTransaction as SpendingTx,
|
||||
SpendingTransactionsResponse as SpendingTxResponse,
|
||||
SpendingIncomeTx as IncomeTx,
|
||||
SpendingRule,
|
||||
CategoryGroup,
|
||||
} from '@/types';
|
||||
import type { Category } from '@/types';
|
||||
|
||||
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';
|
||||
|
||||
interface CatEntry {
|
||||
category_id: number | 'other' | null;
|
||||
category_name?: string;
|
||||
amount: number;
|
||||
budget?: number | null;
|
||||
tx_count?: number;
|
||||
avg_3mo?: number;
|
||||
group_id?: number | null;
|
||||
}
|
||||
type RealCatEntry = CatEntry & { category_id: number };
|
||||
|
||||
interface SpendingSummary {
|
||||
by_category?: CatEntry[];
|
||||
total_spending?: number;
|
||||
income?: number;
|
||||
uncategorized_amount?: number;
|
||||
uncategorized_count?: number;
|
||||
}
|
||||
|
||||
interface SpendingTx {
|
||||
id: number;
|
||||
payee?: string | null;
|
||||
date?: string | null;
|
||||
amount: number;
|
||||
spending_category_id?: number | string | null;
|
||||
spending_category_name?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
interface SpendingTxResponse { transactions?: SpendingTx[]; total?: number; pages?: number }
|
||||
|
||||
interface IncomeTx { id: number; payee?: string | null; date?: string | null; amount: number }
|
||||
interface IncomeResponse { transactions?: IncomeTx[]; total?: number; pages?: number }
|
||||
|
||||
interface SpendingRule { id: number; merchant: string; category_name?: string; category_id?: number }
|
||||
interface CategoryGroup { id: number; name: string }
|
||||
interface CopyBudgetsResponse { copied: number; budgets?: { category_id: number; amount: number }[] }
|
||||
|
||||
type Budgets = Record<string, number | null>;
|
||||
|
||||
// pctBar() returns a progress-bar percent plus a YNAB-style status level:
|
||||
|
|
@ -206,7 +232,7 @@ function IncomeSection({ year, month, totalIncome }: { year: number; month: numb
|
|||
const load = useCallback(async (p = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const d = await api.spendingIncome({ year, month, page: p });
|
||||
const d = await api.spendingIncome({ year, month, page: p }) as IncomeResponse;
|
||||
setRows(d.transactions || []);
|
||||
setPages(d.pages || 1);
|
||||
setPage(p);
|
||||
|
|
@ -289,7 +315,7 @@ function RulesManager({ categories }: { categories: Category[] }) {
|
|||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setRules((await api.spendingCategoryRules()).rules || []); }
|
||||
try { setRules(((await api.spendingCategoryRules()) as { rules?: SpendingRule[] }).rules || []); }
|
||||
catch { toast.error('Failed to load rules'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
|
@ -667,15 +693,15 @@ export default function SpendingPage() {
|
|||
const [txPage, setTxPage] = useState(1);
|
||||
const [activeCat, setActiveCat] = useState<number | null | undefined>(undefined); // undefined = all
|
||||
const { data: summaryRaw = null, isPending: loading, error: summaryErrObj } = useSpendingSummary(year, month);
|
||||
const summary = summaryRaw;
|
||||
const summary = summaryRaw as SpendingSummary | null;
|
||||
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 txTotal = txData?.total || 0;
|
||||
const txPages = txData?.pages || 1;
|
||||
const { data: categories = [], error: catErrObj } = useSpendingCategories();
|
||||
const { data: categoryGroupsRaw = [] } = useCategoryGroups();
|
||||
const categoryGroups = categoryGroupsRaw;
|
||||
const categoryGroups = categoryGroupsRaw as CategoryGroup[];
|
||||
const summaryError = summaryErrObj ? (summaryErrObj.message || 'Failed to load spending summary') : null;
|
||||
const txError = txErrObj ? (txErrObj.message || 'Failed to load transactions') : null;
|
||||
const catError = catErrObj ? (catErrObj.message || 'Failed to load categories') : null;
|
||||
|
|
@ -743,7 +769,7 @@ export default function SpendingPage() {
|
|||
const handleCopyBudgets = async () => {
|
||||
setCopying(true);
|
||||
try {
|
||||
const d = await api.copySpendingBudgets({ year, month });
|
||||
const d = await api.copySpendingBudgets({ year, month }) as CopyBudgetsResponse;
|
||||
if (d.copied === 0) {
|
||||
toast.info('No budgets found in the previous month to copy.');
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -53,15 +53,7 @@ import { getLinkImportPref } from '@/pages/SettingsPage';
|
|||
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
||||
import type {
|
||||
Bill, Category,
|
||||
Subscription,
|
||||
SubscriptionsResponse as SubData,
|
||||
SubscriptionSummary as SubSummary,
|
||||
ExistingBillMatch,
|
||||
Recommendation,
|
||||
SubscriptionTx as SubTx,
|
||||
} from '@/types';
|
||||
import type { Bill, Category } from '@/types';
|
||||
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
|
||||
|
||||
function fmt(v: number | null | undefined): string {
|
||||
|
|
@ -101,6 +93,102 @@ interface CadenceLike {
|
|||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
id: number;
|
||||
name: string;
|
||||
active?: boolean | number;
|
||||
is_subscription?: boolean | number;
|
||||
category_id?: number | null;
|
||||
category_name?: string | null;
|
||||
expected_amount?: number | null;
|
||||
monthly_equivalent?: number | null;
|
||||
yearly_equivalent?: number | null;
|
||||
next_due_date?: string | null;
|
||||
subscription_type?: string;
|
||||
due_day?: number | null;
|
||||
cycle_type?: string | null;
|
||||
billing_cycle?: string | null;
|
||||
cycle_day?: number | string | null;
|
||||
autopay_enabled?: unknown;
|
||||
has_2fa?: unknown;
|
||||
has_merchant_rule?: unknown;
|
||||
has_linked_transactions?: unknown;
|
||||
reminder_days_before?: number | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TopType { type: string; monthly_total: number }
|
||||
interface SubSummary {
|
||||
active_count?: number;
|
||||
paused_count?: number;
|
||||
monthly_total?: number;
|
||||
yearly_total?: number;
|
||||
top_type?: TopType | null;
|
||||
}
|
||||
interface SubData { summary?: SubSummary; subscriptions?: Subscription[] }
|
||||
|
||||
interface CatalogMatch {
|
||||
id?: number | string;
|
||||
name: string;
|
||||
subscription_type?: string;
|
||||
website?: string;
|
||||
starting_monthly_usd?: number | null;
|
||||
starting_annual_usd?: number | null;
|
||||
}
|
||||
interface ExistingBillMatch {
|
||||
id: number;
|
||||
name: string;
|
||||
expected_amount?: number | null;
|
||||
due_day?: number | null;
|
||||
reasons?: string[];
|
||||
}
|
||||
interface RecEvidence {
|
||||
identity?: { label?: string };
|
||||
amount?: { label?: string; match?: string };
|
||||
cadence?: { label?: string; recurring?: boolean };
|
||||
amount_range?: { min: number; max: number };
|
||||
ambiguity?: { ambiguous?: boolean; label?: string; reasons?: string[] };
|
||||
}
|
||||
interface RecTransaction { id: number; payee?: string; description?: string; memo?: string; date?: string; account?: string; amount?: number }
|
||||
interface Recommendation {
|
||||
id: number | string;
|
||||
name: string;
|
||||
subscription_type?: string;
|
||||
occurrence_count?: number;
|
||||
last_seen_date?: string;
|
||||
expected_amount?: number | null;
|
||||
monthly_equivalent?: number | null;
|
||||
confidence?: number;
|
||||
cycle_type?: string;
|
||||
merchant?: string;
|
||||
decline_key?: string;
|
||||
transaction_ids?: (number | string)[];
|
||||
accounts?: string[];
|
||||
reasons?: string[];
|
||||
evidence?: RecEvidence;
|
||||
existing_bill_match?: ExistingBillMatch | null;
|
||||
catalog_match?: CatalogMatch | null;
|
||||
transactions?: RecTransaction[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SubTx {
|
||||
id: number;
|
||||
amount: number;
|
||||
payee?: string;
|
||||
description?: string;
|
||||
memo?: string;
|
||||
account_name?: string;
|
||||
data_source_name?: string;
|
||||
account?: string;
|
||||
match_status?: string;
|
||||
matched_bill_name?: string;
|
||||
catalog_match?: CatalogMatch | null;
|
||||
posted_date?: string;
|
||||
date?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SubDraft {
|
||||
name?: string;
|
||||
category_id?: number | null;
|
||||
|
|
@ -1112,9 +1200,9 @@ type FlatItem =
|
|||
export default function SubscriptionsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
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 recommendations = recommendationsRaw;
|
||||
const recommendations = recommendationsRaw as Recommendation[];
|
||||
const { data: categories = [] } = useCategories();
|
||||
const { data: bills = [] } = useBills();
|
||||
// Optimistic updates write through the query cache so every existing call site
|
||||
|
|
@ -1182,7 +1270,7 @@ export default function SubscriptionsPage() {
|
|||
txDebounce.current = setTimeout(async () => {
|
||||
setTxSearching(true);
|
||||
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 ?? []));
|
||||
} catch (err) {
|
||||
setTxResults([]);
|
||||
|
|
@ -1212,7 +1300,7 @@ export default function SubscriptionsPage() {
|
|||
async function acceptRecommendation(recommendation: Recommendation) {
|
||||
setBusyId(`rec-${recommendation.id}`);
|
||||
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.`);
|
||||
await refreshAll();
|
||||
if (getLinkImportPref() && recommendation.merchant && created?.id) {
|
||||
|
|
@ -1248,7 +1336,7 @@ export default function SubscriptionsPage() {
|
|||
recommendation.merchant,
|
||||
recommendation.catalog_match?.id,
|
||||
recommendation.confidence,
|
||||
);
|
||||
) as { matched_count?: number; bill_name?: string };
|
||||
toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`);
|
||||
setMatchTarget(null);
|
||||
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
|
||||
|
|
|
|||
240
client/types.ts
240
client/types.ts
|
|
@ -10,19 +10,6 @@
|
|||
// sparkline) are left `unknown` and refined when a consumer needs them.
|
||||
import type { Cents, Dollars } from '@/lib/money';
|
||||
|
||||
// The authenticated user (GET /auth/me → `user`). Re-exported from
|
||||
// `@/hooks/useAuth` for the many components that import it from there.
|
||||
export interface User {
|
||||
id: number;
|
||||
username?: string;
|
||||
role?: string;
|
||||
display_name?: string;
|
||||
displayName?: string;
|
||||
name?: string;
|
||||
is_default_admin?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// A bank transaction as serialized by the server. Unlike bill/payment amounts,
|
||||
// raw bank-transaction amounts stay in CENTS on the wire — hence branded `Cents`
|
||||
// (format them with formatCentsUSD, never formatUSD).
|
||||
|
|
@ -41,233 +28,6 @@ export interface BankTransaction {
|
|||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// The bank ledger (GET /transactions/bank-ledger): accounts, the transaction
|
||||
// occurrence with spending/match metadata, and the month summary. Raw
|
||||
// transaction/account amounts stay in CENTS on this endpoint (the page formats
|
||||
// them with a local formatCents), so they're plain `number` cents, not Dollars.
|
||||
export interface BankLedgerTransaction extends BankTransaction {
|
||||
transacted_at?: string | null; // narrower than BankTransaction's for date slicing
|
||||
category?: string | null;
|
||||
pending?: boolean;
|
||||
ignored?: boolean;
|
||||
match_status?: string | null;
|
||||
matched_bill_name?: string | null;
|
||||
spending_category_id?: number | null;
|
||||
spending_category_name?: string | null;
|
||||
suggested_match?: { display_name?: string; category?: string } | null;
|
||||
account_org_name?: string | null;
|
||||
account_type?: string | null;
|
||||
}
|
||||
|
||||
export interface BankAccount {
|
||||
id: number | string;
|
||||
org_name?: string;
|
||||
name?: string;
|
||||
account_type?: string;
|
||||
currency?: string;
|
||||
balance?: number;
|
||||
available_balance?: number | null;
|
||||
transaction_count?: number;
|
||||
last_transaction_date?: string;
|
||||
}
|
||||
|
||||
export interface BankCategoryBreakdown { name: string; total?: number; count?: number }
|
||||
|
||||
export interface BankLedgerSummary {
|
||||
inflow?: number;
|
||||
outflow?: number;
|
||||
net?: number;
|
||||
matched?: number;
|
||||
unmatched?: number;
|
||||
pending?: number;
|
||||
total?: number;
|
||||
latest_date?: string;
|
||||
category_breakdown?: BankCategoryBreakdown[];
|
||||
}
|
||||
|
||||
export interface BankLedger {
|
||||
accounts?: BankAccount[];
|
||||
transactions?: BankLedgerTransaction[];
|
||||
summary?: BankLedgerSummary;
|
||||
total?: number;
|
||||
sources?: { last_sync_at?: string | null }[];
|
||||
enabled?: boolean;
|
||||
has_connections?: boolean;
|
||||
}
|
||||
|
||||
export interface AutoCategorizeChange { transaction_id: number }
|
||||
export interface AutoCategorizePreview {
|
||||
changes?: AutoCategorizeChange[];
|
||||
categories?: { name: string; count?: number }[];
|
||||
}
|
||||
|
||||
// The spending page (GET /spending/*). Amounts are dollars (server fromCents);
|
||||
// kept as plain `number` because the page works in numbers via a local formatter.
|
||||
export interface SpendingCategoryEntry {
|
||||
category_id: number | 'other' | null;
|
||||
category_name?: string;
|
||||
amount: number;
|
||||
budget?: number | null;
|
||||
tx_count?: number;
|
||||
avg_3mo?: number;
|
||||
group_id?: number | null;
|
||||
}
|
||||
export interface SpendingSummary {
|
||||
by_category?: SpendingCategoryEntry[];
|
||||
total_spending?: number;
|
||||
income?: number;
|
||||
uncategorized_amount?: number;
|
||||
uncategorized_count?: number;
|
||||
}
|
||||
export interface SpendingTransaction {
|
||||
id: number;
|
||||
payee?: string | null;
|
||||
date?: string | null;
|
||||
amount: number;
|
||||
spending_category_id?: number | string | null;
|
||||
spending_category_name?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface SpendingTransactionsResponse { transactions?: SpendingTransaction[]; total?: number; pages?: number }
|
||||
export interface SpendingIncomeTx { id: number; payee?: string | null; date?: string | null; amount: number }
|
||||
export interface SpendingIncomeResponse { transactions?: SpendingIncomeTx[]; total?: number; pages?: number }
|
||||
export interface SpendingRule { id: number; merchant: string; category_name?: string; category_id?: number }
|
||||
export interface CategoryGroup { id: number; name: string }
|
||||
export interface CopyBudgetsResponse { copied: number; budgets?: { category_id: number; amount: number }[] }
|
||||
|
||||
// The subscriptions page (GET /subscriptions, /subscriptions/recommendations,
|
||||
// /subscriptions/transaction-matches). A Subscription is a decorated Bill;
|
||||
// amounts here are dollars kept as plain `number` (the page uses a local
|
||||
// formatter), except SubTx.amount which is a raw bank amount in CENTS.
|
||||
export interface Subscription {
|
||||
id: number;
|
||||
name: string;
|
||||
active?: boolean | number;
|
||||
is_subscription?: boolean | number;
|
||||
category_id?: number | null;
|
||||
category_name?: string | null;
|
||||
expected_amount?: number | null;
|
||||
monthly_equivalent?: number | null;
|
||||
yearly_equivalent?: number | null;
|
||||
next_due_date?: string | null;
|
||||
subscription_type?: string;
|
||||
due_day?: number | null;
|
||||
cycle_type?: string | null;
|
||||
billing_cycle?: string | null;
|
||||
cycle_day?: number | string | null;
|
||||
autopay_enabled?: unknown;
|
||||
has_2fa?: unknown;
|
||||
has_merchant_rule?: unknown;
|
||||
has_linked_transactions?: unknown;
|
||||
reminder_days_before?: number | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export interface SubscriptionTopType { type: string; monthly_total: number }
|
||||
export interface SubscriptionSummary {
|
||||
active_count?: number;
|
||||
paused_count?: number;
|
||||
monthly_total?: number;
|
||||
yearly_total?: number;
|
||||
top_type?: SubscriptionTopType | null;
|
||||
}
|
||||
export interface SubscriptionsResponse { summary?: SubscriptionSummary; subscriptions?: Subscription[] }
|
||||
|
||||
export interface CatalogMatch {
|
||||
id?: number | string;
|
||||
name: string;
|
||||
subscription_type?: string;
|
||||
website?: string;
|
||||
starting_monthly_usd?: number | null;
|
||||
starting_annual_usd?: number | null;
|
||||
}
|
||||
export interface ExistingBillMatch {
|
||||
id: number;
|
||||
name: string;
|
||||
expected_amount?: number | null;
|
||||
due_day?: number | null;
|
||||
reasons?: string[];
|
||||
}
|
||||
export interface RecommendationEvidence {
|
||||
identity?: { label?: string };
|
||||
amount?: { label?: string; match?: string };
|
||||
cadence?: { label?: string; recurring?: boolean };
|
||||
amount_range?: { min: number; max: number };
|
||||
ambiguity?: { ambiguous?: boolean; label?: string; reasons?: string[] };
|
||||
}
|
||||
export interface RecommendationTransaction { id: number; payee?: string; description?: string; memo?: string; date?: string; account?: string; amount?: number }
|
||||
export interface Recommendation {
|
||||
id: number | string;
|
||||
name: string;
|
||||
subscription_type?: string;
|
||||
occurrence_count?: number;
|
||||
last_seen_date?: string;
|
||||
expected_amount?: number | null;
|
||||
monthly_equivalent?: number | null;
|
||||
confidence?: number;
|
||||
cycle_type?: string;
|
||||
merchant?: string;
|
||||
decline_key?: string;
|
||||
transaction_ids?: (number | string)[];
|
||||
accounts?: string[];
|
||||
reasons?: string[];
|
||||
evidence?: RecommendationEvidence;
|
||||
existing_bill_match?: ExistingBillMatch | null;
|
||||
catalog_match?: CatalogMatch | null;
|
||||
transactions?: RecommendationTransaction[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
// The snowball projection (GET /snowball/projection) and settings
|
||||
// (PATCH /snowball/settings). Amounts are dollars kept as plain `number`.
|
||||
export interface SnowballDebtProjection {
|
||||
id: number;
|
||||
name: string;
|
||||
payoff_display?: string;
|
||||
months?: number;
|
||||
total_interest?: number;
|
||||
current_balance?: number;
|
||||
}
|
||||
export interface SnowballProjectionDetail {
|
||||
debts: SnowballDebtProjection[];
|
||||
skipped: { name: string }[];
|
||||
payoff_display?: string;
|
||||
months_to_freedom?: number;
|
||||
total_interest_paid?: number;
|
||||
capped?: boolean;
|
||||
}
|
||||
export interface AvalancheProjectionDetail {
|
||||
months_to_freedom?: number;
|
||||
total_interest_paid?: number;
|
||||
payoff_display?: string;
|
||||
}
|
||||
export interface SnowballProjection {
|
||||
snowball?: SnowballProjectionDetail;
|
||||
avalanche?: AvalancheProjectionDetail;
|
||||
}
|
||||
export interface SnowballSettings {
|
||||
extra_payment?: number;
|
||||
ramsey_mode?: boolean;
|
||||
ready_current_on_bills?: boolean;
|
||||
ready_emergency_fund?: boolean;
|
||||
}
|
||||
|
||||
// A raw bank transaction on the subscription-match search — amount in CENTS.
|
||||
export interface SubscriptionTx {
|
||||
id: number;
|
||||
amount: number;
|
||||
payee?: string;
|
||||
description?: string;
|
||||
memo?: string;
|
||||
account_name?: string;
|
||||
data_source_name?: string;
|
||||
account?: string;
|
||||
match_status?: string;
|
||||
matched_bill_name?: string;
|
||||
catalog_match?: CatalogMatch | null;
|
||||
posted_date?: string;
|
||||
date?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// A bill's month status. Mirrors the server's statusService.
|
||||
export type TrackerStatus =
|
||||
| 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Status:** Current code reference
|
||||
**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
|
||||
|
||||
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.
|
||||
- **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
|
||||
|
|
@ -410,9 +403,8 @@ Response conventions:
|
|||
- Unauthenticated: `401`.
|
||||
- Forbidden: `403`.
|
||||
- Missing resource: `404`.
|
||||
- Conflict: `409` (also used for `DUPLICATE_SUSPECTED` on `POST /payments` — see §5.5).
|
||||
- Conflict: `409`.
|
||||
- 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`)
|
||||
|
||||
|
|
@ -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`.
|
||||
|
||||
**Track A money-integrity notes:**
|
||||
|
||||
- `POST /payments` body now accepts an optional `allow_duplicate: true` flag. When omitted and a live payment already matches the same `(bill_id, paid_date, amount)`, the route returns `409 { error, code: 'DUPLICATE_SUSPECTED' }` instead of silently deduping — silently dropping a legitimately-identical second payment would be a money-integrity bug. The client uses `client/lib/paymentActions.ts → createPaymentOrConfirm` to surface a sonner "Add anyway?" toast; confirming resends with `allow_duplicate: true`.
|
||||
- Every balance-mutation runs in a single transaction so a partial failure cannot leave `bills.current_balance` half-updated relative to the new payment.
|
||||
- New tests: `tests/paymentsRoute.test.js` (route + transactional behavior) and `tests/reconciliation.test.js` (cross-surface reconciliation harness).
|
||||
|
||||
### 5.6 Categories (`/api/categories`)
|
||||
|
||||
User/admin tracker access. Categories have a `spending_enabled` flag (`v0.88`) and a `sort_order` (`v0.75`). Categories can be grouped via `category_groups` (`v1.06`).
|
||||
|
|
@ -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`.
|
||||
- Non-OK responses throw an `Error` whose `status`/`data`/`details`/`code` match the server's structured `errorFormatter`.
|
||||
- `ApiError` interface carries those fields; `TogglePaidResult` and `PaymentRecord` provide typed payment responses; `QueryParams` types the query-string builder.
|
||||
- Exposes grouped functions for `auth`, `admin`, `notifications`, `profile`, `tracker`, `calendar`, `summary`, `bills`, `payments`, `categories`, `settings`, `analytics`, `status`, `version`/`about`, `import`, `export`, `data-sources`, `transactions`, `matches`, `snowball`, `subscriptions`, `spending`, `monthlyStartingAmounts`, `csvImport`, `matchSuggestions`, `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.
|
||||
- **All 16 import sites** that referenced `@/api.js` were normalized to `@/api` so Vite/TS resolve the `.ts`.
|
||||
- **Track C response typing** — see §7 *Domain types* for the full list. In short, the high-traffic domains (`auth`, `bills`, `payments`, `categories`, `tracker`, `snowball`, `subscriptions`, `spending`, `bank-ledger`) all return typed responses via `get<T>` / `post<T>`; the remaining single-consumer non-money casts (adminUsers, version/about/privacy/health/importHistory) are deliberately left as-is.
|
||||
- **Server error-shape consistency (Track D).** The global 500 handler in `server.js` emits `{ error, code: 'INTERNAL_ERROR' }` so an unexpected server error carries the same `{error, code}` field the client can switch on as every structured 4xx. See §5 *Response conventions*.
|
||||
|
||||
### Domain types — `client/types.ts`
|
||||
|
||||
- Branded `Dollars` and `Cents` money types (from `client/lib/money.ts`).
|
||||
- `User` (moved from `@/hooks/useAuth` in Track C; re-exported from `useAuth` for the 7 importers).
|
||||
- `Bill`, `Payment`, `Category`, `TrackerResponse` (with `summary` / `bank_tracking` / `cashflow` / `rows`).
|
||||
- `DriftBill`, `AutopaySuggestion`, `AmountSuggestion`, `AutopayStats`.
|
||||
- `BankTransaction` (integer cents on the wire).
|
||||
- `BankLedgerTransaction extends BankTransaction` (Track C: bank-ledger domain types) — preserves the `Cents` brand.
|
||||
- `BankAccount`, `BankLedger`, `BankLedgerSummary`, `BankCategoryBreakdown`, `AutoCategorizePreview`, `AutoCategorizeChange`.
|
||||
- `SnowballProjection` (Track C) + `SnowballProjectionDetail` / `AvalancheProjectionDetail` / `SnowballDebtProjection`.
|
||||
- `SnowballSettings` (Track C).
|
||||
- `Subscription`, `SubscriptionTopType`, `SubscriptionSummary`, `SubscriptionsResponse`, `CatalogMatch`, `ExistingBillMatch`, `Recommendation` (Track C: subscriptions domain types) + `RecommendationEvidence` / `RecommendationTransaction` / `SubscriptionTx`.
|
||||
- `SpendingCategoryEntry`, `SpendingSummary`, `SpendingTransaction`, `SpendingTransactionsResponse`, `SpendingIncomeTx`, `SpendingIncomeResponse`, `SpendingRule`, `CategoryGroup`, `CopyBudgetsResponse` (Track C: spending domain types).
|
||||
- `TimelineBill` for the Safe-to-Spend SVG timeline.
|
||||
- Money fields are typed as `Dollars` for bill/payment/tracker/summary amounts and as `Cents` for raw bank transactions — so the unit mixup bug class is unrepresentable in typed code.
|
||||
- A `money.type-test.ts` never-imported guard uses `@ts-expect-error` to assert each unit mixup is a real compile error, so `tsc --noEmit` fails loudly if the branding ever regresses.
|
||||
- **Single-consumer non-money casts left in place** (deliberate Track C stop): `adminUsers` with its page-local `AdminUser`, and the one-off `version` / `about` / `privacy` / `health` / `importHistory` doc shapes. Central types add ~nothing for a single reader; typing them is the low-value tail.
|
||||
|
||||
### Auth state
|
||||
|
||||
`client/hooks/useAuth.tsx`:
|
||||
|
||||
- `AuthContext` typed (a `createContext(null)` would have made `useContext` return `never`).
|
||||
- `User` interface lives in `client/types.ts` (moved there in Track C; `useAuth` re-exports it for the 7 importers that previously imported it from `@/hooks/useAuth`).
|
||||
- Maintains `user`, `singleUserMode`, `loading`.
|
||||
- Calls `api.authMode()` and `api.me()` on startup; `api.me()` is now `get<User>` (typed).
|
||||
- Calls `api.authMode()` and `api.me()` on startup.
|
||||
- Exposes `logout()`, `logoutAll()`, `refresh()`.
|
||||
- `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/`
|
||||
|
||||
- `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.
|
||||
- `billDrafts.ts` — `SourceBill` / `Template` / `Category` shapes for `makeBillDraft`.
|
||||
- `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.
|
||||
- `reorder.ts` — generic `moveInArray<T>`.
|
||||
- `version.ts` — typed release-notes shapes; the Vite-injected `__APP_VERSION__` is declared inline.
|
||||
- `paymentActions.ts` — `createPaymentOrConfirm(payload, onSuccess)` (Track A). The deliberate manual-add path treats the server's `409 DUPLICATE_SUSPECTED` as a question rather than an error: losing a legitimately-identical second payment (same bill, date, amount) is itself a money-integrity bug, so the server returns 409 instead of silently deduping and the helper surfaces a sonner "Add anyway?" toast whose confirm action resends with `allow_duplicate: true`. `onSuccess` runs after any real creation (immediate or confirmed retry). Non-duplicate errors re-throw for the caller's existing `catch`.
|
||||
|
||||
### Frontend build & lint
|
||||
|
||||
|
|
@ -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).
|
||||
- `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.
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ router.post('/:id/undo-auto', (req, res) => {
|
|||
// POST /api/payments — create single payment
|
||||
router.post('/', (req, res) => {
|
||||
const db = getDb();
|
||||
const { bill_id, amount, paid_date, method, notes, payment_source, autopay_failure, 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' });
|
||||
if (validation.error) {
|
||||
|
|
@ -202,41 +202,15 @@ router.post('/', (req, res) => {
|
|||
if (!bill)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
|
||||
// The deliberate manual add must not *silently* drop a same-key payment — a user
|
||||
// may legitimately record two identical payments on the same day, and dropping
|
||||
// one is itself a money-integrity bug. Surface a 409 the client turns into an
|
||||
// "add anyway?" confirm; a resend with allow_duplicate bypasses this. (The
|
||||
// automated one-click paths — /quick, /bulk, autopay-confirm — dedupe silently
|
||||
// instead, because a repeat there is a retry, not a second intent.)
|
||||
if (!allow_duplicate) {
|
||||
const existingDuplicate = db.prepare(
|
||||
`SELECT p.* FROM payments p
|
||||
WHERE p.bill_id = ? AND p.paid_date = ? AND p.amount = ? AND p.${SQL_NOT_DELETED}
|
||||
ORDER BY p.id DESC LIMIT 1`
|
||||
).get(bill.id, payment.paid_date, payment.amount);
|
||||
if (existingDuplicate) {
|
||||
return res.status(409).json({
|
||||
...standardizeError('A matching payment already exists for this bill, date, and amount', 'DUPLICATE_SUSPECTED', 'amount'),
|
||||
existing: serializePayment(existingDuplicate),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const balCalc = computeBalanceDelta(bill, payment.amount);
|
||||
const failureFlag = autopay_failure ? 1 : 0;
|
||||
|
||||
// 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(
|
||||
const result = db.prepare(
|
||||
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source, autopay_failure) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment.payment_source, failureFlag);
|
||||
applyBalanceDelta(db, bill.id, balCalc);
|
||||
return r.lastInsertRowid;
|
||||
});
|
||||
const paymentId = insertManualPayment();
|
||||
|
||||
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(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)
|
||||
|
|
@ -341,11 +315,7 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
|
|||
}
|
||||
|
||||
const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);
|
||||
|
||||
// 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(`
|
||||
const result = db.prepare(`
|
||||
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
|
|
@ -358,14 +328,12 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
|
|||
balCalc?.interest_delta ?? null,
|
||||
'manual',
|
||||
);
|
||||
|
||||
applyBalanceDelta(db, bill.id, balCalc);
|
||||
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
|
||||
.run(req.user.id, bill.id, ym.year, ym.month);
|
||||
return r.lastInsertRowid;
|
||||
});
|
||||
const paymentId = confirmAutopay();
|
||||
|
||||
res.status(201).json({ created: true, payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(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
|
||||
|
|
@ -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);
|
||||
let nextInterestDelta = existing.interest_delta ?? null;
|
||||
// Compute the balance mutation without writing yet — the write happens inside
|
||||
// the transaction below so it lands atomically with the payment UPDATE.
|
||||
let balCalc = null;
|
||||
let paymentPortion = null;
|
||||
let restoredBalance = null;
|
||||
if (bill) {
|
||||
// Reverse only the *payment* portion of the stored delta (not the interest component)
|
||||
// so that interest already charged this month is not double-counted. For legacy rows
|
||||
// where interest_delta is NULL, fall back to reversing the full delta as before.
|
||||
const interestPortion = existing.interest_delta ?? 0;
|
||||
paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null;
|
||||
restoredBalance = bill.current_balance;
|
||||
const paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null;
|
||||
let restoredBalance = bill.current_balance;
|
||||
if (paymentPortion != null && bill.current_balance != null) {
|
||||
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
|
||||
// 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.
|
||||
balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount);
|
||||
const balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount);
|
||||
nextBalanceDelta = balCalc?.balance_delta ?? null;
|
||||
nextInterestDelta = balCalc?.interest_delta ?? null;
|
||||
}
|
||||
|
||||
const { autopay_failure } = req.body;
|
||||
const nextAutopayFailure = autopay_failure !== undefined ? (autopay_failure ? 1 : 0) : existing.autopay_failure;
|
||||
|
||||
// Atomic: the bill-balance write and the payment UPDATE land together or neither,
|
||||
// so an edit never leaves the balance reversed without the row updated (or vice versa).
|
||||
const applyEdit = db.transaction(() => {
|
||||
if (bill) {
|
||||
if (balCalc) {
|
||||
applyBalanceDelta(db, existing.bill_id, balCalc);
|
||||
} else if (paymentPortion != null && restoredBalance != null) {
|
||||
|
|
@ -537,6 +491,10 @@ router.put('/:id', (req, res) => {
|
|||
.run(restoredBalance, existing.bill_id);
|
||||
}
|
||||
}
|
||||
|
||||
const { autopay_failure } = req.body;
|
||||
const nextAutopayFailure = autopay_failure !== undefined ? (autopay_failure ? 1 : 0) : existing.autopay_failure;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE payments SET
|
||||
amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, interest_delta = ?,
|
||||
|
|
@ -555,8 +513,6 @@ router.put('/:id', (req, res) => {
|
|||
req.params.id,
|
||||
req.user.id,
|
||||
);
|
||||
});
|
||||
applyEdit();
|
||||
|
||||
res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id)));
|
||||
});
|
||||
|
|
@ -568,11 +524,9 @@ router.delete('/:id', (req, res) => {
|
|||
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
||||
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
|
||||
|
||||
// Atomic: reverse the balance delta and soft-delete the payment together, so a
|
||||
// failure can't leave the balance restored while the payment is still active
|
||||
// (or vice versa). If this payment charged interest this month, clear
|
||||
// Reverse any balance delta that was stored when this payment was created.
|
||||
// If this payment was the one that charged interest this month, clear
|
||||
// interest_accrued_month so the next payment can re-accrue correctly.
|
||||
const softDeletePayment = db.transaction(() => {
|
||||
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
||||
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
|
||||
if (bill?.current_balance != null) {
|
||||
|
|
@ -586,9 +540,8 @@ router.delete('/:id', (req, res) => {
|
|||
`).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)").run(req.params.id, req.user.id);
|
||||
});
|
||||
softDeletePayment();
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
|
@ -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 (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
|
||||
|
||||
// Atomic: re-apply the balance delta and un-delete the payment together, so a
|
||||
// failure can't leave the balance re-applied while the payment stays deleted
|
||||
// (or vice versa). If this payment originally charged interest, restore
|
||||
// interest_accrued_month to the payment's month so future same-month payments
|
||||
// skip interest.
|
||||
const restorePayment = db.transaction(() => {
|
||||
// Re-apply the balance delta (undo the reversal done on delete).
|
||||
// If this payment originally charged interest, restore interest_accrued_month
|
||||
// to the month of the payment so future same-month payments skip interest.
|
||||
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
||||
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
|
||||
if (bill?.current_balance != null) {
|
||||
|
|
@ -619,9 +569,8 @@ router.post('/:id/restore', (req, res) => {
|
|||
`).run(reapplied, interestMonth, interestMonth, payment.bill_id);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)').run(req.params.id, req.user.id);
|
||||
});
|
||||
restorePayment();
|
||||
res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id)));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -196,7 +196,6 @@ app.use((err, req, res, next) => {
|
|||
|
||||
res.status(err.status || 500).json({
|
||||
error: 'Internal server error',
|
||||
code: 'INTERNAL_ERROR',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue