Compare commits

..

9 Commits

Author SHA1 Message Date
null 4af738f947 feat(ts): type the shared Category API response (A4)
Adds Category (+ CategoryBillSummary) to @/types and wires categories/
createCategory/updateCategory. Category has no money field of its own (budgets
live on the spending endpoints); the nested per-bill total_paid is a raw SQL sum,
so it's deliberately left unbranded. With categories now typed, useSpending
Categories drops its `any` cast and dead d.categories branch.

Category, Bill, Payment, and the Tracker envelope are the cross-cutting types
used by many components — worth defining upfront. The remaining single-consumer
page responses (summary, analytics, spending, snowball, subscriptions, bank
ledger) are typed alongside their page conversions in phase B, where the exact
fields consumed are visible (more accurate than a speculative interface).

typecheck 0, build green, 48 client tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:27:08 -05:00
null 337ad95a3e feat(ts): type Bill + Payment API responses with branded Dollars (A2-A3)
Adds Bill and reuses Payment domain types (money → Dollars, mirroring
serializeBill/serializePayment which spread the DB row and convert the money
columns). Wired: bills/allBills/deletedBills → Bill[], bill/createBill/updateBill
→ Bill; quickPay/createPayment/updatePayment/restorePayment → Payment. Replaced
api.ts's minimal PaymentRecord with the richer @/types Payment (usePaymentActions
still resolves payment.id). typecheck 0, build green, 48 client tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:24:35 -05:00
null 3c7a55c3a1 feat(ts): type the Tracker API response with branded Dollars (A1)
New client/types.ts domain-types module. Types the full /tracker response —
TrackerResponse { summary, bank_tracking, cashflow, rows } — with every money
field branded `Dollars` (the server serializes cents→dollars, verified against
statusService.buildTrackerRow + trackerService.getTracker + buildBankTracking +
buildSafeToSpend). api.tracker() now returns Promise<TrackerResponse>.

TrackerRow + TrackerStatus move to @/types (canonical) and are re-exported from
@/lib/trackerUtils for compat; trackerUtils' row money fields upgrade from plain
`number` to branded `Dollars` (rowThreshold -> Dollars, paymentSummary re-brands
its computed amounts via asDollars so callers can fmt() them directly). This is
the first endpoint where the money brand flows end-to-end: fetch layer -> row
helpers, and (in phase B) into the components that format them.

Nested payloads no typed consumer reads yet (summary.trend, autopay_suggestion/
_stats, sparkline) are left `unknown`, to refine when consumed. asDollars is an
identity at runtime, so behavior is unchanged: typecheck 0, lint 0 errors, build
green, 48 client tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:21:45 -05:00
null d02d3c9c72 refactor(ts): convert React Query hooks to TypeScript (TS10)
hooks/useQueries.js -> .ts. Typed params and QueryParams-typed query builders
(QueryParams now exported from api.ts). Query result data stays unknown until
endpoint response shapes are typed.

TypeScript caught dead config: three hooks passed `cacheTime` to useQuery, which
React Query v5 renamed to `gcTime` and silently ignores — so those caches were
garbage-collected at the 5-min default instead of the intended 30 min / 2 hr.
Renamed to gcTime so the intended retention takes effect (memory-retention only;
no fetch or correctness change).

Verified: typecheck 0, lint 0 errors (47 warns), build green, 48 client tests,
17/17 e2e probe (every page renders).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:13:05 -05:00
null 9b805e60b2 refactor(ts): convert the API client to TypeScript (TS9)
client/api.js -> api.ts. Types the fetch infrastructure: generic _fetch<T>
and get/post/put/patch/del<T> helpers, an ApiError interface (status/code/
details/data), a QueryParams type for the query-string builder, and File-typed
upload helpers. Endpoint response shapes are typed incrementally — most methods
default to Promise<unknown>; the ones consumed by typed .ts files get real
shapes (quickPay -> PaymentRecord, togglePaid -> TogglePaidResult, settings ->
settings map).

Typing togglePaid's result surfaced a latent narrowing bug in usePaymentActions:
the un-pay Undo closure read result.paymentId (number|undefined) inside a
deferred async callback where TS re-widens the if-narrowed value — fixed by
capturing the id in a const before the closure.

The 16 files importing '@/api.js' with an explicit extension were normalized to
'@/api' so Vite/TS resolve the .ts.

Verified: typecheck 0, lint 0 errors (47 warns), build green, 48 client tests,
and 17/17 e2e probe (every page renders, all API paths respond) — the central
fetch-module rename is runtime-safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:09:04 -05:00
null 8c30a7ab09 refactor(ts): convert small hooks + version to TypeScript (TS8)
- useAutoSave.ts — generic useAutoSave<T>; AutoSaveStatus union; timer ref
  typed ReturnType<typeof setTimeout>; pending ref T|null narrows on != null.
- useSearchPanelPreference.ts — typed [boolean, setter] tuple return.
- version.ts — ReleaseNotes/ReleaseHighlight shapes; the Vite-injected
  __APP_VERSION__ global declared inline (declare const).

No behavior change. useAutoSave.test.jsx imports the .ts transparently and
passes. typecheck 0, lint 0 errors (47 warns), build green, 48 client tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:01:30 -05:00
null 3c51464bec refactor(ts): convert pure lib utilities to TypeScript (TS7)
Five self-contained leaf modules, no behavior change:
- reorder.ts — generic moveInArray<T>, reorderPayload, movedItemId
- billingSchedule.ts — a Schedule union + typed normalize/label helpers
- billDrafts.ts — SourceBill/Template/Category shapes for makeBillDraft
- trackerTableColumns.ts — typed column parse/normalize
- cashflowUtils.ts — typed SVG step-path timeline geometry (Safe-to-Spend)

noUncheckedIndexedAccess handled via in-range `!` (guarded lengths) and
destructuring defaults. Their .test.js suites import the .ts transparently via
Vite/vitest and still pass. typecheck 0, lint 0 errors (47 warns), build green,
48 client tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:59:16 -05:00
null 7c6be66794 refactor(ts): convert client/lib/trackerUtils to TypeScript (TS6)
The tracker row/status/sort helpers are now typed. Introduces a TrackerRow
domain interface (the fields these helpers read) and a TrackerStatus union
(paid|autodraft|upcoming|due_soon|late|missed|skipped). Row money fields stay
plain `number` for now — branding them Dollars is a later increment, done when
the tracker API response itself is typed so the brand flows in from the source.

Strict-mode handling: STATUS_SORT_ORDER is Record<TrackerStatus,number> (no
undefined on lookup); moveInArray guards the noUncheckedIndexedAccess `T |
undefined` from splice; the sort comparator relies on aliased-condition
narrowing to reach string|number in each branch. typecheck 0 errors, lint 0
errors (47 warnings unchanged), build green, 48 client tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:53:45 -05:00
null 7bb68db442 refactor(ts): convert usePaymentActions to TypeScript (TS5)
The shared quick-pay / toggle-paid mutation hooks are now typed: PayableRow for
the row refs, typed mutation payloads, and Dollars for the money amounts (so the
branded type flows through — a caller must supply branded dollars). Strict catch
clauses narrowed via an errMessage(unknown) helper. typecheck + lint (react-hooks
now enforced on this .ts) + build + 48 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:47:12 -05:00
31 changed files with 1088 additions and 646 deletions

View File

