2026-05-31 15:52:50 -05:00
|
|
|
// 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())
|
2026-06-10 19:28:54 -05:00
|
|
|
.then(d => d.token || '')
|
|
|
|
|
.catch(() => {
|
|
|
|
|
_csrfFetch = null; // don't cache a failed fetch
|
|
|
|
|
return '';
|
|
|
|
|
});
|
2026-05-31 15:52:50 -05:00
|
|
|
}
|
|
|
|
|
return _csrfFetch;
|
2026-05-09 13:03:36 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-10 19:28:54 -05:00
|
|
|
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) {
|
2026-05-03 19:51:57 -05:00
|
|
|
const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include' };
|
2026-05-09 13:03:36 -05:00
|
|
|
// Add CSRF token header for state-changing methods
|
2026-06-10 19:28:54 -05:00
|
|
|
if (MUTATING_METHODS.includes(method)) {
|
2026-05-31 15:52:50 -05:00
|
|
|
const csrfToken = await getCsrfToken();
|
2026-05-09 13:03:36 -05:00
|
|
|
if (csrfToken) {
|
|
|
|
|
opts.headers['x-csrf-token'] = csrfToken;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
|
|
|
const res = await fetch('/api' + path, opts);
|
2026-06-10 19:28:54 -05:00
|
|
|
const data = await parseJsonSafe(res);
|
2026-05-03 19:51:57 -05:00
|
|
|
if (!res.ok) {
|
2026-06-10 19:28:54 -05:00
|
|
|
// 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}`);
|
2026-05-03 19:51:57 -05:00
|
|
|
err.status = res.status;
|
2026-06-10 19:28:54 -05:00
|
|
|
err.data = data || {};
|
|
|
|
|
err.details = data?.details || [];
|
|
|
|
|
err.code = data?.code;
|
2026-05-03 19:51:57 -05:00
|
|
|
throw err;
|
|
|
|
|
}
|
2026-06-10 19:28:54 -05:00
|
|
|
return data ?? {};
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
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}` : '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 04:31:25 -05:00
|
|
|
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);
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
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),
|
2026-05-14 21:00:07 -05:00
|
|
|
acknowledgePrivacy: () => post('/auth/acknowledge-privacy'),
|
|
|
|
|
acknowledgeVersion: () => post('/auth/acknowledge-version'),
|
2026-05-15 01:36:56 -05:00
|
|
|
loginHistory: () => get('/auth/login-history'),
|
2026-06-04 04:31:25 -05:00
|
|
|
// 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),
|
2026-06-04 21:57:42 -05:00
|
|
|
copySpendingBudgets: (d) => post('/spending/budgets/copy', d),
|
2026-06-04 19:53:38 -05:00
|
|
|
spendingIncome: (p) => get('/spending/income', p),
|
2026-06-04 04:31:25 -05:00
|
|
|
spendingCategoryRules: () => get('/spending/category-rules'),
|
|
|
|
|
addSpendingRule: (d) => post('/spending/category-rules', d),
|
|
|
|
|
deleteSpendingRule: (id) => del(`/spending/category-rules/${id}`),
|
|
|
|
|
|
2026-06-04 04:10:14 -05:00
|
|
|
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),
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-06-05 22:05:23 -05:00
|
|
|
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),
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// 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),
|
2026-05-04 23:34:24 -05:00
|
|
|
updateUserActive: (id, data) => put(`/admin/users/${id}/active`, data),
|
2026-05-03 19:51:57 -05:00
|
|
|
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'),
|
2026-05-09 13:03:36 -05:00
|
|
|
seedDemoData: () => post('/user/seed-demo-data'),
|
|
|
|
|
clearDemoData: () => post('/user/clear-demo-data'),
|
2026-05-11 15:00:35 -05:00
|
|
|
seededStatus: () => get('/user/seeded-status'),
|
2026-05-03 19:51:57 -05:00
|
|
|
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) => {
|
2026-05-31 15:52:50 -05:00
|
|
|
const csrfToken = await getCsrfToken();
|
2026-05-03 19:51:57 -05:00
|
|
|
const res = await fetch('/api/admin/backups/import', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'include',
|
2026-05-31 15:52:50 -05:00
|
|
|
headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': csrfToken },
|
2026-05-03 19:51:57 -05:00
|
|
|
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),
|
2026-06-03 21:43:54 -05:00
|
|
|
testPushNotification: () => post('/notifications/test-push', {}),
|
2026-05-03 19:51:57 -05:00
|
|
|
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
|
2026-05-16 15:38:28 -05:00
|
|
|
tracker: (y, m, params = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`),
|
2026-05-03 19:51:57 -05:00
|
|
|
upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`),
|
2026-05-30 13:19:09 -05:00
|
|
|
overdueCount: () => get('/tracker/overdue-count'),
|
|
|
|
|
snoozeOverdue: (id, data) => put(`/bills/${id}/monthly-state`, data),
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-04 13:14:32 -05:00
|
|
|
// Calendar
|
|
|
|
|
calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`),
|
2026-06-07 15:53:46 -05:00
|
|
|
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 }),
|
2026-05-04 13:14:32 -05:00
|
|
|
|
2026-05-04 16:38:03 -05:00
|
|
|
// Summary
|
|
|
|
|
summary: (y, m) => get(`/summary?year=${y}&month=${m}`),
|
|
|
|
|
saveSummaryIncome: (data) => put('/summary/income', data),
|
2026-05-04 20:12:57 -05:00
|
|
|
getMonthlyStartingAmounts: (y, m) => get(`/monthly-starting-amounts?year=${y}&month=${m}`),
|
|
|
|
|
updateMonthlyStartingAmounts: (data) => put('/monthly-starting-amounts', data),
|
2026-05-04 16:38:03 -05:00
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// Bills
|
2026-05-16 15:38:28 -05:00
|
|
|
bills: (params = {}) => get(`/bills${queryString(params)}`),
|
|
|
|
|
allBills: (params = {}) => get(`/bills${queryString({ inactive: true, ...params })}`),
|
2026-05-16 10:56:56 -05:00
|
|
|
billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`),
|
2026-05-03 19:51:57 -05:00
|
|
|
bill: (id) => get(`/bills/${id}`),
|
|
|
|
|
createBill: (data) => post('/bills', data),
|
|
|
|
|
updateBill: (id, data) => put(`/bills/${id}`, data),
|
2026-05-30 16:13:37 -05:00
|
|
|
reorderBills: (order) => put('/bills/reorder', order),
|
|
|
|
|
archiveBill: (id, archived = true) => put(`/bills/${id}/archived`, { archived }),
|
2026-05-14 02:11:54 -05:00
|
|
|
updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }),
|
2026-05-15 00:03:32 -05:00
|
|
|
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}` : ''}`);
|
|
|
|
|
},
|
2026-05-14 03:00:01 -05:00
|
|
|
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
|
2026-05-03 19:51:57 -05:00
|
|
|
deleteBill: (id) => del(`/bills/${id}`),
|
2026-05-16 10:34:32 -05:00
|
|
|
restoreBill: (id) => post(`/bills/${id}/restore`),
|
2026-05-16 15:38:28 -05:00
|
|
|
duplicateBill: (id, data) => post(`/bills/${id}/duplicate`, data),
|
2026-06-07 14:49:39 -05:00
|
|
|
verifyAutopay: (id) => post(`/bills/${id}/verify-autopay`, {}),
|
2026-05-09 13:03:36 -05:00
|
|
|
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
|
2026-05-03 19:51:57 -05:00
|
|
|
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
|
2026-05-16 21:36:04 -05:00
|
|
|
billTransactions: (id) => get(`/bills/${id}/transactions`),
|
2026-06-03 21:21:38 -05:00
|
|
|
syncBillSimplefinPayments: (id) => post(`/bills/${id}/sync-simplefin-payments`),
|
2026-06-04 20:45:11 -05:00
|
|
|
allBillMerchantRules: () => get('/bills/merchant-rules'),
|
2026-06-03 21:21:38 -05:00
|
|
|
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}`),
|
2026-06-04 02:36:36 -05:00
|
|
|
merchantRuleCandidates: (id) => get(`/bills/${id}/merchant-rules/candidates`),
|
|
|
|
|
toggleRuleAutoAttribute: (id, ruleId, on) => _fetch('PATCH', `/bills/${id}/merchant-rules/${ruleId}/auto-attribute`, { enabled: on }),
|
2026-06-04 02:05:15 -05:00
|
|
|
importHistoricalPayments: (id, ids) => post(`/bills/${id}/merchant-rules/import-historical`, { transaction_ids: ids }),
|
2026-05-03 19:51:57 -05:00
|
|
|
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}`),
|
2026-05-30 14:33:55 -05:00
|
|
|
driftReport: () => get('/bills/drift-report'),
|
|
|
|
|
snoozeBillDrift: (id) => post(`/bills/${id}/snooze-drift`, {}),
|
2026-05-16 15:38:28 -05:00
|
|
|
billTemplates: () => get('/bills/templates'),
|
|
|
|
|
saveBillTemplate: (data) => post('/bills/templates', data),
|
|
|
|
|
deleteBillTemplate: (id) => del(`/bills/templates/${id}`),
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-28 22:54:07 -05:00
|
|
|
// Subscriptions
|
|
|
|
|
subscriptions: () => get('/subscriptions'),
|
2026-05-29 03:02:36 -05:00
|
|
|
confirmTransactionMatch: (transactionId, billId) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }),
|
2026-06-06 21:15:08 -05:00
|
|
|
matchRecommendationToBill: (transactionIds, billId, merchant, catalogId, confidence) => post('/subscriptions/recommendations/match-bill', {
|
|
|
|
|
transaction_ids: transactionIds,
|
|
|
|
|
bill_id: billId,
|
|
|
|
|
merchant,
|
|
|
|
|
catalog_id: catalogId,
|
|
|
|
|
confidence,
|
|
|
|
|
}),
|
2026-05-28 22:54:07 -05:00
|
|
|
subscriptionRecommendations: () => get('/subscriptions/recommendations'),
|
2026-05-30 17:27:15 -05:00
|
|
|
subscriptionTransactionMatches: (params = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`),
|
2026-05-28 22:54:07 -05:00
|
|
|
updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data),
|
|
|
|
|
createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data),
|
2026-06-06 21:15:08 -05:00
|
|
|
declineRecommendation: (recommendation) => post('/subscriptions/recommendations/decline', {
|
|
|
|
|
decline_key: recommendation?.decline_key || recommendation,
|
|
|
|
|
catalog_id: recommendation?.catalog_match?.id,
|
|
|
|
|
merchant: recommendation?.merchant,
|
|
|
|
|
confidence: recommendation?.confidence,
|
|
|
|
|
}),
|
2026-06-06 20:02:13 -05:00
|
|
|
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}`),
|
2026-05-28 22:54:07 -05:00
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// Payments
|
|
|
|
|
quickPay: (data) => post('/payments/quick', data),
|
2026-05-16 15:38:28 -05:00
|
|
|
confirmAutopaySuggestion: (billId, data) => post(`/payments/autopay-suggestions/${billId}/confirm`, data),
|
|
|
|
|
dismissAutopaySuggestion: (billId, data) => post(`/payments/autopay-suggestions/${billId}/dismiss`, data),
|
2026-05-03 19:51:57 -05:00
|
|
|
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`),
|
2026-06-06 17:34:09 -05:00
|
|
|
recentAutoMatched: () => get('/payments/recent-auto'),
|
|
|
|
|
undoAutoMatch: (id) => post(`/payments/${id}/undo-auto`),
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-14 02:11:54 -05:00
|
|
|
// Snowball
|
2026-05-30 17:27:15 -05:00
|
|
|
snowball: () => get('/snowball'),
|
|
|
|
|
snowballSettings: () => get('/snowball/settings'),
|
|
|
|
|
saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data),
|
|
|
|
|
saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items),
|
2026-06-03 21:50:29 -05:00
|
|
|
snowballProjection: (params = {}) => get(`/snowball/projection${queryString(params)}`),
|
2026-05-30 17:27:15 -05:00
|
|
|
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`, {}),
|
2026-05-14 02:11:54 -05:00
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// Categories
|
|
|
|
|
categories: () => get('/categories'),
|
|
|
|
|
createCategory: (data) => post('/categories', data),
|
2026-05-30 20:04:50 -05:00
|
|
|
reorderCategories: (order) => put('/categories/reorder', order),
|
2026-05-03 19:51:57 -05:00
|
|
|
updateCategory: (id, data) => put(`/categories/${id}`, data),
|
2026-06-04 20:01:51 -05:00
|
|
|
toggleCategorySpending: (id, val) => patch(`/categories/${id}/spending`, { spending_enabled: val }),
|
2026-05-03 19:51:57 -05:00
|
|
|
deleteCategory: (id) => del(`/categories/${id}`),
|
2026-05-16 10:34:32 -05:00
|
|
|
restoreCategory: (id) => post(`/categories/${id}/restore`),
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-06-14 19:21:34 -05:00
|
|
|
// 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}`),
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// Settings
|
|
|
|
|
settings: () => get('/settings'),
|
|
|
|
|
saveSettings: (data) => put('/settings', data),
|
|
|
|
|
|
2026-05-04 13:14:32 -05:00
|
|
|
// 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}` : ''}`);
|
|
|
|
|
},
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// Status
|
|
|
|
|
status: () => get('/status'),
|
|
|
|
|
|
|
|
|
|
// Version (public)
|
2026-05-04 20:12:57 -05:00
|
|
|
about: () => get('/about'),
|
2026-05-15 22:45:38 -05:00
|
|
|
privacy: () => get('/privacy'),
|
2026-05-09 16:25:12 -05:00
|
|
|
aboutAdmin: () => get('/about-admin'),
|
2026-05-31 19:37:01 -05:00
|
|
|
roadmap: (refresh = false) => get(`/about-admin/roadmap${refresh ? '?refresh=1' : ''}`),
|
2026-05-14 21:00:07 -05:00
|
|
|
updateStatus: () => get('/version/update-status'),
|
|
|
|
|
checkForUpdates: () => post('/about-admin/check-updates'),
|
v0.25.0: roadmap redesign, import CSRF fix, AdminDashboard removed
- RoadmapPage: kanban-style priority lanes, shadcn Collapsible/Tabs,
lazy-loaded activity log, admin-only /api/about/roadmap + /dev-log endpoints
- Import CSRF fix: added x-csrf-token header to importAdminBackup,
previewSpreadsheetImport, previewUserDbImport raw fetch() calls
- Removed AdminDashboard.jsx, replaced by RoadmapPage
- Added @radix-ui/react-collapsible + collapsible shadcn component
- Security audit by Private_Hudson: PASS (CSRF fix verified,
admin endpoints gated, path traversal mitigated, XSS safe)
2026-05-11 21:42:36 -05:00
|
|
|
devLog: () => get('/about-admin/dev-log'),
|
2026-05-03 19:51:57 -05:00
|
|
|
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();
|
2026-05-31 15:52:50 -05:00
|
|
|
const csrfToken = await getCsrfToken();
|
2026-05-03 19:51:57 -05:00
|
|
|
const res = await fetch(`/api/import/spreadsheet/preview${qs ? `?${qs}` : ''}`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/octet-stream',
|
2026-05-31 15:52:50 -05:00
|
|
|
'x-csrf-token': csrfToken,
|
2026-05-03 19:51:57 -05:00
|
|
|
...(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),
|
2026-05-16 20:26:09 -05:00
|
|
|
previewCsvTransactionImport: async (file) => {
|
2026-05-31 15:52:50 -05:00
|
|
|
const csrfToken = await getCsrfToken();
|
2026-05-16 20:26:09 -05:00
|
|
|
const res = await fetch('/api/import/csv/preview', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'text/csv',
|
2026-05-31 15:52:50 -05:00
|
|
|
'x-csrf-token': csrfToken,
|
2026-05-16 20:26:09 -05:00
|
|
|
...(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),
|
2026-05-03 19:51:57 -05:00
|
|
|
importHistory: () => get('/import/history'),
|
|
|
|
|
|
2026-05-16 21:36:04 -05:00
|
|
|
// Transactions
|
|
|
|
|
transactions: (params = {}) => get(`/transactions${queryString(params)}`),
|
2026-06-12 03:59:42 -05:00
|
|
|
bankTransactionsLedger: (params = {}) => get(`/transactions/bank-ledger${queryString(params)}`),
|
2026-05-16 21:36:04 -05:00
|
|
|
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`),
|
2026-06-06 19:15:06 -05:00
|
|
|
unmatchTransactionBulk: (matches) => post('/transactions/unmatch-bulk', { matches }),
|
2026-05-16 21:36:04 -05:00
|
|
|
ignoreTransaction: (id) => post(`/transactions/${id}/ignore`),
|
|
|
|
|
unignoreTransaction: (id) => post(`/transactions/${id}/unignore`),
|
2026-06-14 15:15:31 -05:00
|
|
|
transactionMerchantMatch: (id) => get(`/transactions/${id}/merchant-match`),
|
|
|
|
|
applyTransactionMerchantMatch: (id) => post(`/transactions/${id}/apply-merchant-match`),
|
|
|
|
|
autoCategorizeTransactions: (opts = {}) => post('/transactions/auto-categorize', opts),
|
2026-05-16 21:36:04 -05:00
|
|
|
|
|
|
|
|
// Match suggestions
|
|
|
|
|
matchSuggestions: (params = {}) => get(`/matches/suggestions${queryString(params)}`),
|
|
|
|
|
rejectMatchSuggestion: (id) => post(`/matches/${encodeURIComponent(id)}/reject`),
|
|
|
|
|
|
feat: SimpleFin bank sync with encrypted token storage
New services:
services/encryptionService.js — AES-256-GCM with SHA-256 derived key
services/simplefinService.js — protocol layer: claim token, fetch accounts/transactions, normalize to DB shapes
services/bankSyncService.js — orchestration: connect, sync, disconnect with encrypted access URL storage
Modified:
routes/dataSources.js — status, connect, sync, disconnect endpoints (gate on BANK_SYNC_ENABLED=true)
client/api.js — simplefinStatus, connectSimplefin, syncDataSource, deleteDataSource, dataSources
client/pages/SettingsPage.jsx — BankSyncSection with connected account info, sync/disconnect actions, setup token input
.env.example — BANK_SYNC_ENABLED, TOKEN_ENCRYPTION_KEY, SIMPLEFIN_APP_NAME
2026-05-28 21:30:20 -05:00
|
|
|
// 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`),
|
2026-05-29 19:58:52 -05:00
|
|
|
backfillDataSource: (id) => post(`/data-sources/${id}/backfill`),
|
feat: SimpleFin bank sync with encrypted token storage
New services:
services/encryptionService.js — AES-256-GCM with SHA-256 derived key
services/simplefinService.js — protocol layer: claim token, fetch accounts/transactions, normalize to DB shapes
services/bankSyncService.js — orchestration: connect, sync, disconnect with encrypted access URL storage
Modified:
routes/dataSources.js — status, connect, sync, disconnect endpoints (gate on BANK_SYNC_ENABLED=true)
client/api.js — simplefinStatus, connectSimplefin, syncDataSource, deleteDataSource, dataSources
client/pages/SettingsPage.jsx — BankSyncSection with connected account info, sync/disconnect actions, setup token input
.env.example — BANK_SYNC_ENABLED, TOKEN_ENCRYPTION_KEY, SIMPLEFIN_APP_NAME
2026-05-28 21:30:20 -05:00
|
|
|
deleteDataSource: (id) => del(`/data-sources/${id}`),
|
2026-05-29 01:06:20 -05:00
|
|
|
dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`),
|
|
|
|
|
setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }),
|
2026-06-03 21:09:26 -05:00
|
|
|
allFinancialAccounts: () => get('/data-sources/accounts/all'),
|
2026-06-04 00:06:16 -05:00
|
|
|
syncAllSources: () => post('/data-sources/sync-all', {}),
|
|
|
|
|
attributePaymentToMonth: (id, paid_date) => _fetch('PATCH', `/payments/${id}/attribute-to-month`, { paid_date }),
|
feat: SimpleFin bank sync with encrypted token storage
New services:
services/encryptionService.js — AES-256-GCM with SHA-256 derived key
services/simplefinService.js — protocol layer: claim token, fetch accounts/transactions, normalize to DB shapes
services/bankSyncService.js — orchestration: connect, sync, disconnect with encrypted access URL storage
Modified:
routes/dataSources.js — status, connect, sync, disconnect endpoints (gate on BANK_SYNC_ENABLED=true)
client/api.js — simplefinStatus, connectSimplefin, syncDataSource, deleteDataSource, dataSources
client/pages/SettingsPage.jsx — BankSyncSection with connected account info, sync/disconnect actions, setup token input
.env.example — BANK_SYNC_ENABLED, TOKEN_ENCRYPTION_KEY, SIMPLEFIN_APP_NAME
2026-05-28 21:30:20 -05:00
|
|
|
|
2026-05-28 22:06:15 -05:00
|
|
|
// Admin — bank sync feature flag
|
|
|
|
|
bankSyncConfig: () => get('/admin/bank-sync-config'),
|
|
|
|
|
setBankSyncConfig: (data) => put('/admin/bank-sync-config', data),
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// User SQLite import
|
|
|
|
|
previewUserDbImport: async (file) => {
|
2026-05-31 15:52:50 -05:00
|
|
|
const csrfToken = await getCsrfToken();
|
2026-05-03 19:51:57 -05:00
|
|
|
const res = await fetch('/api/import/user-db/preview', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/octet-stream',
|
2026-05-31 15:52:50 -05:00
|
|
|
'x-csrf-token': csrfToken,
|
2026-05-03 19:51:57 -05:00
|
|
|
...(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),
|
|
|
|
|
};
|