From 9b805e60b2c64aae6a418d179deab74434e0692e Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 22:09:04 -0500 Subject: [PATCH] refactor(ts): convert the API client to TypeScript (TS9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client/api.js -> api.ts. Types the fetch infrastructure: generic _fetch and get/post/put/patch/del 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; 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 --- HISTORY.md | 1 + client/api.js | 499 ----------------- client/api.ts | 527 ++++++++++++++++++ .../components/tracker/DriftInsightPanel.jsx | 2 +- client/components/tracker/EditableCell.jsx | 2 +- .../tracker/LowerThisMonthButton.jsx | 2 +- .../components/tracker/MobileTrackerRow.jsx | 2 +- .../components/tracker/MonthlyStateDialog.jsx | 2 +- client/components/tracker/NotesCell.jsx | 2 +- .../tracker/OverdueCommandCenter.jsx | 2 +- .../tracker/PaymentLedgerDialog.jsx | 2 +- client/components/tracker/PaymentModal.jsx | 2 +- .../tracker/StartingAmountsEditDialog.jsx | 2 +- client/components/tracker/TrackerBucket.jsx | 2 +- client/components/tracker/TrackerRow.jsx | 2 +- client/hooks/usePaymentActions.ts | 5 +- client/pages/CategoriesPage.jsx | 2 +- client/pages/SummaryPage.jsx | 2 +- client/pages/TrackerPage.jsx | 2 +- 19 files changed, 546 insertions(+), 516 deletions(-) delete mode 100644 client/api.js create mode 100644 client/api.ts diff --git a/HISTORY.md b/HISTORY.md index b3e14c2..aaade2b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -6,6 +6,7 @@ - **[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`), `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` 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` and `get`/`post`/`put`/`patch`/`del` 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` 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. ### 🧪 Money-service test coverage diff --git a/client/api.js b/client/api.js deleted file mode 100644 index ef4322f..0000000 --- a/client/api.js +++ /dev/null @@ -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), -}; diff --git a/client/api.ts b/client/api.ts new file mode 100644 index 0000000..53b6c44 --- /dev/null +++ b/client/api.ts @@ -0,0 +1,527 @@ +// 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 | null = null; +async function getCsrfToken(): Promise { + 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; +type QueryParams = Record; + +/** 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; +} + +/** Minimal shape of a payment record returned by the pay endpoints. */ +export interface PaymentRecord { + id: number; + [key: string]: unknown; +} + +/** 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 { + 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: string, path: string, body?: unknown, _retried = false): Promise { + const headers: Record = { '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(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 = (path: string, params?: QueryParams): Promise => _fetch('GET', path + (params ? queryString(params) : '')); +const post = (path: string, body?: unknown): Promise => _fetch('POST', path, body); +const put = (path: string, body?: unknown): Promise => _fetch('PUT', path, body); +const patch = (path: string, body?: unknown): Promise => _fetch('PATCH', path, body); +const del = (path: string): Promise => _fetch('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(`/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(`/bills${queryString(params)}`), + allBills: (params: QueryParams = {}) => get(`/bills${queryString({ inactive: true, ...params })}`), + deletedBills: () => get('/bills/deleted'), + billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`), + bill: (id: Id) => get(`/bills/${id}`), + createBill: (data: Body) => post('/bills', data), + updateBill: (id: Id, data: Body) => put(`/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(`/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('/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('/payments', data), + updatePayment: (id: Id, data: Body) => put(`/payments/${id}`, data), + deletePayment: (id: Id) => del(`/payments/${id}`), + restorePayment: (id: Id) => post(`/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('/categories'), + createCategory: (data: Body) => post('/categories', data), + reorderCategories: (order: Body) => put('/categories/reorder', order), + updateCategory: (id: Id, data: Body) => put(`/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>('/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), +}; diff --git a/client/components/tracker/DriftInsightPanel.jsx b/client/components/tracker/DriftInsightPanel.jsx index a80875c..1d84bac 100644 --- a/client/components/tracker/DriftInsightPanel.jsx +++ b/client/components/tracker/DriftInsightPanel.jsx @@ -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'; diff --git a/client/components/tracker/EditableCell.jsx b/client/components/tracker/EditableCell.jsx index e3474b7..29954e7 100644 --- a/client/components/tracker/EditableCell.jsx +++ b/client/components/tracker/EditableCell.jsx @@ -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'; diff --git a/client/components/tracker/LowerThisMonthButton.jsx b/client/components/tracker/LowerThisMonthButton.jsx index e97bdbf..e6e47d4 100644 --- a/client/components/tracker/LowerThisMonthButton.jsx +++ b/client/components/tracker/LowerThisMonthButton.jsx @@ -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'; diff --git a/client/components/tracker/MobileTrackerRow.jsx b/client/components/tracker/MobileTrackerRow.jsx index 3799874..6f142c4 100644 --- a/client/components/tracker/MobileTrackerRow.jsx +++ b/client/components/tracker/MobileTrackerRow.jsx @@ -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'; diff --git a/client/components/tracker/MonthlyStateDialog.jsx b/client/components/tracker/MonthlyStateDialog.jsx index 4d8d9c4..9b0314e 100644 --- a/client/components/tracker/MonthlyStateDialog.jsx +++ b/client/components/tracker/MonthlyStateDialog.jsx @@ -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'; diff --git a/client/components/tracker/NotesCell.jsx b/client/components/tracker/NotesCell.jsx index da7611a..77d3f05 100644 --- a/client/components/tracker/NotesCell.jsx +++ b/client/components/tracker/NotesCell.jsx @@ -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. diff --git a/client/components/tracker/OverdueCommandCenter.jsx b/client/components/tracker/OverdueCommandCenter.jsx index 2cbf141..d4d111d 100644 --- a/client/components/tracker/OverdueCommandCenter.jsx +++ b/client/components/tracker/OverdueCommandCenter.jsx @@ -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'; diff --git a/client/components/tracker/PaymentLedgerDialog.jsx b/client/components/tracker/PaymentLedgerDialog.jsx index 794ba2b..a660356 100644 --- a/client/components/tracker/PaymentLedgerDialog.jsx +++ b/client/components/tracker/PaymentLedgerDialog.jsx @@ -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'; diff --git a/client/components/tracker/PaymentModal.jsx b/client/components/tracker/PaymentModal.jsx index ed50311..25e7134 100644 --- a/client/components/tracker/PaymentModal.jsx +++ b/client/components/tracker/PaymentModal.jsx @@ -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'; diff --git a/client/components/tracker/StartingAmountsEditDialog.jsx b/client/components/tracker/StartingAmountsEditDialog.jsx index e56c352..9716e69 100644 --- a/client/components/tracker/StartingAmountsEditDialog.jsx +++ b/client/components/tracker/StartingAmountsEditDialog.jsx @@ -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'; diff --git a/client/components/tracker/TrackerBucket.jsx b/client/components/tracker/TrackerBucket.jsx index 0f39560..f1add05 100644 --- a/client/components/tracker/TrackerBucket.jsx +++ b/client/components/tracker/TrackerBucket.jsx @@ -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, diff --git a/client/components/tracker/TrackerRow.jsx b/client/components/tracker/TrackerRow.jsx index fed866c..afd9df5 100644 --- a/client/components/tracker/TrackerRow.jsx +++ b/client/components/tracker/TrackerRow.jsx @@ -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'; diff --git a/client/hooks/usePaymentActions.ts b/client/hooks/usePaymentActions.ts index 94fabe4..ca479af 100644 --- a/client/hooks/usePaymentActions.ts +++ b/client/hooks/usePaymentActions.ts @@ -1,6 +1,6 @@ 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'; @@ -79,12 +79,13 @@ export function useTogglePaid(year: number, month: number) { { 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) { diff --git a/client/pages/CategoriesPage.jsx b/client/pages/CategoriesPage.jsx index 9ef761b..e5e2c35 100644 --- a/client/pages/CategoriesPage.jsx +++ b/client/pages/CategoriesPage.jsx @@ -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'; diff --git a/client/pages/SummaryPage.jsx b/client/pages/SummaryPage.jsx index d1d5e37..dc64025 100644 --- a/client/pages/SummaryPage.jsx +++ b/client/pages/SummaryPage.jsx @@ -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'; diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index f6a17f0..09a3b39 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -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';