@ -5,6 +5,9 @@
- **[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] Branded `Cents`/`Dollars` types** — converted `client/lib/money.js``money.ts` with `type Cents = number & { __unit: 'cents' }` / `type Dollars = number & { __unit: 'dollars' }` plus `asCents`/`asDollars`/`centsToDollars`/`dollarsToCents`. The formatters now require the correct branded unit (a bare number won't type-check), so a typed caller **physically cannot format cents as dollars** (the 100×-too-big bug — cf. QA-B9-01) or vice-versa. A never-imported `money.type-test.ts` guard uses `@ts-expect-error` to assert each unit mixup is a real compile error, so `typecheck` fails loudly if the branding ever regresses (verified: removing a guard makes tsc error `Argument of type 1234 is not assignable to DollarsInput`). Existing `.jsx` callers are unaffected (checkJs off). Full CI (lint + typecheck + server 181 + client 48 + build) green.
- **[Client] ESLint now type-aware, and the migration is proceeding file-by-file** — extended the flat config with a `client/**/*.{ts,tsx}` block (typescript-eslint parser, same react-hooks/react-refresh enforcement, TS-aware unused-vars) so the `.ts` files get the same correctness linting as the `.jsx` ones. Converted, each its own commit and verified green (typecheck + lint + build + 48 client tests): `client/lib/utils.ts` (the app-wide `cn`/`fmt`/date/format helpers — `fmt` now inherits `formatUSD`'s branded-dollars input), `client/hooks/usePaymentActions.ts` (the shared quick-pay/toggle-paid `useMutation` hooks — typed payloads + `Dollars` on the money amounts so the brand flows to callers, strict-catch errors narrowed via an `errMessage(unknown)` helper), `client/lib/trackerUtils.ts` (the row/status/sort helpers — introduces a `TrackerRow` domain interface and a `TrackerStatus` union; row money fields stay `number` until the API is typed), and a batch of pure leaf utilities — `reorder.ts` (generic `moveInArray<T>`), `billingSchedule.ts` (a `Schedule` union), `billDrafts.ts` (`SourceBill`/`Template`/`Category` shapes for `makeBillDraft`), `trackerTableColumns.ts`, and `cashflowUtils.ts` (typed SVG timeline geometry for the Safe-to-Spend card). The `.test.js` suites that import these resolve the `.ts` transparently and still pass (48 client tests). Also converted two small hooks — `useAutoSave.ts` (a generic `useAutoSave<T>` debounced-save hook with an `AutoSaveStatus` union) and `useSearchPanelPreference.ts` (a typed `[boolean, setter]` tuple) — and `version.ts` (release-notes shapes; the Vite-injected `__APP_VERSION__` is declared inline).
- **[Client] Typed the API client (`client/api.js` → `api.ts`) — the fetch layer's infrastructure** — the central request layer is now TypeScript: a generic `_fetch<T>` and `get`/`post`/`put`/`patch`/`del<T>` helpers, an `ApiError` interface carrying the server's `status`/`code`/`details`/`data`, a `QueryParams` type for the query-string builder, and `File`-typed upload helpers. Endpoint **response** shapes are typed incrementally — most methods resolve to `Promise<unknown>` for now (callers narrow), with the handful already consumed by typed `.ts` files given real shapes: `quickPay``PaymentRecord` (`id`), `togglePaid``TogglePaidResult` (`paymentId?`), `settings` → a settings map. Doing so immediately surfaced a **latent narrowing bug** in `usePaymentActions`: the un-pay Undo closure read `result.paymentId` (now typed `number | undefined`) inside a deferred async callback where TS correctly re-widens the `if`-narrowed value — fixed by capturing the id in a const. The 16 files that imported `@/api.js` with an explicit extension were normalized to `@/api` so Vite/TS resolve the `.ts`. Verified end-to-end: typecheck + lint + build + 48 client tests, and the **17/17 e2e probe** (every page renders, all API paths respond) confirms the central-module rename is runtime-safe.
- **[Client] Typed the React Query hooks (`hooks/useQueries.js` → `.ts`) — and it caught dead config** — the shared `useTracker`/`useBills`/`useSummary`/`useSpending*`/`useSnowball*`/`useBankLedger`/etc. hooks are now TypeScript (typed params + `QueryParams`-typed query builders, exported from `api.ts`). Converting surfaced that three hooks passed **`cacheTime`** to `useQuery` — an option **React Query v5 renamed to `gcTime` and silently ignores** (so those caches were being GC'd at the 5-min default, not the intended 30 min / 2 hr). TypeScript rejected the unknown property; renamed to `gcTime` so the intended cache-retention actually takes effect (memory-retention only — no fetch/correctness change). Query result data is `unknown` until the endpoint response shapes are typed. Verified: typecheck + lint + build + 48 tests + 17/17 e2e probe.
### 🧪 Money-service test coverage

View File

@ -1,499 +0,0 @@
// Fetch CSRF token from the server once and cache in memory.
// The cookie is httpOnly so document.cookie cannot access it directly.
let _csrfFetch = null;
async function getCsrfToken() {
if (!_csrfFetch) {
_csrfFetch = fetch('/api/auth/csrf-token', { credentials: 'include' })
.then(r => r.json())
.then(d => d.token || '')
.catch(() => {
_csrfFetch = null; // don't cache a failed fetch
return '';
});
}
return _csrfFetch;
}
const MUTATING_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH'];
// Parse a response body without assuming it is JSON. Returns null when the
// body is empty (204) or not valid JSON (e.g. an HTML error page from a proxy).
async function parseJsonSafe(res) {
if (res.status === 204) return null;
const text = await res.text();
if (!text) return null;
try { return JSON.parse(text); } catch { return null; }
}
async function _fetch(method, path, body, _retried = false) {
const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include' };
// Add CSRF token header for state-changing methods
if (MUTATING_METHODS.includes(method)) {
const csrfToken = await getCsrfToken();
if (csrfToken) {
opts.headers['x-csrf-token'] = csrfToken;
}
}
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch('/api' + path, opts);
const data = await parseJsonSafe(res);
if (!res.ok) {
// Stale CSRF token (cookie rotated/expired since first fetch): refresh the
// cached token and retry the request once instead of forcing a page reload.
if (!_retried && res.status === 403 && data?.code === 'CSRF_INVALID' && MUTATING_METHODS.includes(method)) {
_csrfFetch = null;
return _fetch(method, path, body, true);
}
const err = new Error(data?.message || data?.error || `HTTP ${res.status}`);
err.status = res.status;
err.data = data || {};
err.details = data?.details || [];
err.code = data?.code;
throw err;
}
return data ?? {};
}
function queryString(params = {}) {
const qs = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') qs.set(key, String(value));
});
const value = qs.toString();
return value ? `?${value}` : '';
}
const get = (path, params) => _fetch('GET', path + (params ? queryString(params) : ''));
const post = (path, body) => _fetch('POST', path, body);
const put = (path, body) => _fetch('PUT', path, body);
const patch = (path, body) => _fetch('PATCH', path, body);
const del = (path) => _fetch('DELETE', path);
function filenameFromDisposition(value) {
if (!value) return null;
const match = value.match(/filename="?([^"]+)"?/i);
return match ? match[1] : null;
}
export const api = {
// Auth
me: () => get('/auth/me'),
authMode: () => get('/auth/mode'),
login: (data) => post('/auth/login', data),
logout: () => post('/auth/logout'),
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
changePassword: (data) => post('/auth/change-password', data),
acknowledgePrivacy: () => post('/auth/acknowledge-privacy'),
acknowledgeVersion: () => post('/auth/acknowledge-version'),
loginHistory: () => get('/auth/login-history'),
// Spending
spendingSummary: (p) => get('/spending/summary', p),
spendingTransactions:(p) => get('/spending/transactions', p),
categorizeTransaction: (id, d) => patch(`/spending/transactions/${id}/category`, d),
spendingBudgets: (p) => get('/spending/budgets', p),
setSpendingBudget: (d) => put('/spending/budgets', d),
copySpendingBudgets: (d) => post('/spending/budgets/copy', d),
spendingIncome: (p) => get('/spending/income', p),
spendingCategoryRules: () => get('/spending/category-rules'),
addSpendingRule: (d) => post('/spending/category-rules', d),
deleteSpendingRule: (id) => del(`/spending/category-rules/${id}`),
totpStatus: () => get('/auth/totp/status'),
totpSetup: () => get('/auth/totp/setup'),
totpEnable: (data) => post('/auth/totp/enable', data),
totpDisable: (data) => post('/auth/totp/disable', data),
totpChallenge: (data) => post('/auth/totp/challenge', data),
webauthnStatus: () => get('/auth/webauthn/status'),
webauthnSetup: () => get('/auth/webauthn/setup'),
webauthnEnable: (data) => post('/auth/webauthn/enable', data),
webauthnDisable: (data) => post('/auth/webauthn/disable', data),
webauthnCredentials: () => get('/auth/webauthn/credentials'),
webauthnDeleteCred: (id, data) => _fetch('DELETE', `/auth/webauthn/credentials/${encodeURIComponent(id)}`, data),
webauthnChallenge: (data) => post('/auth/webauthn/challenge', data),
// Admin
hasUsers: () => get('/admin/has-users'),
adminUsers: () => get('/admin/users'),
createUser: (data) => post('/admin/users', data),
resetPassword: (id, data) => put(`/admin/users/${id}/password`, data),
updateUserRole: (id, data) => put(`/admin/users/${id}/role`, data),
updateUserActive: (id, data) => put(`/admin/users/${id}/active`, data),
deleteUser: (id) => del(`/admin/users/${id}`),
authModeConfig: () => get('/admin/auth-mode'),
setAuthMode: (data) => put('/admin/auth-mode', data),
testOidcConfig: (data) => post('/admin/auth-mode/oidc-test', data),
adminBackups: () => get('/admin/backups'),
createAdminBackup: () => post('/admin/backups'),
deleteAdminBackup: (id) => del(`/admin/backups/${encodeURIComponent(id)}`),
restoreAdminBackup: (id) => post(`/admin/backups/${encodeURIComponent(id)}/restore`),
adminBackupSettings: () => get('/admin/backups/settings'),
saveAdminBackupSettings: (data) => put('/admin/backups/settings', data),
runScheduledBackupNow: () => post('/admin/backups/run-scheduled-now'),
adminCleanup: () => get('/admin/cleanup'),
saveAdminCleanup: (data) => put('/admin/cleanup', data),
runAdminCleanup: () => post('/admin/cleanup/run'),
seedDemoData: () => post('/user/seed-demo-data'),
clearDemoData: () => post('/user/clear-demo-data'),
seededStatus: () => get('/user/seeded-status'),
eraseMyData: (confirm) => post('/user/erase-data', { confirm }),
downloadAdminBackup: async (id) => {
const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, {
credentials: 'include',
});
if (!res.ok) {
let data = {};
try { data = await res.json(); } catch {}
throw new Error(data.error || `HTTP ${res.status}`);
}
return {
blob: await res.blob(),
filename: filenameFromDisposition(res.headers.get('Content-Disposition')) || id,
};
},
importAdminBackup: async (file) => {
const csrfToken = await getCsrfToken();
const res = await fetch('/api/admin/backups/import', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': csrfToken },
body: file,
});
const data = await res.json();
if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`);
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
}
return data;
},
// Notifications (admin)
notifAdmin: () => get('/notifications/admin'),
saveNotifAdmin: (data) => put('/notifications/admin', data),
testEmail: (data) => post('/notifications/test', data),
testPushNotification: () => post('/notifications/test-push', {}),
notifMe: () => get('/notifications/me'),
saveNotifMe: (data) => put('/notifications/me', data),
// Profile
profile: () => get('/profile'),
updateProfile: (data) => _fetch('PATCH', '/profile', data),
profileSettings: () => get('/profile/settings'),
updateProfileSettings: (data) => _fetch('PATCH', '/profile/settings', data),
changeProfilePassword: (data) => post('/profile/change-password', data),
profileExports: () => get('/profile/exports'),
profileImportHistory: () => get('/profile/import-history'),
// Tracker
tracker: (y, m, params = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`),
upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`),
overdueCount: () => get('/tracker/overdue-count'),
snoozeOverdue: (id, data) => put(`/bills/${id}/monthly-state`, data),
// Calendar
calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`),
calendarFeed: () => get('/calendar/feed'),
createCalendarFeed: () => post('/calendar/feed', {}),
regenerateCalendarFeed: () => post('/calendar/feed/regenerate', {}),
revokeCalendarFeed: () => del('/calendar/feed'),
calendarFeedPreview:(limit = 10) => get('/calendar/feed/preview', { limit }),
// Summary
summary: (y, m) => get(`/summary?year=${y}&month=${m}`),
saveSummaryIncome: (data) => put('/summary/income', data),
getMonthlyStartingAmounts: (y, m) => get(`/monthly-starting-amounts?year=${y}&month=${m}`),
updateMonthlyStartingAmounts: (data) => put('/monthly-starting-amounts', data),
// Bills
bills: (params = {}) => get(`/bills${queryString(params)}`),
allBills: (params = {}) => get(`/bills${queryString({ inactive: true, ...params })}`),
deletedBills: () => get('/bills/deleted'),
billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`),
bill: (id) => get(`/bills/${id}`),
createBill: (data) => post('/bills', data),
updateBill: (id, data) => put(`/bills/${id}`, data),
reorderBills: (order) => put('/bills/reorder', order),
archiveBill: (id, archived = true) => put(`/bills/${id}/archived`, { archived }),
updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }),
billAmortization: (id, opts = {}) => {
const params = new URLSearchParams();
if (opts.payment) params.set('payment', String(opts.payment));
if (opts.max_months) params.set('max_months', String(opts.max_months));
const qs = params.toString();
return get(`/bills/${id}/amortization${qs ? `?${qs}` : ''}`);
},
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
deleteBill: (id) => del(`/bills/${id}`),
restoreBill: (id) => post(`/bills/${id}/restore`),
duplicateBill: (id, data) => post(`/bills/${id}/duplicate`, data),
verifyAutopay: (id) => post(`/bills/${id}/verify-autopay`, {}),
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
billTransactions: (id) => get(`/bills/${id}/transactions`),
syncBillSimplefinPayments: (id) => post(`/bills/${id}/sync-simplefin-payments`),
allBillMerchantRules: () => get('/bills/merchant-rules'),
billMerchantRules: (id) => get(`/bills/${id}/merchant-rules`),
previewMerchantRule: (id, merchant) => get(`/bills/${id}/merchant-rules/preview?merchant=${encodeURIComponent(merchant)}`),
addMerchantRule: (id, merchant) => post(`/bills/${id}/merchant-rules`, { merchant }),
deleteMerchantRule: (id, ruleId) => del(`/bills/${id}/merchant-rules/${ruleId}`),
merchantRuleCandidates: (id) => get(`/bills/${id}/merchant-rules/candidates`),
toggleRuleAutoAttribute: (id, ruleId, on) => _fetch('PATCH', `/bills/${id}/merchant-rules/${ruleId}/auto-attribute`, { enabled: on }),
importHistoricalPayments: (id, ids) => post(`/bills/${id}/merchant-rules/import-historical`, { transaction_ids: ids }),
billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data),
billHistoryRanges: (id) => get(`/bills/${id}/history-ranges`),
createBillHistoryRange: (id, data) => post(`/bills/${id}/history-ranges`, data),
updateBillHistoryRange: (id, rangeId, data) => put(`/bills/${id}/history-ranges/${rangeId}`, data),
deleteBillHistoryRange: (id, rangeId) => del(`/bills/${id}/history-ranges/${rangeId}`),
driftReport: () => get('/bills/drift-report'),
snoozeBillDrift: (id) => post(`/bills/${id}/snooze-drift`, {}),
billTemplates: () => get('/bills/templates'),
saveBillTemplate: (data) => post('/bills/templates', data),
deleteBillTemplate: (id) => del(`/bills/templates/${id}`),
// Subscriptions
subscriptions: () => get('/subscriptions'),
confirmTransactionMatch: (transactionId, billId) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }),
matchRecommendationToBill: (transactionIds, billId, merchant, catalogId, confidence) => post('/subscriptions/recommendations/match-bill', {
transaction_ids: transactionIds,
bill_id: billId,
merchant,
catalog_id: catalogId,
confidence,
}),
subscriptionRecommendations: () => get('/subscriptions/recommendations'),
subscriptionTransactionMatches: (params = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`),
updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data),
createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data),
declineRecommendation: (recommendation) => post('/subscriptions/recommendations/decline', {
decline_key: recommendation?.decline_key || recommendation,
catalog_id: recommendation?.catalog_match?.id,
merchant: recommendation?.merchant,
confidence: recommendation?.confidence,
}),
subscriptionCatalog: () => get('/subscriptions/catalog'),
updateSubscriptionCatalogLink:(id, catalogId) => _fetch('PUT', `/subscriptions/${id}/catalog-link`, { catalog_id: catalogId }),
addCatalogDescriptor: (catalogId, d) => post(`/subscriptions/catalog/${catalogId}/descriptors`, { descriptor: d }),
deleteCatalogDescriptor: (id) => _fetch('DELETE', `/subscriptions/catalog/descriptors/${id}`),
// Payments
quickPay: (data) => post('/payments/quick', data),
confirmAutopaySuggestion: (billId, data) => post(`/payments/autopay-suggestions/${billId}/confirm`, data),
dismissAutopaySuggestion: (billId, data) => post(`/payments/autopay-suggestions/${billId}/dismiss`, data),
bulkPay: (items) => post('/payments/bulk', items),
createPayment: (data) => post('/payments', data),
updatePayment: (id, data) => put(`/payments/${id}`, data),
deletePayment: (id) => del(`/payments/${id}`),
restorePayment: (id) => post(`/payments/${id}/restore`),
recentAutoMatched: () => get('/payments/recent-auto'),
undoAutoMatch: (id) => post(`/payments/${id}/undo-auto`),
// Snowball
snowball: () => get('/snowball'),
snowballSettings: () => get('/snowball/settings'),
saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data),
saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items),
snowballProjection: (params = {}) => get(`/snowball/projection${queryString(params)}`),
snowballPlans: () => get('/snowball/plans'),
snowballActivePlan: () => get('/snowball/plans/active'),
startSnowballPlan: (data) => post('/snowball/plans', data),
updateSnowballPlan: (id, d) => _fetch('PATCH', `/snowball/plans/${id}`, d),
pauseSnowballPlan: (id) => post(`/snowball/plans/${id}/pause`, {}),
resumeSnowballPlan: (id) => post(`/snowball/plans/${id}/resume`, {}),
completeSnowballPlan: (id) => post(`/snowball/plans/${id}/complete`, {}),
abandonSnowballPlan: (id) => post(`/snowball/plans/${id}/abandon`, {}),
// Categories
categories: () => get('/categories'),
createCategory: (data) => post('/categories', data),
reorderCategories: (order) => put('/categories/reorder', order),
updateCategory: (id, data) => put(`/categories/${id}`, data),
toggleCategorySpending: (id, val) => patch(`/categories/${id}/spending`, { spending_enabled: val }),
deleteCategory: (id) => del(`/categories/${id}`),
restoreCategory: (id) => post(`/categories/${id}/restore`),
// Category groups
categoryGroups: () => get('/categories/groups'),
createCategoryGroup: (data) => post('/categories/groups', data),
updateCategoryGroup: (id, data) => put(`/categories/groups/${id}`, data),
deleteCategoryGroup: (id) => del(`/categories/groups/${id}`),
// Settings
settings: () => get('/settings'),
saveSettings: (data) => put('/settings', data),
// Analytics
analyticsSummary: (params = {}) => {
const qs = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') qs.set(key, String(value));
});
const query = qs.toString();
return get(`/analytics/summary${query ? `?${query}` : ''}`);
},
// Status
status: () => get('/status'),
// Version (public)
about: () => get('/about'),
privacy: () => get('/privacy'),
aboutAdmin: () => get('/about-admin'),
roadmap: (refresh = false) => get(`/about-admin/roadmap${refresh ? '?refresh=1' : ''}`),
updateStatus: () => get('/version/update-status'),
checkForUpdates: () => post('/about-admin/check-updates'),
getUpdateCheckSetting: () => get('/about-admin/update-check-setting'),
setUpdateCheckSetting: (enabled) => put('/about-admin/update-check-setting', { enabled }),
devLog: () => get('/about-admin/dev-log'),
version: () => get('/version'),
releaseHistory: () => get('/version/history'),
// Export (returns a URL to navigate to, not a fetch)
exportUrl: (year, fmt) => `/api/export?year=${year}&format=${fmt||'csv'}`,
// Spreadsheet Import
previewSpreadsheetImport: async (file, options = {}) => {
const params = new URLSearchParams();
if (options.parseAllSheets) params.set('parse_all_sheets', 'true');
if (options.defaultYear) params.set('year', String(options.defaultYear));
if (options.defaultMonth) params.set('month', String(options.defaultMonth));
const qs = params.toString();
const csrfToken = await getCsrfToken();
const res = await fetch(`/api/import/spreadsheet/preview${qs ? `?${qs}` : ''}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/octet-stream',
'x-csrf-token': csrfToken,
...(file.name ? { 'X-Filename': file.name } : {}),
},
body: file,
});
const data = await res.json();
if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`);
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
}
return data;
},
applySpreadsheetImport: (data) => post('/import/spreadsheet/apply', data),
previewCsvTransactionImport: async (file) => {
const csrfToken = await getCsrfToken();
const res = await fetch('/api/import/csv/preview', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'text/csv',
'x-csrf-token': csrfToken,
...(file.name ? { 'X-Filename': file.name } : {}),
},
body: file,
});
const data = await res.json();
if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`);
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
}
return data;
},
commitCsvTransactionImport: (data) => post('/import/csv/commit', data),
previewOfxTransactionImport: async (file) => {
const csrfToken = await getCsrfToken();
const res = await fetch('/api/import/ofx/preview', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/x-ofx',
'x-csrf-token': csrfToken,
...(file.name ? { 'X-Filename': file.name } : {}),
},
body: file,
});
const data = await res.json();
if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`);
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
}
return data;
},
commitOfxTransactionImport: (data) => post('/import/ofx/commit', data),
importHistory: () => get('/import/history'),
// Transactions
transactions: (params = {}) => get(`/transactions${queryString(params)}`),
bankTransactionsLedger: (params = {}) => get(`/transactions/bank-ledger${queryString(params)}`),
createManualTransaction: (data) => post('/transactions/manual', data),
updateTransaction: (id, data) => put(`/transactions/${id}`, data),
deleteTransaction: (id) => del(`/transactions/${id}`),
matchTransaction: (id, billId) => post(`/transactions/${id}/match`, { billId }),
unmatchTransaction: (id) => post(`/transactions/${id}/unmatch`),
unmatchTransactionBulk: (matches) => post('/transactions/unmatch-bulk', { matches }),
ignoreTransaction: (id) => post(`/transactions/${id}/ignore`),
unignoreTransaction: (id) => post(`/transactions/${id}/unignore`),
transactionMerchantMatch: (id) => get(`/transactions/${id}/merchant-match`),
applyTransactionMerchantMatch: (id) => post(`/transactions/${id}/apply-merchant-match`),
autoCategorizeTransactions: (opts = {}) => post('/transactions/auto-categorize', opts),
// Match suggestions
matchSuggestions: (params = {}) => get(`/matches/suggestions${queryString(params)}`),
rejectMatchSuggestion: (id) => post(`/matches/${encodeURIComponent(id)}/reject`),
// Data sources & SimpleFIN bank sync
dataSources: (params = {}) => get(`/data-sources${queryString(params)}`),
simplefinStatus: () => get('/data-sources/simplefin/status'),
connectSimplefin: (setupToken) => post('/data-sources/simplefin/connect', { setupToken }),
syncDataSource: (id) => post(`/data-sources/${id}/sync`),
backfillDataSource: (id) => post(`/data-sources/${id}/backfill`),
deleteDataSource: (id) => del(`/data-sources/${id}`),
dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`),
setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }),
allFinancialAccounts: () => get('/data-sources/accounts/all'),
syncAllSources: () => post('/data-sources/sync-all', {}),
attributePaymentToMonth: (id, paid_date) => _fetch('PATCH', `/payments/${id}/attribute-to-month`, { paid_date }),
// Admin — bank sync feature flag
bankSyncConfig: () => get('/admin/bank-sync-config'),
setBankSyncConfig: (data) => put('/admin/bank-sync-config', data),
// User SQLite import
previewUserDbImport: async (file) => {
const csrfToken = await getCsrfToken();
const res = await fetch('/api/import/user-db/preview', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/octet-stream',
'x-csrf-token': csrfToken,
...(file.name ? { 'X-Filename': file.name } : {}),
},
body: file,
});
const data = await res.json();
if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`);
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
}
return data;
},
applyUserDbImport: (data) => post('/import/user-db/apply', data),
};

