Compare commits
9 Commits
6a2db3780f
...
03d15bd061
| Author | SHA1 | Date |
|---|---|---|
|
|
03d15bd061 | |
|
|
1df4b1bf87 | |
|
|
65a477cab1 | |
|
|
6bb8c63d39 | |
|
|
8265b4a97f | |
|
|
b267599ba5 | |
|
|
c223f62408 | |
|
|
dd5bf92fc1 | |
|
|
60848964b6 |
18
HISTORY.md
18
HISTORY.md
|
|
@ -1,6 +1,24 @@
|
||||||
# Bill Tracker — Changelog
|
# Bill Tracker — Changelog
|
||||||
## v0.41.0
|
## v0.41.0
|
||||||
|
|
||||||
|
### 🔌 API response typing cleanup (Track C) — promoting page shapes into `@/types`, endpoint by endpoint
|
||||||
|
|
||||||
|
- **[Client] Auth: `User` moved to its proper home + auth endpoints typed.** `User` lived oddly in `@/hooks/useAuth`; moved it to `@/types` (re-exported from `useAuth` so the 7 importers keep working) and typed `me`/`login`/`totpChallenge`/`hasUsers` with `get<T>`/`post<T>`, dropping those casts on the Admin + Login pages. *(Deliberate stop: the remaining single-consumer, non-money casts — `adminUsers` with its page-local `AdminUser`, and the one-off `version`/`about`/`privacy`/`health`/`importHistory` doc shapes — are left as-is; central types add ~nothing for a single reader, and typing them is the low-value tail. Likewise the **optimistic-UI/`toast.promise` rewrite** (planned Track D) is intentionally not done — it's behavior-visible churn on already-working handlers, the worst risk/reward of the remaining work.)*
|
||||||
|
- **[Server] Error-shape consistency (Track D).** The global 500 handler ([server.js](server.js)) returned `{ error: 'Internal server error' }` with no `code`; added `code: 'INTERNAL_ERROR'` so an unexpected server error carries the same `{error, code}` field the client can switch on as every structured 4xx.
|
||||||
|
- **[Client] Snowball projection + settings typed.** Promoted `SnowballProjection` (+ `SnowballProjectionDetail`/`AvalancheProjectionDetail`/`SnowballDebtProjection`) and `SnowballSettings` into `@/types`, and wired `snowball` (→ `Bill[]`), `snowballSettings`/`saveSnowballSettings` (→ `SnowballSettings`), and `snowballProjection` (→ `SnowballProjection`). Removed the projection/settings/bills casts. (The plan-lifecycle endpoints still cast to `SnowballPlan`, which is a component-owned type in `PlanHistoryPanel` — left as-is to avoid a cross-file type move for marginal gain.)
|
||||||
|
- **[Client] Subscriptions domain typed.** Promoted the `SubscriptionsPage` shapes into `@/types` (`Subscription`, `SubscriptionSummary`/`…TopType`, `SubscriptionsResponse`, `Recommendation` + `RecommendationEvidence`/`…Transaction`/`ExistingBillMatch`/`CatalogMatch`, `SubscriptionTx`) and wired `subscriptions`/`subscriptionRecommendations`/`subscriptionTransactionMatches`/`createSubscriptionFromRecommendation`/`matchRecommendationToBill` with `get<T>`/`post<T>`. Dropped the `(r: any)` in `useSubscriptionRecommendations` (now typed) and the 5 page casts; removed an unused `CatalogMatch` import (Track E).
|
||||||
|
- **[Client] Spending domain typed.** Promoted `SpendingPage`'s local response interfaces into `@/types` (`SpendingCategoryEntry`, `SpendingSummary`, `SpendingTransaction`/`…Response`, `SpendingIncomeTx`/`…Response`, `SpendingRule`, `CategoryGroup`, `CopyBudgetsResponse`) and wired `spendingSummary`/`spendingTransactions`/`spendingIncome`/`spendingCategoryRules`/`copySpendingBudgets`/`categoryGroups` with `get<T>`/`post<T>`. `useSpendingSummary`/`useSpendingTransactions`/`useCategoryGroups` now return typed data; the 6 spending `as {…}` casts are gone. Removed 2 now-unused type aliases the cast-removal orphaned (Track E).
|
||||||
|
- **[Client] Bank-ledger domain typed end-to-end.** Promoted the `BankTransactionsPage` local interfaces (`Tx`/`Account`/`Ledger`/`LedgerSummary`/`CategoryBreakdown`/auto-categorize preview) into `@/types` as `BankLedgerTransaction` (extends the existing `BankTransaction`, so raw amounts stay branded `Cents`), `BankAccount`, `BankLedger`, `BankLedgerSummary`, `BankCategoryBreakdown`, `AutoCategorizePreview`. Wired the endpoints in `api.ts` (`bankTransactionsLedger` → `get<BankLedger>`, `matchTransaction`/`unmatchTransaction` → `{ transaction }`, `ignoreTransaction`/`unignoreTransaction` → the transaction, `applyTransactionMerchantMatch`/`autoCategorizeTransactions` → their result shapes), so `useBankLedger`'s `data` is now typed and **all 9 `as {…}` casts in the page are gone** — a server field rename now fails in `@/types`, not silently at 9 call sites. Dropped a now-dead `Cents` import (Track E). typecheck/lint/build clean.
|
||||||
|
|
||||||
|
### 🧮 Cross-surface money reconciliation harness (Track B) — pins "same number, same truth"
|
||||||
|
|
||||||
|
- **[Money integrity] Added `tests/reconciliation.test.js` — the totals every surface shows for a month must agree.** The app has repeatedly shipped "same number, different truth" drift (QA-B5/B9) where the Tracker, Summary, Analytics, Bills, and bank ledger each gate a bill's monthly occurrence slightly differently — so an annual/off-month bill inflates one surface's "expected" but not another's. The harness hand-seeds the exact shapes that historically broke gating (a $900 **off-month annual** bill, a **quarterly** bill that *does* fall in the month, plus plain monthly bills with a full + a partial payment) and pins the invariants, **comparing in integer cents** (never post-`fromCents` floats, which drift on rounding): `Analytics.expected` == `Tracker.total_expected`, `Σ resolveDueDate-gated Bills` == `Tracker.total_expected`, `Summary.expense_total` (via the real `GET /summary` handler) == `Tracker.total_expected`, `Analytics.actual` == `Tracker.total_paid`, and — the linchpin — the off-month annual bill inflates **no** surface. All four money surfaces reconcile today (the QA-B5 fixes hold); the harness makes a future regression fail loudly. (Bank-ledger occurrence gating is already pinned by `trackerService.test.js`.) Full suite 193 green.
|
||||||
|
|
||||||
|
### 💰 Payment atomicity audit (Track A) — every balance mutation is now transactional
|
||||||
|
|
||||||
|
- **[Money integrity] Wrapped the four remaining non-atomic payment paths in `db.transaction()`.** Quick-pay and bulk-pay were already atomic (X1), but the audit found the INSERT/UPDATE + `applyBalanceDelta` pair split across two un-transactional statements on **manual `POST /payments`**, **autopay-suggestion confirm**, **`DELETE` (unpay)**, and **`POST /:id/restore`** — plus **`PUT` (edit)**, where the bill-balance write and the payment `UPDATE` weren't atomic. A crash/constraint failure between the two statements could leave a payment without its balance adjustment (or a balance reversed without the row deleted). Each is now a single transaction: the row write and the balance mutation land together or neither. Behavior-preserving — `tests/paymentsRoute.test.js` pins the create → delete → restore → edit balance round-trip and the double-restore no-op.
|
||||||
|
- **[Money integrity] Manual `POST /payments` now flags a suspected duplicate instead of blindly creating one — without silently dropping a legitimate one.** The deliberate manual-add path had *no* dedupe guard, so a double-submit created a second payment. But unlike the automated one-click paths (`/quick`, `/bulk`, autopay-confirm — which dedupe *silently* because a repeat there is a retry), the manual path may legitimately record two identical payments on the same day, so silently deduping would *lose a real payment* — itself a money bug. Resolution: the server returns **`409 DUPLICATE_SUSPECTED`** (with the existing payment attached) on a same `bill+date+amount`; a shared client helper (`client/lib/paymentActions.ts`, used by the tracker inline cell, the partial-payment ledger dialog, and the bill modal) turns that into an **"add anyway?"** toast that resends with `allow_duplicate`. Best of both: retry-safety on the auto paths, no lost-write on the deliberate one. (A DB `UNIQUE` index was rejected — it would forbid legitimately-identical payments; an idempotency-key header is the documented upgrade if this ever goes multi-process.) Full server suite 188 green; e2e probe 17/17. (Track A)
|
||||||
|
|
||||||
### 🔷 TypeScript foundation + branded money types
|
### 🔷 TypeScript foundation + branded money types
|
||||||
|
|
||||||
- **[Client] Adopted TypeScript incrementally, starting with the money boundary** — the move off plain JS. Upgraded `jsconfig` → `tsconfig` with full `strict` + `noUncheckedIndexedAccess`, but `allowJs` + `checkJs:false` so every existing `.js`/`.jsx` keeps resolving and running untouched — only `.ts`/`.tsx` are type-checked, so it's a file-by-file migration with nothing breaking in between. Added `npm run typecheck` (`tsc --noEmit`) and wired it into `npm run ci`. Installed TypeScript 6 + `@types/react`/`react-dom` 19.
|
- **[Client] Adopted TypeScript incrementally, starting with the money boundary** — the move off plain JS. Upgraded `jsconfig` → `tsconfig` with full `strict` + `noUncheckedIndexedAccess`, but `allowJs` + `checkJs:false` so every existing `.js`/`.jsx` keeps resolving and running untouched — only `.ts`/`.tsx` are type-checked, so it's a file-by-file migration with nothing breaking in between. Added `npm run typecheck` (`tsc --noEmit`) and wired it into `npm run ci`. Installed TypeScript 6 + `@types/react`/`react-dom` 19.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import type { Bill, Category, Payment, TrackerResponse } from '@/types';
|
import type {
|
||||||
|
Bill, Category, Payment, TrackerResponse, User,
|
||||||
|
BankLedger, BankLedgerTransaction, AutoCategorizePreview,
|
||||||
|
SpendingSummary, SpendingTransactionsResponse, SpendingIncomeResponse,
|
||||||
|
SpendingRule, CopyBudgetsResponse, CategoryGroup,
|
||||||
|
SubscriptionsResponse, Recommendation, SubscriptionTx,
|
||||||
|
SnowballProjection, SnowballSettings,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
// Fetch CSRF token from the server once and cache in memory.
|
// Fetch CSRF token from the server once and cache in memory.
|
||||||
// The cookie is httpOnly so document.cookie cannot access it directly.
|
// The cookie is httpOnly so document.cookie cannot access it directly.
|
||||||
|
|
@ -101,9 +108,9 @@ function filenameFromDisposition(value: string | null): string | null {
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// Auth
|
// Auth
|
||||||
me: () => get('/auth/me'),
|
me: () => get<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'),
|
||||||
authMode: () => get('/auth/mode'),
|
authMode: () => get('/auth/mode'),
|
||||||
login: (data: Body) => post('/auth/login', data),
|
login: (data: Body) => post<{ requires_totp?: boolean; challenge_token?: string; user?: User }>('/auth/login', data),
|
||||||
logout: () => post('/auth/logout'),
|
logout: () => post('/auth/logout'),
|
||||||
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
|
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
|
||||||
changePassword: (data: Body) => post('/auth/change-password', data),
|
changePassword: (data: Body) => post('/auth/change-password', data),
|
||||||
|
|
@ -111,14 +118,14 @@ export const api = {
|
||||||
acknowledgeVersion: () => post('/auth/acknowledge-version'),
|
acknowledgeVersion: () => post('/auth/acknowledge-version'),
|
||||||
loginHistory: () => get('/auth/login-history'),
|
loginHistory: () => get('/auth/login-history'),
|
||||||
// Spending
|
// Spending
|
||||||
spendingSummary: (p?: QueryParams) => get('/spending/summary', p),
|
spendingSummary: (p?: QueryParams) => get<SpendingSummary>('/spending/summary', p),
|
||||||
spendingTransactions:(p?: QueryParams) => get('/spending/transactions', p),
|
spendingTransactions:(p?: QueryParams) => get<SpendingTransactionsResponse>('/spending/transactions', p),
|
||||||
categorizeTransaction: (id: Id, d: Body) => patch(`/spending/transactions/${id}/category`, d),
|
categorizeTransaction: (id: Id, d: Body) => patch(`/spending/transactions/${id}/category`, d),
|
||||||
spendingBudgets: (p?: QueryParams) => get('/spending/budgets', p),
|
spendingBudgets: (p?: QueryParams) => get('/spending/budgets', p),
|
||||||
setSpendingBudget: (d: Body) => put('/spending/budgets', d),
|
setSpendingBudget: (d: Body) => put('/spending/budgets', d),
|
||||||
copySpendingBudgets: (d: Body) => post('/spending/budgets/copy', d),
|
copySpendingBudgets: (d: Body) => post<CopyBudgetsResponse>('/spending/budgets/copy', d),
|
||||||
spendingIncome: (p?: QueryParams) => get('/spending/income', p),
|
spendingIncome: (p?: QueryParams) => get<SpendingIncomeResponse>('/spending/income', p),
|
||||||
spendingCategoryRules: () => get('/spending/category-rules'),
|
spendingCategoryRules: () => get<{ rules?: SpendingRule[] }>('/spending/category-rules'),
|
||||||
addSpendingRule: (d: Body) => post('/spending/category-rules', d),
|
addSpendingRule: (d: Body) => post('/spending/category-rules', d),
|
||||||
deleteSpendingRule: (id: Id) => del(`/spending/category-rules/${id}`),
|
deleteSpendingRule: (id: Id) => del(`/spending/category-rules/${id}`),
|
||||||
|
|
||||||
|
|
@ -126,7 +133,7 @@ export const api = {
|
||||||
totpSetup: () => get('/auth/totp/setup'),
|
totpSetup: () => get('/auth/totp/setup'),
|
||||||
totpEnable: (data: Body) => post('/auth/totp/enable', data),
|
totpEnable: (data: Body) => post('/auth/totp/enable', data),
|
||||||
totpDisable: (data: Body) => post('/auth/totp/disable', data),
|
totpDisable: (data: Body) => post('/auth/totp/disable', data),
|
||||||
totpChallenge: (data: Body) => post('/auth/totp/challenge', data),
|
totpChallenge: (data: Body) => post<{ user?: User }>('/auth/totp/challenge', data),
|
||||||
|
|
||||||
webauthnStatus: () => get('/auth/webauthn/status'),
|
webauthnStatus: () => get('/auth/webauthn/status'),
|
||||||
webauthnSetup: () => get('/auth/webauthn/setup'),
|
webauthnSetup: () => get('/auth/webauthn/setup'),
|
||||||
|
|
@ -137,7 +144,7 @@ export const api = {
|
||||||
webauthnChallenge: (data: Body) => post('/auth/webauthn/challenge', data),
|
webauthnChallenge: (data: Body) => post('/auth/webauthn/challenge', data),
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
hasUsers: () => get('/admin/has-users'),
|
hasUsers: () => get<{ has_users?: boolean }>('/admin/has-users'),
|
||||||
adminUsers: () => get('/admin/users'),
|
adminUsers: () => get('/admin/users'),
|
||||||
createUser: (data: Body) => post('/admin/users', data),
|
createUser: (data: Body) => post('/admin/users', data),
|
||||||
resetPassword: (id: Id, data: Body) => put(`/admin/users/${id}/password`, data),
|
resetPassword: (id: Id, data: Body) => put(`/admin/users/${id}/password`, data),
|
||||||
|
|
@ -280,19 +287,19 @@ export const api = {
|
||||||
deleteBillTemplate: (id: Id) => del(`/bills/templates/${id}`),
|
deleteBillTemplate: (id: Id) => del(`/bills/templates/${id}`),
|
||||||
|
|
||||||
// Subscriptions
|
// Subscriptions
|
||||||
subscriptions: () => get('/subscriptions'),
|
subscriptions: () => get<SubscriptionsResponse>('/subscriptions'),
|
||||||
confirmTransactionMatch: (transactionId: Id, billId: Id) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }),
|
confirmTransactionMatch: (transactionId: Id, billId: Id) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }),
|
||||||
matchRecommendationToBill: (transactionIds: unknown, billId: Id, merchant: unknown, catalogId: unknown, confidence: unknown) => post('/subscriptions/recommendations/match-bill', {
|
matchRecommendationToBill: (transactionIds: unknown, billId: Id, merchant: unknown, catalogId: unknown, confidence: unknown) => post<{ matched_count?: number; bill_name?: string }>('/subscriptions/recommendations/match-bill', {
|
||||||
transaction_ids: transactionIds,
|
transaction_ids: transactionIds,
|
||||||
bill_id: billId,
|
bill_id: billId,
|
||||||
merchant,
|
merchant,
|
||||||
catalog_id: catalogId,
|
catalog_id: catalogId,
|
||||||
confidence,
|
confidence,
|
||||||
}),
|
}),
|
||||||
subscriptionRecommendations: () => get('/subscriptions/recommendations'),
|
subscriptionRecommendations: () => get<{ recommendations?: Recommendation[] }>('/subscriptions/recommendations'),
|
||||||
subscriptionTransactionMatches: (params: QueryParams = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`),
|
subscriptionTransactionMatches: (params: QueryParams = {}) => get<SubscriptionTx[] | { transactions?: SubscriptionTx[] }>(`/subscriptions/transaction-matches${queryString(params)}`),
|
||||||
updateSubscription: (id: Id, data: Body) => _fetch('PATCH', `/subscriptions/${id}`, data),
|
updateSubscription: (id: Id, data: Body) => _fetch('PATCH', `/subscriptions/${id}`, data),
|
||||||
createSubscriptionFromRecommendation: (data: Body) => post('/subscriptions/recommendations/create', data),
|
createSubscriptionFromRecommendation: (data: Body) => post<{ id?: number; name?: string }>('/subscriptions/recommendations/create', data),
|
||||||
declineRecommendation: (recommendation: any) => post('/subscriptions/recommendations/decline', {
|
declineRecommendation: (recommendation: any) => post('/subscriptions/recommendations/decline', {
|
||||||
decline_key: recommendation?.decline_key || recommendation,
|
decline_key: recommendation?.decline_key || recommendation,
|
||||||
catalog_id: recommendation?.catalog_match?.id,
|
catalog_id: recommendation?.catalog_match?.id,
|
||||||
|
|
@ -317,11 +324,11 @@ export const api = {
|
||||||
undoAutoMatch: (id: Id) => post(`/payments/${id}/undo-auto`),
|
undoAutoMatch: (id: Id) => post(`/payments/${id}/undo-auto`),
|
||||||
|
|
||||||
// Snowball
|
// Snowball
|
||||||
snowball: () => get('/snowball'),
|
snowball: () => get<Bill[]>('/snowball'),
|
||||||
snowballSettings: () => get('/snowball/settings'),
|
snowballSettings: () => get<SnowballSettings>('/snowball/settings'),
|
||||||
saveSnowballSettings: (data: Body) => _fetch('PATCH', '/snowball/settings', data),
|
saveSnowballSettings: (data: Body) => _fetch<SnowballSettings>('PATCH', '/snowball/settings', data),
|
||||||
saveSnowballOrder: (items: Body) => _fetch('PATCH', '/snowball/order', items),
|
saveSnowballOrder: (items: Body) => _fetch('PATCH', '/snowball/order', items),
|
||||||
snowballProjection: (params: QueryParams = {}) => get(`/snowball/projection${queryString(params)}`),
|
snowballProjection: (params: QueryParams = {}) => get<SnowballProjection>(`/snowball/projection${queryString(params)}`),
|
||||||
snowballPlans: () => get('/snowball/plans'),
|
snowballPlans: () => get('/snowball/plans'),
|
||||||
snowballActivePlan: () => get('/snowball/plans/active'),
|
snowballActivePlan: () => get('/snowball/plans/active'),
|
||||||
startSnowballPlan: (data: Body) => post('/snowball/plans', data),
|
startSnowballPlan: (data: Body) => post('/snowball/plans', data),
|
||||||
|
|
@ -341,7 +348,7 @@ export const api = {
|
||||||
restoreCategory: (id: Id) => post(`/categories/${id}/restore`),
|
restoreCategory: (id: Id) => post(`/categories/${id}/restore`),
|
||||||
|
|
||||||
// Category groups
|
// Category groups
|
||||||
categoryGroups: () => get('/categories/groups'),
|
categoryGroups: () => get<CategoryGroup[]>('/categories/groups'),
|
||||||
createCategoryGroup: (data: Body) => post('/categories/groups', data),
|
createCategoryGroup: (data: Body) => post('/categories/groups', data),
|
||||||
updateCategoryGroup: (id: Id, data: Body) => put(`/categories/groups/${id}`, data),
|
updateCategoryGroup: (id: Id, data: Body) => put(`/categories/groups/${id}`, data),
|
||||||
deleteCategoryGroup: (id: Id) => del(`/categories/groups/${id}`),
|
deleteCategoryGroup: (id: Id) => del(`/categories/groups/${id}`),
|
||||||
|
|
@ -461,18 +468,18 @@ export const api = {
|
||||||
|
|
||||||
// Transactions
|
// Transactions
|
||||||
transactions: (params: QueryParams = {}) => get(`/transactions${queryString(params)}`),
|
transactions: (params: QueryParams = {}) => get(`/transactions${queryString(params)}`),
|
||||||
bankTransactionsLedger: (params: QueryParams = {}) => get(`/transactions/bank-ledger${queryString(params)}`),
|
bankTransactionsLedger: (params: QueryParams = {}) => get<BankLedger>(`/transactions/bank-ledger${queryString(params)}`),
|
||||||
createManualTransaction: (data: Body) => post('/transactions/manual', data),
|
createManualTransaction: (data: Body) => post('/transactions/manual', data),
|
||||||
updateTransaction: (id: Id, data: Body) => put(`/transactions/${id}`, data),
|
updateTransaction: (id: Id, data: Body) => put(`/transactions/${id}`, data),
|
||||||
deleteTransaction: (id: Id) => del(`/transactions/${id}`),
|
deleteTransaction: (id: Id) => del(`/transactions/${id}`),
|
||||||
matchTransaction: (id: Id, billId: Id) => post(`/transactions/${id}/match`, { billId }),
|
matchTransaction: (id: Id, billId: Id) => post<{ transaction: BankLedgerTransaction }>(`/transactions/${id}/match`, { billId }),
|
||||||
unmatchTransaction: (id: Id) => post(`/transactions/${id}/unmatch`),
|
unmatchTransaction: (id: Id) => post<{ transaction: BankLedgerTransaction }>(`/transactions/${id}/unmatch`),
|
||||||
unmatchTransactionBulk: (matches: Body) => post('/transactions/unmatch-bulk', { matches }),
|
unmatchTransactionBulk: (matches: Body) => post('/transactions/unmatch-bulk', { matches }),
|
||||||
ignoreTransaction: (id: Id) => post(`/transactions/${id}/ignore`),
|
ignoreTransaction: (id: Id) => post<BankLedgerTransaction>(`/transactions/${id}/ignore`),
|
||||||
unignoreTransaction: (id: Id) => post(`/transactions/${id}/unignore`),
|
unignoreTransaction: (id: Id) => post<BankLedgerTransaction>(`/transactions/${id}/unignore`),
|
||||||
transactionMerchantMatch: (id: Id) => get(`/transactions/${id}/merchant-match`),
|
transactionMerchantMatch: (id: Id) => get(`/transactions/${id}/merchant-match`),
|
||||||
applyTransactionMerchantMatch: (id: Id) => post(`/transactions/${id}/apply-merchant-match`),
|
applyTransactionMerchantMatch: (id: Id) => post<{ matched?: boolean; category?: { id: number; name: string } }>(`/transactions/${id}/apply-merchant-match`),
|
||||||
autoCategorizeTransactions: (opts: Body = {}) => post('/transactions/auto-categorize', opts),
|
autoCategorizeTransactions: (opts: Body = {}) => post<AutoCategorizePreview>('/transactions/auto-categorize', opts),
|
||||||
|
|
||||||
// Match suggestions
|
// Match suggestions
|
||||||
matchSuggestions: (params: QueryParams = {}) => get(`/matches/suggestions${queryString(params)}`),
|
matchSuggestions: (params: QueryParams = {}) => get(`/matches/suggestions${queryString(params)}`),
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
|
import { createPaymentOrConfirm } from '@/lib/paymentActions';
|
||||||
import { cn, todayStr, errMessage } from '@/lib/utils';
|
import { cn, todayStr, errMessage } from '@/lib/utils';
|
||||||
import DebtDetailsSection from '@/components/bill-modal/DebtDetailsSection';
|
import DebtDetailsSection from '@/components/bill-modal/DebtDetailsSection';
|
||||||
import AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator';
|
import AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator';
|
||||||
|
|
@ -368,17 +369,22 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
|
||||||
notes: paymentNotes || null,
|
notes: paymentNotes || null,
|
||||||
payment_source: 'manual',
|
payment_source: 'manual',
|
||||||
};
|
};
|
||||||
|
const finishPaymentSave = async (successMessage: string) => {
|
||||||
|
toast.success(successMessage);
|
||||||
|
resetPaymentForm();
|
||||||
|
setPaymentFormOpen(false);
|
||||||
|
await loadPayments();
|
||||||
|
onSave?.();
|
||||||
|
};
|
||||||
if (editingPayment) {
|
if (editingPayment) {
|
||||||
await api.updatePayment(editingPayment.id, payload);
|
await api.updatePayment(editingPayment.id, payload);
|
||||||
toast.success('Payment updated');
|
await finishPaymentSave('Payment updated');
|
||||||
} else {
|
} else {
|
||||||
await api.createPayment({ ...payload, bill_id: bill?.id });
|
await createPaymentOrConfirm(
|
||||||
toast.success('Payment added');
|
{ ...payload, bill_id: bill?.id },
|
||||||
|
() => finishPaymentSave('Payment added'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
resetPaymentForm();
|
|
||||||
setPaymentFormOpen(false);
|
|
||||||
await loadPayments();
|
|
||||||
onSave?.();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(errMessage(err, 'Payment could not be saved.'));
|
toast.error(errMessage(err, 'Payment could not be saved.'));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
|
import { createPaymentOrConfirm } from '@/lib/paymentActions';
|
||||||
import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
|
import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import type { Dollars } from '@/lib/money';
|
import type { Dollars } from '@/lib/money';
|
||||||
|
|
@ -47,15 +48,18 @@ export function EditableCell({ row, field, threshold, defaultPaymentDate, refres
|
||||||
if (field === 'amount') update.amount = parseFloat(val);
|
if (field === 'amount') update.amount = parseFloat(val);
|
||||||
if (field === 'date') update.paid_date = val;
|
if (field === 'date') update.paid_date = val;
|
||||||
await api.updatePayment(row.payments[0]!.id, update);
|
await api.updatePayment(row.payments[0]!.id, update);
|
||||||
|
toast.success('Saved');
|
||||||
|
refresh();
|
||||||
} else {
|
} else {
|
||||||
await api.createPayment({
|
await createPaymentOrConfirm(
|
||||||
bill_id: row.id,
|
{
|
||||||
amount: field === 'amount' ? parseFloat(val) : threshold,
|
bill_id: row.id,
|
||||||
paid_date: field === 'date' ? val : defaultPaymentDate,
|
amount: field === 'amount' ? parseFloat(val) : threshold,
|
||||||
});
|
paid_date: field === 'date' ? val : defaultPaymentDate,
|
||||||
|
},
|
||||||
|
() => { toast.success('Saved'); refresh(); },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
toast.success('Saved');
|
|
||||||
refresh();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(errMessage(err));
|
toast.error(errMessage(err));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { createPaymentOrConfirm } from '@/lib/paymentActions';
|
||||||
import { fmt, fmtDate, errMessage } from '@/lib/utils';
|
import { fmt, fmtDate, errMessage } from '@/lib/utils';
|
||||||
import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
|
import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -53,16 +53,16 @@ export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymen
|
||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.createPayment({
|
await createPaymentOrConfirm(
|
||||||
bill_id: row.id,
|
{
|
||||||
amount: parsedAmount,
|
bill_id: row.id,
|
||||||
paid_date: date,
|
amount: parsedAmount,
|
||||||
method: method === METHOD_NONE ? null : method,
|
paid_date: date,
|
||||||
notes: notes || null,
|
method: method === METHOD_NONE ? null : method,
|
||||||
});
|
notes: notes || null,
|
||||||
toast.success('Partial payment added');
|
},
|
||||||
onSaved?.();
|
() => { toast.success('Partial payment added'); onSaved?.(); onClose?.(); },
|
||||||
onClose?.();
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(errMessage(err, 'Failed to add payment'));
|
toast.error(errMessage(err, 'Failed to add payment'));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,8 @@
|
||||||
import { createContext, useContext, useEffect, useState, type Dispatch, type SetStateAction, type ReactNode } from 'react';
|
import { createContext, useContext, useEffect, useState, type Dispatch, type SetStateAction, type ReactNode } from 'react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
|
||||||
export interface User {
|
export type { User }; // re-export so existing `@/hooks/useAuth` importers keep working
|
||||||
id: number;
|
|
||||||
username?: string;
|
|
||||||
role?: string;
|
|
||||||
display_name?: string;
|
|
||||||
displayName?: string;
|
|
||||||
name?: string;
|
|
||||||
is_default_admin?: boolean;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
user?: User | null;
|
user?: User | null;
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,7 @@ export function useSubscriptions() {
|
||||||
export function useSubscriptionRecommendations() {
|
export function useSubscriptionRecommendations() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['subscription-recommendations'],
|
queryKey: ['subscription-recommendations'],
|
||||||
queryFn: () => api.subscriptionRecommendations().then((r: any) => r.recommendations || []),
|
queryFn: () => api.subscriptionRecommendations().then(r => r.recommendations || []),
|
||||||
staleTime: 1000 * 60 * 5,
|
staleTime: 1000 * 60 * 5,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { api, type ApiError } from '@/api';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { errMessage } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a manual payment, treating the server's "duplicate suspected" (409) as a
|
||||||
|
* question rather than an error. The deliberate manual-add path must never
|
||||||
|
* *silently* drop a legitimately-identical second payment (same bill, date,
|
||||||
|
* amount) — losing a real payment is itself a money-integrity bug. So the server
|
||||||
|
* returns `409 DUPLICATE_SUSPECTED` instead of silently deduping, and here we
|
||||||
|
* surface an "add anyway?" toast; confirming resends with `allow_duplicate`.
|
||||||
|
*
|
||||||
|
* `onSuccess` runs after any *real* creation — immediate OR confirmed-retry — so
|
||||||
|
* callers put their toast/refresh/close there. Any non-duplicate error re-throws
|
||||||
|
* for the caller's existing `catch`.
|
||||||
|
*/
|
||||||
|
export async function createPaymentOrConfirm(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
onSuccess: () => void | Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const create = async (allowDuplicate: boolean) => {
|
||||||
|
await api.createPayment(allowDuplicate ? { ...payload, allow_duplicate: true } : payload);
|
||||||
|
await onSuccess();
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await create(false);
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as ApiError;
|
||||||
|
if (e?.status === 409 && e?.code === 'DUPLICATE_SUSPECTED') {
|
||||||
|
toast.warning('A matching payment already exists for this bill, date, and amount.', {
|
||||||
|
description: 'Add it anyway?',
|
||||||
|
action: {
|
||||||
|
label: 'Add anyway',
|
||||||
|
onClick: () => {
|
||||||
|
create(true).catch(retryErr => toast.error(errMessage(retryErr, 'Failed to add payment')));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return; // handled — not an error the caller should re-toast
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,7 @@ export default function AdminPage() {
|
||||||
|
|
||||||
const loadMe = useCallback(async () => {
|
const loadMe = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const d = await api.me() as { user?: User };
|
const d = await api.me();
|
||||||
setMe(d.user ?? null);
|
setMe(d.user ?? null);
|
||||||
} catch {
|
} catch {
|
||||||
navigate('/login', { replace: true });
|
navigate('/login', { replace: true });
|
||||||
|
|
@ -42,7 +42,7 @@ export default function AdminPage() {
|
||||||
|
|
||||||
const loadHasUsers = useCallback(async () => {
|
const loadHasUsers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const d = await api.hasUsers() as { has_users?: boolean };
|
const d = await api.hasUsers();
|
||||||
setHasUsers(!!d.has_users);
|
setHasUsers(!!d.has_users);
|
||||||
if (d.has_users) loadUsers();
|
if (d.has_users) loadUsers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,17 @@ import { CategoryPicker } from '@/components/transactions/CategoryPicker';
|
||||||
import { MatchBillDialog } from '@/components/transactions/MatchBillDialog';
|
import { MatchBillDialog } from '@/components/transactions/MatchBillDialog';
|
||||||
import BillModal from '@/components/BillModal';
|
import BillModal from '@/components/BillModal';
|
||||||
import { cn, fmtDate, categoryColor, localDateString, errMessage } from '@/lib/utils';
|
import { cn, fmtDate, categoryColor, localDateString, errMessage } from '@/lib/utils';
|
||||||
import { formatUSD, asDollars, type Cents } from '@/lib/money';
|
import { formatUSD, asDollars } from '@/lib/money';
|
||||||
import type { Bill, Category, BankTransaction } from '@/types';
|
import type {
|
||||||
|
Bill, Category, BankTransaction,
|
||||||
|
BankLedgerTransaction as Tx,
|
||||||
|
BankAccount as Account,
|
||||||
|
BankLedger as Ledger,
|
||||||
|
BankLedgerSummary as LedgerSummary,
|
||||||
|
BankCategoryBreakdown as CategoryBreakdown,
|
||||||
|
AutoCategorizeChange as AutoCatChange,
|
||||||
|
AutoCategorizePreview as AutoCatPreview,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
function fmt(v: number | null | undefined): string {
|
function fmt(v: number | null | undefined): string {
|
||||||
return formatUSD(asDollars(Number(v) || 0));
|
return formatUSD(asDollars(Number(v) || 0));
|
||||||
|
|
@ -40,71 +49,6 @@ function fmt(v: number | null | undefined): string {
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
const TABLE_COLUMN_COUNT = 8;
|
const TABLE_COLUMN_COUNT = 8;
|
||||||
|
|
||||||
interface Tx {
|
|
||||||
id: number;
|
|
||||||
amount: Cents;
|
|
||||||
payee?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
memo?: string | null;
|
|
||||||
category?: string | null;
|
|
||||||
posted_date?: string | null;
|
|
||||||
transacted_at?: string | null;
|
|
||||||
pending?: boolean;
|
|
||||||
ignored?: boolean;
|
|
||||||
match_status?: string | null;
|
|
||||||
matched_bill_name?: string | null;
|
|
||||||
spending_category_id?: number | null;
|
|
||||||
spending_category_name?: string | null;
|
|
||||||
suggested_match?: { display_name?: string; category?: string } | null;
|
|
||||||
account_org_name?: string | null;
|
|
||||||
account_name?: string | null;
|
|
||||||
account_type?: string | null;
|
|
||||||
currency?: string | null;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Account {
|
|
||||||
id: number | string;
|
|
||||||
org_name?: string;
|
|
||||||
name?: string;
|
|
||||||
account_type?: string;
|
|
||||||
currency?: string;
|
|
||||||
balance?: number;
|
|
||||||
available_balance?: number | null;
|
|
||||||
transaction_count?: number;
|
|
||||||
last_transaction_date?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CategoryBreakdown { name: string; total?: number; count?: number }
|
|
||||||
|
|
||||||
interface LedgerSummary {
|
|
||||||
inflow?: number;
|
|
||||||
outflow?: number;
|
|
||||||
net?: number;
|
|
||||||
matched?: number;
|
|
||||||
unmatched?: number;
|
|
||||||
pending?: number;
|
|
||||||
total?: number;
|
|
||||||
latest_date?: string;
|
|
||||||
category_breakdown?: CategoryBreakdown[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Ledger {
|
|
||||||
accounts?: Account[];
|
|
||||||
transactions?: Tx[];
|
|
||||||
summary?: LedgerSummary;
|
|
||||||
total?: number;
|
|
||||||
sources?: { last_sync_at?: string | null }[];
|
|
||||||
enabled?: boolean;
|
|
||||||
has_connections?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AutoCatChange { transaction_id: number }
|
|
||||||
interface AutoCatPreview {
|
|
||||||
changes?: AutoCatChange[];
|
|
||||||
categories?: { name: string; count?: number }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const flowOptions = [
|
const flowOptions = [
|
||||||
{ value: 'all', label: 'All' },
|
{ value: 'all', label: 'All' },
|
||||||
{ value: 'in', label: 'Money in' },
|
{ value: 'in', label: 'Money in' },
|
||||||
|
|
@ -384,7 +328,7 @@ export default function BankTransactionsPage() {
|
||||||
// React Query keys the ledger on the filters/page.
|
// React Query keys the ledger on the filters/page.
|
||||||
const { data: ledgerRaw = null, isPending: loading, isFetching, error: ledgerErr, refetch: loadLedger } =
|
const { data: ledgerRaw = null, isPending: loading, isFetching, error: ledgerErr, refetch: loadLedger } =
|
||||||
useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize: PAGE_SIZE });
|
useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize: PAGE_SIZE });
|
||||||
const ledger = ledgerRaw as Ledger | null;
|
const ledger = ledgerRaw;
|
||||||
const error = ledgerErr ? (ledgerErr.message || 'Unable to load bank transactions') : '';
|
const error = ledgerErr ? (ledgerErr.message || 'Unable to load bank transactions') : '';
|
||||||
const setLedger = useCallback((u: (prev: Ledger | undefined) => Ledger | undefined) => queryClient.setQueryData<Ledger>(
|
const setLedger = useCallback((u: (prev: Ledger | undefined) => Ledger | undefined) => queryClient.setQueryData<Ledger>(
|
||||||
['bank-ledger', accountId, flow, page, query, sortBy, sortDir], u), [queryClient, accountId, flow, page, query, sortBy, sortDir]);
|
['bank-ledger', accountId, flow, page, query, sortBy, sortDir], u), [queryClient, accountId, flow, page, query, sortBy, sortDir]);
|
||||||
|
|
@ -433,7 +377,7 @@ export default function BankTransactionsPage() {
|
||||||
const handleApplySuggestion = useCallback(async (tx: Tx) => {
|
const handleApplySuggestion = useCallback(async (tx: Tx) => {
|
||||||
setApplyingSuggestionId(tx.id);
|
setApplyingSuggestionId(tx.id);
|
||||||
try {
|
try {
|
||||||
const result = await api.applyTransactionMerchantMatch(tx.id) as { matched?: boolean; category?: { id: number; name: string } };
|
const result = await api.applyTransactionMerchantMatch(tx.id);
|
||||||
if (result.matched && result.category) {
|
if (result.matched && result.category) {
|
||||||
updateTransaction(tx.id, {
|
updateTransaction(tx.id, {
|
||||||
spending_category_id: result.category.id,
|
spending_category_id: result.category.id,
|
||||||
|
|
@ -452,7 +396,7 @@ export default function BankTransactionsPage() {
|
||||||
const handleAutoCategorize = useCallback(async () => {
|
const handleAutoCategorize = useCallback(async () => {
|
||||||
setAutoCategorizing(true);
|
setAutoCategorizing(true);
|
||||||
try {
|
try {
|
||||||
const preview = await api.autoCategorizeTransactions({ dry_run: true }) as AutoCatPreview;
|
const preview = await api.autoCategorizeTransactions({ dry_run: true });
|
||||||
if (!preview?.changes?.length) {
|
if (!preview?.changes?.length) {
|
||||||
toast.success('No new matches found');
|
toast.success('No new matches found');
|
||||||
return;
|
return;
|
||||||
|
|
@ -479,7 +423,7 @@ export default function BankTransactionsPage() {
|
||||||
const handleConfirmAutoCategorize = useCallback(async () => {
|
const handleConfirmAutoCategorize = useCallback(async () => {
|
||||||
setAutoCategorizing(true);
|
setAutoCategorizing(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.autoCategorizeTransactions() as { changes?: AutoCatChange[] };
|
const result = await api.autoCategorizeTransactions();
|
||||||
const changes = result?.changes || [];
|
const changes = result?.changes || [];
|
||||||
setAutoCategorizePreview(null);
|
setAutoCategorizePreview(null);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|
@ -501,7 +445,7 @@ export default function BankTransactionsPage() {
|
||||||
if (!matchTarget) return;
|
if (!matchTarget) return;
|
||||||
setMatchSubmitting(true);
|
setMatchSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.matchTransaction(matchTarget.id, billId) as { transaction: Tx };
|
const result = await api.matchTransaction(matchTarget.id, billId);
|
||||||
updateTransaction(matchTarget.id, result.transaction);
|
updateTransaction(matchTarget.id, result.transaction);
|
||||||
setMatchTarget(null);
|
setMatchTarget(null);
|
||||||
toast.success('Transaction matched to bill');
|
toast.success('Transaction matched to bill');
|
||||||
|
|
@ -538,7 +482,7 @@ export default function BankTransactionsPage() {
|
||||||
setMatchTarget(null);
|
setMatchTarget(null);
|
||||||
if (tx && newBill?.id) {
|
if (tx && newBill?.id) {
|
||||||
try {
|
try {
|
||||||
const result = await api.matchTransaction(tx.id, newBill.id) as { transaction: Tx };
|
const result = await api.matchTransaction(tx.id, newBill.id);
|
||||||
updateTransaction(tx.id, result.transaction);
|
updateTransaction(tx.id, result.transaction);
|
||||||
toast.success('Bill created and matched to transaction');
|
toast.success('Bill created and matched to transaction');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -550,7 +494,7 @@ export default function BankTransactionsPage() {
|
||||||
|
|
||||||
const handleUnmatch = useCallback(async (tx: Tx) => {
|
const handleUnmatch = useCallback(async (tx: Tx) => {
|
||||||
try {
|
try {
|
||||||
const result = await api.unmatchTransaction(tx.id) as { transaction: Tx };
|
const result = await api.unmatchTransaction(tx.id);
|
||||||
updateTransaction(tx.id, result.transaction);
|
updateTransaction(tx.id, result.transaction);
|
||||||
toast.success('Transaction unmatched');
|
toast.success('Transaction unmatched');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -561,7 +505,7 @@ export default function BankTransactionsPage() {
|
||||||
|
|
||||||
const handleIgnore = useCallback(async (tx: Tx) => {
|
const handleIgnore = useCallback(async (tx: Tx) => {
|
||||||
try {
|
try {
|
||||||
const transaction = await api.ignoreTransaction(tx.id) as Partial<Tx>;
|
const transaction = await api.ignoreTransaction(tx.id);
|
||||||
updateTransaction(tx.id, transaction);
|
updateTransaction(tx.id, transaction);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(errMessage(err, 'Failed to ignore transaction'));
|
toast.error(errMessage(err, 'Failed to ignore transaction'));
|
||||||
|
|
@ -571,7 +515,7 @@ export default function BankTransactionsPage() {
|
||||||
|
|
||||||
const handleUnignore = useCallback(async (tx: Tx) => {
|
const handleUnignore = useCallback(async (tx: Tx) => {
|
||||||
try {
|
try {
|
||||||
const transaction = await api.unignoreTransaction(tx.id) as Partial<Tx>;
|
const transaction = await api.unignoreTransaction(tx.id);
|
||||||
updateTransaction(tx.id, transaction);
|
updateTransaction(tx.id, transaction);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(errMessage(err, 'Failed to unignore transaction'));
|
toast.error(errMessage(err, 'Failed to unignore transaction'));
|
||||||
|
|
@ -626,7 +570,7 @@ export default function BankTransactionsPage() {
|
||||||
const results = await Promise.allSettled(targets.map(tx => api.applyTransactionMerchantMatch(tx.id)));
|
const results = await Promise.allSettled(targets.map(tx => api.applyTransactionMerchantMatch(tx.id)));
|
||||||
let applied = 0;
|
let applied = 0;
|
||||||
results.forEach((r, i) => {
|
results.forEach((r, i) => {
|
||||||
const val = r.status === 'fulfilled' ? r.value as { matched?: boolean; category?: { id: number; name: string } } : null;
|
const val = r.status === 'fulfilled' ? r.value : null;
|
||||||
if (val?.matched && val.category) {
|
if (val?.matched && val.category) {
|
||||||
updateTransaction(targets[i]!.id, {
|
updateTransaction(targets[i]!.id, {
|
||||||
spending_category_id: val.category.id,
|
spending_category_id: val.category.id,
|
||||||
|
|
@ -679,7 +623,7 @@ export default function BankTransactionsPage() {
|
||||||
let applied = 0;
|
let applied = 0;
|
||||||
results.forEach((r, i) => {
|
results.forEach((r, i) => {
|
||||||
if (r.status === 'fulfilled') {
|
if (r.status === 'fulfilled') {
|
||||||
updateTransaction(targets[i]!.id, r.value as Partial<Tx>);
|
updateTransaction(targets[i]!.id, r.value);
|
||||||
applied++;
|
applied++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ export default function LoginPage() {
|
||||||
setError('');
|
setError('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.login({ username, password }) as { requires_totp?: boolean; challenge_token?: string; user: User };
|
const data = await api.login({ username, password });
|
||||||
if (data.requires_totp) {
|
if (data.requires_totp) {
|
||||||
setTotpChallenge(data.challenge_token ?? null);
|
setTotpChallenge(data.challenge_token ?? null);
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
|
|
@ -94,7 +94,7 @@ export default function LoginPage() {
|
||||||
setUseRecovery(false);
|
setUseRecovery(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handlePostLogin(data.user);
|
handlePostLogin(data.user!);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errMessage(err, 'Login failed.'));
|
setError(errMessage(err, 'Login failed.'));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -110,8 +110,8 @@ export default function LoginPage() {
|
||||||
const payload: { challenge_token: string | null; recovery_code?: string; code?: string } = { challenge_token: totpChallenge };
|
const payload: { challenge_token: string | null; recovery_code?: string; code?: string } = { challenge_token: totpChallenge };
|
||||||
if (useRecovery) payload.recovery_code = totpCode;
|
if (useRecovery) payload.recovery_code = totpCode;
|
||||||
else payload.code = totpCode;
|
else payload.code = totpCode;
|
||||||
const data = await api.totpChallenge(payload) as { user: User };
|
const data = await api.totpChallenge(payload);
|
||||||
handlePostLogin(data.user);
|
handlePostLogin(data.user!);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setTotpError(errMessage(err, 'Invalid code.'));
|
setTotpError(errMessage(err, 'Invalid code.'));
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,13 @@ import PlanStatusBanner, { type ActivePlan } from '@/components/snowball/PlanSta
|
||||||
import PlanHistoryPanel, { type SnowballPlan } from '@/components/snowball/PlanHistoryPanel';
|
import PlanHistoryPanel, { type SnowballPlan } from '@/components/snowball/PlanHistoryPanel';
|
||||||
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
||||||
import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money';
|
import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money';
|
||||||
import type { Bill, Category } from '@/types';
|
import type {
|
||||||
|
Bill, Category,
|
||||||
|
SnowballProjection as Projection,
|
||||||
|
SnowballProjectionDetail as SnowballProj,
|
||||||
|
AvalancheProjectionDetail as AvalancheProj,
|
||||||
|
SnowballSettings,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
// ── formatters ────────────────────────────────────────────────────────────────
|
// ── formatters ────────────────────────────────────────────────────────────────
|
||||||
function fmt(val: number | null | undefined): string {
|
function fmt(val: number | null | undefined): string {
|
||||||
|
|
@ -51,38 +57,6 @@ function isRamseyOrdered(debts: Bill[]): boolean {
|
||||||
return debts.every((debt, index) => debt.id === sorted[index]?.id);
|
return debts.every((debt, index) => debt.id === sorted[index]?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DebtProj {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
payoff_display?: string;
|
|
||||||
months?: number;
|
|
||||||
total_interest?: number;
|
|
||||||
current_balance?: number;
|
|
||||||
}
|
|
||||||
interface SnowballProj {
|
|
||||||
debts: DebtProj[];
|
|
||||||
skipped: { name: string }[];
|
|
||||||
payoff_display?: string;
|
|
||||||
months_to_freedom?: number;
|
|
||||||
total_interest_paid?: number;
|
|
||||||
capped?: boolean;
|
|
||||||
}
|
|
||||||
interface AvalancheProj {
|
|
||||||
months_to_freedom?: number;
|
|
||||||
total_interest_paid?: number;
|
|
||||||
payoff_display?: string;
|
|
||||||
}
|
|
||||||
interface Projection {
|
|
||||||
snowball?: SnowballProj;
|
|
||||||
avalanche?: AvalancheProj;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SnowballSettings {
|
|
||||||
extra_payment?: number;
|
|
||||||
ramsey_mode?: boolean;
|
|
||||||
ready_current_on_bills?: boolean;
|
|
||||||
ready_emergency_fund?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReadinessItem {
|
interface ReadinessItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -347,11 +321,11 @@ function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, dis
|
||||||
export default function SnowballPage() {
|
export default function SnowballPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: billsRaw = [], isPending: billsPending, error: snowballError } = useSnowball();
|
const { data: billsRaw = [], isPending: billsPending, error: snowballError } = useSnowball();
|
||||||
const bills = (billsRaw ?? []) as Bill[];
|
const bills = billsRaw ?? [];
|
||||||
const { data: categoriesRaw = [] } = useCategories();
|
const { data: categoriesRaw = [] } = useCategories();
|
||||||
const categories = (categoriesRaw ?? []) as Category[];
|
const categories = (categoriesRaw ?? []) as Category[];
|
||||||
const { data: settingsRaw } = useSnowballSettings();
|
const { data: settingsRaw } = useSnowballSettings();
|
||||||
const settings = settingsRaw as SnowballSettings | undefined;
|
const settings = settingsRaw;
|
||||||
const { data: activePlanRaw = null } = useSnowballActivePlan();
|
const { data: activePlanRaw = null } = useSnowballActivePlan();
|
||||||
const activePlan = (activePlanRaw ?? null) as SnowballPlan | null;
|
const activePlan = (activePlanRaw ?? null) as SnowballPlan | null;
|
||||||
const { data: allPlansRaw = [] } = useSnowballPlans();
|
const { data: allPlansRaw = [] } = useSnowballPlans();
|
||||||
|
|
@ -395,7 +369,7 @@ export default function SnowballPage() {
|
||||||
try {
|
try {
|
||||||
const extra = parseFloat(typedExtraRef.current);
|
const extra = parseFloat(typedExtraRef.current);
|
||||||
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
|
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
|
||||||
setProjection(await api.snowballProjection(params) as Projection);
|
setProjection(await api.snowballProjection(params));
|
||||||
setProjectionError(false);
|
setProjectionError(false);
|
||||||
} catch {
|
} catch {
|
||||||
setProjectionError(true);
|
setProjectionError(true);
|
||||||
|
|
@ -491,7 +465,7 @@ export default function SnowballPage() {
|
||||||
if (val === extraPaymentRef.current) return;
|
if (val === extraPaymentRef.current) return;
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) }) as SnowballSettings;
|
const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) });
|
||||||
const saved = Number(result.extra_payment) > 0 ? String(result.extra_payment) : '';
|
const saved = Number(result.extra_payment) > 0 ? String(result.extra_payment) : '';
|
||||||
extraPaymentRef.current = saved;
|
extraPaymentRef.current = saved;
|
||||||
setExtraPayment(saved);
|
setExtraPayment(saved);
|
||||||
|
|
@ -504,7 +478,7 @@ export default function SnowballPage() {
|
||||||
const handleRamseyModeChange = async (checked: boolean) => {
|
const handleRamseyModeChange = async (checked: boolean) => {
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.saveSnowballSettings({ ramsey_mode: checked }) as SnowballSettings;
|
const result = await api.saveSnowballSettings({ ramsey_mode: checked });
|
||||||
const nextMode = result.ramsey_mode !== false;
|
const nextMode = result.ramsey_mode !== false;
|
||||||
setRamseyMode(nextMode);
|
setRamseyMode(nextMode);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
|
|
@ -530,7 +504,7 @@ export default function SnowballPage() {
|
||||||
|
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.saveSnowballSettings(payload) as SnowballSettings;
|
const result = await api.saveSnowballSettings(payload);
|
||||||
setReadyCurrentOnBills(!!result.ready_current_on_bills);
|
setReadyCurrentOnBills(!!result.ready_current_on_bills);
|
||||||
setReadyEmergencyFund(!!result.ready_emergency_fund);
|
setReadyEmergencyFund(!!result.ready_emergency_fund);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -583,7 +557,7 @@ export default function SnowballPage() {
|
||||||
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
|
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
|
||||||
try {
|
try {
|
||||||
setProjectionLoading(true);
|
setProjectionLoading(true);
|
||||||
const result = await api.snowballProjection(params) as Projection;
|
const result = await api.snowballProjection(params);
|
||||||
setProjection(result);
|
setProjection(result);
|
||||||
setProjectionError(false);
|
setProjectionError(false);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,16 @@ import {
|
||||||
import { CategoryPicker } from '@/components/transactions/CategoryPicker';
|
import { CategoryPicker } from '@/components/transactions/CategoryPicker';
|
||||||
import { formatUSD, asDollars } from '@/lib/money';
|
import { formatUSD, asDollars } from '@/lib/money';
|
||||||
import { errMessage } from '@/lib/utils';
|
import { errMessage } from '@/lib/utils';
|
||||||
import type { Category } from '@/types';
|
import type {
|
||||||
|
Category,
|
||||||
|
SpendingCategoryEntry as CatEntry,
|
||||||
|
SpendingSummary,
|
||||||
|
SpendingTransaction as SpendingTx,
|
||||||
|
SpendingTransactionsResponse as SpendingTxResponse,
|
||||||
|
SpendingIncomeTx as IncomeTx,
|
||||||
|
SpendingRule,
|
||||||
|
CategoryGroup,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
|
||||||
|
|
@ -35,43 +44,8 @@ function settingEnabled(value: unknown, fallback = true): boolean {
|
||||||
|
|
||||||
type Level = 'over' | 'warn' | 'ok';
|
type Level = 'over' | 'warn' | 'ok';
|
||||||
|
|
||||||
interface CatEntry {
|
|
||||||
category_id: number | 'other' | null;
|
|
||||||
category_name?: string;
|
|
||||||
amount: number;
|
|
||||||
budget?: number | null;
|
|
||||||
tx_count?: number;
|
|
||||||
avg_3mo?: number;
|
|
||||||
group_id?: number | null;
|
|
||||||
}
|
|
||||||
type RealCatEntry = CatEntry & { category_id: number };
|
type RealCatEntry = CatEntry & { category_id: number };
|
||||||
|
|
||||||
interface SpendingSummary {
|
|
||||||
by_category?: CatEntry[];
|
|
||||||
total_spending?: number;
|
|
||||||
income?: number;
|
|
||||||
uncategorized_amount?: number;
|
|
||||||
uncategorized_count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpendingTx {
|
|
||||||
id: number;
|
|
||||||
payee?: string | null;
|
|
||||||
date?: string | null;
|
|
||||||
amount: number;
|
|
||||||
spending_category_id?: number | string | null;
|
|
||||||
spending_category_name?: string | null;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
interface SpendingTxResponse { transactions?: SpendingTx[]; total?: number; pages?: number }
|
|
||||||
|
|
||||||
interface IncomeTx { id: number; payee?: string | null; date?: string | null; amount: number }
|
|
||||||
interface IncomeResponse { transactions?: IncomeTx[]; total?: number; pages?: number }
|
|
||||||
|
|
||||||
interface SpendingRule { id: number; merchant: string; category_name?: string; category_id?: number }
|
|
||||||
interface CategoryGroup { id: number; name: string }
|
|
||||||
interface CopyBudgetsResponse { copied: number; budgets?: { category_id: number; amount: number }[] }
|
|
||||||
|
|
||||||
type Budgets = Record<string, number | null>;
|
type Budgets = Record<string, number | null>;
|
||||||
|
|
||||||
// pctBar() returns a progress-bar percent plus a YNAB-style status level:
|
// pctBar() returns a progress-bar percent plus a YNAB-style status level:
|
||||||
|
|
@ -232,7 +206,7 @@ function IncomeSection({ year, month, totalIncome }: { year: number; month: numb
|
||||||
const load = useCallback(async (p = 1) => {
|
const load = useCallback(async (p = 1) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const d = await api.spendingIncome({ year, month, page: p }) as IncomeResponse;
|
const d = await api.spendingIncome({ year, month, page: p });
|
||||||
setRows(d.transactions || []);
|
setRows(d.transactions || []);
|
||||||
setPages(d.pages || 1);
|
setPages(d.pages || 1);
|
||||||
setPage(p);
|
setPage(p);
|
||||||
|
|
@ -315,7 +289,7 @@ function RulesManager({ categories }: { categories: Category[] }) {
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try { setRules(((await api.spendingCategoryRules()) as { rules?: SpendingRule[] }).rules || []); }
|
try { setRules((await api.spendingCategoryRules()).rules || []); }
|
||||||
catch { toast.error('Failed to load rules'); }
|
catch { toast.error('Failed to load rules'); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -693,15 +667,15 @@ export default function SpendingPage() {
|
||||||
const [txPage, setTxPage] = useState(1);
|
const [txPage, setTxPage] = useState(1);
|
||||||
const [activeCat, setActiveCat] = useState<number | null | undefined>(undefined); // undefined = all
|
const [activeCat, setActiveCat] = useState<number | null | undefined>(undefined); // undefined = all
|
||||||
const { data: summaryRaw = null, isPending: loading, error: summaryErrObj } = useSpendingSummary(year, month);
|
const { data: summaryRaw = null, isPending: loading, error: summaryErrObj } = useSpendingSummary(year, month);
|
||||||
const summary = summaryRaw as SpendingSummary | null;
|
const summary = summaryRaw;
|
||||||
const { data: txDataRaw, isFetching: txLoading, error: txErrObj } = useSpendingTransactions({ year, month, activeCat, page: txPage });
|
const { data: txDataRaw, isFetching: txLoading, error: txErrObj } = useSpendingTransactions({ year, month, activeCat, page: txPage });
|
||||||
const txData = txDataRaw as SpendingTxResponse | undefined;
|
const txData = txDataRaw;
|
||||||
const transactions = useMemo(() => txData?.transactions || [], [txData]);
|
const transactions = useMemo(() => txData?.transactions || [], [txData]);
|
||||||
const txTotal = txData?.total || 0;
|
const txTotal = txData?.total || 0;
|
||||||
const txPages = txData?.pages || 1;
|
const txPages = txData?.pages || 1;
|
||||||
const { data: categories = [], error: catErrObj } = useSpendingCategories();
|
const { data: categories = [], error: catErrObj } = useSpendingCategories();
|
||||||
const { data: categoryGroupsRaw = [] } = useCategoryGroups();
|
const { data: categoryGroupsRaw = [] } = useCategoryGroups();
|
||||||
const categoryGroups = categoryGroupsRaw as CategoryGroup[];
|
const categoryGroups = categoryGroupsRaw;
|
||||||
const summaryError = summaryErrObj ? (summaryErrObj.message || 'Failed to load spending summary') : null;
|
const summaryError = summaryErrObj ? (summaryErrObj.message || 'Failed to load spending summary') : null;
|
||||||
const txError = txErrObj ? (txErrObj.message || 'Failed to load transactions') : null;
|
const txError = txErrObj ? (txErrObj.message || 'Failed to load transactions') : null;
|
||||||
const catError = catErrObj ? (catErrObj.message || 'Failed to load categories') : null;
|
const catError = catErrObj ? (catErrObj.message || 'Failed to load categories') : null;
|
||||||
|
|
@ -769,7 +743,7 @@ export default function SpendingPage() {
|
||||||
const handleCopyBudgets = async () => {
|
const handleCopyBudgets = async () => {
|
||||||
setCopying(true);
|
setCopying(true);
|
||||||
try {
|
try {
|
||||||
const d = await api.copySpendingBudgets({ year, month }) as CopyBudgetsResponse;
|
const d = await api.copySpendingBudgets({ year, month });
|
||||||
if (d.copied === 0) {
|
if (d.copied === 0) {
|
||||||
toast.info('No budgets found in the previous month to copy.');
|
toast.info('No budgets found in the previous month to copy.');
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,15 @@ import { getLinkImportPref } from '@/pages/SettingsPage';
|
||||||
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
||||||
import type { Bill, Category } from '@/types';
|
import type {
|
||||||
|
Bill, Category,
|
||||||
|
Subscription,
|
||||||
|
SubscriptionsResponse as SubData,
|
||||||
|
SubscriptionSummary as SubSummary,
|
||||||
|
ExistingBillMatch,
|
||||||
|
Recommendation,
|
||||||
|
SubscriptionTx as SubTx,
|
||||||
|
} from '@/types';
|
||||||
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
|
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
|
||||||
|
|
||||||
function fmt(v: number | null | undefined): string {
|
function fmt(v: number | null | undefined): string {
|
||||||
|
|
@ -93,102 +101,6 @@ interface CadenceLike {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Subscription {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
active?: boolean | number;
|
|
||||||
is_subscription?: boolean | number;
|
|
||||||
category_id?: number | null;
|
|
||||||
category_name?: string | null;
|
|
||||||
expected_amount?: number | null;
|
|
||||||
monthly_equivalent?: number | null;
|
|
||||||
yearly_equivalent?: number | null;
|
|
||||||
next_due_date?: string | null;
|
|
||||||
subscription_type?: string;
|
|
||||||
due_day?: number | null;
|
|
||||||
cycle_type?: string | null;
|
|
||||||
billing_cycle?: string | null;
|
|
||||||
cycle_day?: number | string | null;
|
|
||||||
autopay_enabled?: unknown;
|
|
||||||
has_2fa?: unknown;
|
|
||||||
has_merchant_rule?: unknown;
|
|
||||||
has_linked_transactions?: unknown;
|
|
||||||
reminder_days_before?: number | null;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TopType { type: string; monthly_total: number }
|
|
||||||
interface SubSummary {
|
|
||||||
active_count?: number;
|
|
||||||
paused_count?: number;
|
|
||||||
monthly_total?: number;
|
|
||||||
yearly_total?: number;
|
|
||||||
top_type?: TopType | null;
|
|
||||||
}
|
|
||||||
interface SubData { summary?: SubSummary; subscriptions?: Subscription[] }
|
|
||||||
|
|
||||||
interface CatalogMatch {
|
|
||||||
id?: number | string;
|
|
||||||
name: string;
|
|
||||||
subscription_type?: string;
|
|
||||||
website?: string;
|
|
||||||
starting_monthly_usd?: number | null;
|
|
||||||
starting_annual_usd?: number | null;
|
|
||||||
}
|
|
||||||
interface ExistingBillMatch {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
expected_amount?: number | null;
|
|
||||||
due_day?: number | null;
|
|
||||||
reasons?: string[];
|
|
||||||
}
|
|
||||||
interface RecEvidence {
|
|
||||||
identity?: { label?: string };
|
|
||||||
amount?: { label?: string; match?: string };
|
|
||||||
cadence?: { label?: string; recurring?: boolean };
|
|
||||||
amount_range?: { min: number; max: number };
|
|
||||||
ambiguity?: { ambiguous?: boolean; label?: string; reasons?: string[] };
|
|
||||||
}
|
|
||||||
interface RecTransaction { id: number; payee?: string; description?: string; memo?: string; date?: string; account?: string; amount?: number }
|
|
||||||
interface Recommendation {
|
|
||||||
id: number | string;
|
|
||||||
name: string;
|
|
||||||
subscription_type?: string;
|
|
||||||
occurrence_count?: number;
|
|
||||||
last_seen_date?: string;
|
|
||||||
expected_amount?: number | null;
|
|
||||||
monthly_equivalent?: number | null;
|
|
||||||
confidence?: number;
|
|
||||||
cycle_type?: string;
|
|
||||||
merchant?: string;
|
|
||||||
decline_key?: string;
|
|
||||||
transaction_ids?: (number | string)[];
|
|
||||||
accounts?: string[];
|
|
||||||
reasons?: string[];
|
|
||||||
evidence?: RecEvidence;
|
|
||||||
existing_bill_match?: ExistingBillMatch | null;
|
|
||||||
catalog_match?: CatalogMatch | null;
|
|
||||||
transactions?: RecTransaction[];
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SubTx {
|
|
||||||
id: number;
|
|
||||||
amount: number;
|
|
||||||
payee?: string;
|
|
||||||
description?: string;
|
|
||||||
memo?: string;
|
|
||||||
account_name?: string;
|
|
||||||
data_source_name?: string;
|
|
||||||
account?: string;
|
|
||||||
match_status?: string;
|
|
||||||
matched_bill_name?: string;
|
|
||||||
catalog_match?: CatalogMatch | null;
|
|
||||||
posted_date?: string;
|
|
||||||
date?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SubDraft {
|
interface SubDraft {
|
||||||
name?: string;
|
name?: string;
|
||||||
category_id?: number | null;
|
category_id?: number | null;
|
||||||
|
|
@ -1200,9 +1112,9 @@ type FlatItem =
|
||||||
export default function SubscriptionsPage() {
|
export default function SubscriptionsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: subData, isPending: loading } = useSubscriptions();
|
const { data: subData, isPending: loading } = useSubscriptions();
|
||||||
const data: SubData = (subData as SubData | undefined) || { summary: {}, subscriptions: [] };
|
const data: SubData = subData || { summary: {}, subscriptions: [] };
|
||||||
const { data: recommendationsRaw = [], isPending: recommendationsLoading } = useSubscriptionRecommendations();
|
const { data: recommendationsRaw = [], isPending: recommendationsLoading } = useSubscriptionRecommendations();
|
||||||
const recommendations = recommendationsRaw as Recommendation[];
|
const recommendations = recommendationsRaw;
|
||||||
const { data: categories = [] } = useCategories();
|
const { data: categories = [] } = useCategories();
|
||||||
const { data: bills = [] } = useBills();
|
const { data: bills = [] } = useBills();
|
||||||
// Optimistic updates write through the query cache so every existing call site
|
// Optimistic updates write through the query cache so every existing call site
|
||||||
|
|
@ -1270,7 +1182,7 @@ export default function SubscriptionsPage() {
|
||||||
txDebounce.current = setTimeout(async () => {
|
txDebounce.current = setTimeout(async () => {
|
||||||
setTxSearching(true);
|
setTxSearching(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.subscriptionTransactionMatches({ q, limit: 50 }) as SubTx[] | { transactions?: SubTx[] };
|
const result = await api.subscriptionTransactionMatches({ q, limit: 50 });
|
||||||
setTxResults(Array.isArray(result) ? result : (result?.transactions ?? []));
|
setTxResults(Array.isArray(result) ? result : (result?.transactions ?? []));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setTxResults([]);
|
setTxResults([]);
|
||||||
|
|
@ -1300,7 +1212,7 @@ export default function SubscriptionsPage() {
|
||||||
async function acceptRecommendation(recommendation: Recommendation) {
|
async function acceptRecommendation(recommendation: Recommendation) {
|
||||||
setBusyId(`rec-${recommendation.id}`);
|
setBusyId(`rec-${recommendation.id}`);
|
||||||
try {
|
try {
|
||||||
const created = await api.createSubscriptionFromRecommendation(recommendation) as { id?: number; name?: string } | null;
|
const created = await api.createSubscriptionFromRecommendation(recommendation);
|
||||||
toast.success(`${recommendation.name} is now tracked.`);
|
toast.success(`${recommendation.name} is now tracked.`);
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
if (getLinkImportPref() && recommendation.merchant && created?.id) {
|
if (getLinkImportPref() && recommendation.merchant && created?.id) {
|
||||||
|
|
@ -1336,7 +1248,7 @@ export default function SubscriptionsPage() {
|
||||||
recommendation.merchant,
|
recommendation.merchant,
|
||||||
recommendation.catalog_match?.id,
|
recommendation.catalog_match?.id,
|
||||||
recommendation.confidence,
|
recommendation.confidence,
|
||||||
) as { matched_count?: number; bill_name?: string };
|
);
|
||||||
toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`);
|
toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`);
|
||||||
setMatchTarget(null);
|
setMatchTarget(null);
|
||||||
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
|
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
|
||||||
|
|
|
||||||
240
client/types.ts
240
client/types.ts
|
|
@ -10,6 +10,19 @@
|
||||||
// sparkline) are left `unknown` and refined when a consumer needs them.
|
// sparkline) are left `unknown` and refined when a consumer needs them.
|
||||||
import type { Cents, Dollars } from '@/lib/money';
|
import type { Cents, Dollars } from '@/lib/money';
|
||||||
|
|
||||||
|
// The authenticated user (GET /auth/me → `user`). Re-exported from
|
||||||
|
// `@/hooks/useAuth` for the many components that import it from there.
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username?: string;
|
||||||
|
role?: string;
|
||||||
|
display_name?: string;
|
||||||
|
displayName?: string;
|
||||||
|
name?: string;
|
||||||
|
is_default_admin?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
// A bank transaction as serialized by the server. Unlike bill/payment amounts,
|
// A bank transaction as serialized by the server. Unlike bill/payment amounts,
|
||||||
// raw bank-transaction amounts stay in CENTS on the wire — hence branded `Cents`
|
// raw bank-transaction amounts stay in CENTS on the wire — hence branded `Cents`
|
||||||
// (format them with formatCentsUSD, never formatUSD).
|
// (format them with formatCentsUSD, never formatUSD).
|
||||||
|
|
@ -28,6 +41,233 @@ export interface BankTransaction {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The bank ledger (GET /transactions/bank-ledger): accounts, the transaction
|
||||||
|
// occurrence with spending/match metadata, and the month summary. Raw
|
||||||
|
// transaction/account amounts stay in CENTS on this endpoint (the page formats
|
||||||
|
// them with a local formatCents), so they're plain `number` cents, not Dollars.
|
||||||
|
export interface BankLedgerTransaction extends BankTransaction {
|
||||||
|
transacted_at?: string | null; // narrower than BankTransaction's for date slicing
|
||||||
|
category?: string | null;
|
||||||
|
pending?: boolean;
|
||||||
|
ignored?: boolean;
|
||||||
|
match_status?: string | null;
|
||||||
|
matched_bill_name?: string | null;
|
||||||
|
spending_category_id?: number | null;
|
||||||
|
spending_category_name?: string | null;
|
||||||
|
suggested_match?: { display_name?: string; category?: string } | null;
|
||||||
|
account_org_name?: string | null;
|
||||||
|
account_type?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankAccount {
|
||||||
|
id: number | string;
|
||||||
|
org_name?: string;
|
||||||
|
name?: string;
|
||||||
|
account_type?: string;
|
||||||
|
currency?: string;
|
||||||
|
balance?: number;
|
||||||
|
available_balance?: number | null;
|
||||||
|
transaction_count?: number;
|
||||||
|
last_transaction_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankCategoryBreakdown { name: string; total?: number; count?: number }
|
||||||
|
|
||||||
|
export interface BankLedgerSummary {
|
||||||
|
inflow?: number;
|
||||||
|
outflow?: number;
|
||||||
|
net?: number;
|
||||||
|
matched?: number;
|
||||||
|
unmatched?: number;
|
||||||
|
pending?: number;
|
||||||
|
total?: number;
|
||||||
|
latest_date?: string;
|
||||||
|
category_breakdown?: BankCategoryBreakdown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankLedger {
|
||||||
|
accounts?: BankAccount[];
|
||||||
|
transactions?: BankLedgerTransaction[];
|
||||||
|
summary?: BankLedgerSummary;
|
||||||
|
total?: number;
|
||||||
|
sources?: { last_sync_at?: string | null }[];
|
||||||
|
enabled?: boolean;
|
||||||
|
has_connections?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoCategorizeChange { transaction_id: number }
|
||||||
|
export interface AutoCategorizePreview {
|
||||||
|
changes?: AutoCategorizeChange[];
|
||||||
|
categories?: { name: string; count?: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The spending page (GET /spending/*). Amounts are dollars (server fromCents);
|
||||||
|
// kept as plain `number` because the page works in numbers via a local formatter.
|
||||||
|
export interface SpendingCategoryEntry {
|
||||||
|
category_id: number | 'other' | null;
|
||||||
|
category_name?: string;
|
||||||
|
amount: number;
|
||||||
|
budget?: number | null;
|
||||||
|
tx_count?: number;
|
||||||
|
avg_3mo?: number;
|
||||||
|
group_id?: number | null;
|
||||||
|
}
|
||||||
|
export interface SpendingSummary {
|
||||||
|
by_category?: SpendingCategoryEntry[];
|
||||||
|
total_spending?: number;
|
||||||
|
income?: number;
|
||||||
|
uncategorized_amount?: number;
|
||||||
|
uncategorized_count?: number;
|
||||||
|
}
|
||||||
|
export interface SpendingTransaction {
|
||||||
|
id: number;
|
||||||
|
payee?: string | null;
|
||||||
|
date?: string | null;
|
||||||
|
amount: number;
|
||||||
|
spending_category_id?: number | string | null;
|
||||||
|
spending_category_name?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
export interface SpendingTransactionsResponse { transactions?: SpendingTransaction[]; total?: number; pages?: number }
|
||||||
|
export interface SpendingIncomeTx { id: number; payee?: string | null; date?: string | null; amount: number }
|
||||||
|
export interface SpendingIncomeResponse { transactions?: SpendingIncomeTx[]; total?: number; pages?: number }
|
||||||
|
export interface SpendingRule { id: number; merchant: string; category_name?: string; category_id?: number }
|
||||||
|
export interface CategoryGroup { id: number; name: string }
|
||||||
|
export interface CopyBudgetsResponse { copied: number; budgets?: { category_id: number; amount: number }[] }
|
||||||
|
|
||||||
|
// The subscriptions page (GET /subscriptions, /subscriptions/recommendations,
|
||||||
|
// /subscriptions/transaction-matches). A Subscription is a decorated Bill;
|
||||||
|
// amounts here are dollars kept as plain `number` (the page uses a local
|
||||||
|
// formatter), except SubTx.amount which is a raw bank amount in CENTS.
|
||||||
|
export interface Subscription {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
active?: boolean | number;
|
||||||
|
is_subscription?: boolean | number;
|
||||||
|
category_id?: number | null;
|
||||||
|
category_name?: string | null;
|
||||||
|
expected_amount?: number | null;
|
||||||
|
monthly_equivalent?: number | null;
|
||||||
|
yearly_equivalent?: number | null;
|
||||||
|
next_due_date?: string | null;
|
||||||
|
subscription_type?: string;
|
||||||
|
due_day?: number | null;
|
||||||
|
cycle_type?: string | null;
|
||||||
|
billing_cycle?: string | null;
|
||||||
|
cycle_day?: number | string | null;
|
||||||
|
autopay_enabled?: unknown;
|
||||||
|
has_2fa?: unknown;
|
||||||
|
has_merchant_rule?: unknown;
|
||||||
|
has_linked_transactions?: unknown;
|
||||||
|
reminder_days_before?: number | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
export interface SubscriptionTopType { type: string; monthly_total: number }
|
||||||
|
export interface SubscriptionSummary {
|
||||||
|
active_count?: number;
|
||||||
|
paused_count?: number;
|
||||||
|
monthly_total?: number;
|
||||||
|
yearly_total?: number;
|
||||||
|
top_type?: SubscriptionTopType | null;
|
||||||
|
}
|
||||||
|
export interface SubscriptionsResponse { summary?: SubscriptionSummary; subscriptions?: Subscription[] }
|
||||||
|
|
||||||
|
export interface CatalogMatch {
|
||||||
|
id?: number | string;
|
||||||
|
name: string;
|
||||||
|
subscription_type?: string;
|
||||||
|
website?: string;
|
||||||
|
starting_monthly_usd?: number | null;
|
||||||
|
starting_annual_usd?: number | null;
|
||||||
|
}
|
||||||
|
export interface ExistingBillMatch {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
expected_amount?: number | null;
|
||||||
|
due_day?: number | null;
|
||||||
|
reasons?: string[];
|
||||||
|
}
|
||||||
|
export interface RecommendationEvidence {
|
||||||
|
identity?: { label?: string };
|
||||||
|
amount?: { label?: string; match?: string };
|
||||||
|
cadence?: { label?: string; recurring?: boolean };
|
||||||
|
amount_range?: { min: number; max: number };
|
||||||
|
ambiguity?: { ambiguous?: boolean; label?: string; reasons?: string[] };
|
||||||
|
}
|
||||||
|
export interface RecommendationTransaction { id: number; payee?: string; description?: string; memo?: string; date?: string; account?: string; amount?: number }
|
||||||
|
export interface Recommendation {
|
||||||
|
id: number | string;
|
||||||
|
name: string;
|
||||||
|
subscription_type?: string;
|
||||||
|
occurrence_count?: number;
|
||||||
|
last_seen_date?: string;
|
||||||
|
expected_amount?: number | null;
|
||||||
|
monthly_equivalent?: number | null;
|
||||||
|
confidence?: number;
|
||||||
|
cycle_type?: string;
|
||||||
|
merchant?: string;
|
||||||
|
decline_key?: string;
|
||||||
|
transaction_ids?: (number | string)[];
|
||||||
|
accounts?: string[];
|
||||||
|
reasons?: string[];
|
||||||
|
evidence?: RecommendationEvidence;
|
||||||
|
existing_bill_match?: ExistingBillMatch | null;
|
||||||
|
catalog_match?: CatalogMatch | null;
|
||||||
|
transactions?: RecommendationTransaction[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
// The snowball projection (GET /snowball/projection) and settings
|
||||||
|
// (PATCH /snowball/settings). Amounts are dollars kept as plain `number`.
|
||||||
|
export interface SnowballDebtProjection {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
payoff_display?: string;
|
||||||
|
months?: number;
|
||||||
|
total_interest?: number;
|
||||||
|
current_balance?: number;
|
||||||
|
}
|
||||||
|
export interface SnowballProjectionDetail {
|
||||||
|
debts: SnowballDebtProjection[];
|
||||||
|
skipped: { name: string }[];
|
||||||
|
payoff_display?: string;
|
||||||
|
months_to_freedom?: number;
|
||||||
|
total_interest_paid?: number;
|
||||||
|
capped?: boolean;
|
||||||
|
}
|
||||||
|
export interface AvalancheProjectionDetail {
|
||||||
|
months_to_freedom?: number;
|
||||||
|
total_interest_paid?: number;
|
||||||
|
payoff_display?: string;
|
||||||
|
}
|
||||||
|
export interface SnowballProjection {
|
||||||
|
snowball?: SnowballProjectionDetail;
|
||||||
|
avalanche?: AvalancheProjectionDetail;
|
||||||
|
}
|
||||||
|
export interface SnowballSettings {
|
||||||
|
extra_payment?: number;
|
||||||
|
ramsey_mode?: boolean;
|
||||||
|
ready_current_on_bills?: boolean;
|
||||||
|
ready_emergency_fund?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A raw bank transaction on the subscription-match search — amount in CENTS.
|
||||||
|
export interface SubscriptionTx {
|
||||||
|
id: number;
|
||||||
|
amount: number;
|
||||||
|
payee?: string;
|
||||||
|
description?: string;
|
||||||
|
memo?: string;
|
||||||
|
account_name?: string;
|
||||||
|
data_source_name?: string;
|
||||||
|
account?: string;
|
||||||
|
match_status?: string;
|
||||||
|
matched_bill_name?: string;
|
||||||
|
catalog_match?: CatalogMatch | null;
|
||||||
|
posted_date?: string;
|
||||||
|
date?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
// A bill's month status. Mirrors the server's statusService.
|
// A bill's month status. Mirrors the server's statusService.
|
||||||
export type TrackerStatus =
|
export type TrackerStatus =
|
||||||
| 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';
|
| 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
**Status:** Current code reference
|
**Status:** Current code reference
|
||||||
**Last Updated:** 2026-07-05
|
**Last Updated:** 2026-07-05
|
||||||
**Version:** 0.40.0
|
**Version:** 0.40.0 (package version; the in-flight Track A/B/C/D commits target **v0.41.0** — see HISTORY.md and the `Last Updated` note below)
|
||||||
**Primary stack:** Node.js 22 + Express 4 (CommonJS), React 19 + Vite 5 (TypeScript, `strict` + `noUncheckedIndexedAccess`), Tailwind CSS 3 + shadcn/ui, TanStack Query 5, Sonner, SQLite via `better-sqlite3` 12, `@simplewebauthn/*` 13, `otplib` 13, `openid-client` 5
|
**Primary stack:** Node.js 22 + Express 4 (CommonJS), React 19 + Vite 5 (TypeScript, `strict` + `noUncheckedIndexedAccess`), Tailwind CSS 3 + shadcn/ui, TanStack Query 5, Sonner, SQLite via `better-sqlite3` 12, `@simplewebauthn/*` 13, `otplib` 13, `openid-client` 5
|
||||||
|
|
||||||
This manual reflects the current application code in `server.js`, `routes/`, `services/`, `middleware/`, `db/`, `client/`, `package.json`, `Dockerfile`, `docker-compose.yml`, `tsconfig.json`, `eslint.config.mjs`, and `vite.config.mjs`. It is written as a current-state reference, not a changelog. The previous version of this manual (v0.28.1) is fully superseded.
|
This manual reflects the current application code in `server.js`, `routes/`, `services/`, `middleware/`, `db/`, `client/`, `package.json`, `Dockerfile`, `docker-compose.yml`, `tsconfig.json`, `eslint.config.mjs`, and `vite.config.mjs`. It is written as a current-state reference, not a changelog. The previous version of this manual (v0.28.1) is fully superseded.
|
||||||
|
|
@ -16,6 +16,13 @@ Notable changes since the previous manual:
|
||||||
- **New features:** SimpleFIN bank sync, WebAuthn + TOTP 2FA, calendar feed (`/api/calendar/feed.ics`), subscription catalog + recommendations, spending categories / budgets, snowball plans (lifecycle), push notifications (ntfy / Gotify / Discord / Telegram), push notification column on users, encrypted secrets at rest with HKDF-derived key, money-cents migration (in progress), category groups, banker-rule auto-attribute.
|
- **New features:** SimpleFIN bank sync, WebAuthn + TOTP 2FA, calendar feed (`/api/calendar/feed.ics`), subscription catalog + recommendations, spending categories / budgets, snowball plans (lifecycle), push notifications (ntfy / Gotify / Discord / Telegram), push notification column on users, encrypted secrets at rest with HKDF-derived key, money-cents migration (in progress), category groups, banker-rule auto-attribute.
|
||||||
- **Schema:** migrations `v0.2` → `v1.06`. Rollback map covers `v0.44` → `v1.04`.
|
- **Schema:** migrations `v0.2` → `v1.06`. Rollback map covers `v0.44` → `v1.04`.
|
||||||
|
|
||||||
|
Since the v0.40.0 manual shipped, **8 in-flight commits** targeting v0.41.0 have been added locally and are documented in this manual ahead of push:
|
||||||
|
|
||||||
|
- **Track A (money integrity):** every payment balance-mutation now runs in a single transaction. `POST /payments` returns `409 { code: 'DUPLICATE_SUSPECTED' }` instead of silently deduping when a live payment matches `(bill_id, paid_date, amount)`; the client surfaces a sonner "Add anyway?" toast via `client/lib/paymentActions.ts → createPaymentOrConfirm` and resends with `allow_duplicate: true` to confirm.
|
||||||
|
- **Track B (tests):** `tests/paymentsRoute.test.js` (route + transactional behavior) and `tests/reconciliation.test.js` (cross-surface reconciliation harness).
|
||||||
|
- **Track C (API response typing):** high-traffic domains (`auth`, `snowball`, `subscriptions`, `spending`, `bank-ledger`) promoted page-local shapes into `client/types.ts` and wired through `get<T>` / `post<T>`. `User` moved from `@/hooks/useAuth` to `@/types` (re-exported for 7 importers). The single-consumer non-money casts (`adminUsers` and the one-off `version` / `about` / `privacy` / `health` / `importHistory` doc shapes) are deliberately left as-is.
|
||||||
|
- **Track D (server error-shape consistency):** the global 500 handler in `server.js` now emits `{ error, code: 'INTERNAL_ERROR' }` so an unexpected server error carries the same `{error, code}` field the client can switch on as every structured 4xx.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. System Overview
|
## 1. System Overview
|
||||||
|
|
@ -403,8 +410,9 @@ Response conventions:
|
||||||
- Unauthenticated: `401`.
|
- Unauthenticated: `401`.
|
||||||
- Forbidden: `403`.
|
- Forbidden: `403`.
|
||||||
- Missing resource: `404`.
|
- Missing resource: `404`.
|
||||||
- Conflict: `409`.
|
- Conflict: `409` (also used for `DUPLICATE_SUSPECTED` on `POST /payments` — see §5.5).
|
||||||
- Rate-limited: `429`.
|
- Rate-limited: `429`.
|
||||||
|
- **Server error (Track D):** the global 500 handler in `server.js` emits `{ error: 'Internal server error', code: 'INTERNAL_ERROR' }` so an unexpected server error carries the same `{error, code}` field the client can switch on as every structured 4xx.
|
||||||
|
|
||||||
### 5.1 Auth (`/api/auth`)
|
### 5.1 Auth (`/api/auth`)
|
||||||
|
|
||||||
|
|
@ -467,6 +475,12 @@ User/admin tracker access. Soft delete via `deleted_at`. `payment_source` ∈ `{
|
||||||
|
|
||||||
Endpoints: `GET /payments?bill_id=&year=&month=`, `GET /payments/:id`, `POST /payments`, `POST /payments/quick`, `POST /payments/bulk` (max 50), `PUT /payments/:id`, `DELETE /payments/:id`, `POST /payments/:id/restore`.
|
Endpoints: `GET /payments?bill_id=&year=&month=`, `GET /payments/:id`, `POST /payments`, `POST /payments/quick`, `POST /payments/bulk` (max 50), `PUT /payments/:id`, `DELETE /payments/:id`, `POST /payments/:id/restore`.
|
||||||
|
|
||||||
|
**Track A money-integrity notes:**
|
||||||
|
|
||||||
|
- `POST /payments` body now accepts an optional `allow_duplicate: true` flag. When omitted and a live payment already matches the same `(bill_id, paid_date, amount)`, the route returns `409 { error, code: 'DUPLICATE_SUSPECTED' }` instead of silently deduping — silently dropping a legitimately-identical second payment would be a money-integrity bug. The client uses `client/lib/paymentActions.ts → createPaymentOrConfirm` to surface a sonner "Add anyway?" toast; confirming resends with `allow_duplicate: true`.
|
||||||
|
- Every balance-mutation runs in a single transaction so a partial failure cannot leave `bills.current_balance` half-updated relative to the new payment.
|
||||||
|
- New tests: `tests/paymentsRoute.test.js` (route + transactional behavior) and `tests/reconciliation.test.js` (cross-surface reconciliation harness).
|
||||||
|
|
||||||
### 5.6 Categories (`/api/categories`)
|
### 5.6 Categories (`/api/categories`)
|
||||||
|
|
||||||
User/admin tracker access. Categories have a `spending_enabled` flag (`v0.88`) and a `sort_order` (`v0.75`). Categories can be grouped via `category_groups` (`v1.06`).
|
User/admin tracker access. Categories have a `spending_enabled` flag (`v0.88`) and a `sort_order` (`v0.75`). Categories can be grouped via `category_groups` (`v1.06`).
|
||||||
|
|
@ -1293,27 +1307,38 @@ Routes (all rendered through `Routes` with `ErrorBoundary`, lazy + `Suspense` fo
|
||||||
- Reads CSRF token from the `bt_csrf_token` cookie **or** fetches once from `/api/auth/csrf-token` and caches the token in memory; sends `x-csrf-token` on `POST`/`PUT`/`PATCH`/`DELETE`.
|
- Reads CSRF token from the `bt_csrf_token` cookie **or** fetches once from `/api/auth/csrf-token` and caches the token in memory; sends `x-csrf-token` on `POST`/`PUT`/`PATCH`/`DELETE`.
|
||||||
- Non-OK responses throw an `Error` whose `status`/`data`/`details`/`code` match the server's structured `errorFormatter`.
|
- Non-OK responses throw an `Error` whose `status`/`data`/`details`/`code` match the server's structured `errorFormatter`.
|
||||||
- `ApiError` interface carries those fields; `TogglePaidResult` and `PaymentRecord` provide typed payment responses; `QueryParams` types the query-string builder.
|
- `ApiError` interface carries those fields; `TogglePaidResult` and `PaymentRecord` provide typed payment responses; `QueryParams` types the query-string builder.
|
||||||
- Exposes grouped functions for `auth`, `admin`, `notifications`, `profile`, `tracker`, `calendar`, `summary`, `bills`, `payments`, `categories`, `settings`, `analytics`, `status`, `version`/`about`, `import`, `export`, `data-sources`, `transactions`, `matches`, `snowball`, `subscriptions`, `spending`, `monthlyStartingAmounts`, `csvImport`, `matchSuggestions`.
|
- Exposes grouped functions for `auth`, `admin`, `notifications`, `profile`, `tracker`, `calendar`, `summary`, `bills`, `payments`, `categories`, `settings`, `analytics`, `status`, `version`/`about`, `import`, `export`, `data-sources`, `transactions`, `matches`, `snowball`, `subscriptions`, `spending`, `monthlyStartingAmounts`, `csvImport`, `matchSuggestions`, `bankTransactionsLedger`, `matchTransaction`, `unmatchTransaction`, `ignoreTransaction`, `unignoreTransaction`, `applyTransactionMerchantMatch`, `autoCategorizeTransactions`, `categoryGroups`, `copySpendingBudgets`, `spendingIncome`, `spendingCategoryRules`, `createPaymentOrConfirm` (via `client/lib/paymentActions.ts`).
|
||||||
- File download/upload endpoints use raw `fetch` because responses/bodies are blobs or octet streams.
|
- File download/upload endpoints use raw `fetch` because responses/bodies are blobs or octet streams.
|
||||||
- **All 16 import sites** that referenced `@/api.js` were normalized to `@/api` so Vite/TS resolve the `.ts`.
|
- **All 16 import sites** that referenced `@/api.js` were normalized to `@/api` so Vite/TS resolve the `.ts`.
|
||||||
|
- **Track C response typing** — see §7 *Domain types* for the full list. In short, the high-traffic domains (`auth`, `bills`, `payments`, `categories`, `tracker`, `snowball`, `subscriptions`, `spending`, `bank-ledger`) all return typed responses via `get<T>` / `post<T>`; the remaining single-consumer non-money casts (adminUsers, version/about/privacy/health/importHistory) are deliberately left as-is.
|
||||||
|
- **Server error-shape consistency (Track D).** The global 500 handler in `server.js` emits `{ error, code: 'INTERNAL_ERROR' }` so an unexpected server error carries the same `{error, code}` field the client can switch on as every structured 4xx. See §5 *Response conventions*.
|
||||||
|
|
||||||
### Domain types — `client/types.ts`
|
### Domain types — `client/types.ts`
|
||||||
|
|
||||||
- Branded `Dollars` and `Cents` money types (from `client/lib/money.ts`).
|
- Branded `Dollars` and `Cents` money types (from `client/lib/money.ts`).
|
||||||
|
- `User` (moved from `@/hooks/useAuth` in Track C; re-exported from `useAuth` for the 7 importers).
|
||||||
- `Bill`, `Payment`, `Category`, `TrackerResponse` (with `summary` / `bank_tracking` / `cashflow` / `rows`).
|
- `Bill`, `Payment`, `Category`, `TrackerResponse` (with `summary` / `bank_tracking` / `cashflow` / `rows`).
|
||||||
- `DriftBill`, `AutopaySuggestion`, `AmountSuggestion`, `AutopayStats`.
|
- `DriftBill`, `AutopaySuggestion`, `AmountSuggestion`, `AutopayStats`.
|
||||||
- `BankTransaction` (integer cents on the wire).
|
- `BankTransaction` (integer cents on the wire).
|
||||||
|
- `BankLedgerTransaction extends BankTransaction` (Track C: bank-ledger domain types) — preserves the `Cents` brand.
|
||||||
|
- `BankAccount`, `BankLedger`, `BankLedgerSummary`, `BankCategoryBreakdown`, `AutoCategorizePreview`, `AutoCategorizeChange`.
|
||||||
|
- `SnowballProjection` (Track C) + `SnowballProjectionDetail` / `AvalancheProjectionDetail` / `SnowballDebtProjection`.
|
||||||
|
- `SnowballSettings` (Track C).
|
||||||
|
- `Subscription`, `SubscriptionTopType`, `SubscriptionSummary`, `SubscriptionsResponse`, `CatalogMatch`, `ExistingBillMatch`, `Recommendation` (Track C: subscriptions domain types) + `RecommendationEvidence` / `RecommendationTransaction` / `SubscriptionTx`.
|
||||||
|
- `SpendingCategoryEntry`, `SpendingSummary`, `SpendingTransaction`, `SpendingTransactionsResponse`, `SpendingIncomeTx`, `SpendingIncomeResponse`, `SpendingRule`, `CategoryGroup`, `CopyBudgetsResponse` (Track C: spending domain types).
|
||||||
- `TimelineBill` for the Safe-to-Spend SVG timeline.
|
- `TimelineBill` for the Safe-to-Spend SVG timeline.
|
||||||
- Money fields are typed as `Dollars` for bill/payment/tracker/summary amounts and as `Cents` for raw bank transactions — so the unit mixup bug class is unrepresentable in typed code.
|
- Money fields are typed as `Dollars` for bill/payment/tracker/summary amounts and as `Cents` for raw bank transactions — so the unit mixup bug class is unrepresentable in typed code.
|
||||||
- A `money.type-test.ts` never-imported guard uses `@ts-expect-error` to assert each unit mixup is a real compile error, so `tsc --noEmit` fails loudly if the branding ever regresses.
|
- A `money.type-test.ts` never-imported guard uses `@ts-expect-error` to assert each unit mixup is a real compile error, so `tsc --noEmit` fails loudly if the branding ever regresses.
|
||||||
|
- **Single-consumer non-money casts left in place** (deliberate Track C stop): `adminUsers` with its page-local `AdminUser`, and the one-off `version` / `about` / `privacy` / `health` / `importHistory` doc shapes. Central types add ~nothing for a single reader; typing them is the low-value tail.
|
||||||
|
|
||||||
### Auth state
|
### Auth state
|
||||||
|
|
||||||
`client/hooks/useAuth.tsx`:
|
`client/hooks/useAuth.tsx`:
|
||||||
|
|
||||||
- `AuthContext` typed (a `createContext(null)` would have made `useContext` return `never`).
|
- `AuthContext` typed (a `createContext(null)` would have made `useContext` return `never`).
|
||||||
|
- `User` interface lives in `client/types.ts` (moved there in Track C; `useAuth` re-exports it for the 7 importers that previously imported it from `@/hooks/useAuth`).
|
||||||
- Maintains `user`, `singleUserMode`, `loading`.
|
- Maintains `user`, `singleUserMode`, `loading`.
|
||||||
- Calls `api.authMode()` and `api.me()` on startup.
|
- Calls `api.authMode()` and `api.me()` on startup; `api.me()` is now `get<User>` (typed).
|
||||||
- Exposes `logout()`, `logoutAll()`, `refresh()`.
|
- Exposes `logout()`, `logoutAll()`, `refresh()`.
|
||||||
- `singleUserMode` is sourced from `/auth/mode`; `RequireAuth` consults it.
|
- `singleUserMode` is sourced from `/auth/mode`; `RequireAuth` consults it.
|
||||||
|
|
||||||
|
|
@ -1373,7 +1398,7 @@ Routes (all rendered through `Routes` with `ErrorBoundary`, lazy + `Suspense` fo
|
||||||
### `client/lib/`
|
### `client/lib/`
|
||||||
|
|
||||||
- `money.ts` — branded `Cents`/`Dollars` types, `asCents`/`asDollars`/`centsToDollars`/`dollarsToCents`, formatters. A `formatUSD(dollars)` and `formatCentsUSD(cents)` split mirrors the server's `formatUSD`/`formatCentsUSD` split. Inputs are coerced defensively so null / `''` / undefined / NaN never render as `$NaN`.
|
- `money.ts` — branded `Cents`/`Dollars` types, `asCents`/`asDollars`/`centsToDollars`/`dollarsToCents`, formatters. A `formatUSD(dollars)` and `formatCentsUSD(cents)` split mirrors the server's `formatUSD`/`formatCentsUSD` split. Inputs are coerced defensively so null / `''` / undefined / NaN never render as `$NaN`.
|
||||||
- `utils.ts` — `cn`, `fmt`, date/format helpers. `fmt` inherits `formatUSD`'s branded-dollars input.
|
- `utils.ts` — `cn`, `fmt`, date/format helpers, `errMessage(unknown, fallback)` for narrowing `unknown` caught errors into strings. `fmt` inherits `formatUSD`'s branded-dollars input.
|
||||||
- `trackerUtils.ts` — row/status/sort helpers, `TrackerRow` domain interface, `TrackerStatus` union.
|
- `trackerUtils.ts` — row/status/sort helpers, `TrackerRow` domain interface, `TrackerStatus` union.
|
||||||
- `billDrafts.ts` — `SourceBill` / `Template` / `Category` shapes for `makeBillDraft`.
|
- `billDrafts.ts` — `SourceBill` / `Template` / `Category` shapes for `makeBillDraft`.
|
||||||
- `billingSchedule.ts` — `Schedule` union and helpers.
|
- `billingSchedule.ts` — `Schedule` union and helpers.
|
||||||
|
|
@ -1381,6 +1406,7 @@ Routes (all rendered through `Routes` with `ErrorBoundary`, lazy + `Suspense` fo
|
||||||
- `trackerTableColumns.ts` — typed column descriptors.
|
- `trackerTableColumns.ts` — typed column descriptors.
|
||||||
- `reorder.ts` — generic `moveInArray<T>`.
|
- `reorder.ts` — generic `moveInArray<T>`.
|
||||||
- `version.ts` — typed release-notes shapes; the Vite-injected `__APP_VERSION__` is declared inline.
|
- `version.ts` — typed release-notes shapes; the Vite-injected `__APP_VERSION__` is declared inline.
|
||||||
|
- `paymentActions.ts` — `createPaymentOrConfirm(payload, onSuccess)` (Track A). The deliberate manual-add path treats the server's `409 DUPLICATE_SUSPECTED` as a question rather than an error: losing a legitimately-identical second payment (same bill, date, amount) is itself a money-integrity bug, so the server returns 409 instead of silently deduping and the helper surfaces a sonner "Add anyway?" toast whose confirm action resends with `allow_duplicate: true`. `onSuccess` runs after any real creation (immediate or confirmed retry). Non-duplicate errors re-throw for the caller's existing `catch`.
|
||||||
|
|
||||||
### Frontend build & lint
|
### Frontend build & lint
|
||||||
|
|
||||||
|
|
@ -1688,4 +1714,17 @@ End-to-end checks run against the codebase:
|
||||||
- `npm run typecheck` was green at the time of the v0.41.0 entry in `HISTORY.md` (0 errors); 48 client unit tests pass; 17/17 e2e probe (every page renders, all API paths respond).
|
- `npm run typecheck` was green at the time of the v0.41.0 entry in `HISTORY.md` (0 errors); 48 client unit tests pass; 17/17 e2e probe (every page renders, all API paths respond).
|
||||||
- `node --check` on every server JS file under `server.js`, `db`, `middleware`, `routes`, `services`, `utils` (`npm run check:server`).
|
- `node --check` on every server JS file under `server.js`, `db`, `middleware`, `routes`, `services`, `utils` (`npm run check:server`).
|
||||||
|
|
||||||
|
Track A/B/C/D additions (targeting v0.41.0, un-pushed as of this update):
|
||||||
|
|
||||||
|
- `6084896` (Track A) — `fix(money): make every payment balance-mutation atomic`
|
||||||
|
- `dd5bf92` (Track A) — `fix(money): manual payment path flags suspected dupes (409) w/o losing legit ones`
|
||||||
|
- `c223f62` (Track B) — `test(money): cross-surface reconciliation harness`
|
||||||
|
- `b267599` (Track C) — `refactor(ts): type the bank-ledger API responses`
|
||||||
|
- `8265b4a` (Track C) — `refactor(ts): type the spending API responses`
|
||||||
|
- `6bb8c63` (Track C) — `refactor(ts): type the subscriptions API responses`
|
||||||
|
- `65a477c` (Track C) — `refactor(ts): type snowball projection + settings responses`
|
||||||
|
- `1df4b1b` (Track C/D) — `refactor(ts): move User to @/types + type auth endpoints; add code to 500`
|
||||||
|
|
||||||
|
Diff stats across the 8 commits: 19 files changed, 813 insertions(+), 415 deletions(-). Notable: `client/types.ts` grew by 240 lines; new file `client/lib/paymentActions.ts` (~40 lines, `createPaymentOrConfirm`); `client/api.ts` re-exports 9 new typed methods; `routes/payments.js` grew by ~150 lines for the transactional + duplicate-detection logic; `server.js` gained a single line (`code: 'INTERNAL_ERROR'`).
|
||||||
|
|
||||||
The previous manual (v0.28.1) contained stale route/page descriptions and missed major features added between v0.28 and v0.40 (subscriptions, spending, snowball plans, SimpleFIN, WebAuthn, TOTP, push notifications, category groups, calendar feed, money-cents migration in progress, React 18 → 19 + TypeScript migration, branded money types). This version replaces that with a current-state engineering reference.
|
The previous manual (v0.28.1) contained stale route/page descriptions and missed major features added between v0.28 and v0.40 (subscriptions, spending, snowball plans, SimpleFIN, WebAuthn, TOTP, push notifications, category groups, calendar feed, money-cents migration in progress, React 18 → 19 + TypeScript migration, branded money types). This version replaces that with a current-state engineering reference.
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ router.post('/:id/undo-auto', (req, res) => {
|
||||||
// POST /api/payments — create single payment
|
// POST /api/payments — create single payment
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { bill_id, amount, paid_date, method, notes, payment_source, autopay_failure } = req.body;
|
const { bill_id, amount, paid_date, method, notes, payment_source, autopay_failure, allow_duplicate } = req.body;
|
||||||
|
|
||||||
const validation = validatePaymentInput({ bill_id, amount, paid_date, payment_source: payment_source ?? 'manual' });
|
const validation = validatePaymentInput({ bill_id, amount, paid_date, payment_source: payment_source ?? 'manual' });
|
||||||
if (validation.error) {
|
if (validation.error) {
|
||||||
|
|
@ -202,15 +202,41 @@ router.post('/', (req, res) => {
|
||||||
if (!bill)
|
if (!bill)
|
||||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
|
// The deliberate manual add must not *silently* drop a same-key payment — a user
|
||||||
|
// may legitimately record two identical payments on the same day, and dropping
|
||||||
|
// one is itself a money-integrity bug. Surface a 409 the client turns into an
|
||||||
|
// "add anyway?" confirm; a resend with allow_duplicate bypasses this. (The
|
||||||
|
// automated one-click paths — /quick, /bulk, autopay-confirm — dedupe silently
|
||||||
|
// instead, because a repeat there is a retry, not a second intent.)
|
||||||
|
if (!allow_duplicate) {
|
||||||
|
const existingDuplicate = db.prepare(
|
||||||
|
`SELECT p.* FROM payments p
|
||||||
|
WHERE p.bill_id = ? AND p.paid_date = ? AND p.amount = ? AND p.${SQL_NOT_DELETED}
|
||||||
|
ORDER BY p.id DESC LIMIT 1`
|
||||||
|
).get(bill.id, payment.paid_date, payment.amount);
|
||||||
|
if (existingDuplicate) {
|
||||||
|
return res.status(409).json({
|
||||||
|
...standardizeError('A matching payment already exists for this bill, date, and amount', 'DUPLICATE_SUSPECTED', 'amount'),
|
||||||
|
existing: serializePayment(existingDuplicate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const balCalc = computeBalanceDelta(bill, payment.amount);
|
const balCalc = computeBalanceDelta(bill, payment.amount);
|
||||||
const failureFlag = autopay_failure ? 1 : 0;
|
const failureFlag = autopay_failure ? 1 : 0;
|
||||||
const result = db.prepare(
|
|
||||||
'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);
|
// Atomic: the INSERT and the balance update land together or neither, so a
|
||||||
|
// mid-way failure never leaves a payment without its balance adjustment.
|
||||||
|
const insertManualPayment = db.transaction(() => {
|
||||||
|
const r = db.prepare(
|
||||||
|
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source, autopay_failure) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null, balCalc?.balance_delta ?? null, balCalc?.interest_delta ?? null, payment.payment_source, failureFlag);
|
||||||
|
applyBalanceDelta(db, bill.id, balCalc);
|
||||||
|
return r.lastInsertRowid;
|
||||||
|
});
|
||||||
|
const paymentId = insertManualPayment();
|
||||||
|
|
||||||
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)));
|
res.status(201).json(serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(paymentId)));
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/payments/quick — pay a bill (expected amount, today)
|
// POST /api/payments/quick — pay a bill (expected amount, today)
|
||||||
|
|
@ -315,25 +341,31 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);
|
const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);
|
||||||
const result = db.prepare(`
|
|
||||||
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`).run(
|
|
||||||
bill.id,
|
|
||||||
suggestedPayment.amount,
|
|
||||||
suggestedPayment.paid_date,
|
|
||||||
'autopay',
|
|
||||||
'Confirmed autopay suggestion',
|
|
||||||
balCalc?.balance_delta ?? null,
|
|
||||||
balCalc?.interest_delta ?? null,
|
|
||||||
'manual',
|
|
||||||
);
|
|
||||||
|
|
||||||
applyBalanceDelta(db, bill.id, balCalc);
|
// Atomic: the payment INSERT, its balance update, and clearing the dismissal
|
||||||
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
|
// must all land or none, so a mid-way failure can't leave a half-recorded pay.
|
||||||
.run(req.user.id, bill.id, ym.year, ym.month);
|
const confirmAutopay = db.transaction(() => {
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta, interest_delta, payment_source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
bill.id,
|
||||||
|
suggestedPayment.amount,
|
||||||
|
suggestedPayment.paid_date,
|
||||||
|
'autopay',
|
||||||
|
'Confirmed autopay suggestion',
|
||||||
|
balCalc?.balance_delta ?? null,
|
||||||
|
balCalc?.interest_delta ?? null,
|
||||||
|
'manual',
|
||||||
|
);
|
||||||
|
applyBalanceDelta(db, bill.id, balCalc);
|
||||||
|
db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
|
||||||
|
.run(req.user.id, bill.id, ym.year, ym.month);
|
||||||
|
return r.lastInsertRowid;
|
||||||
|
});
|
||||||
|
const paymentId = confirmAutopay();
|
||||||
|
|
||||||
res.status(201).json({ created: true, payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid)) });
|
res.status(201).json({ created: true, payment: serializePayment(db.prepare('SELECT * FROM payments WHERE id = ?').get(paymentId)) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/payments/autopay-suggestions/:billId/dismiss
|
// POST /api/payments/autopay-suggestions/:billId/dismiss
|
||||||
|
|
@ -467,13 +499,18 @@ router.put('/:id', (req, res) => {
|
||||||
|
|
||||||
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(existing.bill_id, req.user.id);
|
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(existing.bill_id, req.user.id);
|
||||||
let nextInterestDelta = existing.interest_delta ?? null;
|
let nextInterestDelta = existing.interest_delta ?? null;
|
||||||
|
// Compute the balance mutation without writing yet — the write happens inside
|
||||||
|
// the transaction below so it lands atomically with the payment UPDATE.
|
||||||
|
let balCalc = null;
|
||||||
|
let paymentPortion = null;
|
||||||
|
let restoredBalance = null;
|
||||||
if (bill) {
|
if (bill) {
|
||||||
// Reverse only the *payment* portion of the stored delta (not the interest component)
|
// Reverse only the *payment* portion of the stored delta (not the interest component)
|
||||||
// so that interest already charged this month is not double-counted. For legacy rows
|
// so that interest already charged this month is not double-counted. For legacy rows
|
||||||
// where interest_delta is NULL, fall back to reversing the full delta as before.
|
// where interest_delta is NULL, fall back to reversing the full delta as before.
|
||||||
const interestPortion = existing.interest_delta ?? 0;
|
const interestPortion = existing.interest_delta ?? 0;
|
||||||
const paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null;
|
paymentPortion = existing.balance_delta != null ? existing.balance_delta - interestPortion : null;
|
||||||
let restoredBalance = bill.current_balance;
|
restoredBalance = bill.current_balance;
|
||||||
if (paymentPortion != null && bill.current_balance != null) {
|
if (paymentPortion != null && bill.current_balance != null) {
|
||||||
restoredBalance = Math.max(0, bill.current_balance - paymentPortion);
|
restoredBalance = Math.max(0, bill.current_balance - paymentPortion);
|
||||||
}
|
}
|
||||||
|
|
@ -481,38 +518,45 @@ router.put('/:id', (req, res) => {
|
||||||
// interest_accrued_month is still set to this month (if interest was charged) so
|
// interest_accrued_month is still set to this month (if interest was charged) so
|
||||||
// computeBalanceDelta will skip interest when the payment is within the same month,
|
// computeBalanceDelta will skip interest when the payment is within the same month,
|
||||||
// and charge a fresh month of interest if editing into a new calendar month.
|
// and charge a fresh month of interest if editing into a new calendar month.
|
||||||
const balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount);
|
balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount);
|
||||||
nextBalanceDelta = balCalc?.balance_delta ?? null;
|
nextBalanceDelta = balCalc?.balance_delta ?? null;
|
||||||
nextInterestDelta = balCalc?.interest_delta ?? null;
|
nextInterestDelta = balCalc?.interest_delta ?? null;
|
||||||
if (balCalc) {
|
|
||||||
applyBalanceDelta(db, existing.bill_id, balCalc);
|
|
||||||
} else if (paymentPortion != null && restoredBalance != null) {
|
|
||||||
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
|
||||||
.run(restoredBalance, existing.bill_id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { autopay_failure } = req.body;
|
const { autopay_failure } = req.body;
|
||||||
const nextAutopayFailure = autopay_failure !== undefined ? (autopay_failure ? 1 : 0) : existing.autopay_failure;
|
const nextAutopayFailure = autopay_failure !== undefined ? (autopay_failure ? 1 : 0) : existing.autopay_failure;
|
||||||
|
|
||||||
db.prepare(`
|
// Atomic: the bill-balance write and the payment UPDATE land together or neither,
|
||||||
UPDATE payments SET
|
// so an edit never leaves the balance reversed without the row updated (or vice versa).
|
||||||
amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, interest_delta = ?,
|
const applyEdit = db.transaction(() => {
|
||||||
payment_source = ?, autopay_failure = ?, updated_at = datetime('now')
|
if (bill) {
|
||||||
WHERE id = ?
|
if (balCalc) {
|
||||||
AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)
|
applyBalanceDelta(db, existing.bill_id, balCalc);
|
||||||
`).run(
|
} else if (paymentPortion != null && restoredBalance != null) {
|
||||||
nextAmount,
|
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
nextPaidDate,
|
.run(restoredBalance, existing.bill_id);
|
||||||
method !== undefined ? (method || null) : existing.method,
|
}
|
||||||
notes !== undefined ? (notes || null) : existing.notes,
|
}
|
||||||
nextBalanceDelta,
|
db.prepare(`
|
||||||
nextInterestDelta,
|
UPDATE payments SET
|
||||||
nextPaymentSource,
|
amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?, interest_delta = ?,
|
||||||
nextAutopayFailure,
|
payment_source = ?, autopay_failure = ?, updated_at = datetime('now')
|
||||||
req.params.id,
|
WHERE id = ?
|
||||||
req.user.id,
|
AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)
|
||||||
);
|
`).run(
|
||||||
|
nextAmount,
|
||||||
|
nextPaidDate,
|
||||||
|
method !== undefined ? (method || null) : existing.method,
|
||||||
|
notes !== undefined ? (notes || null) : existing.notes,
|
||||||
|
nextBalanceDelta,
|
||||||
|
nextInterestDelta,
|
||||||
|
nextPaymentSource,
|
||||||
|
nextAutopayFailure,
|
||||||
|
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)));
|
res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id)));
|
||||||
});
|
});
|
||||||
|
|
@ -524,24 +568,27 @@ router.delete('/:id', (req, res) => {
|
||||||
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
||||||
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
|
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
|
||||||
|
|
||||||
// Reverse any balance delta that was stored when this payment was created.
|
// Atomic: reverse the balance delta and soft-delete the payment together, so a
|
||||||
// If this payment was the one that charged interest this month, clear
|
// failure can't leave the balance restored while the payment is still active
|
||||||
|
// (or vice versa). If this payment charged interest this month, clear
|
||||||
// interest_accrued_month so the next payment can re-accrue correctly.
|
// interest_accrued_month so the next payment can re-accrue correctly.
|
||||||
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
const softDeletePayment = db.transaction(() => {
|
||||||
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 (!payment.accounting_excluded && payment.balance_delta != null) {
|
||||||
if (bill?.current_balance != null) {
|
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
|
||||||
const restored = Math.max(0, bill.current_balance - payment.balance_delta);
|
if (bill?.current_balance != null) {
|
||||||
db.prepare(`
|
const restored = Math.max(0, bill.current_balance - payment.balance_delta);
|
||||||
UPDATE bills
|
db.prepare(`
|
||||||
SET current_balance = ?,
|
UPDATE bills
|
||||||
interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
|
SET current_balance = ?,
|
||||||
updated_at = datetime('now')
|
interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
|
||||||
WHERE id = ?
|
updated_at = datetime('now')
|
||||||
`).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
|
WHERE id = ?
|
||||||
|
`).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)").run(req.params.id, req.user.id);
|
||||||
|
});
|
||||||
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)").run(req.params.id, req.user.id);
|
softDeletePayment();
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -552,25 +599,29 @@ router.post('/:id/restore', (req, res) => {
|
||||||
if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
|
if (!payment) return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
|
||||||
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
|
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
|
||||||
|
|
||||||
// Re-apply the balance delta (undo the reversal done on delete).
|
// Atomic: re-apply the balance delta and un-delete the payment together, so a
|
||||||
// If this payment originally charged interest, restore interest_accrued_month
|
// failure can't leave the balance re-applied while the payment stays deleted
|
||||||
// to the month of the payment so future same-month payments skip interest.
|
// (or vice versa). If this payment originally charged interest, restore
|
||||||
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
// interest_accrued_month to the payment's month so future same-month payments
|
||||||
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);
|
// skip interest.
|
||||||
if (bill?.current_balance != null) {
|
const restorePayment = db.transaction(() => {
|
||||||
const reapplied = Math.max(0, bill.current_balance + payment.balance_delta);
|
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
||||||
const interestMonth = payment.interest_delta != null ? (payment.paid_date?.slice(0, 7) ?? null) : 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);
|
||||||
db.prepare(`
|
if (bill?.current_balance != null) {
|
||||||
UPDATE bills
|
const reapplied = Math.max(0, bill.current_balance + payment.balance_delta);
|
||||||
SET current_balance = ?,
|
const interestMonth = payment.interest_delta != null ? (payment.paid_date?.slice(0, 7) ?? null) : null;
|
||||||
interest_accrued_month = CASE WHEN ? IS NOT NULL THEN ? ELSE interest_accrued_month END,
|
db.prepare(`
|
||||||
updated_at = datetime('now')
|
UPDATE bills
|
||||||
WHERE id = ?
|
SET current_balance = ?,
|
||||||
`).run(reapplied, interestMonth, interestMonth, payment.bill_id);
|
interest_accrued_month = CASE WHEN ? IS NOT NULL THEN ? ELSE interest_accrued_month END,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(reapplied, interestMonth, interestMonth, payment.bill_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)').run(req.params.id, req.user.id);
|
||||||
|
});
|
||||||
db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)').run(req.params.id, req.user.id);
|
restorePayment();
|
||||||
res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id)));
|
res.json(serializePayment(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,7 @@ app.use((err, req, res, next) => {
|
||||||
|
|
||||||
res.status(err.status || 500).json({
|
res.status(err.status || 500).json({
|
||||||
error: 'Internal server error',
|
error: 'Internal server error',
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Track A — the manual create / delete / restore / edit payment paths must apply
|
||||||
|
// their balance mutation atomically with the row write (each is now wrapped in a
|
||||||
|
// single db.transaction()). These tests pin the *observable* invariant the wrap
|
||||||
|
// guarantees: the bill balance and the payment state stay consistent across a
|
||||||
|
// create → delete → restore → edit round-trip, and a double-restore is a no-op.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
|
||||||
|
const dbPath = path.join(os.tmpdir(), `bill-tracker-payments-${process.pid}.sqlite`);
|
||||||
|
process.env.DB_PATH = dbPath;
|
||||||
|
|
||||||
|
const { getDb, closeDb } = require('../db/database');
|
||||||
|
const router = require('../routes/payments');
|
||||||
|
|
||||||
|
function handler(method, routePath) {
|
||||||
|
const layer = router.stack.find(l => l.route?.path === routePath && l.route.methods[method]);
|
||||||
|
assert.ok(layer, `${method.toUpperCase()} ${routePath} exists`);
|
||||||
|
return layer.route.stack[layer.route.stack.length - 1].handle;
|
||||||
|
}
|
||||||
|
function call(method, routePath, { userId, params = {}, body = {} } = {}) {
|
||||||
|
const h = handler(method, routePath);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const req = { params, body, query: {}, user: { id: userId, role: 'user' } };
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
status(c) { this.statusCode = c; return this; },
|
||||||
|
json(d) { resolve({ status: this.statusCode, data: d }); },
|
||||||
|
};
|
||||||
|
h(req, res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function balanceOf(billId) {
|
||||||
|
return getDb().prepare('SELECT current_balance FROM bills WHERE id = ?').get(billId).current_balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
let userId, billId;
|
||||||
|
test.before(() => {
|
||||||
|
const db = getDb();
|
||||||
|
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('pay-user','x','user',1)").run().lastInsertRowid;
|
||||||
|
// $100 expected, $1,000 balance (cents), 0% interest so deltas are pure principal.
|
||||||
|
billId = db.prepare(
|
||||||
|
"INSERT INTO bills (user_id, name, due_day, expected_amount, current_balance, minimum_payment, interest_rate, active) VALUES (?, 'Loan', 1, 10000, 100000, 5000, 0, 1)",
|
||||||
|
).run(userId).lastInsertRowid;
|
||||||
|
});
|
||||||
|
test.after(() => {
|
||||||
|
closeDb();
|
||||||
|
for (const s of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + s); } catch {} }
|
||||||
|
});
|
||||||
|
|
||||||
|
let paymentId;
|
||||||
|
test('manual POST /payments creates the payment (201) and drops the balance once', async () => {
|
||||||
|
const { status, data } = await call('post', '/', {
|
||||||
|
userId, body: { bill_id: billId, amount: 100, paid_date: '2026-07-01' },
|
||||||
|
});
|
||||||
|
assert.equal(status, 201);
|
||||||
|
assert.equal(data.amount, 100, 'serialized amount in dollars');
|
||||||
|
paymentId = data.id;
|
||||||
|
assert.equal(balanceOf(billId), 90000, '1000.00 − 100.00 = 900.00 (cents)');
|
||||||
|
const count = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
|
||||||
|
assert.equal(count, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE reverses the balance and soft-deletes atomically', async () => {
|
||||||
|
const { status, data } = await call('delete', '/:id', { userId, params: { id: paymentId } });
|
||||||
|
assert.equal(status, 200);
|
||||||
|
assert.equal(data.success, true);
|
||||||
|
assert.equal(balanceOf(billId), 100000, 'balance restored to 1000.00');
|
||||||
|
const active = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
|
||||||
|
assert.equal(active, 0, 'payment is soft-deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore re-applies the balance drop atomically', async () => {
|
||||||
|
const { status } = await call('post', '/:id/restore', { userId, params: { id: paymentId } });
|
||||||
|
assert.equal(status, 200);
|
||||||
|
assert.equal(balanceOf(billId), 90000, 'balance dropped to 900.00 again');
|
||||||
|
const active = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
|
||||||
|
assert.equal(active, 1, 'payment is active again');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('double-restore is a no-op (404, balance unchanged)', async () => {
|
||||||
|
const before = balanceOf(billId);
|
||||||
|
const { status } = await call('post', '/:id/restore', { userId, params: { id: paymentId } });
|
||||||
|
assert.equal(status, 404, 'already-active payment cannot be restored again');
|
||||||
|
assert.equal(balanceOf(billId), before, 'balance not double-applied');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT edit reverses the old amount and applies the new one atomically', async () => {
|
||||||
|
const { status, data } = await call('put', '/:id', {
|
||||||
|
userId, params: { id: paymentId }, body: { amount: 50 },
|
||||||
|
});
|
||||||
|
assert.equal(status, 200);
|
||||||
|
assert.equal(data.amount, 50, 'amount updated to $50');
|
||||||
|
// reverse $100 (→ 1000.00) then apply $50 (→ 950.00)
|
||||||
|
assert.equal(balanceOf(billId), 95000, '1000.00 − 50.00 = 950.00 (cents)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual POST of a same bill+date+amount is a suspected duplicate (409, no new row, balance unchanged)', async () => {
|
||||||
|
const before = balanceOf(billId);
|
||||||
|
const { status, data } = await call('post', '/', {
|
||||||
|
userId, body: { bill_id: billId, amount: 50, paid_date: '2026-07-01' },
|
||||||
|
});
|
||||||
|
assert.equal(status, 409);
|
||||||
|
assert.equal(data.code, 'DUPLICATE_SUSPECTED');
|
||||||
|
assert.ok(data.existing?.id, 'returns the existing payment so the client can show it');
|
||||||
|
assert.equal(balanceOf(billId), before, 'balance not touched by a rejected duplicate');
|
||||||
|
const active = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
|
||||||
|
assert.equal(active, 1, 'still one payment');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual POST with allow_duplicate bypasses the guard and records the second payment', async () => {
|
||||||
|
const { status } = await call('post', '/', {
|
||||||
|
userId, body: { bill_id: billId, amount: 50, paid_date: '2026-07-01', allow_duplicate: true },
|
||||||
|
});
|
||||||
|
assert.equal(status, 201);
|
||||||
|
assert.equal(balanceOf(billId), 90000, '950.00 − 50.00 = 900.00 (cents)');
|
||||||
|
const active = getDb().prepare('SELECT COUNT(*) c FROM payments WHERE bill_id = ? AND deleted_at IS NULL').get(billId).c;
|
||||||
|
assert.equal(active, 2, 'a legitimately-identical second payment is recorded');
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Track B — cross-surface money reconciliation. The Tracker, Analytics, and the
|
||||||
|
// raw Bills list must agree on the same month's expected/paid totals — the app
|
||||||
|
// has repeatedly had "same number, different truth" drift (QA-B5/B9) where one
|
||||||
|
// surface gates a bill's occurrence differently from another. This harness seeds
|
||||||
|
// the exact shapes that historically broke gating (an off-month annual bill, a
|
||||||
|
// quarterly bill that DOES occur, plus plain monthly bills) and pins the
|
||||||
|
// invariants that must hold, comparing in INTEGER CENTS (never post-fromCents
|
||||||
|
// floats, which drift on rounding).
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
|
||||||
|
const dbPath = path.join(os.tmpdir(), `bill-tracker-recon-${process.pid}.sqlite`);
|
||||||
|
process.env.DB_PATH = dbPath;
|
||||||
|
|
||||||
|
const { getDb, closeDb } = require('../db/database');
|
||||||
|
const { getTracker } = require('../services/trackerService');
|
||||||
|
const { getAnalyticsSummary } = require('../services/analyticsService');
|
||||||
|
const { resolveDueDate } = require('../services/statusService');
|
||||||
|
const summaryRouter = require('../routes/summary');
|
||||||
|
|
||||||
|
// Fixed "now" so occurrence gating is deterministic: June 20, 2026.
|
||||||
|
const NOW = new Date(2026, 5, 20, 12, 0, 0);
|
||||||
|
const YEAR = 2026, MONTH = 6, KEY = '2026-06';
|
||||||
|
const cents = (dollars) => Math.round(dollars * 100);
|
||||||
|
|
||||||
|
let db, userId;
|
||||||
|
test.before(() => {
|
||||||
|
db = getDb();
|
||||||
|
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('recon','x','user',1)").run().lastInsertRowid;
|
||||||
|
const ins = db.prepare(`
|
||||||
|
INSERT INTO bills (user_id, name, due_day, billing_cycle, cycle_type, cycle_day, expected_amount, active)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||||
|
`);
|
||||||
|
const monthlyA = ins.run(userId, 'Monthly A', 15, 'monthly', 'monthly', null, 10000).lastInsertRowid; // $100, occurs
|
||||||
|
const monthlyB = ins.run(userId, 'Monthly B', 1, 'monthly', 'monthly', null, 20000).lastInsertRowid; // $200, occurs
|
||||||
|
ins.run(userId, 'Quarterly (Mar anchor)', 10, 'quarterly', 'quarterly', '3', 30000); // $300, Mar/Jun/Sep/Dec → occurs in June
|
||||||
|
ins.run(userId, 'Annual (Jan)', 1, 'annually', 'annual', '1', 90000); // $900, NOT due in June
|
||||||
|
|
||||||
|
const pay = db.prepare("INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, ?, ?, 'manual')");
|
||||||
|
pay.run(monthlyA, 10000, '2026-06-15'); // pay A in full ($100)
|
||||||
|
pay.run(monthlyB, 5000, '2026-06-10'); // partial toward B ($50)
|
||||||
|
});
|
||||||
|
test.after(() => {
|
||||||
|
closeDb();
|
||||||
|
for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
|
||||||
|
});
|
||||||
|
|
||||||
|
function analyticsMonthRow() {
|
||||||
|
const summary = getAnalyticsSummary(userId, { year: YEAR, month: MONTH, months: 12 });
|
||||||
|
return (summary.expected_vs_actual || []).find(r => r.month === KEY) || { expected: 0, actual: 0 };
|
||||||
|
}
|
||||||
|
function billsGatedExpectedCents() {
|
||||||
|
// The raw Bills surface: sum the occurrence-gated active bills' expected_amount.
|
||||||
|
const bills = db.prepare('SELECT * FROM bills WHERE user_id = ? AND active = 1 AND deleted_at IS NULL').all(userId);
|
||||||
|
return bills
|
||||||
|
.filter(b => resolveDueDate(b, YEAR, MONTH))
|
||||||
|
.reduce((sum, b) => sum + (b.expected_amount || 0), 0);
|
||||||
|
}
|
||||||
|
function summaryExpenseTotalCents() {
|
||||||
|
// The Summary surface: invoke GET /summary and read its gated expense_total.
|
||||||
|
const layer = summaryRouter.stack.find(l => l.route?.path === '/' && l.route.methods.get);
|
||||||
|
const handle = layer.route.stack[layer.route.stack.length - 1].handle;
|
||||||
|
let captured;
|
||||||
|
const req = { query: { year: String(YEAR), month: String(MONTH) }, user: { id: userId } };
|
||||||
|
const res = { status() { return this; }, json(d) { captured = d; } };
|
||||||
|
handle(req, res);
|
||||||
|
return cents(captured.summary.expense_total);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Analytics.expected reconciles with Tracker.total_expected for the month (integer cents)', () => {
|
||||||
|
const tracker = getTracker(userId, { year: YEAR, month: MONTH }, NOW);
|
||||||
|
const analytics = analyticsMonthRow();
|
||||||
|
// Gated June bills: $100 + $200 + $300 (quarterly) = $600; the $900 annual is excluded.
|
||||||
|
assert.equal(cents(tracker.summary.total_expected), 60000, 'tracker total_expected gated to $600');
|
||||||
|
assert.equal(cents(analytics.expected), cents(tracker.summary.total_expected), 'analytics.expected == tracker.total_expected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Bills gated sum reconciles with Tracker.total_expected (integer cents)', () => {
|
||||||
|
const tracker = getTracker(userId, { year: YEAR, month: MONTH }, NOW);
|
||||||
|
assert.equal(billsGatedExpectedCents(), cents(tracker.summary.total_expected), 'Σ resolveDueDate-gated bills == tracker total_expected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Summary.expense_total reconciles with Tracker.total_expected for the month (integer cents)', () => {
|
||||||
|
const tracker = getTracker(userId, { year: YEAR, month: MONTH }, NOW);
|
||||||
|
assert.equal(summaryExpenseTotalCents(), cents(tracker.summary.total_expected), 'summary expense_total == tracker total_expected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Analytics.actual reconciles with Tracker.total_paid for the month (integer cents)', () => {
|
||||||
|
const tracker = getTracker(userId, { year: YEAR, month: MONTH }, NOW);
|
||||||
|
const analytics = analyticsMonthRow();
|
||||||
|
// Payments in June: $100 (A) + $50 (B partial) = $150.
|
||||||
|
assert.equal(cents(tracker.summary.total_paid), 15000, 'tracker total_paid = $150');
|
||||||
|
assert.equal(cents(analytics.actual), cents(tracker.summary.total_paid), 'analytics.actual == tracker.total_paid');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the off-month annual bill inflates NO surface (gating is consistent everywhere)', () => {
|
||||||
|
const tracker = getTracker(userId, { year: YEAR, month: MONTH }, NOW);
|
||||||
|
const analytics = analyticsMonthRow();
|
||||||
|
assert.equal(tracker.rows.filter(r => r.name === 'Annual (Jan)').length, 0, 'annual bill absent from tracker rows');
|
||||||
|
// If the annual $900 leaked into any surface, the totals would be $1,500 not $600.
|
||||||
|
assert.equal(cents(tracker.summary.total_expected), 60000);
|
||||||
|
assert.equal(cents(analytics.expected), 60000);
|
||||||
|
assert.equal(billsGatedExpectedCents(), 60000);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue