// Read CSRF token from cookie function getCsrfToken() { if (typeof document === 'undefined') return ''; const name = 'bt_csrf_token'; const match = document.cookie.match(new RegExp(name + '=([^;]+)')); return match ? match[1] : ''; } async function _fetch(method, path, body) { const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include' }; // Add CSRF token header for state-changing methods if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) { const csrfToken = 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 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; } const get = (path) => _fetch('GET', path); const post = (path, body) => _fetch('POST', path, body); const put = (path, body) => _fetch('PUT', path, body); const del = (path) => _fetch('DELETE', path); 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}` : ''; } 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'), // 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'), 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 res = await fetch('/api/admin/backups/import', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': getCsrfToken() }, 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), 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}`), // Calendar calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`), // 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 })}`), 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), 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), 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`), 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}`), billTemplates: () => get('/bills/templates'), saveBillTemplate: (data) => post('/bills/templates', data), deleteBillTemplate: (id) => del(`/bills/templates/${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`), // Snowball snowball: () => get('/snowball'), snowballSettings: () => get('/snowball/settings'), saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data), saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items), snowballProjection: () => get('/snowball/projection'), // Categories categories: () => get('/categories'), createCategory: (data) => post('/categories', data), updateCategory: (id, data) => put(`/categories/${id}`, data), deleteCategory: (id) => del(`/categories/${id}`), restoreCategory: (id) => post(`/categories/${id}/restore`), // 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: () => get('/about-admin/roadmap'), updateStatus: () => get('/version/update-status'), checkForUpdates: () => post('/about-admin/check-updates'), 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 res = await fetch(`/api/import/spreadsheet/preview${qs ? `?${qs}` : ''}`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': getCsrfToken(), ...(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 res = await fetch('/api/import/csv/preview', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'text/csv', 'x-csrf-token': getCsrfToken(), ...(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), importHistory: () => get('/import/history'), // Transactions transactions: (params = {}) => get(`/transactions${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`), ignoreTransaction: (id) => post(`/transactions/${id}/ignore`), unignoreTransaction: (id) => post(`/transactions/${id}/unignore`), // 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`), deleteDataSource: (id) => del(`/data-sources/${id}`), // 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 res = await fetch('/api/import/user-db/preview', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': getCsrfToken(), ...(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), };