523
client/api.ts Normal file
View File

@ -0,0 +1,523 @@
import type { Bill, Category, Payment, TrackerResponse } from '@/types';
// Fetch CSRF token from the server once and cache in memory.
// The cookie is httpOnly so document.cookie cannot access it directly.
let _csrfFetch: Promise<string> | null = null;
async function getCsrfToken(): Promise<string> {
if (!_csrfFetch) {
_csrfFetch = fetch('/api/auth/csrf-token', { credentials: 'include' })
.then(r => r.json())
.then(d => d.token || '')
.catch(() => {
_csrfFetch = null; // don't cache a failed fetch
return '';
});
}
return _csrfFetch;
}
// Common parameter/body shapes for the client. Endpoint responses are typed
// incrementally — most methods currently resolve to `unknown` (callers narrow),
// with the money-critical/consumed ones given real shapes below.
type Id = number | string;
type Body = unknown;
export type QueryParams = Record<string, string | number | boolean | null | undefined>;
/** Error thrown by the API client, carrying the server's structured fields. */
export interface ApiError extends Error {
status?: number;
data?: unknown;
details?: unknown[];
code?: string;
}
/** Result of toggling a tracker row paid/unpaid. */
export interface TogglePaidResult {
paymentId?: number;
[key: string]: unknown;
}
const MUTATING_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH'];
// Parse a response body without assuming it is JSON. Returns null when the
// body is empty (204) or not valid JSON (e.g. an HTML error page from a proxy).
async function parseJsonSafe(res: Response): Promise<any> {
if (res.status === 204) return null;
const text = await res.text();
if (!text) return null;
try { return JSON.parse(text); } catch { return null; }
}
async function _fetch<T = unknown>(method: string, path: string, body?: unknown, _retried = false): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const opts: RequestInit = { method, headers, credentials: 'include' };
// Add CSRF token header for state-changing methods
if (MUTATING_METHODS.includes(method)) {
const csrfToken = await getCsrfToken();
if (csrfToken) {
headers['x-csrf-token'] = csrfToken;
}
}
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch('/api' + path, opts);
const data = await parseJsonSafe(res);
if (!res.ok) {
// Stale CSRF token (cookie rotated/expired since first fetch): refresh the
// cached token and retry the request once instead of forcing a page reload.
if (!_retried && res.status === 403 && data?.code === 'CSRF_INVALID' && MUTATING_METHODS.includes(method)) {
_csrfFetch = null;
return _fetch<T>(method, path, body, true);
}
const err = new Error(data?.message || data?.error || `HTTP ${res.status}`) as ApiError;
err.status = res.status;
err.data = data || {};
err.details = data?.details || [];
err.code = data?.code;
throw err;
}
return (data ?? {}) as T;
}
function queryString(params: QueryParams = {}): string {
const qs = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') qs.set(key, String(value));
});
const value = qs.toString();
return value ? `?${value}` : '';
}
const get = <T = unknown>(path: string, params?: QueryParams): Promise<T> => _fetch<T>('GET', path + (params ? queryString(params) : ''));
const post = <T = unknown>(path: string, body?: unknown): Promise<T> => _fetch<T>('POST', path, body);
const put = <T = unknown>(path: string, body?: unknown): Promise<T> => _fetch<T>('PUT', path, body);
const patch = <T = unknown>(path: string, body?: unknown): Promise<T> => _fetch<T>('PATCH', path, body);
const del = <T = unknown>(path: string): Promise<T> => _fetch<T>('DELETE', path);
function filenameFromDisposition(value: string | null): string | null {
if (!value) return null;
const match = value.match(/filename="?([^"]+)"?/i);
return match ? (match[1] ?? null) : null;
}
export const api = {
// Auth
me: () => get('/auth/me'),
authMode: () => get('/auth/mode'),
login: (data: Body) => post('/auth/login', data),
logout: () => post('/auth/logout'),
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
changePassword: (data: Body) => post('/auth/change-password', data),
acknowledgePrivacy: () => post('/auth/acknowledge-privacy'),
acknowledgeVersion: () => post('/auth/acknowledge-version'),
loginHistory: () => get('/auth/login-history'),
// Spending
spendingSummary: (p?: QueryParams) => get('/spending/summary', p),
spendingTransactions:(p?: QueryParams) => get('/spending/transactions', p),
categorizeTransaction: (id: Id, d: Body) => patch(`/spending/transactions/${id}/category`, d),
spendingBudgets: (p?: QueryParams) => get('/spending/budgets', p),
setSpendingBudget: (d: Body) => put('/spending/budgets', d),
copySpendingBudgets: (d: Body) => post('/spending/budgets/copy', d),
spendingIncome: (p?: QueryParams) => get('/spending/income', p),
spendingCategoryRules: () => get('/spending/category-rules'),
addSpendingRule: (d: Body) => post('/spending/category-rules', d),
deleteSpendingRule: (id: Id) => del(`/spending/category-rules/${id}`),
totpStatus: () => get('/auth/totp/status'),
totpSetup: () => get('/auth/totp/setup'),
totpEnable: (data: Body) => post('/auth/totp/enable', data),
totpDisable: (data: Body) => post('/auth/totp/disable', data),
totpChallenge: (data: Body) => post('/auth/totp/challenge', data),
webauthnStatus: () => get('/auth/webauthn/status'),
webauthnSetup: () => get('/auth/webauthn/setup'),
webauthnEnable: (data: Body) => post('/auth/webauthn/enable', data),
webauthnDisable: (data: Body) => post('/auth/webauthn/disable', data),
webauthnCredentials: () => get('/auth/webauthn/credentials'),
webauthnDeleteCred: (id: Id, data: Body) => _fetch('DELETE', `/auth/webauthn/credentials/${encodeURIComponent(id)}`, data),
webauthnChallenge: (data: Body) => post('/auth/webauthn/challenge', data),
// Admin
hasUsers: () => get('/admin/has-users'),
adminUsers: () => get('/admin/users'),
createUser: (data: Body) => post('/admin/users', data),
resetPassword: (id: Id, data: Body) => put(`/admin/users/${id}/password`, data),
updateUserRole: (id: Id, data: Body) => put(`/admin/users/${id}/role`, data),
updateUserActive: (id: Id, data: Body) => put(`/admin/users/${id}/active`, data),
deleteUser: (id: Id) => del(`/admin/users/${id}`),
authModeConfig: () => get('/admin/auth-mode'),
setAuthMode: (data: Body) => put('/admin/auth-mode', data),
testOidcConfig: (data: Body) => post('/admin/auth-mode/oidc-test', data),
adminBackups: () => get('/admin/backups'),
createAdminBackup: () => post('/admin/backups'),
deleteAdminBackup: (id: Id) => del(`/admin/backups/${encodeURIComponent(id)}`),
restoreAdminBackup: (id: Id) => post(`/admin/backups/${encodeURIComponent(id)}/restore`),
adminBackupSettings: () => get('/admin/backups/settings'),
saveAdminBackupSettings: (data: Body) => put('/admin/backups/settings', data),
runScheduledBackupNow: () => post('/admin/backups/run-scheduled-now'),
adminCleanup: () => get('/admin/cleanup'),
saveAdminCleanup: (data: Body) => put('/admin/cleanup', data),
runAdminCleanup: () => post('/admin/cleanup/run'),
seedDemoData: () => post('/user/seed-demo-data'),
clearDemoData: () => post('/user/clear-demo-data'),
seededStatus: () => get('/user/seeded-status'),
eraseMyData: (confirm: unknown) => post('/user/erase-data', { confirm }),
downloadAdminBackup: async (id: Id) => {
const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, {
credentials: 'include',
});
if (!res.ok) {
let data: any = {};
try { data = await res.json(); } catch {}
throw new Error(data.error || `HTTP ${res.status}`);
}
return {
blob: await res.blob(),
filename: filenameFromDisposition(res.headers.get('Content-Disposition')) || String(id),
};
},
importAdminBackup: async (file: File) => {
const csrfToken = await getCsrfToken();
const res = await fetch('/api/admin/backups/import', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': csrfToken },
body: file,
});
const data = await res.json();
if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError;
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
}
return data;
},
// Notifications (admin)
notifAdmin: () => get('/notifications/admin'),
saveNotifAdmin: (data: Body) => put('/notifications/admin', data),
testEmail: (data: Body) => post('/notifications/test', data),
testPushNotification: () => post('/notifications/test-push', {}),
notifMe: () => get('/notifications/me'),
saveNotifMe: (data: Body) => put('/notifications/me', data),
// Profile
profile: () => get('/profile'),
updateProfile: (data: Body) => _fetch('PATCH', '/profile', data),
profileSettings: () => get('/profile/settings'),
updateProfileSettings: (data: Body) => _fetch('PATCH', '/profile/settings', data),
changeProfilePassword: (data: Body) => post('/profile/change-password', data),
profileExports: () => get('/profile/exports'),
profileImportHistory: () => get('/profile/import-history'),
// Tracker
tracker: (y: number, m: number, params: QueryParams = {}) => get<TrackerResponse>(`/tracker${queryString({ year: y, month: m, ...params })}`),
upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`),
overdueCount: () => get('/tracker/overdue-count'),
snoozeOverdue: (id: Id, data: Body) => put(`/bills/${id}/monthly-state`, data),
// Calendar
calendar: (y: number, m: number) => get(`/calendar?year=${y}&month=${m}`),
calendarFeed: () => get('/calendar/feed'),
createCalendarFeed: () => post('/calendar/feed', {}),
regenerateCalendarFeed: () => post('/calendar/feed/regenerate', {}),
revokeCalendarFeed: () => del('/calendar/feed'),
calendarFeedPreview:(limit = 10) => get('/calendar/feed/preview', { limit }),
// Summary
summary: (y: number, m: number) => get(`/summary?year=${y}&month=${m}`),
saveSummaryIncome: (data: Body) => put('/summary/income', data),
getMonthlyStartingAmounts: (y: number, m: number) => get(`/monthly-starting-amounts?year=${y}&month=${m}`),
updateMonthlyStartingAmounts: (data: Body) => put('/monthly-starting-amounts', data),
// Bills
bills: (params: QueryParams = {}) => get<Bill[]>(`/bills${queryString(params)}`),
allBills: (params: QueryParams = {}) => get<Bill[]>(`/bills${queryString({ inactive: true, ...params })}`),
deletedBills: () => get<Bill[]>('/bills/deleted'),
billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`),
bill: (id: Id) => get<Bill>(`/bills/${id}`),
createBill: (data: Body) => post<Bill>('/bills', data),
updateBill: (id: Id, data: Body) => put<Bill>(`/bills/${id}`, data),
reorderBills: (order: Body) => put('/bills/reorder', order),
archiveBill: (id: Id, archived = true) => put(`/bills/${id}/archived`, { archived }),
updateBillBalance: (id: Id, bal: unknown) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }),
billAmortization: (id: Id, opts: { payment?: number | string; max_months?: number | string } = {}) => {
const params = new URLSearchParams();
if (opts.payment) params.set('payment', String(opts.payment));
if (opts.max_months) params.set('max_months', String(opts.max_months));
const qs = params.toString();
return get(`/bills/${id}/amortization${qs ? `?${qs}` : ''}`);
},
updateBillSnowball: (id: Id, data: Body) => _fetch('PATCH', `/bills/${id}/snowball`, data),
deleteBill: (id: Id) => del(`/bills/${id}`),
restoreBill: (id: Id) => post(`/bills/${id}/restore`),
duplicateBill: (id: Id, data: Body) => post(`/bills/${id}/duplicate`, data),
verifyAutopay: (id: Id) => post(`/bills/${id}/verify-autopay`, {}),
togglePaid: (id: Id, data: Body) => post<TogglePaidResult>(`/bills/${id}/toggle-paid`, data),
billPayments: (id: Id, p?: number, l?: number) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
billTransactions: (id: Id) => get(`/bills/${id}/transactions`),
syncBillSimplefinPayments: (id: Id) => post(`/bills/${id}/sync-simplefin-payments`),
allBillMerchantRules: () => get('/bills/merchant-rules'),
billMerchantRules: (id: Id) => get(`/bills/${id}/merchant-rules`),
previewMerchantRule: (id: Id, merchant: string) => get(`/bills/${id}/merchant-rules/preview?merchant=${encodeURIComponent(merchant)}`),
addMerchantRule: (id: Id, merchant: string) => post(`/bills/${id}/merchant-rules`, { merchant }),
deleteMerchantRule: (id: Id, ruleId: Id) => del(`/bills/${id}/merchant-rules/${ruleId}`),
merchantRuleCandidates: (id: Id) => get(`/bills/${id}/merchant-rules/candidates`),
toggleRuleAutoAttribute: (id: Id, ruleId: Id, on: unknown) => _fetch('PATCH', `/bills/${id}/merchant-rules/${ruleId}/auto-attribute`, { enabled: on }),
importHistoricalPayments: (id: Id, ids: unknown) => post(`/bills/${id}/merchant-rules/import-historical`, { transaction_ids: ids }),
billMonthlyState: (id: Id, y: number, m: number) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`),
saveBillMonthlyState: (id: Id, data: Body) => put(`/bills/${id}/monthly-state`, data),
billHistoryRanges: (id: Id) => get(`/bills/${id}/history-ranges`),
createBillHistoryRange: (id: Id, data: Body) => post(`/bills/${id}/history-ranges`, data),
updateBillHistoryRange: (id: Id, rangeId: Id, data: Body) => put(`/bills/${id}/history-ranges/${rangeId}`, data),
deleteBillHistoryRange: (id: Id, rangeId: Id) => del(`/bills/${id}/history-ranges/${rangeId}`),
driftReport: () => get('/bills/drift-report'),
snoozeBillDrift: (id: Id) => post(`/bills/${id}/snooze-drift`, {}),
billTemplates: () => get('/bills/templates'),
saveBillTemplate: (data: Body) => post('/bills/templates', data),
deleteBillTemplate: (id: Id) => del(`/bills/templates/${id}`),
// Subscriptions
subscriptions: () => get('/subscriptions'),
confirmTransactionMatch: (transactionId: Id, billId: Id) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }),
matchRecommendationToBill: (transactionIds: unknown, billId: Id, merchant: unknown, catalogId: unknown, confidence: unknown) => post('/subscriptions/recommendations/match-bill', {
transaction_ids: transactionIds,
bill_id: billId,
merchant,
catalog_id: catalogId,
confidence,
}),
subscriptionRecommendations: () => get('/subscriptions/recommendations'),
subscriptionTransactionMatches: (params: QueryParams = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`),
updateSubscription: (id: Id, data: Body) => _fetch('PATCH', `/subscriptions/${id}`, data),
createSubscriptionFromRecommendation: (data: Body) => post('/subscriptions/recommendations/create', data),
declineRecommendation: (recommendation: any) => post('/subscriptions/recommendations/decline', {
decline_key: recommendation?.decline_key || recommendation,
catalog_id: recommendation?.catalog_match?.id,
merchant: recommendation?.merchant,
confidence: recommendation?.confidence,
}),
subscriptionCatalog: () => get('/subscriptions/catalog'),
updateSubscriptionCatalogLink:(id: Id, catalogId: Id) => _fetch('PUT', `/subscriptions/${id}/catalog-link`, { catalog_id: catalogId }),
addCatalogDescriptor: (catalogId: Id, d: unknown) => post(`/subscriptions/catalog/${catalogId}/descriptors`, { descriptor: d }),
deleteCatalogDescriptor: (id: Id) => _fetch('DELETE', `/subscriptions/catalog/descriptors/${id}`),
// Payments
quickPay: (data: Body) => post<Payment>('/payments/quick', data),
confirmAutopaySuggestion: (billId: Id, data: Body) => post(`/payments/autopay-suggestions/${billId}/confirm`, data),
dismissAutopaySuggestion: (billId: Id, data: Body) => post(`/payments/autopay-suggestions/${billId}/dismiss`, data),
bulkPay: (items: Body) => post('/payments/bulk', items),
createPayment: (data: Body) => post<Payment>('/payments', data),
updatePayment: (id: Id, data: Body) => put<Payment>(`/payments/${id}`, data),
deletePayment: (id: Id) => del(`/payments/${id}`),
restorePayment: (id: Id) => post<Payment>(`/payments/${id}/restore`),
recentAutoMatched: () => get('/payments/recent-auto'),
undoAutoMatch: (id: Id) => post(`/payments/${id}/undo-auto`),
// Snowball
snowball: () => get('/snowball'),
snowballSettings: () => get('/snowball/settings'),
saveSnowballSettings: (data: Body) => _fetch('PATCH', '/snowball/settings', data),
saveSnowballOrder: (items: Body) => _fetch('PATCH', '/snowball/order', items),
snowballProjection: (params: QueryParams = {}) => get(`/snowball/projection${queryString(params)}`),
snowballPlans: () => get('/snowball/plans'),
snowballActivePlan: () => get('/snowball/plans/active'),
startSnowballPlan: (data: Body) => post('/snowball/plans', data),
updateSnowballPlan: (id: Id, d: Body) => _fetch('PATCH', `/snowball/plans/${id}`, d),
pauseSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/pause`, {}),
resumeSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/resume`, {}),
completeSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/complete`, {}),
abandonSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/abandon`, {}),
// Categories
categories: () => get<Category[]>('/categories'),
createCategory: (data: Body) => post<Category>('/categories', data),
reorderCategories: (order: Body) => put('/categories/reorder', order),
updateCategory: (id: Id, data: Body) => put<Category>(`/categories/${id}`, data),
toggleCategorySpending: (id: Id, val: unknown) => patch(`/categories/${id}/spending`, { spending_enabled: val }),
deleteCategory: (id: Id) => del(`/categories/${id}`),
restoreCategory: (id: Id) => post(`/categories/${id}/restore`),
// Category groups
categoryGroups: () => get('/categories/groups'),
createCategoryGroup: (data: Body) => post('/categories/groups', data),
updateCategoryGroup: (id: Id, data: Body) => put(`/categories/groups/${id}`, data),
deleteCategoryGroup: (id: Id) => del(`/categories/groups/${id}`),
// Settings
settings: () => get<Record<string, unknown>>('/settings'),
saveSettings: (data: Body) => put('/settings', data),
// Analytics
analyticsSummary: (params: QueryParams = {}) => {
const qs = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') qs.set(key, String(value));
});
const query = qs.toString();
return get(`/analytics/summary${query ? `?${query}` : ''}`);
},
// Status
status: () => get('/status'),
// Version (public)
about: () => get('/about'),
privacy: () => get('/privacy'),
aboutAdmin: () => get('/about-admin'),
roadmap: (refresh = false) => get(`/about-admin/roadmap${refresh ? '?refresh=1' : ''}`),
updateStatus: () => get('/version/update-status'),
checkForUpdates: () => post('/about-admin/check-updates'),
getUpdateCheckSetting: () => get('/about-admin/update-check-setting'),
setUpdateCheckSetting: (enabled: unknown) => put('/about-admin/update-check-setting', { enabled }),
devLog: () => get('/about-admin/dev-log'),
version: () => get('/version'),
releaseHistory: () => get('/version/history'),
// Export (returns a URL to navigate to, not a fetch)
exportUrl: (year: Id, fmt?: string): string => `/api/export?year=${year}&format=${fmt||'csv'}`,
// Spreadsheet Import
previewSpreadsheetImport: async (file: File, options: { parseAllSheets?: boolean; defaultYear?: number | string; defaultMonth?: number | string } = {}) => {
const params = new URLSearchParams();
if (options.parseAllSheets) params.set('parse_all_sheets', 'true');
if (options.defaultYear) params.set('year', String(options.defaultYear));
if (options.defaultMonth) params.set('month', String(options.defaultMonth));
const qs = params.toString();
const csrfToken = await getCsrfToken();
const res = await fetch(`/api/import/spreadsheet/preview${qs ? `?${qs}` : ''}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/octet-stream',
'x-csrf-token': csrfToken,
...(file.name ? { 'X-Filename': file.name } : {}),
},
body: file,
});
const data = await res.json();
if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError;
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
}
return data;
},
applySpreadsheetImport: (data: Body) => post('/import/spreadsheet/apply', data),
previewCsvTransactionImport: async (file: File) => {
const csrfToken = await getCsrfToken();
const res = await fetch('/api/import/csv/preview', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'text/csv',
'x-csrf-token': csrfToken,
...(file.name ? { 'X-Filename': file.name } : {}),
},
body: file,
});
const data = await res.json();
if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError;
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
}
return data;
},
commitCsvTransactionImport: (data: Body) => post('/import/csv/commit', data),
previewOfxTransactionImport: async (file: File) => {
const csrfToken = await getCsrfToken();
const res = await fetch('/api/import/ofx/preview', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/x-ofx',
'x-csrf-token': csrfToken,
...(file.name ? { 'X-Filename': file.name } : {}),
},
body: file,
});
const data = await res.json();
if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError;
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
}
return data;
},
commitOfxTransactionImport: (data: Body) => post('/import/ofx/commit', data),
importHistory: () => get('/import/history'),
// Transactions
transactions: (params: QueryParams = {}) => get(`/transactions${queryString(params)}`),
bankTransactionsLedger: (params: QueryParams = {}) => get(`/transactions/bank-ledger${queryString(params)}`),
createManualTransaction: (data: Body) => post('/transactions/manual', data),
updateTransaction: (id: Id, data: Body) => put(`/transactions/${id}`, data),
deleteTransaction: (id: Id) => del(`/transactions/${id}`),
matchTransaction: (id: Id, billId: Id) => post(`/transactions/${id}/match`, { billId }),
unmatchTransaction: (id: Id) => post(`/transactions/${id}/unmatch`),
unmatchTransactionBulk: (matches: Body) => post('/transactions/unmatch-bulk', { matches }),
ignoreTransaction: (id: Id) => post(`/transactions/${id}/ignore`),
unignoreTransaction: (id: Id) => post(`/transactions/${id}/unignore`),
transactionMerchantMatch: (id: Id) => get(`/transactions/${id}/merchant-match`),
applyTransactionMerchantMatch: (id: Id) => post(`/transactions/${id}/apply-merchant-match`),
autoCategorizeTransactions: (opts: Body = {}) => post('/transactions/auto-categorize', opts),
// Match suggestions
matchSuggestions: (params: QueryParams = {}) => get(`/matches/suggestions${queryString(params)}`),
rejectMatchSuggestion: (id: Id) => post(`/matches/${encodeURIComponent(id)}/reject`),
// Data sources & SimpleFIN bank sync
dataSources: (params: QueryParams = {}) => get(`/data-sources${queryString(params)}`),
simplefinStatus: () => get('/data-sources/simplefin/status'),
connectSimplefin: (setupToken: unknown) => post('/data-sources/simplefin/connect', { setupToken }),
syncDataSource: (id: Id) => post(`/data-sources/${id}/sync`),
backfillDataSource: (id: Id) => post(`/data-sources/${id}/backfill`),
deleteDataSource: (id: Id) => del(`/data-sources/${id}`),
dataSourceAccounts: (sourceId: Id) => get(`/data-sources/${sourceId}/accounts`),
setAccountMonitored: (sourceId: Id, accountId: Id, monitored: unknown) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }),
allFinancialAccounts: () => get('/data-sources/accounts/all'),
syncAllSources: () => post('/data-sources/sync-all', {}),
attributePaymentToMonth: (id: Id, paid_date: unknown) => _fetch('PATCH', `/payments/${id}/attribute-to-month`, { paid_date }),
// Admin — bank sync feature flag
bankSyncConfig: () => get('/admin/bank-sync-config'),
setBankSyncConfig: (data: Body) => put('/admin/bank-sync-config', data),
// User SQLite import
previewUserDbImport: async (file: File) => {
const csrfToken = await getCsrfToken();
const res = await fetch('/api/import/user-db/preview', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/octet-stream',
'x-csrf-token': csrfToken,
...(file.name ? { 'X-Filename': file.name } : {}),
},
body: file,
});
const data = await res.json();
if (!res.ok) {
const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError;
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
}
return data;
},
applyUserDbImport: (data: Body) => post('/import/user-db/apply', data),
};

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { TrendingUp, TrendingDown, ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { api } from '@/api';
import { cn, fmt } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';

View File

@ -1,6 +1,6 @@
import { useState, useRef } from 'react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { api } from '@/api';
import { cn, fmt, fmtDate } from '@/lib/utils';
import { Input } from '@/components/ui/input';

View File

@ -1,6 +1,6 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { api } from '@/api';
import { cn, fmt } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { MONTHS, rowThreshold, paymentSummary } from '@/lib/trackerUtils';

View File

@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react';
import { motion } from 'framer-motion';
import { ArrowDown, ArrowUp, GripVertical, Pencil, TrendingUp } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { api } from '@/api';
import { cn, fmt, fmtDate } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { api } from '@/api';
import { fmt } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

View File

@ -1,6 +1,6 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { api } from '@/api';
import { cn } from '@/lib/utils';
// Monthly notes stored in monthly_bill_state per-month, not per-bill.

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { AlertCircle, ChevronDown, BellOff, SkipForward, CreditCard } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { api } from '@/api';
import { cn, fmt, localDateString } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';

View File

@ -1,7 +1,7 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { api } from '@/api';
import { fmt, fmtDate } from '@/lib/utils';
import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
import { Button } from '@/components/ui/button';

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { api } from '@/api';
import { fmt } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { LayoutGroup } from 'framer-motion';
import { ArrowDown, ArrowUp, CheckCircle2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { api } from '@/api';
import { cn, fmt } from '@/lib/utils';
import {
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,

View File

@ -2,7 +2,7 @@ import React, { useState, useRef, useTransition, useEffect } from 'react';
import { ArrowDown, ArrowUp, CheckCircle2, Clock, GripVertical, Pencil, TrendingUp, X } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { api } from '@/api';
import { cn, fmt, fmtDate } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

View File

@ -1,5 +1,15 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error';
type SaveFn<T> = (payload: T) => unknown;
export interface UseAutoSave<T> {
status: AutoSaveStatus;
schedule: (payload: T, delay?: number) => void;
flush: () => void;
}
/**
* Debounced auto-save with a status the UI can render.
*
@ -10,14 +20,14 @@ import { useCallback, useEffect, useRef, useState } from 'react';
*
* Pending changes are flushed on unmount so navigating away never loses edits.
*/
export function useAutoSave(saveFn, defaultDelay = 400) {
const [status, setStatus] = useState('idle');
const timer = useRef(null);
const pending = useRef(null);
const saveRef = useRef(saveFn);
export function useAutoSave<T>(saveFn: SaveFn<T>, defaultDelay = 400): UseAutoSave<T> {
const [status, setStatus] = useState<AutoSaveStatus>('idle');
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const pending = useRef<T | null>(null);
const saveRef = useRef<SaveFn<T>>(saveFn);
saveRef.current = saveFn;
const run = useCallback(async (payload) => {
const run = useCallback(async (payload: T) => {
pending.current = null;
setStatus('saving');
try {
@ -28,7 +38,7 @@ export function useAutoSave(saveFn, defaultDelay = 400) {
}
}, []);
const schedule = useCallback((payload, delay = defaultDelay) => {
const schedule = useCallback((payload: T, delay = defaultDelay) => {
pending.current = payload;
clearTimeout(timer.current);
timer.current = setTimeout(() => run(payload), delay);

View File

@ -1,9 +1,21 @@
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { api } from '@/api';
import { fmt } from '@/lib/utils';
import type { Dollars } from '@/lib/money';
import { useInvalidateTrackerData } from '@/hooks/useQueries';
/** The bits of a tracker row these payment actions need. */
interface PayableRow {
id: number;
name: string;
}
/** Narrow an unknown error (strict catch clauses) to a message string. */
function errMessage(err: unknown, fallback: string): string {
return err instanceof Error && err.message ? err.message : fallback;
}
// Quick-pay a tracker row (record a payment for `val` on `paidDate`) with a
// reversible Undo toast. A React Query mutation: `isPending` comes for free and
// the tracker/badge caches are invalidated on settle. Shared by the desktop and
@ -11,11 +23,11 @@ import { useInvalidateTrackerData } from '@/hooks/useQueries';
export function useQuickPay() {
const invalidate = useInvalidateTrackerData();
const mutation = useMutation({
mutationFn: (payload) => api.quickPay(payload),
mutationFn: (payload: { bill_id: number; amount: number; paid_date: string }) => api.quickPay(payload),
onSettled: () => invalidate(),
});
const run = (row, val, paidDate) => mutation.mutate(
const run = (row: PayableRow, val: Dollars, paidDate: string) => mutation.mutate(
{ bill_id: row.id, amount: val, paid_date: paidDate },
{
onSuccess: (payment) => {
@ -28,48 +40,56 @@ export function useQuickPay() {
toast.success('Payment removed');
invalidate();
} catch (err) {
toast.error(err.message || 'Failed to undo payment.');
toast.error(errMessage(err, 'Failed to undo payment.'));
}
},
},
});
},
onError: (err) => toast.error(err.message || 'Failed to add payment.'),
onError: (err) => toast.error(errMessage(err, 'Failed to add payment.')),
},
);
return { run, isPending: mutation.isPending };
}
interface TogglePaidOptions {
wasPaid: boolean;
threshold: Dollars;
setOptimisticPaid: (value: boolean | undefined) => void;
}
// Toggle a tracker row paid/unpaid. The caller owns the instant optimistic flip
// (its local `setOptimisticPaid`) so the row updates immediately; this hook rolls
// it back on error, invalidates the tracker/badge caches on settle, and shows the
// right toast (an Undo when un-paying, a specific "paid" message otherwise).
// Shared by the desktop and mobile tracker rows.
export function useTogglePaid(year, month) {
export function useTogglePaid(year: number, month: number) {
const invalidate = useInvalidateTrackerData();
const mutation = useMutation({
mutationFn: ({ rowId, amount }) => api.togglePaid(rowId, { amount, year, month }),
mutationFn: ({ rowId, amount }: { rowId: number; amount: number | undefined }) =>
api.togglePaid(rowId, { amount, year, month }),
onSettled: () => invalidate(),
});
const run = (row, { wasPaid, threshold, setOptimisticPaid }) => {
const run = (row: PayableRow, { wasPaid, threshold, setOptimisticPaid }: TogglePaidOptions) => {
setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles
mutation.mutate(
{ rowId: row.id, amount: wasPaid ? undefined : threshold },
{
onSuccess: (result) => {
if (wasPaid && result.paymentId) {
const paymentId = result.paymentId; // capture the narrowed id for the deferred Undo closure
toast.success('Payment moved to recovery', {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restorePayment(result.paymentId);
await api.restorePayment(paymentId);
toast.success('Payment restored');
invalidate();
} catch (err) {
toast.error(err.message || 'Failed to restore payment');
toast.error(errMessage(err, 'Failed to restore payment'));
}
},
},
@ -80,7 +100,7 @@ export function useTogglePaid(year, month) {
},
onError: (err) => {
setOptimisticPaid(undefined); // roll back the instant flip
toast.error(err.message || 'Failed to toggle payment status');
toast.error(errMessage(err, 'Failed to toggle payment status'));
},
},
);

View File

@ -1,14 +1,14 @@
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { useCallback } from 'react';
import { api } from '@/api';
import { api, type QueryParams } from '@/api';
// Custom hook for fetching tracker data
export function useTracker(year, month) {
export function useTracker(year: number, month: number) {
return useQuery({
queryKey: ['tracker', year, month],
queryFn: () => api.tracker(year, month),
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 30, // 30 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
});
}
@ -18,7 +18,7 @@ export function useBills() {
queryKey: ['bills'],
queryFn: () => api.allBills(),
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 30, // 30 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
});
}
@ -28,7 +28,7 @@ export function useCategories() {
queryKey: ['categories'],
queryFn: () => api.categories(),
staleTime: 1000 * 60 * 60, // 1 hour
cacheTime: 1000 * 60 * 60 * 2, // 2 hours
gcTime: 1000 * 60 * 60 * 2, // 2 hours
});
}
@ -72,7 +72,7 @@ export function useInvalidateTrackerData() {
// to it is instant. No-op if it's already cached and fresh.
export function usePrefetchTracker() {
const queryClient = useQueryClient();
return useCallback((year, month) => {
return useCallback((year: number, month: number) => {
queryClient.prefetchQuery({
queryKey: ['tracker', year, month],
queryFn: () => api.tracker(year, month),
@ -86,7 +86,7 @@ export function usePrefetchTracker() {
// dedup, cancellation, and out-of-order responses — no manual sequence guards.
// keepPreviousData keeps the last result visible while a new month/filter loads.
export function useAnalyticsSummary(params) {
export function useAnalyticsSummary(params?: QueryParams) {
return useQuery({
queryKey: ['analytics-summary', params],
queryFn: () => api.analyticsSummary(params),
@ -111,7 +111,7 @@ export function useDeletedBills() {
});
}
export function useSummary(year, month) {
export function useSummary(year: number, month: number) {
return useQuery({
queryKey: ['summary', year, month],
queryFn: () => api.summary(year, month),
@ -123,7 +123,17 @@ export function useSummary(year, month) {
});
}
export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize }) {
interface BankLedgerArgs {
accountId: string;
flow: string;
page: number;
query: string;
sortBy: string;
sortDir: string;
pageSize: number;
}
export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize }: BankLedgerArgs) {
return useQuery({
queryKey: ['bank-ledger', accountId, flow, page, query, sortBy, sortDir],
queryFn: () => api.bankTransactionsLedger({
@ -140,7 +150,7 @@ export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, p
});
}
export function useSpendingSummary(year, month) {
export function useSpendingSummary(year: number, month: number) {
return useQuery({
queryKey: ['spending-summary', year, month],
queryFn: () => api.spendingSummary({ year, month }),
@ -149,11 +159,18 @@ export function useSpendingSummary(year, month) {
});
}
export function useSpendingTransactions({ year, month, activeCat, page }) {
interface SpendingTransactionsArgs {
year: number;
month: number;
activeCat?: number | string | null;
page: number;
}
export function useSpendingTransactions({ year, month, activeCat, page }: SpendingTransactionsArgs) {
return useQuery({
queryKey: ['spending-transactions', year, month, activeCat ?? 'all', page],
queryFn: () => {
const params = { year, month, page, limit: 50 };
const params: QueryParams = { year, month, page, limit: 50 };
if (activeCat === null) params.category_id = 'null';
else if (activeCat !== undefined) params.category_id = activeCat;
return api.spendingTransactions(params);
@ -167,8 +184,8 @@ export function useSpendingCategories() {
return useQuery({
queryKey: ['spending-categories'],
queryFn: async () => {
const d = await api.categories();
return (d.categories || d || []).filter(c => !c.deleted_at && c.spending_enabled);
const cats = await api.categories();
return cats.filter(c => !c.deleted_at && c.spending_enabled);
},
staleTime: 1000 * 60 * 5,
});
@ -201,7 +218,7 @@ export function useSnowballActivePlan() {
export function useSnowballPlans() {
return useQuery({
queryKey: ['snowball-plans'],
queryFn: () => api.snowballPlans().then(d => d?.plans ?? []).catch(() => []),
queryFn: () => api.snowballPlans().then((d: any) => d?.plans ?? []).catch(() => []),
staleTime: 1000 * 60 * 2,
});
}
@ -217,7 +234,7 @@ export function useSubscriptions() {
export function useSubscriptionRecommendations() {
return useQuery({
queryKey: ['subscription-recommendations'],
queryFn: () => api.subscriptionRecommendations().then(r => r.recommendations || []),
queryFn: () => api.subscriptionRecommendations().then((r: any) => r.recommendations || []),
staleTime: 1000 * 60 * 5,
});
}

View File

@ -3,11 +3,11 @@ import { api } from '@/api';
const SETTING_KEY = 'search_bars_collapsed';
function settingToBool(value) {
function settingToBool(value: unknown): boolean {
return value === true || value === 'true' || value === '1' || value === 1;
}
export function useSearchPanelPreference() {
export function useSearchPanelPreference(): [boolean, (nextCollapsed: boolean) => void] {
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
@ -23,7 +23,7 @@ export function useSearchPanelPreference() {
return () => { mounted = false; };
}, []);
const saveCollapsed = useCallback((nextCollapsed) => {
const saveCollapsed = useCallback((nextCollapsed: boolean) => {
setCollapsed(nextCollapsed);
api.saveSettings({ [SETTING_KEY]: nextCollapsed ? 'true' : 'false' }).catch(() => {});
}, []);

View File

@ -1,42 +0,0 @@
import { billingCycleForSchedule, scheduleValue } from './billingSchedule';
function categoryForTemplate(template, categories = []) {
const keywords = template?.categoryKeywords || [];
const match = categories.find(category => {
const name = String(category.name || '').toLowerCase();
return keywords.some(keyword => name.includes(keyword));
});
return match?.id ?? null;
}
function categoryIdOrFallback(categoryId, template, categories = []) {
if (categoryId && categories.some(category => String(category.id) === String(categoryId))) {
return categoryId;
}
return template ? categoryForTemplate(template, categories) : null;
}
export function makeBillDraft(source, { copy = false, template = null, categories = [] } = {}) {
const data = source || {};
return {
...data,
id: undefined,
source_bill_id: copy && data.id != null ? data.id : undefined,
active: 1,
name: copy
? `${data.name || 'Bill'} (Copy)`
: (data.name || template?.name || ''),
category_id: categoryIdOrFallback(data.category_id, template, categories),
due_day: data.due_day || 1,
expected_amount: data.expected_amount ?? 0,
billing_cycle: billingCycleForSchedule(scheduleValue(data)),
cycle_type: scheduleValue(data),
cycle_day: String(data.cycle_day || '1'),
autopay_enabled: !!data.autopay_enabled,
autodraft_status: data.autodraft_status || (data.autopay_enabled ? 'assumed_paid' : 'none'),
auto_mark_paid: !!data.auto_mark_paid,
has_2fa: !!data.has_2fa,
snowball_include: !!data.snowball_include,
snowball_exempt: !!data.snowball_exempt,
};
}

82
client/lib/billDrafts.ts Normal file
View File

@ -0,0 +1,82 @@
import { billingCycleForSchedule, scheduleValue } from './billingSchedule';
interface Category {
id: string | number;
name?: string | null;
}
interface Template {
name?: string | null;
categoryKeywords?: string[];
}
// The source bill a draft is seeded from. The specific fields the draft reads
// are declared; the index signature carries every other field through the spread.
interface SourceBill {
id?: string | number | null;
name?: string | null;
category_id?: string | number | null;
due_day?: number | null;
expected_amount?: number | null;
cycle_type?: string | null;
billing_cycle?: string | null;
cycle_day?: string | number | null;
autopay_enabled?: unknown;
autodraft_status?: string | null;
auto_mark_paid?: unknown;
has_2fa?: unknown;
snowball_include?: unknown;
snowball_exempt?: unknown;
[key: string]: unknown;
}
interface MakeBillDraftOptions {
copy?: boolean;
template?: Template | null;
categories?: Category[];
}
function categoryForTemplate(template: Template | null | undefined, categories: Category[] = []): string | number | null {
const keywords = template?.categoryKeywords || [];
const match = categories.find(category => {
const name = String(category.name || '').toLowerCase();
return keywords.some(keyword => name.includes(keyword));
});
return match?.id ?? null;
}
function categoryIdOrFallback(
categoryId: string | number | null | undefined,
template: Template | null,
categories: Category[] = [],
): string | number | null {
if (categoryId && categories.some(category => String(category.id) === String(categoryId))) {
return categoryId;
}
return template ? categoryForTemplate(template, categories) : null;
}
export function makeBillDraft(source: SourceBill | null | undefined, { copy = false, template = null, categories = [] }: MakeBillDraftOptions = {}) {
const data: SourceBill = source || {};
return {
...data,
id: undefined,
source_bill_id: copy && data.id != null ? data.id : undefined,
active: 1,
name: copy
? `${data.name || 'Bill'} (Copy)`
: (data.name || template?.name || ''),
category_id: categoryIdOrFallback(data.category_id, template, categories),
due_day: data.due_day || 1,
expected_amount: data.expected_amount ?? 0,
billing_cycle: billingCycleForSchedule(scheduleValue(data)),
cycle_type: scheduleValue(data),
cycle_day: String(data.cycle_day || '1'),
autopay_enabled: !!data.autopay_enabled,
autodraft_status: data.autodraft_status || (data.autopay_enabled ? 'assumed_paid' : 'none'),
auto_mark_paid: !!data.auto_mark_paid,
has_2fa: !!data.has_2fa,
snowball_include: !!data.snowball_include,
snowball_exempt: !!data.snowball_exempt,
};
}

View File

@ -1,4 +1,6 @@
export const BILLING_SCHEDULE_OPTIONS = [
export type Schedule = 'monthly' | 'weekly' | 'biweekly' | 'quarterly' | 'annual';
export const BILLING_SCHEDULE_OPTIONS: [Schedule, string][] = [
['monthly', 'Monthly'],
['weekly', 'Weekly'],
['biweekly', 'Biweekly'],
@ -6,22 +8,27 @@ export const BILLING_SCHEDULE_OPTIONS = [
['annual', 'Annual'],
];
const LABELS = Object.fromEntries(BILLING_SCHEDULE_OPTIONS);
const LABELS: Record<string, string> = Object.fromEntries(BILLING_SCHEDULE_OPTIONS);
export function scheduleFromBillingCycle(billingCycle) {
export interface ScheduleBill {
cycle_type?: string | null;
billing_cycle?: string | null;
}
export function scheduleFromBillingCycle(billingCycle: string | null | undefined): Schedule {
const value = String(billingCycle || '').toLowerCase();
if (value === 'quarterly') return 'quarterly';
if (value === 'annually' || value === 'annual') return 'annual';
return 'monthly';
}
export function normalizeSchedule(value, fallback = 'monthly') {
export function normalizeSchedule(value: string | null | undefined, fallback = 'monthly'): string {
if (!value) return fallback;
const normalized = String(value).toLowerCase();
return LABELS[normalized] ? normalized : fallback;
}
export function scheduleValue(bill = {}) {
export function scheduleValue(bill: ScheduleBill = {}): string {
const cycleType = normalizeSchedule(bill.cycle_type, '');
const billingCycle = String(bill.billing_cycle || '').toLowerCase();
@ -32,14 +39,14 @@ export function scheduleValue(bill = {}) {
return cycleType || scheduleFromBillingCycle(billingCycle);
}
export function scheduleLabel(valueOrBill) {
export function scheduleLabel(valueOrBill: string | ScheduleBill | null | undefined): string {
const value = valueOrBill && typeof valueOrBill === 'object'
? scheduleValue(valueOrBill)
: normalizeSchedule(valueOrBill);
return LABELS[value] || 'Monthly';
}
export function billingCycleForSchedule(schedule) {
export function billingCycleForSchedule(schedule: string | null | undefined): string {
const value = normalizeSchedule(schedule);
if (value === 'quarterly') return 'quarterly';
if (value === 'annual') return 'annually';
@ -47,7 +54,7 @@ export function billingCycleForSchedule(schedule) {
return 'monthly';
}
export function defaultCycleDayForSchedule(schedule) {
export function defaultCycleDayForSchedule(schedule: string | null | undefined): string {
const value = normalizeSchedule(schedule);
return value === 'weekly' || value === 'biweekly' ? 'monday' : '1';
}

View File

@ -1,5 +1,30 @@
// Pure helpers for the Safe-to-Spend card. No DOM, no React — unit-testable.
export interface TimelineEntry {
date: string;
balance: number | string | null;
bills?: unknown[];
payday?: unknown;
}
export interface GeometryPoint {
x: number;
y: number;
date: string;
balance: number;
bills: unknown[];
isDrop: boolean;
isPayday: boolean;
isLast: boolean;
}
export interface TimelineGeometry {
line: string;
area: string;
points: GeometryPoint[];
zeroY: number;
}
/**
* Convert the server's cashflow timeline into SVG step-path geometry.
* Returns { line, area, points, zeroY } or null when there is nothing to draw.
@ -9,24 +34,32 @@
* - Y maps balances; the domain always includes 0 so the zero line is honest.
* - Step shape: balance holds flat until a bill's day, then drops.
*/
export function buildTimelineGeometry(timeline, width, height, pad = 4) {
export function buildTimelineGeometry(
timeline: TimelineEntry[] | null | undefined,
width: number,
height: number,
pad = 4,
): TimelineGeometry | null {
if (!Array.isArray(timeline) || timeline.length < 2) return null;
const t0 = Date.parse(timeline[0].date);
const t1 = Date.parse(timeline[timeline.length - 1].date);
// length >= 2 (guarded), so first/last exist — the `!` satisfies noUncheckedIndexedAccess.
const first = timeline[0]!;
const last = timeline[timeline.length - 1]!;
const t0 = Date.parse(first.date);
const t1 = Date.parse(last.date);
const span = Math.max(t1 - t0, 1);
const balances = timeline.map(t => Number(t.balance) || 0);
const start = Number(timeline[0].balance) || 0;
const start = Number(first.balance) || 0;
// Domain: [min(0, lowest), max(starting, highest, smallest positive head-room)]
const lo = Math.min(0, ...balances);
const hi = Math.max(start, ...balances, 1);
const range = Math.max(hi - lo, 1);
const x = (date) => pad + ((Date.parse(date) - t0) / span) * (width - pad * 2);
const y = (bal) => pad + (1 - ((bal - lo) / range)) * (height - pad * 2);
const x = (date: string): number => pad + ((Date.parse(date) - t0) / span) * (width - pad * 2);
const y = (bal: number): number => pad + (1 - ((bal - lo) / range)) * (height - pad * 2);
const points = timeline.map((t, i) => ({
const points: GeometryPoint[] = timeline.map((t, i) => ({
x: x(t.date),
y: y(Number(t.balance) || 0),
date: t.date,
@ -38,19 +71,19 @@ export function buildTimelineGeometry(timeline, width, height, pad = 4) {
}));
// Step path: horizontal to each next x, then vertical drop.
let line = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
let line = `M ${points[0]!.x.toFixed(2)} ${points[0]!.y.toFixed(2)}`;
for (let i = 1; i < points.length; i++) {
line += ` H ${points[i].x.toFixed(2)} V ${points[i].y.toFixed(2)}`;
line += ` H ${points[i]!.x.toFixed(2)} V ${points[i]!.y.toFixed(2)}`;
}
const baseY = y(Math.min(0, lo) === lo && lo < 0 ? lo : 0);
const area = `${line} V ${baseY.toFixed(2)} H ${points[0].x.toFixed(2)} Z`;
const area = `${line} V ${baseY.toFixed(2)} H ${points[0]!.x.toFixed(2)} Z`;
return { line, area, points, zeroY: y(0) };
}
/** "5 days" / "tomorrow" / "today" */
export function daysUntilLabel(days) {
export function daysUntilLabel(days: number | string | null | undefined): string {
const n = Number(days);
if (!Number.isFinite(n) || n <= 0) return 'today';
if (n === 1) return 'tomorrow';
@ -58,15 +91,15 @@ export function daysUntilLabel(days) {
}
/** "Jul 1" from "2026-07-01" — no Date parsing, no timezone traps. */
export function shortDate(dateStr) {
export function shortDate(dateStr: string | null | undefined): string {
if (!dateStr || typeof dateStr !== 'string') return '';
const [, m, d] = dateStr.split('-');
const [, m = '', d = ''] = dateStr.split('-');
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${MONTHS[parseInt(m, 10) - 1]} ${parseInt(d, 10)}`;
}
/** Split upcoming bills into the visible few plus an overflow count. */
export function splitUpcoming(upcoming, maxVisible = 4) {
export function splitUpcoming<T>(upcoming: T[] | null | undefined, maxVisible = 4): { visible: T[]; overflow: number } {
const list = Array.isArray(upcoming) ? upcoming : [];
return {
visible: list.slice(0, maxVisible),

View File

@ -1,19 +1,27 @@
export function moveInArray(items, fromIndex, toIndex) {
interface Identified {
id: string | number;
}
export function moveInArray<T>(items: T[], fromIndex: number, toIndex: number): T[] {
if (!Array.isArray(items)) return [];
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= items.length || toIndex >= items.length) {
return items;
}
const next = [...items];
const [moved] = next.splice(fromIndex, 1);
if (moved === undefined) return next; // in-range by the guard above; satisfies noUncheckedIndexedAccess
next.splice(toIndex, 0, moved);
return next;
}
export function reorderPayload(items) {
export function reorderPayload(items: ReadonlyArray<Identified> | null | undefined): Record<string, number> {
return Object.fromEntries((items || []).map((item, index) => [item.id, index]));
}
export function movedItemId(before, after) {
export function movedItemId(
before: ReadonlyArray<Identified> | null | undefined,
after: ReadonlyArray<Identified> | null | undefined,
): string | number | null {
const moved = (after || []).find((item, index) => item.id !== before?.[index]?.id);
return moved?.id || after?.[0]?.id || null;
}

View File

@ -12,19 +12,19 @@ export const TRACKER_TABLE_COLUMNS = [
export const TRACKER_TABLE_COLUMN_KEYS = TRACKER_TABLE_COLUMNS.map(column => column.key);
export const DEFAULT_TRACKER_TABLE_COLUMNS = [...TRACKER_TABLE_COLUMN_KEYS];
export function parseTrackerTableColumns(value) {
export function parseTrackerTableColumns(value: unknown): string[] {
if (Array.isArray(value)) return normalizeTrackerTableColumns(value);
if (!value) return DEFAULT_TRACKER_TABLE_COLUMNS;
try {
const parsed = JSON.parse(value);
const parsed = JSON.parse(value as string);
return normalizeTrackerTableColumns(parsed);
} catch {
return DEFAULT_TRACKER_TABLE_COLUMNS;
}
}
export function normalizeTrackerTableColumns(columns) {
export function normalizeTrackerTableColumns(columns: unknown): string[] {
const valid = new Set(TRACKER_TABLE_COLUMN_KEYS);
return Array.isArray(columns)
? columns.filter(column => valid.has(column))
@ -32,6 +32,6 @@ export function normalizeTrackerTableColumns(columns) {
: DEFAULT_TRACKER_TABLE_COLUMNS;
}
export function trackerTableColumnsToSetting(columns) {
export function trackerTableColumnsToSetting(columns: unknown): string {
return JSON.stringify(normalizeTrackerTableColumns(columns));
}

View File

@ -1,4 +1,10 @@
import { todayStr } from '@/lib/utils';
import { asDollars, type Dollars } from '@/lib/money';
import type { TrackerRow, TrackerStatus } from '@/types';
// Re-export the shared domain types so existing `@/lib/trackerUtils` importers
// keep resolving them (the canonical definitions live in `@/types`).
export type { TrackerRow, TrackerStatus } from '@/types';
export const MONTHS = [
'January','February','March','April','May','June',
@ -12,6 +18,8 @@ export const TRACKER_SORT_DEFAULT = 'manual';
export const TRACKER_SORT_ASC = 'asc';
export const TRACKER_SORT_DESC = 'desc';
export type TrackerSortDir = typeof TRACKER_SORT_ASC | typeof TRACKER_SORT_DESC;
export const TRACKER_SORT_OPTIONS = [
{ key: TRACKER_SORT_DEFAULT, label: 'Custom order', defaultDir: TRACKER_SORT_ASC },
{ key: 'name', label: 'Bill name', defaultDir: TRACKER_SORT_ASC },
@ -51,7 +59,11 @@ export const STATUS_META = {
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
};
export function paymentDateForTrackerMonth(year, month, dueDay) {
export function paymentDateForTrackerMonth(
year: number,
month: number,
dueDay: number | string | null | undefined,
): string {
const now = new Date();
if (year === now.getFullYear() && month === now.getMonth() + 1) {
return todayStr();
@ -65,7 +77,7 @@ export function paymentDateForTrackerMonth(year, month, dueDay) {
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
export function amountSearchText(...values) {
export function amountSearchText(...values: unknown[]): string {
return values
.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
.flatMap(value => {
@ -75,11 +87,11 @@ export function amountSearchText(...values) {
.join(' ');
}
export function rowThreshold(row) {
export function rowThreshold(row: TrackerRow): Dollars {
return row.actual_amount != null ? row.actual_amount : row.expected_amount;
}
export function rowEffectiveStatus(row) {
export function rowEffectiveStatus(row: TrackerRow): TrackerStatus {
if (row.is_skipped) return 'skipped';
const threshold = rowThreshold(row);
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
@ -89,24 +101,24 @@ export function rowEffectiveStatus(row) {
// A bill's month is "settled" when its status is paid or autodraft. Mirrors the
// server's statusService.isPaidStatus — the single low-level check the various
// row/badge/calendar components share.
export function isPaidStatus(status) {
export function isPaidStatus(status: string): boolean {
return status === 'paid' || status === 'autodraft';
}
export function rowIsPaid(row) {
export function rowIsPaid(row: TrackerRow): boolean {
const status = rowEffectiveStatus(row);
if (row.autopay_suggestion && status === 'autodraft') return false;
return isPaidStatus(status);
}
export function rowIsDebt(row) {
export function rowIsDebt(row: TrackerRow): boolean {
const category = String(row.category_name || '').toLowerCase();
return Number(row.current_balance) > 0
|| row.minimum_payment != null
|| ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token));
}
const STATUS_SORT_ORDER = {
const STATUS_SORT_ORDER: Record<TrackerStatus, number> = {
missed: 0,
late: 1,
due_soon: 2,
@ -116,18 +128,20 @@ const STATUS_SORT_ORDER = {
skipped: 6,
};
function parseDateSortValue(value) {
function parseDateSortValue(value: string | null | undefined): number | null {
if (!value) return null;
const parsed = Date.parse(`${value}T00:00:00`);
return Number.isFinite(parsed) ? parsed : null;
}
function numericSortValue(value) {
function numericSortValue(value: unknown): number {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
function trackerSortValue(row, key) {
type SortValue = string | number | null | undefined;
function trackerSortValue(row: TrackerRow, key: string): SortValue {
switch (key) {
case 'name':
return String(row.name || '').toLowerCase();
@ -150,7 +164,7 @@ function trackerSortValue(row, key) {
}
}
function compareSortValues(a, b, dir) {
function compareSortValues(a: SortValue, b: SortValue, dir: string): number {
const aMissing = a === null || a === undefined || a === '';
const bMissing = b === null || b === undefined || b === '';
if (aMissing && bMissing) return 0;
@ -166,15 +180,15 @@ function compareSortValues(a, b, dir) {
return dir === TRACKER_SORT_DESC ? -result : result;
}
export function normalizeTrackerSortKey(key) {
export function normalizeTrackerSortKey(key: string): string {
return TRACKER_SORT_LABELS[key] ? key : TRACKER_SORT_DEFAULT;
}
export function normalizeTrackerSortDir(dir) {
export function normalizeTrackerSortDir(dir: string): TrackerSortDir {
return dir === TRACKER_SORT_DESC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC;
}
export function sortTrackerRows(rows, sortKey, sortDir) {
export function sortTrackerRows(rows: TrackerRow[], sortKey: string, sortDir: string): TrackerRow[] {
const key = normalizeTrackerSortKey(sortKey);
if (key === TRACKER_SORT_DEFAULT) return rows;
@ -200,14 +214,15 @@ export function sortTrackerRows(rows, sortKey, sortDir) {
.map(item => item.row);
}
export function moveInArray(items, fromIndex, toIndex) {
export function moveInArray<T>(items: T[], fromIndex: number, toIndex: number): T[] {
const next = [...items];
const [moved] = next.splice(fromIndex, 1);
if (moved === undefined) return next;
next.splice(toIndex, 0, moved);
return next;
}
export function paymentSummary(row, threshold) {
export function paymentSummary(row: TrackerRow, threshold: Dollars) {
const target = Number(threshold) || 0;
const paid = Number(row.total_paid) || 0;
const paidTowardDue = Number.isFinite(Number(row.paid_toward_due))
@ -218,12 +233,14 @@ export function paymentSummary(row, threshold) {
: Math.max(paid - target, 0);
const remaining = Math.max(target - paidTowardDue, 0);
const percent = target > 0 ? Math.min(100, Math.round((paidTowardDue / target) * 100)) : 0;
// Re-brand the computed amounts as dollars (arithmetic on branded numbers
// yields a plain number) so callers can pass them straight to fmt/formatUSD.
return {
target,
paid,
paidTowardDue,
overpaid,
remaining,
target: asDollars(target),
paid: asDollars(paid),
paidTowardDue: asDollars(paidTowardDue),
overpaid: asDollars(overpaid),
remaining: asDollars(remaining),
percent,
count: Array.isArray(row.payments) ? row.payments.length : 0,
partial: paid > 0 && remaining > 0,

View File

@ -1,10 +1,24 @@
// __APP_VERSION__ is injected by Vite at build time from package.json.
// Do not hardcode a version string here — update package.json instead.
/* global __APP_VERSION__ */
declare const __APP_VERSION__: string;
export const APP_VERSION = __APP_VERSION__;
export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = {
export interface ReleaseHighlight {
icon: string;
title: string;
desc: string;
}
export interface ReleaseNotes {
version: string;
date: string;
highlights: ReleaseHighlight[];
image: { src: string; alt: string };
}
export const RELEASE_NOTES: ReleaseNotes = {
version: APP_VERSION,
date: '2026-05-31',
highlights: [

View File

@ -4,7 +4,7 @@ import { toast } from 'sonner';
import {
ArrowDown, ArrowUp, ChevronDown, GripVertical, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw, ShoppingCart,
} from 'lucide-react';
import { api } from '@/api.js';
import { api } from '@/api';
import { Button, buttonVariants } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { InputDialog } from '@/components/ui/input-dialog';

View File

@ -17,7 +17,7 @@ import {
RotateCcw,
Save,
} from 'lucide-react';
import { api } from '@/api.js';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';

View File

@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { api } from '@/api';
import { useTracker, useDriftReport, useInvalidateTrackerData, usePrefetchTracker } from '@/hooks/useQueries';
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import BillModal from '@/components/BillModal';

249
client/types.ts Normal file
View File

@ -0,0 +1,249 @@
// Domain types for the API responses. Money fields are branded `Dollars` — the
// server stores integer cents and serializes dollars over the wire (via
// utils/money.fromCents), so every amount a component receives is dollars. Typing
// them `Dollars` lets `fmt`/`formatUSD` accept them while rejecting raw cents
// (e.g. bank-transaction `*_cents` fields), catching the cents-as-dollars bug
// class at compile time.
//
// Shapes are typed incrementally (endpoint-by-endpoint). Complex nested payloads
// that no typed consumer reads yet (trend series, autopay suggestion/stats,
// sparkline) are left `unknown` and refined when a consumer needs them.
import type { Dollars } from '@/lib/money';
// A bill's month status. Mirrors the server's statusService.
export type TrackerStatus =
| 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';
// A payment record as serialized by the server (money in dollars). serializePayment
// spreads the full DB row, so extra columns pass through as `unknown`.
export interface Payment {
id: number;
bill_id?: number;
amount: Dollars;
paid_date: string;
payment_source?: string | null;
notes?: string | null;
balance_delta?: Dollars | null;
interest_delta?: Dollars | null;
created_at?: string;
deleted_at?: string | null;
[key: string]: unknown;
}
// A category's per-bill rollup (categories list endpoint). `total_paid` here is a
// raw SQL sum (not run through fromCents), so it is deliberately NOT branded.
export interface CategoryBillSummary {
id: number;
name: string;
active: boolean;
payment_count: number;
total_paid: number;
last_paid_date: string | null;
[key: string]: unknown;
}
// A spending/bill category. Has no money field of its own (budgets live on the
// spending endpoints). The list endpoint adds bill rollups; create/update return
// the bare row — so the rollup fields are optional.
export interface Category {
id: number;
user_id?: number;
name: string;
sort_order?: number;
spending_enabled: boolean;
group_id: number | null;
created_at?: string;
updated_at?: string;
bill_count?: number;
active_bill_count?: number;
inactive_bill_count?: number;
payment_count?: number;
bill_names?: string[];
bills?: CategoryBillSummary[];
[key: string]: unknown;
}
// A bill as serialized by the server (billsService.serializeBill spreads the DB
// row and converts the three money columns to dollars). SQLite booleans come back
// as 0/1 integers. Extra joined columns (sparkline, has_merchant_rule, …) pass
// through as `unknown` via the index signature.
export interface Bill {
id: number;
user_id?: number;
name: string;
category_id: number | null;
category_name?: string | null;
due_day: number | null;
override_due_date: string | null;
bucket: string;
expected_amount: Dollars;
interest_rate: number | null;
billing_cycle: string | null;
autopay_enabled: number;
autodraft_status: string | null;
auto_mark_paid: number;
website: string | null;
username: string | null;
account_info: string | null;
has_2fa: number;
notes: string | null;
history_visibility: string | null;
active: number;
cycle_type: string | null;
cycle_day: string | number | null;
current_balance: Dollars | null;
minimum_payment: Dollars | null;
snowball_order: number | null;
snowball_include: number;
snowball_exempt: number;
is_subscription: number;
subscription_type: string | null;
reminder_days_before: number | null;
subscription_source: string | null;
subscription_detected_at: string | null;
created_at?: string;
updated_at?: string;
deleted_at?: string | null;
[key: string]: unknown;
}
// One tracker row: a bill's state for a given month. Built by
// statusService.buildTrackerRow and augmented by trackerService.getTracker.
export interface TrackerRow {
id: number;
name: string;
category_id: number | null;
category_name: string | null;
due_date: string;
due_day: number | null;
bucket: string;
expected_amount: Dollars;
notes: string | null;
total_paid: Dollars;
paid_toward_due: Dollars;
overpaid_amount: Dollars;
balance: Dollars;
has_payment: boolean;
is_settled: boolean;
last_paid_date: string | null;
last_payment_amount: Dollars | null;
status: TrackerStatus;
autopay_enabled: boolean;
autodraft_status: string | null;
auto_mark_paid: boolean;
billing_cycle: string | null;
cycle_type: string | null;
cycle_day: string | number | null;
current_balance: Dollars | null;
minimum_payment: Dollars | null;
interest_rate: number | null;
is_subscription: boolean;
has_2fa: boolean;
has_merchant_rule: boolean;
has_linked_transactions: boolean;
website: string | null;
autopay_verified_at: string | null;
inactive_reason: string | null;
inactivated_at: string | null;
sparkline: unknown;
autopay_stats: unknown;
payments: Payment[];
// Augmented by getTracker:
actual_amount: Dollars | null;
is_skipped: boolean;
snoozed_until: string | null;
autopay_suggestion?: unknown;
previous_month_paid: Dollars;
// Bank-mode augmentation (present only when bank_tracking.enabled):
pending_cleared?: boolean;
bank_pending_count?: number;
}
export interface TrackerSummary {
total_expected: Dollars;
total_starting: Dollars;
has_starting_amounts: boolean;
total_paid: Dollars;
paid_toward_due: Dollars;
remaining: Dollars;
total_remaining: Dollars;
remaining_period: string;
remaining_label: string;
remaining_hint: string;
overdue: Dollars;
count_paid: number;
count_upcoming: number;
count_late: number;
count_autodraft: number;
previous_month_total: Dollars;
trend: unknown;
}
// Bank tracking is off (`{ enabled: false }`) or on with the account snapshot.
export type BankTracking =
| { enabled: false }
| {
enabled: true;
account_id: number;
account_name: string;
org_name: string;
balance: Dollars;
pending_payments: Dollars;
pending_days: number;
effective_balance: Dollars;
unpaid_this_month: Dollars;
remaining: Dollars;
last_updated: string | null;
};
export interface SafeToSpendUpcoming {
id: number;
name: string;
due_date: string;
amount: Dollars;
status: TrackerStatus;
}
export interface CashflowTimelinePoint {
date: string;
balance: number; // running dollar balance; cashflowUtils treats it loosely
bills: unknown[];
payday?: boolean;
}
export interface TrackerCashflow {
next_payday: string | null;
days_until_payday: number;
available: Dollars;
safe_to_spend: Dollars;
still_due_total: Dollars;
still_due_count: number;
upcoming: SafeToSpendUpcoming[];
timeline: CashflowTimelinePoint[];
has_data: boolean;
uses_bank_balance: boolean;
period: string;
period_end_label: string;
period_starting: Dollars;
period_bills_total: Dollars;
period_paid: Dollars;
period_paid_count: number;
period_total_count: number;
period_projected: Dollars;
month_starting: Dollars;
month_bills_total: Dollars;
month_paid: Dollars;
month_paid_count: number;
month_total_count: number;
month_projected: Dollars;
}
export interface TrackerResponse {
year: number;
month: number;
today: string;
summary: TrackerSummary;
bank_tracking: BankTracking;
cashflow: TrackerCashflow;
rows: TrackerRow[];
}