Compare commits
No commits in common. "4af738f947332e8b839763acffbd6ee2c3129675" and "62a99fcaa42208ec1e59e1aa2f174ef9ccfb24e2" have entirely different histories.
4af738f947
...
62a99fcaa4
|
|
@ -5,9 +5,6 @@
|
||||||
|
|
||||||
- **[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] 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] 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.
|
|
||||||
- **[Client] Typed the React Query hooks (`hooks/useQueries.js` → `.ts`) — and it caught dead config** — the shared `useTracker`/`useBills`/`useSummary`/`useSpending*`/`useSnowball*`/`useBankLedger`/etc. hooks are now TypeScript (typed params + `QueryParams`-typed query builders, exported from `api.ts`). Converting surfaced that three hooks passed **`cacheTime`** to `useQuery` — an option **React Query v5 renamed to `gcTime` and silently ignores** (so those caches were being GC'd at the 5-min default, not the intended 30 min / 2 hr). TypeScript rejected the unknown property; renamed to `gcTime` so the intended cache-retention actually takes effect (memory-retention only — no fetch/correctness change). Query result data is `unknown` until the endpoint response shapes are typed. Verified: typecheck + lint + build + 48 tests + 17/17 e2e probe.
|
|
||||||
|
|
||||||
### 🧪 Money-service test coverage
|
### 🧪 Money-service test coverage
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,499 @@
|
||||||
|
// 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),
|
||||||
|
};
|
||||||
523
client/api.ts
523
client/api.ts
|
|
@ -1,523 +0,0 @@
|
||||||
import type { Bill, Category, Payment, TrackerResponse } 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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<TrackerResponse>(`/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<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),
|
|
||||||
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<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('/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<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('/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),
|
|
||||||
};
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { TrendingUp, TrendingDown, ChevronDown } from 'lucide-react';
|
import { TrendingUp, TrendingDown, ChevronDown } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { cn, fmt } from '@/lib/utils';
|
import { cn, fmt } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { cn, fmt } from '@/lib/utils';
|
import { cn, fmt } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { MONTHS, rowThreshold, paymentSummary } from '@/lib/trackerUtils';
|
import { MONTHS, rowThreshold, paymentSummary } from '@/lib/trackerUtils';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowDown, ArrowUp, GripVertical, Pencil, TrendingUp } from 'lucide-react';
|
import { ArrowDown, ArrowUp, GripVertical, Pencil, TrendingUp } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { fmt } from '@/lib/utils';
|
import { fmt } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// Monthly notes stored in monthly_bill_state — per-month, not per-bill.
|
// Monthly notes stored in monthly_bill_state — per-month, not per-bill.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { AlertCircle, ChevronDown, BellOff, SkipForward, CreditCard } from 'lucide-react';
|
import { AlertCircle, ChevronDown, BellOff, SkipForward, CreditCard } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { cn, fmt, localDateString } from '@/lib/utils';
|
import { cn, fmt, localDateString } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { fmt, fmtDate } from '@/lib/utils';
|
import { fmt, fmtDate } from '@/lib/utils';
|
||||||
import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
|
import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { fmt } from '@/lib/utils';
|
import { fmt } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||||
import { LayoutGroup } from 'framer-motion';
|
import { LayoutGroup } from 'framer-motion';
|
||||||
import { ArrowDown, ArrowUp, CheckCircle2, Loader2 } from 'lucide-react';
|
import { ArrowDown, ArrowUp, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { cn, fmt } from '@/lib/utils';
|
import { cn, fmt } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
|
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
|
||||||
|
|
|
||||||
|
|
@ -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 { ArrowDown, ArrowUp, CheckCircle2, Clock, GripVertical, Pencil, TrendingUp, X } from 'lucide-react';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,5 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
|
||||||
|
|
||||||
type SaveFn<T> = (payload: T) => unknown;
|
|
||||||
|
|
||||||
export interface UseAutoSave<T> {
|
|
||||||
status: AutoSaveStatus;
|
|
||||||
schedule: (payload: T, delay?: number) => void;
|
|
||||||
flush: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Debounced auto-save with a status the UI can render.
|
* Debounced auto-save with a status the UI can render.
|
||||||
*
|
*
|
||||||
|
|
@ -20,14 +10,14 @@ export interface UseAutoSave<T> {
|
||||||
*
|
*
|
||||||
* Pending changes are flushed on unmount so navigating away never loses edits.
|
* Pending changes are flushed on unmount so navigating away never loses edits.
|
||||||
*/
|
*/
|
||||||
export function useAutoSave<T>(saveFn: SaveFn<T>, defaultDelay = 400): UseAutoSave<T> {
|
export function useAutoSave(saveFn, defaultDelay = 400) {
|
||||||
const [status, setStatus] = useState<AutoSaveStatus>('idle');
|
const [status, setStatus] = useState('idle');
|
||||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const timer = useRef(null);
|
||||||
const pending = useRef<T | null>(null);
|
const pending = useRef(null);
|
||||||
const saveRef = useRef<SaveFn<T>>(saveFn);
|
const saveRef = useRef(saveFn);
|
||||||
saveRef.current = saveFn;
|
saveRef.current = saveFn;
|
||||||
|
|
||||||
const run = useCallback(async (payload: T) => {
|
const run = useCallback(async (payload) => {
|
||||||
pending.current = null;
|
pending.current = null;
|
||||||
setStatus('saving');
|
setStatus('saving');
|
||||||
try {
|
try {
|
||||||
|
|
@ -38,7 +28,7 @@ export function useAutoSave<T>(saveFn: SaveFn<T>, defaultDelay = 400): UseAutoSa
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const schedule = useCallback((payload: T, delay = defaultDelay) => {
|
const schedule = useCallback((payload, delay = defaultDelay) => {
|
||||||
pending.current = payload;
|
pending.current = payload;
|
||||||
clearTimeout(timer.current);
|
clearTimeout(timer.current);
|
||||||
timer.current = setTimeout(() => run(payload), delay);
|
timer.current = setTimeout(() => run(payload), delay);
|
||||||
|
|
@ -1,21 +1,9 @@
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { fmt } from '@/lib/utils';
|
import { fmt } from '@/lib/utils';
|
||||||
import type { Dollars } from '@/lib/money';
|
|
||||||
import { useInvalidateTrackerData } from '@/hooks/useQueries';
|
import { useInvalidateTrackerData } from '@/hooks/useQueries';
|
||||||
|
|
||||||
/** The bits of a tracker row these payment actions need. */
|
|
||||||
interface PayableRow {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Narrow an unknown error (strict catch clauses) to a message string. */
|
|
||||||
function errMessage(err: unknown, fallback: string): string {
|
|
||||||
return err instanceof Error && err.message ? err.message : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick-pay a tracker row (record a payment for `val` on `paidDate`) with a
|
// Quick-pay a tracker row (record a payment for `val` on `paidDate`) with a
|
||||||
// reversible Undo toast. A React Query mutation: `isPending` comes for free and
|
// reversible Undo toast. A React Query mutation: `isPending` comes for free and
|
||||||
// the tracker/badge caches are invalidated on settle. Shared by the desktop and
|
// the tracker/badge caches are invalidated on settle. Shared by the desktop and
|
||||||
|
|
@ -23,11 +11,11 @@ function errMessage(err: unknown, fallback: string): string {
|
||||||
export function useQuickPay() {
|
export function useQuickPay() {
|
||||||
const invalidate = useInvalidateTrackerData();
|
const invalidate = useInvalidateTrackerData();
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (payload: { bill_id: number; amount: number; paid_date: string }) => api.quickPay(payload),
|
mutationFn: (payload) => api.quickPay(payload),
|
||||||
onSettled: () => invalidate(),
|
onSettled: () => invalidate(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const run = (row: PayableRow, val: Dollars, paidDate: string) => mutation.mutate(
|
const run = (row, val, paidDate) => mutation.mutate(
|
||||||
{ bill_id: row.id, amount: val, paid_date: paidDate },
|
{ bill_id: row.id, amount: val, paid_date: paidDate },
|
||||||
{
|
{
|
||||||
onSuccess: (payment) => {
|
onSuccess: (payment) => {
|
||||||
|
|
@ -40,56 +28,48 @@ export function useQuickPay() {
|
||||||
toast.success('Payment removed');
|
toast.success('Payment removed');
|
||||||
invalidate();
|
invalidate();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(errMessage(err, 'Failed to undo payment.'));
|
toast.error(err.message || 'Failed to undo payment.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(errMessage(err, 'Failed to add payment.')),
|
onError: (err) => toast.error(err.message || 'Failed to add payment.'),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return { run, isPending: mutation.isPending };
|
return { run, isPending: mutation.isPending };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TogglePaidOptions {
|
|
||||||
wasPaid: boolean;
|
|
||||||
threshold: Dollars;
|
|
||||||
setOptimisticPaid: (value: boolean | undefined) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle a tracker row paid/unpaid. The caller owns the instant optimistic flip
|
// Toggle a tracker row paid/unpaid. The caller owns the instant optimistic flip
|
||||||
// (its local `setOptimisticPaid`) so the row updates immediately; this hook rolls
|
// (its local `setOptimisticPaid`) so the row updates immediately; this hook rolls
|
||||||
// it back on error, invalidates the tracker/badge caches on settle, and shows the
|
// it back on error, invalidates the tracker/badge caches on settle, and shows the
|
||||||
// right toast (an Undo when un-paying, a specific "paid" message otherwise).
|
// right toast (an Undo when un-paying, a specific "paid" message otherwise).
|
||||||
// Shared by the desktop and mobile tracker rows.
|
// Shared by the desktop and mobile tracker rows.
|
||||||
export function useTogglePaid(year: number, month: number) {
|
export function useTogglePaid(year, month) {
|
||||||
const invalidate = useInvalidateTrackerData();
|
const invalidate = useInvalidateTrackerData();
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: ({ rowId, amount }: { rowId: number; amount: number | undefined }) =>
|
mutationFn: ({ rowId, amount }) => api.togglePaid(rowId, { amount, year, month }),
|
||||||
api.togglePaid(rowId, { amount, year, month }),
|
|
||||||
onSettled: () => invalidate(),
|
onSettled: () => invalidate(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const run = (row: PayableRow, { wasPaid, threshold, setOptimisticPaid }: TogglePaidOptions) => {
|
const run = (row, { wasPaid, threshold, setOptimisticPaid }) => {
|
||||||
setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles
|
setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles
|
||||||
mutation.mutate(
|
mutation.mutate(
|
||||||
{ rowId: row.id, amount: wasPaid ? undefined : threshold },
|
{ rowId: row.id, amount: wasPaid ? undefined : threshold },
|
||||||
{
|
{
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
if (wasPaid && result.paymentId) {
|
if (wasPaid && result.paymentId) {
|
||||||
const paymentId = result.paymentId; // capture the narrowed id for the deferred Undo closure
|
|
||||||
toast.success('Payment moved to recovery', {
|
toast.success('Payment moved to recovery', {
|
||||||
action: {
|
action: {
|
||||||
label: 'Undo',
|
label: 'Undo',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
try {
|
try {
|
||||||
await api.restorePayment(paymentId);
|
await api.restorePayment(result.paymentId);
|
||||||
toast.success('Payment restored');
|
toast.success('Payment restored');
|
||||||
invalidate();
|
invalidate();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(errMessage(err, 'Failed to restore payment'));
|
toast.error(err.message || 'Failed to restore payment');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -100,7 +80,7 @@ export function useTogglePaid(year: number, month: number) {
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setOptimisticPaid(undefined); // roll back the instant flip
|
setOptimisticPaid(undefined); // roll back the instant flip
|
||||||
toast.error(errMessage(err, 'Failed to toggle payment status'));
|
toast.error(err.message || 'Failed to toggle payment status');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { api, type QueryParams } from '@/api';
|
import { api } from '@/api';
|
||||||
|
|
||||||
// Custom hook for fetching tracker data
|
// Custom hook for fetching tracker data
|
||||||
export function useTracker(year: number, month: number) {
|
export function useTracker(year, month) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['tracker', year, month],
|
queryKey: ['tracker', year, month],
|
||||||
queryFn: () => api.tracker(year, month),
|
queryFn: () => api.tracker(year, month),
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
gcTime: 1000 * 60 * 30, // 30 minutes
|
cacheTime: 1000 * 60 * 30, // 30 minutes
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ export function useBills() {
|
||||||
queryKey: ['bills'],
|
queryKey: ['bills'],
|
||||||
queryFn: () => api.allBills(),
|
queryFn: () => api.allBills(),
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
gcTime: 1000 * 60 * 30, // 30 minutes
|
cacheTime: 1000 * 60 * 30, // 30 minutes
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ export function useCategories() {
|
||||||
queryKey: ['categories'],
|
queryKey: ['categories'],
|
||||||
queryFn: () => api.categories(),
|
queryFn: () => api.categories(),
|
||||||
staleTime: 1000 * 60 * 60, // 1 hour
|
staleTime: 1000 * 60 * 60, // 1 hour
|
||||||
gcTime: 1000 * 60 * 60 * 2, // 2 hours
|
cacheTime: 1000 * 60 * 60 * 2, // 2 hours
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +72,7 @@ export function useInvalidateTrackerData() {
|
||||||
// to it is instant. No-op if it's already cached and fresh.
|
// to it is instant. No-op if it's already cached and fresh.
|
||||||
export function usePrefetchTracker() {
|
export function usePrefetchTracker() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useCallback((year: number, month: number) => {
|
return useCallback((year, month) => {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ['tracker', year, month],
|
queryKey: ['tracker', year, month],
|
||||||
queryFn: () => api.tracker(year, month),
|
queryFn: () => api.tracker(year, month),
|
||||||
|
|
@ -86,7 +86,7 @@ export function usePrefetchTracker() {
|
||||||
// dedup, cancellation, and out-of-order responses — no manual sequence guards.
|
// dedup, cancellation, and out-of-order responses — no manual sequence guards.
|
||||||
// keepPreviousData keeps the last result visible while a new month/filter loads.
|
// keepPreviousData keeps the last result visible while a new month/filter loads.
|
||||||
|
|
||||||
export function useAnalyticsSummary(params?: QueryParams) {
|
export function useAnalyticsSummary(params) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['analytics-summary', params],
|
queryKey: ['analytics-summary', params],
|
||||||
queryFn: () => api.analyticsSummary(params),
|
queryFn: () => api.analyticsSummary(params),
|
||||||
|
|
@ -111,7 +111,7 @@ export function useDeletedBills() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSummary(year: number, month: number) {
|
export function useSummary(year, month) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['summary', year, month],
|
queryKey: ['summary', year, month],
|
||||||
queryFn: () => api.summary(year, month),
|
queryFn: () => api.summary(year, month),
|
||||||
|
|
@ -123,17 +123,7 @@ export function useSummary(year: number, month: number) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BankLedgerArgs {
|
export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize }) {
|
||||||
accountId: string;
|
|
||||||
flow: string;
|
|
||||||
page: number;
|
|
||||||
query: string;
|
|
||||||
sortBy: string;
|
|
||||||
sortDir: string;
|
|
||||||
pageSize: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize }: BankLedgerArgs) {
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['bank-ledger', accountId, flow, page, query, sortBy, sortDir],
|
queryKey: ['bank-ledger', accountId, flow, page, query, sortBy, sortDir],
|
||||||
queryFn: () => api.bankTransactionsLedger({
|
queryFn: () => api.bankTransactionsLedger({
|
||||||
|
|
@ -150,7 +140,7 @@ export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, p
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSpendingSummary(year: number, month: number) {
|
export function useSpendingSummary(year, month) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['spending-summary', year, month],
|
queryKey: ['spending-summary', year, month],
|
||||||
queryFn: () => api.spendingSummary({ year, month }),
|
queryFn: () => api.spendingSummary({ year, month }),
|
||||||
|
|
@ -159,18 +149,11 @@ export function useSpendingSummary(year: number, month: number) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpendingTransactionsArgs {
|
export function useSpendingTransactions({ year, month, activeCat, page }) {
|
||||||
year: number;
|
|
||||||
month: number;
|
|
||||||
activeCat?: number | string | null;
|
|
||||||
page: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSpendingTransactions({ year, month, activeCat, page }: SpendingTransactionsArgs) {
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['spending-transactions', year, month, activeCat ?? 'all', page],
|
queryKey: ['spending-transactions', year, month, activeCat ?? 'all', page],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const params: QueryParams = { year, month, page, limit: 50 };
|
const params = { year, month, page, limit: 50 };
|
||||||
if (activeCat === null) params.category_id = 'null';
|
if (activeCat === null) params.category_id = 'null';
|
||||||
else if (activeCat !== undefined) params.category_id = activeCat;
|
else if (activeCat !== undefined) params.category_id = activeCat;
|
||||||
return api.spendingTransactions(params);
|
return api.spendingTransactions(params);
|
||||||
|
|
@ -184,8 +167,8 @@ export function useSpendingCategories() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['spending-categories'],
|
queryKey: ['spending-categories'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const cats = await api.categories();
|
const d = await api.categories();
|
||||||
return cats.filter(c => !c.deleted_at && c.spending_enabled);
|
return (d.categories || d || []).filter(c => !c.deleted_at && c.spending_enabled);
|
||||||
},
|
},
|
||||||
staleTime: 1000 * 60 * 5,
|
staleTime: 1000 * 60 * 5,
|
||||||
});
|
});
|
||||||
|
|
@ -218,7 +201,7 @@ export function useSnowballActivePlan() {
|
||||||
export function useSnowballPlans() {
|
export function useSnowballPlans() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['snowball-plans'],
|
queryKey: ['snowball-plans'],
|
||||||
queryFn: () => api.snowballPlans().then((d: any) => d?.plans ?? []).catch(() => []),
|
queryFn: () => api.snowballPlans().then(d => d?.plans ?? []).catch(() => []),
|
||||||
staleTime: 1000 * 60 * 2,
|
staleTime: 1000 * 60 * 2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -234,7 +217,7 @@ export function useSubscriptions() {
|
||||||
export function useSubscriptionRecommendations() {
|
export function useSubscriptionRecommendations() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['subscription-recommendations'],
|
queryKey: ['subscription-recommendations'],
|
||||||
queryFn: () => api.subscriptionRecommendations().then((r: any) => r.recommendations || []),
|
queryFn: () => api.subscriptionRecommendations().then(r => r.recommendations || []),
|
||||||
staleTime: 1000 * 60 * 5,
|
staleTime: 1000 * 60 * 5,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -3,11 +3,11 @@ import { api } from '@/api';
|
||||||
|
|
||||||
const SETTING_KEY = 'search_bars_collapsed';
|
const SETTING_KEY = 'search_bars_collapsed';
|
||||||
|
|
||||||
function settingToBool(value: unknown): boolean {
|
function settingToBool(value) {
|
||||||
return value === true || value === 'true' || value === '1' || value === 1;
|
return value === true || value === 'true' || value === '1' || value === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSearchPanelPreference(): [boolean, (nextCollapsed: boolean) => void] {
|
export function useSearchPanelPreference() {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -23,7 +23,7 @@ export function useSearchPanelPreference(): [boolean, (nextCollapsed: boolean) =
|
||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveCollapsed = useCallback((nextCollapsed: boolean) => {
|
const saveCollapsed = useCallback((nextCollapsed) => {
|
||||||
setCollapsed(nextCollapsed);
|
setCollapsed(nextCollapsed);
|
||||||
api.saveSettings({ [SETTING_KEY]: nextCollapsed ? 'true' : 'false' }).catch(() => {});
|
api.saveSettings({ [SETTING_KEY]: nextCollapsed ? 'true' : 'false' }).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { billingCycleForSchedule, scheduleValue } from './billingSchedule';
|
||||||
|
|
||||||
|
function categoryForTemplate(template, categories = []) {
|
||||||
|
const keywords = template?.categoryKeywords || [];
|
||||||
|
const match = categories.find(category => {
|
||||||
|
const name = String(category.name || '').toLowerCase();
|
||||||
|
return keywords.some(keyword => name.includes(keyword));
|
||||||
|
});
|
||||||
|
return match?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryIdOrFallback(categoryId, template, categories = []) {
|
||||||
|
if (categoryId && categories.some(category => String(category.id) === String(categoryId))) {
|
||||||
|
return categoryId;
|
||||||
|
}
|
||||||
|
return template ? categoryForTemplate(template, categories) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeBillDraft(source, { copy = false, template = null, categories = [] } = {}) {
|
||||||
|
const data = source || {};
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
id: undefined,
|
||||||
|
source_bill_id: copy && data.id != null ? data.id : undefined,
|
||||||
|
active: 1,
|
||||||
|
name: copy
|
||||||
|
? `${data.name || 'Bill'} (Copy)`
|
||||||
|
: (data.name || template?.name || ''),
|
||||||
|
category_id: categoryIdOrFallback(data.category_id, template, categories),
|
||||||
|
due_day: data.due_day || 1,
|
||||||
|
expected_amount: data.expected_amount ?? 0,
|
||||||
|
billing_cycle: billingCycleForSchedule(scheduleValue(data)),
|
||||||
|
cycle_type: scheduleValue(data),
|
||||||
|
cycle_day: String(data.cycle_day || '1'),
|
||||||
|
autopay_enabled: !!data.autopay_enabled,
|
||||||
|
autodraft_status: data.autodraft_status || (data.autopay_enabled ? 'assumed_paid' : 'none'),
|
||||||
|
auto_mark_paid: !!data.auto_mark_paid,
|
||||||
|
has_2fa: !!data.has_2fa,
|
||||||
|
snowball_include: !!data.snowball_include,
|
||||||
|
snowball_exempt: !!data.snowball_exempt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { billingCycleForSchedule, scheduleValue } from './billingSchedule';
|
|
||||||
|
|
||||||
interface Category {
|
|
||||||
id: string | number;
|
|
||||||
name?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Template {
|
|
||||||
name?: string | null;
|
|
||||||
categoryKeywords?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// The source bill a draft is seeded from. The specific fields the draft reads
|
|
||||||
// are declared; the index signature carries every other field through the spread.
|
|
||||||
interface SourceBill {
|
|
||||||
id?: string | number | null;
|
|
||||||
name?: string | null;
|
|
||||||
category_id?: string | number | null;
|
|
||||||
due_day?: number | null;
|
|
||||||
expected_amount?: number | null;
|
|
||||||
cycle_type?: string | null;
|
|
||||||
billing_cycle?: string | null;
|
|
||||||
cycle_day?: string | number | null;
|
|
||||||
autopay_enabled?: unknown;
|
|
||||||
autodraft_status?: string | null;
|
|
||||||
auto_mark_paid?: unknown;
|
|
||||||
has_2fa?: unknown;
|
|
||||||
snowball_include?: unknown;
|
|
||||||
snowball_exempt?: unknown;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MakeBillDraftOptions {
|
|
||||||
copy?: boolean;
|
|
||||||
template?: Template | null;
|
|
||||||
categories?: Category[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function categoryForTemplate(template: Template | null | undefined, categories: Category[] = []): string | number | null {
|
|
||||||
const keywords = template?.categoryKeywords || [];
|
|
||||||
const match = categories.find(category => {
|
|
||||||
const name = String(category.name || '').toLowerCase();
|
|
||||||
return keywords.some(keyword => name.includes(keyword));
|
|
||||||
});
|
|
||||||
return match?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function categoryIdOrFallback(
|
|
||||||
categoryId: string | number | null | undefined,
|
|
||||||
template: Template | null,
|
|
||||||
categories: Category[] = [],
|
|
||||||
): string | number | null {
|
|
||||||
if (categoryId && categories.some(category => String(category.id) === String(categoryId))) {
|
|
||||||
return categoryId;
|
|
||||||
}
|
|
||||||
return template ? categoryForTemplate(template, categories) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeBillDraft(source: SourceBill | null | undefined, { copy = false, template = null, categories = [] }: MakeBillDraftOptions = {}) {
|
|
||||||
const data: SourceBill = source || {};
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
id: undefined,
|
|
||||||
source_bill_id: copy && data.id != null ? data.id : undefined,
|
|
||||||
active: 1,
|
|
||||||
name: copy
|
|
||||||
? `${data.name || 'Bill'} (Copy)`
|
|
||||||
: (data.name || template?.name || ''),
|
|
||||||
category_id: categoryIdOrFallback(data.category_id, template, categories),
|
|
||||||
due_day: data.due_day || 1,
|
|
||||||
expected_amount: data.expected_amount ?? 0,
|
|
||||||
billing_cycle: billingCycleForSchedule(scheduleValue(data)),
|
|
||||||
cycle_type: scheduleValue(data),
|
|
||||||
cycle_day: String(data.cycle_day || '1'),
|
|
||||||
autopay_enabled: !!data.autopay_enabled,
|
|
||||||
autodraft_status: data.autodraft_status || (data.autopay_enabled ? 'assumed_paid' : 'none'),
|
|
||||||
auto_mark_paid: !!data.auto_mark_paid,
|
|
||||||
has_2fa: !!data.has_2fa,
|
|
||||||
snowball_include: !!data.snowball_include,
|
|
||||||
snowball_exempt: !!data.snowball_exempt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
export type Schedule = 'monthly' | 'weekly' | 'biweekly' | 'quarterly' | 'annual';
|
export const BILLING_SCHEDULE_OPTIONS = [
|
||||||
|
|
||||||
export const BILLING_SCHEDULE_OPTIONS: [Schedule, string][] = [
|
|
||||||
['monthly', 'Monthly'],
|
['monthly', 'Monthly'],
|
||||||
['weekly', 'Weekly'],
|
['weekly', 'Weekly'],
|
||||||
['biweekly', 'Biweekly'],
|
['biweekly', 'Biweekly'],
|
||||||
|
|
@ -8,27 +6,22 @@ export const BILLING_SCHEDULE_OPTIONS: [Schedule, string][] = [
|
||||||
['annual', 'Annual'],
|
['annual', 'Annual'],
|
||||||
];
|
];
|
||||||
|
|
||||||
const LABELS: Record<string, string> = Object.fromEntries(BILLING_SCHEDULE_OPTIONS);
|
const LABELS = Object.fromEntries(BILLING_SCHEDULE_OPTIONS);
|
||||||
|
|
||||||
export interface ScheduleBill {
|
export function scheduleFromBillingCycle(billingCycle) {
|
||||||
cycle_type?: string | null;
|
|
||||||
billing_cycle?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function scheduleFromBillingCycle(billingCycle: string | null | undefined): Schedule {
|
|
||||||
const value = String(billingCycle || '').toLowerCase();
|
const value = String(billingCycle || '').toLowerCase();
|
||||||
if (value === 'quarterly') return 'quarterly';
|
if (value === 'quarterly') return 'quarterly';
|
||||||
if (value === 'annually' || value === 'annual') return 'annual';
|
if (value === 'annually' || value === 'annual') return 'annual';
|
||||||
return 'monthly';
|
return 'monthly';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeSchedule(value: string | null | undefined, fallback = 'monthly'): string {
|
export function normalizeSchedule(value, fallback = 'monthly') {
|
||||||
if (!value) return fallback;
|
if (!value) return fallback;
|
||||||
const normalized = String(value).toLowerCase();
|
const normalized = String(value).toLowerCase();
|
||||||
return LABELS[normalized] ? normalized : fallback;
|
return LABELS[normalized] ? normalized : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scheduleValue(bill: ScheduleBill = {}): string {
|
export function scheduleValue(bill = {}) {
|
||||||
const cycleType = normalizeSchedule(bill.cycle_type, '');
|
const cycleType = normalizeSchedule(bill.cycle_type, '');
|
||||||
const billingCycle = String(bill.billing_cycle || '').toLowerCase();
|
const billingCycle = String(bill.billing_cycle || '').toLowerCase();
|
||||||
|
|
||||||
|
|
@ -39,14 +32,14 @@ export function scheduleValue(bill: ScheduleBill = {}): string {
|
||||||
return cycleType || scheduleFromBillingCycle(billingCycle);
|
return cycleType || scheduleFromBillingCycle(billingCycle);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scheduleLabel(valueOrBill: string | ScheduleBill | null | undefined): string {
|
export function scheduleLabel(valueOrBill) {
|
||||||
const value = valueOrBill && typeof valueOrBill === 'object'
|
const value = valueOrBill && typeof valueOrBill === 'object'
|
||||||
? scheduleValue(valueOrBill)
|
? scheduleValue(valueOrBill)
|
||||||
: normalizeSchedule(valueOrBill);
|
: normalizeSchedule(valueOrBill);
|
||||||
return LABELS[value] || 'Monthly';
|
return LABELS[value] || 'Monthly';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function billingCycleForSchedule(schedule: string | null | undefined): string {
|
export function billingCycleForSchedule(schedule) {
|
||||||
const value = normalizeSchedule(schedule);
|
const value = normalizeSchedule(schedule);
|
||||||
if (value === 'quarterly') return 'quarterly';
|
if (value === 'quarterly') return 'quarterly';
|
||||||
if (value === 'annual') return 'annually';
|
if (value === 'annual') return 'annually';
|
||||||
|
|
@ -54,7 +47,7 @@ export function billingCycleForSchedule(schedule: string | null | undefined): st
|
||||||
return 'monthly';
|
return 'monthly';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultCycleDayForSchedule(schedule: string | null | undefined): string {
|
export function defaultCycleDayForSchedule(schedule) {
|
||||||
const value = normalizeSchedule(schedule);
|
const value = normalizeSchedule(schedule);
|
||||||
return value === 'weekly' || value === 'biweekly' ? 'monday' : '1';
|
return value === 'weekly' || value === 'biweekly' ? 'monday' : '1';
|
||||||
}
|
}
|
||||||
|
|
@ -1,30 +1,5 @@
|
||||||
// Pure helpers for the Safe-to-Spend card. No DOM, no React — unit-testable.
|
// Pure helpers for the Safe-to-Spend card. No DOM, no React — unit-testable.
|
||||||
|
|
||||||
export interface TimelineEntry {
|
|
||||||
date: string;
|
|
||||||
balance: number | string | null;
|
|
||||||
bills?: unknown[];
|
|
||||||
payday?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GeometryPoint {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
date: string;
|
|
||||||
balance: number;
|
|
||||||
bills: unknown[];
|
|
||||||
isDrop: boolean;
|
|
||||||
isPayday: boolean;
|
|
||||||
isLast: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TimelineGeometry {
|
|
||||||
line: string;
|
|
||||||
area: string;
|
|
||||||
points: GeometryPoint[];
|
|
||||||
zeroY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert the server's cashflow timeline into SVG step-path geometry.
|
* Convert the server's cashflow timeline into SVG step-path geometry.
|
||||||
* Returns { line, area, points, zeroY } or null when there is nothing to draw.
|
* Returns { line, area, points, zeroY } or null when there is nothing to draw.
|
||||||
|
|
@ -34,32 +9,24 @@ export interface TimelineGeometry {
|
||||||
* - Y maps balances; the domain always includes 0 so the zero line is honest.
|
* - Y maps balances; the domain always includes 0 so the zero line is honest.
|
||||||
* - Step shape: balance holds flat until a bill's day, then drops.
|
* - Step shape: balance holds flat until a bill's day, then drops.
|
||||||
*/
|
*/
|
||||||
export function buildTimelineGeometry(
|
export function buildTimelineGeometry(timeline, width, height, pad = 4) {
|
||||||
timeline: TimelineEntry[] | null | undefined,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
pad = 4,
|
|
||||||
): TimelineGeometry | null {
|
|
||||||
if (!Array.isArray(timeline) || timeline.length < 2) return null;
|
if (!Array.isArray(timeline) || timeline.length < 2) return null;
|
||||||
|
|
||||||
// length >= 2 (guarded), so first/last exist — the `!` satisfies noUncheckedIndexedAccess.
|
const t0 = Date.parse(timeline[0].date);
|
||||||
const first = timeline[0]!;
|
const t1 = Date.parse(timeline[timeline.length - 1].date);
|
||||||
const last = timeline[timeline.length - 1]!;
|
|
||||||
const t0 = Date.parse(first.date);
|
|
||||||
const t1 = Date.parse(last.date);
|
|
||||||
const span = Math.max(t1 - t0, 1);
|
const span = Math.max(t1 - t0, 1);
|
||||||
|
|
||||||
const balances = timeline.map(t => Number(t.balance) || 0);
|
const balances = timeline.map(t => Number(t.balance) || 0);
|
||||||
const start = Number(first.balance) || 0;
|
const start = Number(timeline[0].balance) || 0;
|
||||||
// Domain: [min(0, lowest), max(starting, highest, smallest positive head-room)]
|
// Domain: [min(0, lowest), max(starting, highest, smallest positive head-room)]
|
||||||
const lo = Math.min(0, ...balances);
|
const lo = Math.min(0, ...balances);
|
||||||
const hi = Math.max(start, ...balances, 1);
|
const hi = Math.max(start, ...balances, 1);
|
||||||
const range = Math.max(hi - lo, 1);
|
const range = Math.max(hi - lo, 1);
|
||||||
|
|
||||||
const x = (date: string): number => pad + ((Date.parse(date) - t0) / span) * (width - pad * 2);
|
const x = (date) => pad + ((Date.parse(date) - t0) / span) * (width - pad * 2);
|
||||||
const y = (bal: number): number => pad + (1 - ((bal - lo) / range)) * (height - pad * 2);
|
const y = (bal) => pad + (1 - ((bal - lo) / range)) * (height - pad * 2);
|
||||||
|
|
||||||
const points: GeometryPoint[] = timeline.map((t, i) => ({
|
const points = timeline.map((t, i) => ({
|
||||||
x: x(t.date),
|
x: x(t.date),
|
||||||
y: y(Number(t.balance) || 0),
|
y: y(Number(t.balance) || 0),
|
||||||
date: t.date,
|
date: t.date,
|
||||||
|
|
@ -71,19 +38,19 @@ export function buildTimelineGeometry(
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Step path: horizontal to each next x, then vertical drop.
|
// Step path: horizontal to each next x, then vertical drop.
|
||||||
let line = `M ${points[0]!.x.toFixed(2)} ${points[0]!.y.toFixed(2)}`;
|
let line = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
|
||||||
for (let i = 1; i < points.length; i++) {
|
for (let i = 1; i < points.length; i++) {
|
||||||
line += ` H ${points[i]!.x.toFixed(2)} V ${points[i]!.y.toFixed(2)}`;
|
line += ` H ${points[i].x.toFixed(2)} V ${points[i].y.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseY = y(Math.min(0, lo) === lo && lo < 0 ? lo : 0);
|
const baseY = y(Math.min(0, lo) === lo && lo < 0 ? lo : 0);
|
||||||
const area = `${line} V ${baseY.toFixed(2)} H ${points[0]!.x.toFixed(2)} Z`;
|
const area = `${line} V ${baseY.toFixed(2)} H ${points[0].x.toFixed(2)} Z`;
|
||||||
|
|
||||||
return { line, area, points, zeroY: y(0) };
|
return { line, area, points, zeroY: y(0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "5 days" / "tomorrow" / "today" */
|
/** "5 days" / "tomorrow" / "today" */
|
||||||
export function daysUntilLabel(days: number | string | null | undefined): string {
|
export function daysUntilLabel(days) {
|
||||||
const n = Number(days);
|
const n = Number(days);
|
||||||
if (!Number.isFinite(n) || n <= 0) return 'today';
|
if (!Number.isFinite(n) || n <= 0) return 'today';
|
||||||
if (n === 1) return 'tomorrow';
|
if (n === 1) return 'tomorrow';
|
||||||
|
|
@ -91,15 +58,15 @@ export function daysUntilLabel(days: number | string | null | undefined): string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Jul 1" from "2026-07-01" — no Date parsing, no timezone traps. */
|
/** "Jul 1" from "2026-07-01" — no Date parsing, no timezone traps. */
|
||||||
export function shortDate(dateStr: string | null | undefined): string {
|
export function shortDate(dateStr) {
|
||||||
if (!dateStr || typeof dateStr !== 'string') return '';
|
if (!dateStr || typeof dateStr !== 'string') return '';
|
||||||
const [, m = '', d = ''] = dateStr.split('-');
|
const [, m, d] = dateStr.split('-');
|
||||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
return `${MONTHS[parseInt(m, 10) - 1]} ${parseInt(d, 10)}`;
|
return `${MONTHS[parseInt(m, 10) - 1]} ${parseInt(d, 10)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Split upcoming bills into the visible few plus an overflow count. */
|
/** Split upcoming bills into the visible few plus an overflow count. */
|
||||||
export function splitUpcoming<T>(upcoming: T[] | null | undefined, maxVisible = 4): { visible: T[]; overflow: number } {
|
export function splitUpcoming(upcoming, maxVisible = 4) {
|
||||||
const list = Array.isArray(upcoming) ? upcoming : [];
|
const list = Array.isArray(upcoming) ? upcoming : [];
|
||||||
return {
|
return {
|
||||||
visible: list.slice(0, maxVisible),
|
visible: list.slice(0, maxVisible),
|
||||||
|
|
@ -1,27 +1,19 @@
|
||||||
interface Identified {
|
export function moveInArray(items, fromIndex, toIndex) {
|
||||||
id: string | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moveInArray<T>(items: T[], fromIndex: number, toIndex: number): T[] {
|
|
||||||
if (!Array.isArray(items)) return [];
|
if (!Array.isArray(items)) return [];
|
||||||
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= items.length || toIndex >= items.length) {
|
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= items.length || toIndex >= items.length) {
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
const next = [...items];
|
const next = [...items];
|
||||||
const [moved] = next.splice(fromIndex, 1);
|
const [moved] = next.splice(fromIndex, 1);
|
||||||
if (moved === undefined) return next; // in-range by the guard above; satisfies noUncheckedIndexedAccess
|
|
||||||
next.splice(toIndex, 0, moved);
|
next.splice(toIndex, 0, moved);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reorderPayload(items: ReadonlyArray<Identified> | null | undefined): Record<string, number> {
|
export function reorderPayload(items) {
|
||||||
return Object.fromEntries((items || []).map((item, index) => [item.id, index]));
|
return Object.fromEntries((items || []).map((item, index) => [item.id, index]));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function movedItemId(
|
export function movedItemId(before, after) {
|
||||||
before: ReadonlyArray<Identified> | null | undefined,
|
|
||||||
after: ReadonlyArray<Identified> | null | undefined,
|
|
||||||
): string | number | null {
|
|
||||||
const moved = (after || []).find((item, index) => item.id !== before?.[index]?.id);
|
const moved = (after || []).find((item, index) => item.id !== before?.[index]?.id);
|
||||||
return moved?.id || after?.[0]?.id || null;
|
return moved?.id || after?.[0]?.id || null;
|
||||||
}
|
}
|
||||||
|
|
@ -12,19 +12,19 @@ export const TRACKER_TABLE_COLUMNS = [
|
||||||
export const TRACKER_TABLE_COLUMN_KEYS = TRACKER_TABLE_COLUMNS.map(column => column.key);
|
export const TRACKER_TABLE_COLUMN_KEYS = TRACKER_TABLE_COLUMNS.map(column => column.key);
|
||||||
export const DEFAULT_TRACKER_TABLE_COLUMNS = [...TRACKER_TABLE_COLUMN_KEYS];
|
export const DEFAULT_TRACKER_TABLE_COLUMNS = [...TRACKER_TABLE_COLUMN_KEYS];
|
||||||
|
|
||||||
export function parseTrackerTableColumns(value: unknown): string[] {
|
export function parseTrackerTableColumns(value) {
|
||||||
if (Array.isArray(value)) return normalizeTrackerTableColumns(value);
|
if (Array.isArray(value)) return normalizeTrackerTableColumns(value);
|
||||||
if (!value) return DEFAULT_TRACKER_TABLE_COLUMNS;
|
if (!value) return DEFAULT_TRACKER_TABLE_COLUMNS;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value as string);
|
const parsed = JSON.parse(value);
|
||||||
return normalizeTrackerTableColumns(parsed);
|
return normalizeTrackerTableColumns(parsed);
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_TRACKER_TABLE_COLUMNS;
|
return DEFAULT_TRACKER_TABLE_COLUMNS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeTrackerTableColumns(columns: unknown): string[] {
|
export function normalizeTrackerTableColumns(columns) {
|
||||||
const valid = new Set(TRACKER_TABLE_COLUMN_KEYS);
|
const valid = new Set(TRACKER_TABLE_COLUMN_KEYS);
|
||||||
return Array.isArray(columns)
|
return Array.isArray(columns)
|
||||||
? columns.filter(column => valid.has(column))
|
? columns.filter(column => valid.has(column))
|
||||||
|
|
@ -32,6 +32,6 @@ export function normalizeTrackerTableColumns(columns: unknown): string[] {
|
||||||
: DEFAULT_TRACKER_TABLE_COLUMNS;
|
: DEFAULT_TRACKER_TABLE_COLUMNS;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trackerTableColumnsToSetting(columns: unknown): string {
|
export function trackerTableColumnsToSetting(columns) {
|
||||||
return JSON.stringify(normalizeTrackerTableColumns(columns));
|
return JSON.stringify(normalizeTrackerTableColumns(columns));
|
||||||
}
|
}
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
import { todayStr } from '@/lib/utils';
|
import { todayStr } from '@/lib/utils';
|
||||||
import { asDollars, type Dollars } from '@/lib/money';
|
|
||||||
import type { TrackerRow, TrackerStatus } from '@/types';
|
|
||||||
|
|
||||||
// Re-export the shared domain types so existing `@/lib/trackerUtils` importers
|
|
||||||
// keep resolving them (the canonical definitions live in `@/types`).
|
|
||||||
export type { TrackerRow, TrackerStatus } from '@/types';
|
|
||||||
|
|
||||||
export const MONTHS = [
|
export const MONTHS = [
|
||||||
'January','February','March','April','May','June',
|
'January','February','March','April','May','June',
|
||||||
|
|
@ -18,8 +12,6 @@ export const TRACKER_SORT_DEFAULT = 'manual';
|
||||||
export const TRACKER_SORT_ASC = 'asc';
|
export const TRACKER_SORT_ASC = 'asc';
|
||||||
export const TRACKER_SORT_DESC = 'desc';
|
export const TRACKER_SORT_DESC = 'desc';
|
||||||
|
|
||||||
export type TrackerSortDir = typeof TRACKER_SORT_ASC | typeof TRACKER_SORT_DESC;
|
|
||||||
|
|
||||||
export const TRACKER_SORT_OPTIONS = [
|
export const TRACKER_SORT_OPTIONS = [
|
||||||
{ key: TRACKER_SORT_DEFAULT, label: 'Custom order', defaultDir: TRACKER_SORT_ASC },
|
{ key: TRACKER_SORT_DEFAULT, label: 'Custom order', defaultDir: TRACKER_SORT_ASC },
|
||||||
{ key: 'name', label: 'Bill name', defaultDir: TRACKER_SORT_ASC },
|
{ key: 'name', label: 'Bill name', defaultDir: TRACKER_SORT_ASC },
|
||||||
|
|
@ -59,11 +51,7 @@ export const STATUS_META = {
|
||||||
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
|
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function paymentDateForTrackerMonth(
|
export function paymentDateForTrackerMonth(year, month, dueDay) {
|
||||||
year: number,
|
|
||||||
month: number,
|
|
||||||
dueDay: number | string | null | undefined,
|
|
||||||
): string {
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (year === now.getFullYear() && month === now.getMonth() + 1) {
|
if (year === now.getFullYear() && month === now.getMonth() + 1) {
|
||||||
return todayStr();
|
return todayStr();
|
||||||
|
|
@ -77,7 +65,7 @@ export function paymentDateForTrackerMonth(
|
||||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function amountSearchText(...values: unknown[]): string {
|
export function amountSearchText(...values) {
|
||||||
return values
|
return values
|
||||||
.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
|
.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
|
||||||
.flatMap(value => {
|
.flatMap(value => {
|
||||||
|
|
@ -87,11 +75,11 @@ export function amountSearchText(...values: unknown[]): string {
|
||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rowThreshold(row: TrackerRow): Dollars {
|
export function rowThreshold(row) {
|
||||||
return row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
return row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rowEffectiveStatus(row: TrackerRow): TrackerStatus {
|
export function rowEffectiveStatus(row) {
|
||||||
if (row.is_skipped) return 'skipped';
|
if (row.is_skipped) return 'skipped';
|
||||||
const threshold = rowThreshold(row);
|
const threshold = rowThreshold(row);
|
||||||
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
|
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
|
||||||
|
|
@ -101,24 +89,24 @@ export function rowEffectiveStatus(row: TrackerRow): TrackerStatus {
|
||||||
// A bill's month is "settled" when its status is paid or autodraft. Mirrors the
|
// A bill's month is "settled" when its status is paid or autodraft. Mirrors the
|
||||||
// server's statusService.isPaidStatus — the single low-level check the various
|
// server's statusService.isPaidStatus — the single low-level check the various
|
||||||
// row/badge/calendar components share.
|
// row/badge/calendar components share.
|
||||||
export function isPaidStatus(status: string): boolean {
|
export function isPaidStatus(status) {
|
||||||
return status === 'paid' || status === 'autodraft';
|
return status === 'paid' || status === 'autodraft';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rowIsPaid(row: TrackerRow): boolean {
|
export function rowIsPaid(row) {
|
||||||
const status = rowEffectiveStatus(row);
|
const status = rowEffectiveStatus(row);
|
||||||
if (row.autopay_suggestion && status === 'autodraft') return false;
|
if (row.autopay_suggestion && status === 'autodraft') return false;
|
||||||
return isPaidStatus(status);
|
return isPaidStatus(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rowIsDebt(row: TrackerRow): boolean {
|
export function rowIsDebt(row) {
|
||||||
const category = String(row.category_name || '').toLowerCase();
|
const category = String(row.category_name || '').toLowerCase();
|
||||||
return Number(row.current_balance) > 0
|
return Number(row.current_balance) > 0
|
||||||
|| row.minimum_payment != null
|
|| row.minimum_payment != null
|
||||||
|| ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token));
|
|| ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_SORT_ORDER: Record<TrackerStatus, number> = {
|
const STATUS_SORT_ORDER = {
|
||||||
missed: 0,
|
missed: 0,
|
||||||
late: 1,
|
late: 1,
|
||||||
due_soon: 2,
|
due_soon: 2,
|
||||||
|
|
@ -128,20 +116,18 @@ const STATUS_SORT_ORDER: Record<TrackerStatus, number> = {
|
||||||
skipped: 6,
|
skipped: 6,
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseDateSortValue(value: string | null | undefined): number | null {
|
function parseDateSortValue(value) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const parsed = Date.parse(`${value}T00:00:00`);
|
const parsed = Date.parse(`${value}T00:00:00`);
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function numericSortValue(value: unknown): number {
|
function numericSortValue(value) {
|
||||||
const number = Number(value);
|
const number = Number(value);
|
||||||
return Number.isFinite(number) ? number : 0;
|
return Number.isFinite(number) ? number : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortValue = string | number | null | undefined;
|
function trackerSortValue(row, key) {
|
||||||
|
|
||||||
function trackerSortValue(row: TrackerRow, key: string): SortValue {
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'name':
|
case 'name':
|
||||||
return String(row.name || '').toLowerCase();
|
return String(row.name || '').toLowerCase();
|
||||||
|
|
@ -164,7 +150,7 @@ function trackerSortValue(row: TrackerRow, key: string): SortValue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareSortValues(a: SortValue, b: SortValue, dir: string): number {
|
function compareSortValues(a, b, dir) {
|
||||||
const aMissing = a === null || a === undefined || a === '';
|
const aMissing = a === null || a === undefined || a === '';
|
||||||
const bMissing = b === null || b === undefined || b === '';
|
const bMissing = b === null || b === undefined || b === '';
|
||||||
if (aMissing && bMissing) return 0;
|
if (aMissing && bMissing) return 0;
|
||||||
|
|
@ -180,15 +166,15 @@ function compareSortValues(a: SortValue, b: SortValue, dir: string): number {
|
||||||
return dir === TRACKER_SORT_DESC ? -result : result;
|
return dir === TRACKER_SORT_DESC ? -result : result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeTrackerSortKey(key: string): string {
|
export function normalizeTrackerSortKey(key) {
|
||||||
return TRACKER_SORT_LABELS[key] ? key : TRACKER_SORT_DEFAULT;
|
return TRACKER_SORT_LABELS[key] ? key : TRACKER_SORT_DEFAULT;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeTrackerSortDir(dir: string): TrackerSortDir {
|
export function normalizeTrackerSortDir(dir) {
|
||||||
return dir === TRACKER_SORT_DESC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC;
|
return dir === TRACKER_SORT_DESC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortTrackerRows(rows: TrackerRow[], sortKey: string, sortDir: string): TrackerRow[] {
|
export function sortTrackerRows(rows, sortKey, sortDir) {
|
||||||
const key = normalizeTrackerSortKey(sortKey);
|
const key = normalizeTrackerSortKey(sortKey);
|
||||||
if (key === TRACKER_SORT_DEFAULT) return rows;
|
if (key === TRACKER_SORT_DEFAULT) return rows;
|
||||||
|
|
||||||
|
|
@ -214,15 +200,14 @@ export function sortTrackerRows(rows: TrackerRow[], sortKey: string, sortDir: st
|
||||||
.map(item => item.row);
|
.map(item => item.row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moveInArray<T>(items: T[], fromIndex: number, toIndex: number): T[] {
|
export function moveInArray(items, fromIndex, toIndex) {
|
||||||
const next = [...items];
|
const next = [...items];
|
||||||
const [moved] = next.splice(fromIndex, 1);
|
const [moved] = next.splice(fromIndex, 1);
|
||||||
if (moved === undefined) return next;
|
|
||||||
next.splice(toIndex, 0, moved);
|
next.splice(toIndex, 0, moved);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function paymentSummary(row: TrackerRow, threshold: Dollars) {
|
export function paymentSummary(row, threshold) {
|
||||||
const target = Number(threshold) || 0;
|
const target = Number(threshold) || 0;
|
||||||
const paid = Number(row.total_paid) || 0;
|
const paid = Number(row.total_paid) || 0;
|
||||||
const paidTowardDue = Number.isFinite(Number(row.paid_toward_due))
|
const paidTowardDue = Number.isFinite(Number(row.paid_toward_due))
|
||||||
|
|
@ -233,14 +218,12 @@ export function paymentSummary(row: TrackerRow, threshold: Dollars) {
|
||||||
: Math.max(paid - target, 0);
|
: Math.max(paid - target, 0);
|
||||||
const remaining = Math.max(target - paidTowardDue, 0);
|
const remaining = Math.max(target - paidTowardDue, 0);
|
||||||
const percent = target > 0 ? Math.min(100, Math.round((paidTowardDue / target) * 100)) : 0;
|
const percent = target > 0 ? Math.min(100, Math.round((paidTowardDue / target) * 100)) : 0;
|
||||||
// Re-brand the computed amounts as dollars (arithmetic on branded numbers
|
|
||||||
// yields a plain number) so callers can pass them straight to fmt/formatUSD.
|
|
||||||
return {
|
return {
|
||||||
target: asDollars(target),
|
target,
|
||||||
paid: asDollars(paid),
|
paid,
|
||||||
paidTowardDue: asDollars(paidTowardDue),
|
paidTowardDue,
|
||||||
overpaid: asDollars(overpaid),
|
overpaid,
|
||||||
remaining: asDollars(remaining),
|
remaining,
|
||||||
percent,
|
percent,
|
||||||
count: Array.isArray(row.payments) ? row.payments.length : 0,
|
count: Array.isArray(row.payments) ? row.payments.length : 0,
|
||||||
partial: paid > 0 && remaining > 0,
|
partial: paid > 0 && remaining > 0,
|
||||||
|
|
@ -1,24 +1,10 @@
|
||||||
// __APP_VERSION__ is injected by Vite at build time from package.json.
|
// __APP_VERSION__ is injected by Vite at build time from package.json.
|
||||||
// Do not hardcode a version string here — update package.json instead.
|
// Do not hardcode a version string here — update package.json instead.
|
||||||
declare const __APP_VERSION__: string;
|
/* global __APP_VERSION__ */
|
||||||
|
|
||||||
export const APP_VERSION = __APP_VERSION__;
|
export const APP_VERSION = __APP_VERSION__;
|
||||||
export const APP_NAME = 'BillTracker';
|
export const APP_NAME = 'BillTracker';
|
||||||
|
|
||||||
export interface ReleaseHighlight {
|
export const RELEASE_NOTES = {
|
||||||
icon: string;
|
|
||||||
title: string;
|
|
||||||
desc: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReleaseNotes {
|
|
||||||
version: string;
|
|
||||||
date: string;
|
|
||||||
highlights: ReleaseHighlight[];
|
|
||||||
image: { src: string; alt: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseNotes = {
|
|
||||||
version: APP_VERSION,
|
version: APP_VERSION,
|
||||||
date: '2026-05-31',
|
date: '2026-05-31',
|
||||||
highlights: [
|
highlights: [
|
||||||
|
|
@ -4,7 +4,7 @@ import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
ArrowDown, ArrowUp, ChevronDown, GripVertical, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw, ShoppingCart,
|
ArrowDown, ArrowUp, ChevronDown, GripVertical, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw, ShoppingCart,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { Button, buttonVariants } from '@/components/ui/button';
|
import { Button, buttonVariants } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { InputDialog } from '@/components/ui/input-dialog';
|
import { InputDialog } from '@/components/ui/input-dialog';
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Save,
|
Save,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
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 { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api.js';
|
||||||
import { useTracker, useDriftReport, useInvalidateTrackerData, usePrefetchTracker } from '@/hooks/useQueries';
|
import { useTracker, useDriftReport, useInvalidateTrackerData, usePrefetchTracker } from '@/hooks/useQueries';
|
||||||
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
||||||
import BillModal from '@/components/BillModal';
|
import BillModal from '@/components/BillModal';
|
||||||
|
|
|
||||||
249
client/types.ts
249
client/types.ts
|
|
@ -1,249 +0,0 @@
|
||||||
// Domain types for the API responses. Money fields are branded `Dollars` — the
|
|
||||||
// server stores integer cents and serializes dollars over the wire (via
|
|
||||||
// utils/money.fromCents), so every amount a component receives is dollars. Typing
|
|
||||||
// them `Dollars` lets `fmt`/`formatUSD` accept them while rejecting raw cents
|
|
||||||
// (e.g. bank-transaction `*_cents` fields), catching the cents-as-dollars bug
|
|
||||||
// class at compile time.
|
|
||||||
//
|
|
||||||
// Shapes are typed incrementally (endpoint-by-endpoint). Complex nested payloads
|
|
||||||
// that no typed consumer reads yet (trend series, autopay suggestion/stats,
|
|
||||||
// sparkline) are left `unknown` and refined when a consumer needs them.
|
|
||||||
import type { Dollars } from '@/lib/money';
|
|
||||||
|
|
||||||
// A bill's month status. Mirrors the server's statusService.
|
|
||||||
export type TrackerStatus =
|
|
||||||
| 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';
|
|
||||||
|
|
||||||
// A payment record as serialized by the server (money in dollars). serializePayment
|
|
||||||
// spreads the full DB row, so extra columns pass through as `unknown`.
|
|
||||||
export interface Payment {
|
|
||||||
id: number;
|
|
||||||
bill_id?: number;
|
|
||||||
amount: Dollars;
|
|
||||||
paid_date: string;
|
|
||||||
payment_source?: string | null;
|
|
||||||
notes?: string | null;
|
|
||||||
balance_delta?: Dollars | null;
|
|
||||||
interest_delta?: Dollars | null;
|
|
||||||
created_at?: string;
|
|
||||||
deleted_at?: string | null;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A category's per-bill rollup (categories list endpoint). `total_paid` here is a
|
|
||||||
// raw SQL sum (not run through fromCents), so it is deliberately NOT branded.
|
|
||||||
export interface CategoryBillSummary {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
active: boolean;
|
|
||||||
payment_count: number;
|
|
||||||
total_paid: number;
|
|
||||||
last_paid_date: string | null;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A spending/bill category. Has no money field of its own (budgets live on the
|
|
||||||
// spending endpoints). The list endpoint adds bill rollups; create/update return
|
|
||||||
// the bare row — so the rollup fields are optional.
|
|
||||||
export interface Category {
|
|
||||||
id: number;
|
|
||||||
user_id?: number;
|
|
||||||
name: string;
|
|
||||||
sort_order?: number;
|
|
||||||
spending_enabled: boolean;
|
|
||||||
group_id: number | null;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
bill_count?: number;
|
|
||||||
active_bill_count?: number;
|
|
||||||
inactive_bill_count?: number;
|
|
||||||
payment_count?: number;
|
|
||||||
bill_names?: string[];
|
|
||||||
bills?: CategoryBillSummary[];
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A bill as serialized by the server (billsService.serializeBill spreads the DB
|
|
||||||
// row and converts the three money columns to dollars). SQLite booleans come back
|
|
||||||
// as 0/1 integers. Extra joined columns (sparkline, has_merchant_rule, …) pass
|
|
||||||
// through as `unknown` via the index signature.
|
|
||||||
export interface Bill {
|
|
||||||
id: number;
|
|
||||||
user_id?: number;
|
|
||||||
name: string;
|
|
||||||
category_id: number | null;
|
|
||||||
category_name?: string | null;
|
|
||||||
due_day: number | null;
|
|
||||||
override_due_date: string | null;
|
|
||||||
bucket: string;
|
|
||||||
expected_amount: Dollars;
|
|
||||||
interest_rate: number | null;
|
|
||||||
billing_cycle: string | null;
|
|
||||||
autopay_enabled: number;
|
|
||||||
autodraft_status: string | null;
|
|
||||||
auto_mark_paid: number;
|
|
||||||
website: string | null;
|
|
||||||
username: string | null;
|
|
||||||
account_info: string | null;
|
|
||||||
has_2fa: number;
|
|
||||||
notes: string | null;
|
|
||||||
history_visibility: string | null;
|
|
||||||
active: number;
|
|
||||||
cycle_type: string | null;
|
|
||||||
cycle_day: string | number | null;
|
|
||||||
current_balance: Dollars | null;
|
|
||||||
minimum_payment: Dollars | null;
|
|
||||||
snowball_order: number | null;
|
|
||||||
snowball_include: number;
|
|
||||||
snowball_exempt: number;
|
|
||||||
is_subscription: number;
|
|
||||||
subscription_type: string | null;
|
|
||||||
reminder_days_before: number | null;
|
|
||||||
subscription_source: string | null;
|
|
||||||
subscription_detected_at: string | null;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
deleted_at?: string | null;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One tracker row: a bill's state for a given month. Built by
|
|
||||||
// statusService.buildTrackerRow and augmented by trackerService.getTracker.
|
|
||||||
export interface TrackerRow {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
category_id: number | null;
|
|
||||||
category_name: string | null;
|
|
||||||
due_date: string;
|
|
||||||
due_day: number | null;
|
|
||||||
bucket: string;
|
|
||||||
expected_amount: Dollars;
|
|
||||||
notes: string | null;
|
|
||||||
total_paid: Dollars;
|
|
||||||
paid_toward_due: Dollars;
|
|
||||||
overpaid_amount: Dollars;
|
|
||||||
balance: Dollars;
|
|
||||||
has_payment: boolean;
|
|
||||||
is_settled: boolean;
|
|
||||||
last_paid_date: string | null;
|
|
||||||
last_payment_amount: Dollars | null;
|
|
||||||
status: TrackerStatus;
|
|
||||||
autopay_enabled: boolean;
|
|
||||||
autodraft_status: string | null;
|
|
||||||
auto_mark_paid: boolean;
|
|
||||||
billing_cycle: string | null;
|
|
||||||
cycle_type: string | null;
|
|
||||||
cycle_day: string | number | null;
|
|
||||||
current_balance: Dollars | null;
|
|
||||||
minimum_payment: Dollars | null;
|
|
||||||
interest_rate: number | null;
|
|
||||||
is_subscription: boolean;
|
|
||||||
has_2fa: boolean;
|
|
||||||
has_merchant_rule: boolean;
|
|
||||||
has_linked_transactions: boolean;
|
|
||||||
website: string | null;
|
|
||||||
autopay_verified_at: string | null;
|
|
||||||
inactive_reason: string | null;
|
|
||||||
inactivated_at: string | null;
|
|
||||||
sparkline: unknown;
|
|
||||||
autopay_stats: unknown;
|
|
||||||
payments: Payment[];
|
|
||||||
// Augmented by getTracker:
|
|
||||||
actual_amount: Dollars | null;
|
|
||||||
is_skipped: boolean;
|
|
||||||
snoozed_until: string | null;
|
|
||||||
autopay_suggestion?: unknown;
|
|
||||||
previous_month_paid: Dollars;
|
|
||||||
// Bank-mode augmentation (present only when bank_tracking.enabled):
|
|
||||||
pending_cleared?: boolean;
|
|
||||||
bank_pending_count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrackerSummary {
|
|
||||||
total_expected: Dollars;
|
|
||||||
total_starting: Dollars;
|
|
||||||
has_starting_amounts: boolean;
|
|
||||||
total_paid: Dollars;
|
|
||||||
paid_toward_due: Dollars;
|
|
||||||
remaining: Dollars;
|
|
||||||
total_remaining: Dollars;
|
|
||||||
remaining_period: string;
|
|
||||||
remaining_label: string;
|
|
||||||
remaining_hint: string;
|
|
||||||
overdue: Dollars;
|
|
||||||
count_paid: number;
|
|
||||||
count_upcoming: number;
|
|
||||||
count_late: number;
|
|
||||||
count_autodraft: number;
|
|
||||||
previous_month_total: Dollars;
|
|
||||||
trend: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bank tracking is off (`{ enabled: false }`) or on with the account snapshot.
|
|
||||||
export type BankTracking =
|
|
||||||
| { enabled: false }
|
|
||||||
| {
|
|
||||||
enabled: true;
|
|
||||||
account_id: number;
|
|
||||||
account_name: string;
|
|
||||||
org_name: string;
|
|
||||||
balance: Dollars;
|
|
||||||
pending_payments: Dollars;
|
|
||||||
pending_days: number;
|
|
||||||
effective_balance: Dollars;
|
|
||||||
unpaid_this_month: Dollars;
|
|
||||||
remaining: Dollars;
|
|
||||||
last_updated: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface SafeToSpendUpcoming {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
due_date: string;
|
|
||||||
amount: Dollars;
|
|
||||||
status: TrackerStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CashflowTimelinePoint {
|
|
||||||
date: string;
|
|
||||||
balance: number; // running dollar balance; cashflowUtils treats it loosely
|
|
||||||
bills: unknown[];
|
|
||||||
payday?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrackerCashflow {
|
|
||||||
next_payday: string | null;
|
|
||||||
days_until_payday: number;
|
|
||||||
available: Dollars;
|
|
||||||
safe_to_spend: Dollars;
|
|
||||||
still_due_total: Dollars;
|
|
||||||
still_due_count: number;
|
|
||||||
upcoming: SafeToSpendUpcoming[];
|
|
||||||
timeline: CashflowTimelinePoint[];
|
|
||||||
has_data: boolean;
|
|
||||||
uses_bank_balance: boolean;
|
|
||||||
period: string;
|
|
||||||
period_end_label: string;
|
|
||||||
period_starting: Dollars;
|
|
||||||
period_bills_total: Dollars;
|
|
||||||
period_paid: Dollars;
|
|
||||||
period_paid_count: number;
|
|
||||||
period_total_count: number;
|
|
||||||
period_projected: Dollars;
|
|
||||||
month_starting: Dollars;
|
|
||||||
month_bills_total: Dollars;
|
|
||||||
month_paid: Dollars;
|
|
||||||
month_paid_count: number;
|
|
||||||
month_total_count: number;
|
|
||||||
month_projected: Dollars;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrackerResponse {
|
|
||||||
year: number;
|
|
||||||
month: number;
|
|
||||||
today: string;
|
|
||||||
summary: TrackerSummary;
|
|
||||||
bank_tracking: BankTracking;
|
|
||||||
cashflow: TrackerCashflow;
|
|
||||||
rows: TrackerRow[];
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue