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>
This commit is contained in:
null 2026-07-03 22:09:04 -05:00
parent 8c30a7ab09
commit 9b805e60b2
19 changed files with 546 additions and 516 deletions

View File

@ -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<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.
### 🧪 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),
};

527
client/api.ts Normal file
View File

@ -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<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;
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;
}
/** 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<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(`/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<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<PaymentRecord>('/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<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,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) {

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';