BillTracker/client/api.js

358 lines
16 KiB
JavaScript

// 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}`),
overdueCount: () => get('/tracker/overdue-count'),
snoozeOverdue: (id, data) => put(`/bills/${id}/monthly-state`, data),
// 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`),
syncBillSimplefinPayments: (id) => post(`/bills/${id}/sync-simplefin-payments`),
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) => post('/subscriptions/recommendations/match-bill', { transaction_ids: transactionIds, bill_id: billId, merchant }),
subscriptionRecommendations: () => get('/subscriptions/recommendations'),
updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data),
createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data),
declineRecommendation: (declineKey) => post('/subscriptions/recommendations/decline', { decline_key: declineKey }),
// 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`),
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 }),
// 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),
};