Compare commits
9 Commits
62a99fcaa4
...
4af738f947
| Author | SHA1 | Date |
|---|---|---|
|
|
4af738f947 | |
|
|
337ad95a3e | |
|
|
3c7a55c3a1 | |
|
|
d02d3c9c72 | |
|
|
9b805e60b2 | |
|
|
8c30a7ab09 | |
|
|
3c51464bec | |
|
|
7c6be66794 | |
|
|
7bb68db442 |
|
|
@ -5,6 +5,9 @@
|
|||
|
||||
- **[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] 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
|
||||
|
||||
|
|
|
|||
499
client/api.js
499
client/api.js
|
|
@ -1,499 +0,0 @@
|
|||
// 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),
|
||||
};
|
||||
|
|
@ -0,0 +1,523 @@
|
|||
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 { TrendingUp, TrendingDown, ChevronDown } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MONTHS, rowThreshold, paymentSummary } from '@/lib/trackerUtils';
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react';
|
|||
import { motion } from 'framer-motion';
|
||||
import { ArrowDown, ArrowUp, GripVertical, Pencil, TrendingUp } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { fmt } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Monthly notes stored in monthly_bill_state — per-month, not per-bill.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState } from 'react';
|
||||
import { AlertCircle, ChevronDown, BellOff, SkipForward, CreditCard } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt, localDateString } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { fmt, fmtDate } from '@/lib/utils';
|
||||
import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { fmt } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||
import { LayoutGroup } from 'framer-motion';
|
||||
import { ArrowDown, ArrowUp, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt } from '@/lib/utils';
|
||||
import {
|
||||
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
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.
|
||||
*
|
||||
|
|
@ -10,14 +20,14 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
|||
*
|
||||
* Pending changes are flushed on unmount so navigating away never loses edits.
|
||||
*/
|
||||
export function useAutoSave(saveFn, defaultDelay = 400) {
|
||||
const [status, setStatus] = useState('idle');
|
||||
const timer = useRef(null);
|
||||
const pending = useRef(null);
|
||||
const saveRef = useRef(saveFn);
|
||||
export function useAutoSave<T>(saveFn: SaveFn<T>, defaultDelay = 400): UseAutoSave<T> {
|
||||
const [status, setStatus] = useState<AutoSaveStatus>('idle');
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const pending = useRef<T | null>(null);
|
||||
const saveRef = useRef<SaveFn<T>>(saveFn);
|
||||
saveRef.current = saveFn;
|
||||
|
||||
const run = useCallback(async (payload) => {
|
||||
const run = useCallback(async (payload: T) => {
|
||||
pending.current = null;
|
||||
setStatus('saving');
|
||||
try {
|
||||
|
|
@ -28,7 +38,7 @@ export function useAutoSave(saveFn, defaultDelay = 400) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const schedule = useCallback((payload, delay = defaultDelay) => {
|
||||
const schedule = useCallback((payload: T, delay = defaultDelay) => {
|
||||
pending.current = payload;
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => run(payload), delay);
|
||||
|
|
@ -1,9 +1,21 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { fmt } from '@/lib/utils';
|
||||
import type { Dollars } from '@/lib/money';
|
||||
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
|
||||
// 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
|
||||
|
|
@ -11,11 +23,11 @@ import { useInvalidateTrackerData } from '@/hooks/useQueries';
|
|||
export function useQuickPay() {
|
||||
const invalidate = useInvalidateTrackerData();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload) => api.quickPay(payload),
|
||||
mutationFn: (payload: { bill_id: number; amount: number; paid_date: string }) => api.quickPay(payload),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
|
||||
const run = (row, val, paidDate) => mutation.mutate(
|
||||
const run = (row: PayableRow, val: Dollars, paidDate: string) => mutation.mutate(
|
||||
{ bill_id: row.id, amount: val, paid_date: paidDate },
|
||||
{
|
||||
onSuccess: (payment) => {
|
||||
|
|
@ -28,48 +40,56 @@ export function useQuickPay() {
|
|||
toast.success('Payment removed');
|
||||
invalidate();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to undo payment.');
|
||||
toast.error(errMessage(err, 'Failed to undo payment.'));
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (err) => toast.error(err.message || 'Failed to add payment.'),
|
||||
onError: (err) => toast.error(errMessage(err, 'Failed to add payment.')),
|
||||
},
|
||||
);
|
||||
|
||||
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
|
||||
// (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
|
||||
// right toast (an Undo when un-paying, a specific "paid" message otherwise).
|
||||
// Shared by the desktop and mobile tracker rows.
|
||||
export function useTogglePaid(year, month) {
|
||||
export function useTogglePaid(year: number, month: number) {
|
||||
const invalidate = useInvalidateTrackerData();
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ rowId, amount }) => api.togglePaid(rowId, { amount, year, month }),
|
||||
mutationFn: ({ rowId, amount }: { rowId: number; amount: number | undefined }) =>
|
||||
api.togglePaid(rowId, { amount, year, month }),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
|
||||
const run = (row, { wasPaid, threshold, setOptimisticPaid }) => {
|
||||
const run = (row: PayableRow, { wasPaid, threshold, setOptimisticPaid }: TogglePaidOptions) => {
|
||||
setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles
|
||||
mutation.mutate(
|
||||
{ rowId: row.id, amount: wasPaid ? undefined : threshold },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (wasPaid && result.paymentId) {
|
||||
const paymentId = result.paymentId; // capture the narrowed id for the deferred Undo closure
|
||||
toast.success('Payment moved to recovery', {
|
||||
action: {
|
||||
label: 'Undo',
|
||||
onClick: async () => {
|
||||
try {
|
||||
await api.restorePayment(result.paymentId);
|
||||
await api.restorePayment(paymentId);
|
||||
toast.success('Payment restored');
|
||||
invalidate();
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to restore payment');
|
||||
toast.error(errMessage(err, 'Failed to restore payment'));
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
@ -80,7 +100,7 @@ export function useTogglePaid(year, month) {
|
|||
},
|
||||
onError: (err) => {
|
||||
setOptimisticPaid(undefined); // roll back the instant flip
|
||||
toast.error(err.message || 'Failed to toggle payment status');
|
||||
toast.error(errMessage(err, 'Failed to toggle payment status'));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import { api } from '@/api';
|
||||
import { api, type QueryParams } from '@/api';
|
||||
|
||||
// Custom hook for fetching tracker data
|
||||
export function useTracker(year, month) {
|
||||
export function useTracker(year: number, month: number) {
|
||||
return useQuery({
|
||||
queryKey: ['tracker', year, month],
|
||||
queryFn: () => api.tracker(year, month),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
cacheTime: 1000 * 60 * 30, // 30 minutes
|
||||
gcTime: 1000 * 60 * 30, // 30 minutes
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ export function useBills() {
|
|||
queryKey: ['bills'],
|
||||
queryFn: () => api.allBills(),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
cacheTime: 1000 * 60 * 30, // 30 minutes
|
||||
gcTime: 1000 * 60 * 30, // 30 minutes
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ export function useCategories() {
|
|||
queryKey: ['categories'],
|
||||
queryFn: () => api.categories(),
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
cacheTime: 1000 * 60 * 60 * 2, // 2 hours
|
||||
gcTime: 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.
|
||||
export function usePrefetchTracker() {
|
||||
const queryClient = useQueryClient();
|
||||
return useCallback((year, month) => {
|
||||
return useCallback((year: number, month: number) => {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['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.
|
||||
// keepPreviousData keeps the last result visible while a new month/filter loads.
|
||||
|
||||
export function useAnalyticsSummary(params) {
|
||||
export function useAnalyticsSummary(params?: QueryParams) {
|
||||
return useQuery({
|
||||
queryKey: ['analytics-summary', params],
|
||||
queryFn: () => api.analyticsSummary(params),
|
||||
|
|
@ -111,7 +111,7 @@ export function useDeletedBills() {
|
|||
});
|
||||
}
|
||||
|
||||
export function useSummary(year, month) {
|
||||
export function useSummary(year: number, month: number) {
|
||||
return useQuery({
|
||||
queryKey: ['summary', year, month],
|
||||
queryFn: () => api.summary(year, month),
|
||||
|
|
@ -123,7 +123,17 @@ export function useSummary(year, month) {
|
|||
});
|
||||
}
|
||||
|
||||
export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize }) {
|
||||
interface BankLedgerArgs {
|
||||
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({
|
||||
queryKey: ['bank-ledger', accountId, flow, page, query, sortBy, sortDir],
|
||||
queryFn: () => api.bankTransactionsLedger({
|
||||
|
|
@ -140,7 +150,7 @@ export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, p
|
|||
});
|
||||
}
|
||||
|
||||
export function useSpendingSummary(year, month) {
|
||||
export function useSpendingSummary(year: number, month: number) {
|
||||
return useQuery({
|
||||
queryKey: ['spending-summary', year, month],
|
||||
queryFn: () => api.spendingSummary({ year, month }),
|
||||
|
|
@ -149,11 +159,18 @@ export function useSpendingSummary(year, month) {
|
|||
});
|
||||
}
|
||||
|
||||
export function useSpendingTransactions({ year, month, activeCat, page }) {
|
||||
interface SpendingTransactionsArgs {
|
||||
year: number;
|
||||
month: number;
|
||||
activeCat?: number | string | null;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export function useSpendingTransactions({ year, month, activeCat, page }: SpendingTransactionsArgs) {
|
||||
return useQuery({
|
||||
queryKey: ['spending-transactions', year, month, activeCat ?? 'all', page],
|
||||
queryFn: () => {
|
||||
const params = { year, month, page, limit: 50 };
|
||||
const params: QueryParams = { year, month, page, limit: 50 };
|
||||
if (activeCat === null) params.category_id = 'null';
|
||||
else if (activeCat !== undefined) params.category_id = activeCat;
|
||||
return api.spendingTransactions(params);
|
||||
|
|
@ -167,8 +184,8 @@ export function useSpendingCategories() {
|
|||
return useQuery({
|
||||
queryKey: ['spending-categories'],
|
||||
queryFn: async () => {
|
||||
const d = await api.categories();
|
||||
return (d.categories || d || []).filter(c => !c.deleted_at && c.spending_enabled);
|
||||
const cats = await api.categories();
|
||||
return cats.filter(c => !c.deleted_at && c.spending_enabled);
|
||||
},
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
|
|
@ -201,7 +218,7 @@ export function useSnowballActivePlan() {
|
|||
export function useSnowballPlans() {
|
||||
return useQuery({
|
||||
queryKey: ['snowball-plans'],
|
||||
queryFn: () => api.snowballPlans().then(d => d?.plans ?? []).catch(() => []),
|
||||
queryFn: () => api.snowballPlans().then((d: any) => d?.plans ?? []).catch(() => []),
|
||||
staleTime: 1000 * 60 * 2,
|
||||
});
|
||||
}
|
||||
|
|
@ -217,7 +234,7 @@ export function useSubscriptions() {
|
|||
export function useSubscriptionRecommendations() {
|
||||
return useQuery({
|
||||
queryKey: ['subscription-recommendations'],
|
||||
queryFn: () => api.subscriptionRecommendations().then(r => r.recommendations || []),
|
||||
queryFn: () => api.subscriptionRecommendations().then((r: any) => r.recommendations || []),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
}
|
||||
|
|
@ -3,11 +3,11 @@ import { api } from '@/api';
|
|||
|
||||
const SETTING_KEY = 'search_bars_collapsed';
|
||||
|
||||
function settingToBool(value) {
|
||||
function settingToBool(value: unknown): boolean {
|
||||
return value === true || value === 'true' || value === '1' || value === 1;
|
||||
}
|
||||
|
||||
export function useSearchPanelPreference() {
|
||||
export function useSearchPanelPreference(): [boolean, (nextCollapsed: boolean) => void] {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -23,7 +23,7 @@ export function useSearchPanelPreference() {
|
|||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
const saveCollapsed = useCallback((nextCollapsed) => {
|
||||
const saveCollapsed = useCallback((nextCollapsed: boolean) => {
|
||||
setCollapsed(nextCollapsed);
|
||||
api.saveSettings({ [SETTING_KEY]: nextCollapsed ? 'true' : 'false' }).catch(() => {});
|
||||
}, []);
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
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,4 +1,6 @@
|
|||
export const BILLING_SCHEDULE_OPTIONS = [
|
||||
export type Schedule = 'monthly' | 'weekly' | 'biweekly' | 'quarterly' | 'annual';
|
||||
|
||||
export const BILLING_SCHEDULE_OPTIONS: [Schedule, string][] = [
|
||||
['monthly', 'Monthly'],
|
||||
['weekly', 'Weekly'],
|
||||
['biweekly', 'Biweekly'],
|
||||
|
|
@ -6,22 +8,27 @@ export const BILLING_SCHEDULE_OPTIONS = [
|
|||
['annual', 'Annual'],
|
||||
];
|
||||
|
||||
const LABELS = Object.fromEntries(BILLING_SCHEDULE_OPTIONS);
|
||||
const LABELS: Record<string, string> = Object.fromEntries(BILLING_SCHEDULE_OPTIONS);
|
||||
|
||||
export function scheduleFromBillingCycle(billingCycle) {
|
||||
export interface ScheduleBill {
|
||||
cycle_type?: string | null;
|
||||
billing_cycle?: string | null;
|
||||
}
|
||||
|
||||
export function scheduleFromBillingCycle(billingCycle: string | null | undefined): Schedule {
|
||||
const value = String(billingCycle || '').toLowerCase();
|
||||
if (value === 'quarterly') return 'quarterly';
|
||||
if (value === 'annually' || value === 'annual') return 'annual';
|
||||
return 'monthly';
|
||||
}
|
||||
|
||||
export function normalizeSchedule(value, fallback = 'monthly') {
|
||||
export function normalizeSchedule(value: string | null | undefined, fallback = 'monthly'): string {
|
||||
if (!value) return fallback;
|
||||
const normalized = String(value).toLowerCase();
|
||||
return LABELS[normalized] ? normalized : fallback;
|
||||
}
|
||||
|
||||
export function scheduleValue(bill = {}) {
|
||||
export function scheduleValue(bill: ScheduleBill = {}): string {
|
||||
const cycleType = normalizeSchedule(bill.cycle_type, '');
|
||||
const billingCycle = String(bill.billing_cycle || '').toLowerCase();
|
||||
|
||||
|
|
@ -32,14 +39,14 @@ export function scheduleValue(bill = {}) {
|
|||
return cycleType || scheduleFromBillingCycle(billingCycle);
|
||||
}
|
||||
|
||||
export function scheduleLabel(valueOrBill) {
|
||||
export function scheduleLabel(valueOrBill: string | ScheduleBill | null | undefined): string {
|
||||
const value = valueOrBill && typeof valueOrBill === 'object'
|
||||
? scheduleValue(valueOrBill)
|
||||
: normalizeSchedule(valueOrBill);
|
||||
return LABELS[value] || 'Monthly';
|
||||
}
|
||||
|
||||
export function billingCycleForSchedule(schedule) {
|
||||
export function billingCycleForSchedule(schedule: string | null | undefined): string {
|
||||
const value = normalizeSchedule(schedule);
|
||||
if (value === 'quarterly') return 'quarterly';
|
||||
if (value === 'annual') return 'annually';
|
||||
|
|
@ -47,7 +54,7 @@ export function billingCycleForSchedule(schedule) {
|
|||
return 'monthly';
|
||||
}
|
||||
|
||||
export function defaultCycleDayForSchedule(schedule) {
|
||||
export function defaultCycleDayForSchedule(schedule: string | null | undefined): string {
|
||||
const value = normalizeSchedule(schedule);
|
||||
return value === 'weekly' || value === 'biweekly' ? 'monday' : '1';
|
||||
}
|
||||
|
|
@ -1,5 +1,30 @@
|
|||
// 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.
|
||||
* Returns { line, area, points, zeroY } or null when there is nothing to draw.
|
||||
|
|
@ -9,24 +34,32 @@
|
|||
* - 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.
|
||||
*/
|
||||
export function buildTimelineGeometry(timeline, width, height, pad = 4) {
|
||||
export function buildTimelineGeometry(
|
||||
timeline: TimelineEntry[] | null | undefined,
|
||||
width: number,
|
||||
height: number,
|
||||
pad = 4,
|
||||
): TimelineGeometry | null {
|
||||
if (!Array.isArray(timeline) || timeline.length < 2) return null;
|
||||
|
||||
const t0 = Date.parse(timeline[0].date);
|
||||
const t1 = Date.parse(timeline[timeline.length - 1].date);
|
||||
// length >= 2 (guarded), so first/last exist — the `!` satisfies noUncheckedIndexedAccess.
|
||||
const first = timeline[0]!;
|
||||
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 balances = timeline.map(t => Number(t.balance) || 0);
|
||||
const start = Number(timeline[0].balance) || 0;
|
||||
const start = Number(first.balance) || 0;
|
||||
// Domain: [min(0, lowest), max(starting, highest, smallest positive head-room)]
|
||||
const lo = Math.min(0, ...balances);
|
||||
const hi = Math.max(start, ...balances, 1);
|
||||
const range = Math.max(hi - lo, 1);
|
||||
|
||||
const x = (date) => pad + ((Date.parse(date) - t0) / span) * (width - pad * 2);
|
||||
const y = (bal) => pad + (1 - ((bal - lo) / range)) * (height - pad * 2);
|
||||
const x = (date: string): number => pad + ((Date.parse(date) - t0) / span) * (width - pad * 2);
|
||||
const y = (bal: number): number => pad + (1 - ((bal - lo) / range)) * (height - pad * 2);
|
||||
|
||||
const points = timeline.map((t, i) => ({
|
||||
const points: GeometryPoint[] = timeline.map((t, i) => ({
|
||||
x: x(t.date),
|
||||
y: y(Number(t.balance) || 0),
|
||||
date: t.date,
|
||||
|
|
@ -38,19 +71,19 @@ export function buildTimelineGeometry(timeline, width, height, pad = 4) {
|
|||
}));
|
||||
|
||||
// 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++) {
|
||||
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 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) };
|
||||
}
|
||||
|
||||
/** "5 days" / "tomorrow" / "today" */
|
||||
export function daysUntilLabel(days) {
|
||||
export function daysUntilLabel(days: number | string | null | undefined): string {
|
||||
const n = Number(days);
|
||||
if (!Number.isFinite(n) || n <= 0) return 'today';
|
||||
if (n === 1) return 'tomorrow';
|
||||
|
|
@ -58,15 +91,15 @@ export function daysUntilLabel(days) {
|
|||
}
|
||||
|
||||
/** "Jul 1" from "2026-07-01" — no Date parsing, no timezone traps. */
|
||||
export function shortDate(dateStr) {
|
||||
export function shortDate(dateStr: string | null | undefined): string {
|
||||
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'];
|
||||
return `${MONTHS[parseInt(m, 10) - 1]} ${parseInt(d, 10)}`;
|
||||
}
|
||||
|
||||
/** Split upcoming bills into the visible few plus an overflow count. */
|
||||
export function splitUpcoming(upcoming, maxVisible = 4) {
|
||||
export function splitUpcoming<T>(upcoming: T[] | null | undefined, maxVisible = 4): { visible: T[]; overflow: number } {
|
||||
const list = Array.isArray(upcoming) ? upcoming : [];
|
||||
return {
|
||||
visible: list.slice(0, maxVisible),
|
||||
|
|
@ -1,19 +1,27 @@
|
|||
export function moveInArray(items, fromIndex, toIndex) {
|
||||
interface Identified {
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
export function moveInArray<T>(items: T[], fromIndex: number, toIndex: number): T[] {
|
||||
if (!Array.isArray(items)) return [];
|
||||
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= items.length || toIndex >= items.length) {
|
||||
return items;
|
||||
}
|
||||
const next = [...items];
|
||||
const [moved] = next.splice(fromIndex, 1);
|
||||
if (moved === undefined) return next; // in-range by the guard above; satisfies noUncheckedIndexedAccess
|
||||
next.splice(toIndex, 0, moved);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function reorderPayload(items) {
|
||||
export function reorderPayload(items: ReadonlyArray<Identified> | null | undefined): Record<string, number> {
|
||||
return Object.fromEntries((items || []).map((item, index) => [item.id, index]));
|
||||
}
|
||||
|
||||
export function movedItemId(before, after) {
|
||||
export function movedItemId(
|
||||
before: ReadonlyArray<Identified> | null | undefined,
|
||||
after: ReadonlyArray<Identified> | null | undefined,
|
||||
): string | number | null {
|
||||
const moved = (after || []).find((item, index) => item.id !== before?.[index]?.id);
|
||||
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 DEFAULT_TRACKER_TABLE_COLUMNS = [...TRACKER_TABLE_COLUMN_KEYS];
|
||||
|
||||
export function parseTrackerTableColumns(value) {
|
||||
export function parseTrackerTableColumns(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return normalizeTrackerTableColumns(value);
|
||||
if (!value) return DEFAULT_TRACKER_TABLE_COLUMNS;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const parsed = JSON.parse(value as string);
|
||||
return normalizeTrackerTableColumns(parsed);
|
||||
} catch {
|
||||
return DEFAULT_TRACKER_TABLE_COLUMNS;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeTrackerTableColumns(columns) {
|
||||
export function normalizeTrackerTableColumns(columns: unknown): string[] {
|
||||
const valid = new Set(TRACKER_TABLE_COLUMN_KEYS);
|
||||
return Array.isArray(columns)
|
||||
? columns.filter(column => valid.has(column))
|
||||
|
|
@ -32,6 +32,6 @@ export function normalizeTrackerTableColumns(columns) {
|
|||
: DEFAULT_TRACKER_TABLE_COLUMNS;
|
||||
}
|
||||
|
||||
export function trackerTableColumnsToSetting(columns) {
|
||||
export function trackerTableColumnsToSetting(columns: unknown): string {
|
||||
return JSON.stringify(normalizeTrackerTableColumns(columns));
|
||||
}
|
||||
|
|
@ -1,4 +1,10 @@
|
|||
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 = [
|
||||
'January','February','March','April','May','June',
|
||||
|
|
@ -12,6 +18,8 @@ export const TRACKER_SORT_DEFAULT = 'manual';
|
|||
export const TRACKER_SORT_ASC = 'asc';
|
||||
export const TRACKER_SORT_DESC = 'desc';
|
||||
|
||||
export type TrackerSortDir = typeof TRACKER_SORT_ASC | typeof TRACKER_SORT_DESC;
|
||||
|
||||
export const TRACKER_SORT_OPTIONS = [
|
||||
{ key: TRACKER_SORT_DEFAULT, label: 'Custom order', defaultDir: TRACKER_SORT_ASC },
|
||||
{ key: 'name', label: 'Bill name', defaultDir: TRACKER_SORT_ASC },
|
||||
|
|
@ -51,7 +59,11 @@ export const STATUS_META = {
|
|||
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
|
||||
};
|
||||
|
||||
export function paymentDateForTrackerMonth(year, month, dueDay) {
|
||||
export function paymentDateForTrackerMonth(
|
||||
year: number,
|
||||
month: number,
|
||||
dueDay: number | string | null | undefined,
|
||||
): string {
|
||||
const now = new Date();
|
||||
if (year === now.getFullYear() && month === now.getMonth() + 1) {
|
||||
return todayStr();
|
||||
|
|
@ -65,7 +77,7 @@ export function paymentDateForTrackerMonth(year, month, dueDay) {
|
|||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function amountSearchText(...values) {
|
||||
export function amountSearchText(...values: unknown[]): string {
|
||||
return values
|
||||
.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
|
||||
.flatMap(value => {
|
||||
|
|
@ -75,11 +87,11 @@ export function amountSearchText(...values) {
|
|||
.join(' ');
|
||||
}
|
||||
|
||||
export function rowThreshold(row) {
|
||||
export function rowThreshold(row: TrackerRow): Dollars {
|
||||
return row.actual_amount != null ? row.actual_amount : row.expected_amount;
|
||||
}
|
||||
|
||||
export function rowEffectiveStatus(row) {
|
||||
export function rowEffectiveStatus(row: TrackerRow): TrackerStatus {
|
||||
if (row.is_skipped) return 'skipped';
|
||||
const threshold = rowThreshold(row);
|
||||
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
|
||||
|
|
@ -89,24 +101,24 @@ export function rowEffectiveStatus(row) {
|
|||
// 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
|
||||
// row/badge/calendar components share.
|
||||
export function isPaidStatus(status) {
|
||||
export function isPaidStatus(status: string): boolean {
|
||||
return status === 'paid' || status === 'autodraft';
|
||||
}
|
||||
|
||||
export function rowIsPaid(row) {
|
||||
export function rowIsPaid(row: TrackerRow): boolean {
|
||||
const status = rowEffectiveStatus(row);
|
||||
if (row.autopay_suggestion && status === 'autodraft') return false;
|
||||
return isPaidStatus(status);
|
||||
}
|
||||
|
||||
export function rowIsDebt(row) {
|
||||
export function rowIsDebt(row: TrackerRow): boolean {
|
||||
const category = String(row.category_name || '').toLowerCase();
|
||||
return Number(row.current_balance) > 0
|
||||
|| row.minimum_payment != null
|
||||
|| ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token));
|
||||
}
|
||||
|
||||
const STATUS_SORT_ORDER = {
|
||||
const STATUS_SORT_ORDER: Record<TrackerStatus, number> = {
|
||||
missed: 0,
|
||||
late: 1,
|
||||
due_soon: 2,
|
||||
|
|
@ -116,18 +128,20 @@ const STATUS_SORT_ORDER = {
|
|||
skipped: 6,
|
||||
};
|
||||
|
||||
function parseDateSortValue(value) {
|
||||
function parseDateSortValue(value: string | null | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = Date.parse(`${value}T00:00:00`);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function numericSortValue(value) {
|
||||
function numericSortValue(value: unknown): number {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
function trackerSortValue(row, key) {
|
||||
type SortValue = string | number | null | undefined;
|
||||
|
||||
function trackerSortValue(row: TrackerRow, key: string): SortValue {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return String(row.name || '').toLowerCase();
|
||||
|
|
@ -150,7 +164,7 @@ function trackerSortValue(row, key) {
|
|||
}
|
||||
}
|
||||
|
||||
function compareSortValues(a, b, dir) {
|
||||
function compareSortValues(a: SortValue, b: SortValue, dir: string): number {
|
||||
const aMissing = a === null || a === undefined || a === '';
|
||||
const bMissing = b === null || b === undefined || b === '';
|
||||
if (aMissing && bMissing) return 0;
|
||||
|
|
@ -166,15 +180,15 @@ function compareSortValues(a, b, dir) {
|
|||
return dir === TRACKER_SORT_DESC ? -result : result;
|
||||
}
|
||||
|
||||
export function normalizeTrackerSortKey(key) {
|
||||
export function normalizeTrackerSortKey(key: string): string {
|
||||
return TRACKER_SORT_LABELS[key] ? key : TRACKER_SORT_DEFAULT;
|
||||
}
|
||||
|
||||
export function normalizeTrackerSortDir(dir) {
|
||||
export function normalizeTrackerSortDir(dir: string): TrackerSortDir {
|
||||
return dir === TRACKER_SORT_DESC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC;
|
||||
}
|
||||
|
||||
export function sortTrackerRows(rows, sortKey, sortDir) {
|
||||
export function sortTrackerRows(rows: TrackerRow[], sortKey: string, sortDir: string): TrackerRow[] {
|
||||
const key = normalizeTrackerSortKey(sortKey);
|
||||
if (key === TRACKER_SORT_DEFAULT) return rows;
|
||||
|
||||
|
|
@ -200,14 +214,15 @@ export function sortTrackerRows(rows, sortKey, sortDir) {
|
|||
.map(item => item.row);
|
||||
}
|
||||
|
||||
export function moveInArray(items, fromIndex, toIndex) {
|
||||
export function moveInArray<T>(items: T[], fromIndex: number, toIndex: number): T[] {
|
||||
const next = [...items];
|
||||
const [moved] = next.splice(fromIndex, 1);
|
||||
if (moved === undefined) return next;
|
||||
next.splice(toIndex, 0, moved);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function paymentSummary(row, threshold) {
|
||||
export function paymentSummary(row: TrackerRow, threshold: Dollars) {
|
||||
const target = Number(threshold) || 0;
|
||||
const paid = Number(row.total_paid) || 0;
|
||||
const paidTowardDue = Number.isFinite(Number(row.paid_toward_due))
|
||||
|
|
@ -218,12 +233,14 @@ export function paymentSummary(row, threshold) {
|
|||
: Math.max(paid - target, 0);
|
||||
const remaining = Math.max(target - paidTowardDue, 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 {
|
||||
target,
|
||||
paid,
|
||||
paidTowardDue,
|
||||
overpaid,
|
||||
remaining,
|
||||
target: asDollars(target),
|
||||
paid: asDollars(paid),
|
||||
paidTowardDue: asDollars(paidTowardDue),
|
||||
overpaid: asDollars(overpaid),
|
||||
remaining: asDollars(remaining),
|
||||
percent,
|
||||
count: Array.isArray(row.payments) ? row.payments.length : 0,
|
||||
partial: paid > 0 && remaining > 0,
|
||||
|
|
@ -1,10 +1,24 @@
|
|||
// __APP_VERSION__ is injected by Vite at build time from package.json.
|
||||
// Do not hardcode a version string here — update package.json instead.
|
||||
/* global __APP_VERSION__ */
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
export const APP_VERSION = __APP_VERSION__;
|
||||
export const APP_NAME = 'BillTracker';
|
||||
|
||||
export const RELEASE_NOTES = {
|
||||
export interface ReleaseHighlight {
|
||||
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,
|
||||
date: '2026-05-31',
|
||||
highlights: [
|
||||
|
|
@ -4,7 +4,7 @@ import { toast } from 'sonner';
|
|||
import {
|
||||
ArrowDown, ArrowUp, ChevronDown, GripVertical, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw, ShoppingCart,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { InputDialog } from '@/components/ui/input-dialog';
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
RotateCcw,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react';
|
|||
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 { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { api } from '@/api';
|
||||
import { useTracker, useDriftReport, useInvalidateTrackerData, usePrefetchTracker } from '@/hooks/useQueries';
|
||||
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
||||
import BillModal from '@/components/BillModal';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,249 @@
|
|||
// 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