BillTracker/client/api.ts

633 lines
27 KiB
TypeScript
Raw Normal View History

import type {
Bill,
Category,
Payment,
TrackerResponse,
User,
BankLedger,
BankLedgerTransaction,
AutoCategorizePreview,
SpendingSummary,
SpendingTransactionsResponse,
SpendingIncomeResponse,
SpendingRule,
CopyBudgetsResponse,
CategoryGroup,
SubscriptionsResponse,
Recommendation,
SubscriptionTx,
SnowballProjection,
SnowballSettings,
} from '@/types';
// 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;
export 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;
}
/** Build the ApiError every non-ok response throws — one shape, one place. */
function toApiError(
res: Response,
data: { message?: string; error?: string; code?: string; details?: unknown[] } | null,
): ApiError {
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;
return err;
}
/** 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).
// A hung request (server accepted the connection but never responds) would
// otherwise spin the UI forever. 120s is generous enough for the slowest
// legitimate op (large import / bank sync / backup) on a self-hosted LAN while
// still bounding a truly dead request so it surfaces as an error, not a freeze.
const REQUEST_TIMEOUT_MS = 120_000;
// A parsed JSON response body. The error-envelope fields the client reads are
// typed; any other (success) field passes through the index signature. Avoids an
// `any` at the parse boundary without forcing casts at every read site.
type ResponseBody = {
message?: string;
error?: string;
code?: string;
details?: unknown[];
token?: string;
} & Record<string, unknown>;
async function parseJsonSafe(res: Response): Promise<ResponseBody | null> {
if (res.status === 204) return null;
const text = await res.text();
if (!text) return null;
try {
return JSON.parse(text) as ResponseBody;
} 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);
let res: Response;
try {
res = await fetch('/api' + path, { ...opts, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
} catch (e) {
// Timeout (AbortSignal.timeout → TimeoutError) or network failure (TypeError)
// — both would otherwise leave the UI hung or show a raw "Failed to fetch".
const timedOut = (e as Error)?.name === 'TimeoutError';
const err = new Error(
timedOut
? 'The request timed out — the server may be busy or unreachable. Please try again.'
: 'Network error — could not reach the server. Check your connection and try again.',
) as ApiError;
err.status = timedOut ? 408 : 0;
err.code = timedOut ? 'REQUEST_TIMEOUT' : 'NETWORK_ERROR';
throw err;
}
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);
}
fix: four review-confirmed regressions in auth + OIDC flows All found by the multi-angle /code-review over the branch and verified against the installed libraries: - 401 discriminator: the session-expiry redirect keyed off a path prefix (!/auth/*) and falsely logged users out on /profile/change-password with a wrong current password. requireAuth's no-session 401 now carries the distinct AUTH_REQUIRED code — the only signal api.ts dispatches auth:expired on; wrong-credential 401s keep AUTH_ERROR on any path. Test updated to pin the code-based contract on both directions. - OIDC RFC 9207: exchangeAndVerifyTokens rebuilt the callback URL with only code+state, dropping the iss parameter openid-client v6 validates when the provider advertises support (authentik does) — every OIDC login would fail post-upgrade. The full callback query is now forwarded. - OIDC http issuers: v6 enforces HTTPS on discovery/endpoints (v5 didn't) — breaking internal http:// providers on Docker networks. OIDC_ALLOW_INSECURE_HTTP=true opts back in (documented in .env.example). - 2FA downgrade: authService's webauthn fallback caught ANY challenge error and silently downgraded to password-only login; now only the stale-flag 'No registered WebAuthn credentials' case falls through, everything else rethrows. - Burned-challenge retry: the server consumes the single-use 2FA challenge before verifying (anti-replay), so after a server rejection a retry can only ever return 'Challenge expired'. LoginPage now resets to sign-in on server failure (WebAuthn AND the pre-existing TOTP trap); a browser-prompt cancel keeps retry (token still live). Also lazy-loads @simplewebauthn/browser out of the entry bundle, matching ProfilePage. Server 252/252, client 52/52, OIDC smoke 44/44, probe 33/33, e2e 27. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:57:07 -05:00
// Session expired mid-use: the auth middleware is the only source of
// AUTH_REQUIRED (no valid session). Wrong-credential 401s (change-password,
// TOTP disable, …) carry AUTH_ERROR and must NOT log the user out — a path
// prefix can't make that distinction (/profile/change-password proved it).
// AuthProvider listens and clears the user; RequireAuth then redirects to
// /login preserving state.from.
if (res.status === 401 && data?.code === 'AUTH_REQUIRED') {
window.dispatchEvent(new Event('auth:expired'));
}
throw toApiError(res, data);
}
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<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'),
authMode: () => get('/auth/mode'),
login: (data: Body) =>
post<{
requires_totp?: boolean;
requires_webauthn?: boolean;
challenge_token?: string;
webauthn_options?: unknown;
user?: User;
}>('/auth/login', data),
logout: () => post('/auth/logout'),
logoutOthers: () => post<{ success?: boolean; count?: number }>('/auth/logout-others'),
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<SpendingSummary>('/spending/summary', p),
spendingTransactions: (p?: QueryParams) =>
get<SpendingTransactionsResponse>('/spending/transactions', p),
categorizeTransaction: (id: Id, d: Body) => patch(`/spending/transactions/${id}/category`, d),
setSpendingBudget: (d: Body) => put('/spending/budgets', d),
copySpendingBudgets: (d: Body) => post<CopyBudgetsResponse>('/spending/budgets/copy', d),
spendingIncome: (p?: QueryParams) => get<SpendingIncomeResponse>('/spending/income', p),
spendingCategoryRules: () => get<{ rules?: SpendingRule[] }>('/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<{ user?: User }>('/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<{ has_users?: boolean }>('/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: ResponseBody = {};
try {
data = await res.json();
} catch {
/* non-JSON error body */
}
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) {
throw toApiError(res, data);
}
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', {}),
// 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),
// Tracker
tracker: (y: number, m: number, params: QueryParams = {}) =>
get<TrackerResponse>(`/tracker${queryString({ year: y, month: m, ...params })}`),
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<Bill[]>(`/bills${queryString(params)}`),
allBills: (params: QueryParams = {}) =>
get<Bill[]>(`/bills${queryString({ inactive: true, ...params })}`),
deletedBills: () => get<Bill[]>('/bills/deleted'),
billAudit: (includeInactive = false) =>
get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`),
bill: (id: Id) => get<Bill>(`/bills/${id}`),
createBill: (data: Body) => post<Bill>('/bills', data),
updateBill: (id: Id, data: Body) => put<Bill>(`/bills/${id}`, data),
reorderBills: (order: Body) => put('/bills/reorder', order),
updateBillBalance: (id: Id, bal: unknown) =>
_fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }),
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 }),
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),
// Subscriptions
subscriptions: () => get<SubscriptionsResponse>('/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<{ matched_count?: number; bill_name?: string }>(
'/subscriptions/recommendations/match-bill',
{
transaction_ids: transactionIds,
bill_id: billId,
merchant,
catalog_id: catalogId,
confidence,
},
),
subscriptionRecommendations: () =>
get<{ recommendations?: Recommendation[] }>('/subscriptions/recommendations'),
subscriptionTransactionMatches: (params: QueryParams = {}) =>
get<SubscriptionTx[] | { transactions?: SubscriptionTx[] }>(
`/subscriptions/transaction-matches${queryString(params)}`,
),
updateSubscription: (id: Id, data: Body) => _fetch('PATCH', `/subscriptions/${id}`, data),
createSubscriptionFromRecommendation: (data: Body) =>
post<{ id?: number; name?: string }>('/subscriptions/recommendations/create', data),
declineRecommendation: (recommendation: 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: 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<Payment>('/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<Payment>('/payments', data),
updatePayment: (id: Id, data: Body) => put<Payment>(`/payments/${id}`, data),
deletePayment: (id: Id) => del(`/payments/${id}`),
restorePayment: (id: Id) => post<Payment>(`/payments/${id}/restore`),
recentAutoMatched: () => get('/payments/recent-auto'),
undoAutoMatch: (id: Id) => post(`/payments/${id}/undo-auto`),
// Snowball
snowball: () => get<Bill[]>('/snowball'),
snowballSettings: () => get<SnowballSettings>('/snowball/settings'),
saveSnowballSettings: (data: Body) =>
_fetch<SnowballSettings>('PATCH', '/snowball/settings', data),
saveSnowballOrder: (items: Body) => _fetch('PATCH', '/snowball/order', items),
snowballProjection: (params: QueryParams = {}) =>
get<SnowballProjection>(`/snowball/projection${queryString(params)}`),
snowballPlans: () => get<{ plans?: unknown[] }>('/snowball/plans'),
snowballActivePlan: () => get('/snowball/plans/active'),
startSnowballPlan: (data: Body) => post('/snowball/plans', data),
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<Category[]>('/categories'),
createCategory: (data: Body) => post<Category>('/categories', data),
reorderCategories: (order: Body) => put('/categories/reorder', order),
updateCategory: (id: Id, data: Body) => put<Category>(`/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<CategoryGroup[]>('/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'),
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)
// Export a specific date range (both bounds inclusive) — used to export one
// tracker month. The /api/export route accepts ?from&to in addition to ?year.
exportRangeUrl: (from: string, to: string, fmt?: string): string =>
`/api/export?from=${from}&to=${to}&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) {
throw toApiError(res, data);
}
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) {
throw toApiError(res, data);
}
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) {
throw toApiError(res, data);
}
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<BankLedger>(`/transactions/bank-ledger${queryString(params)}`),
matchTransaction: (id: Id, billId: Id) =>
post<{ transaction: BankLedgerTransaction }>(`/transactions/${id}/match`, { billId }),
unmatchTransaction: (id: Id) =>
post<{ transaction: BankLedgerTransaction }>(`/transactions/${id}/unmatch`),
unmatchTransactionBulk: (matches: Body) => post('/transactions/unmatch-bulk', { matches }),
ignoreTransaction: (id: Id) => post<BankLedgerTransaction>(`/transactions/${id}/ignore`),
unignoreTransaction: (id: Id) => post<BankLedgerTransaction>(`/transactions/${id}/unignore`),
applyTransactionMerchantMatch: (id: Id) =>
post<{ matched?: boolean; category?: { id: number; name: string } }>(
`/transactions/${id}/apply-merchant-match`,
),
autoCategorizeTransactions: (opts: Body = {}) =>
post<AutoCategorizePreview>('/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) {
throw toApiError(res, data);
}
return data;
},
applyUserDbImport: (data: Body) => post('/import/user-db/apply', data),
};