112 KiB
Engineering Reference Manual — Bill Tracker
Status: Current code reference
Last Updated: 2026-07-05
Version: 0.40.0 (package version; the in-flight Track A/B/C/D commits target v0.41.0 — see HISTORY.md and the Last Updated note below)
Primary stack: Node.js 22 + Express 4 (CommonJS), React 19 + Vite 5 (TypeScript, strict + noUncheckedIndexedAccess), Tailwind CSS 3 + shadcn/ui, TanStack Query 5, Sonner, SQLite via better-sqlite3 12, @simplewebauthn/* 13, otplib 13, openid-client 5
This manual reflects the current application code in server.js, routes/, services/, middleware/, db/, client/, package.json, Dockerfile, docker-compose.yml, tsconfig.json, eslint.config.mjs, and vite.config.mjs. It is written as a current-state reference, not a changelog. The previous version of this manual (v0.28.1) is fully superseded.
Notable changes since the previous manual:
- Client migrated JSX → TSX. Full strict TypeScript with
noUncheckedIndexedAccess.allowJs+checkJs:falsekeep legacy.jsrunning while.ts/.tsxare type-checked.npm run typecheckwired into CI. - Branded money types.
client/lib/money.tsexposesCents/Dollarsbranded types — a typed caller can no longer format cents as dollars (the 100× bug) by accident. - React 18 → React 19 with
babel-plugin-react-compilerfor automatic memoization. Vite 5 withvite-plugin-pwafor service-worker / installable PWA. - Node 18 → Node 22 in Dockerfile;
node:22-bookwormin CI. - New features: SimpleFIN bank sync, WebAuthn + TOTP 2FA, calendar feed (
/api/calendar/feed.ics), subscription catalog + recommendations, spending categories / budgets, snowball plans (lifecycle), push notifications (ntfy / Gotify / Discord / Telegram), push notification column on users, encrypted secrets at rest with HKDF-derived key, money-cents migration (in progress), category groups, banker-rule auto-attribute. - Schema: migrations
v0.2→v1.06. Rollback map coversv0.44→v1.04.
Since the v0.40.0 manual shipped, 8 in-flight commits targeting v0.41.0 have been added locally and are documented in this manual ahead of push:
- Track A (money integrity): every payment balance-mutation now runs in a single transaction.
POST /paymentsreturns409 { code: 'DUPLICATE_SUSPECTED' }instead of silently deduping when a live payment matches(bill_id, paid_date, amount); the client surfaces a sonner "Add anyway?" toast viaclient/lib/paymentActions.ts → createPaymentOrConfirmand resends withallow_duplicate: trueto confirm. - Track B (tests):
tests/paymentsRoute.test.js(route + transactional behavior) andtests/reconciliation.test.js(cross-surface reconciliation harness). - Track C (API response typing): high-traffic domains (
auth,snowball,subscriptions,spending,bank-ledger) promoted page-local shapes intoclient/types.tsand wired throughget<T>/post<T>.Usermoved from@/hooks/useAuthto@/types(re-exported for 7 importers). The single-consumer non-money casts (adminUsersand the one-offversion/about/privacy/health/importHistorydoc shapes) are deliberately left as-is. - Track D (server error-shape consistency): the global 500 handler in
server.jsnow emits{ error, code: 'INTERNAL_ERROR' }so an unexpected server error carries the same{error, code}field the client can switch on as every structured 4xx.
1. System Overview
Bill Tracker is a self-hosted bill management application. It supports:
- Local username/password authentication, optional single-user mode, optional Authentik/OIDC login, and optional WebAuthn / FIDO2 + TOTP two-factor authentication.
- User-scoped bills, categories (with
category_groupsandspending_enabledflag), payments, monthly bill overrides, monthly income, and starting cash buckets. - Admin user management, backup/restore, cleanup, auth-mode/OIDC configuration, status checks, migration rollback, and SimpleFIN bank-sync admin configuration.
- Spreadsheet, user-SQLite, OFX, and CSV transaction import workflows with preview/apply split.
- CSV, Excel, and user-SQLite export workflows; an iCalendar feed (
/api/calendar/feed.ics) of upcoming bills gated by per-user tokens. - SMTP-based bill due notifications plus ntfy / Gotify / Discord / Telegram push notifications.
- SimpleFIN-based bank sync (data sources, financial accounts, transactions, match suggestions, auto-categorize, merchant rules, merchant-store matches).
- Subscription catalog (top-200 known services), per-user recommendations, declined-subscription hints, recommendation feedback.
- Spending module: categorized transactions, rules, monthly budgets, income breakdown, copy-month workflow.
- Snowball plans: lifecycle (active / paused / completed / abandoned), debt-order projection (snowball/avalanche/minimum-only), payoff scenarios.
- React 19 SPA in TypeScript with branded
Cents/Dollarsmoney types, lazy-loaded routes, global QueryCache error toasts, and CSRF-protected JSON APIs.
Runtime flow:
server.jsinitializes SQLite throughdb/database.js, runsdb/migrations/versionedMigrations.jspluslegacyReconcileMigrations.js, seeds defaults/admin user, cleans expired sessions, then starts Express.- Express applies
securityHeaders, optional CORS,express.json({ limit: '100kb' }),cookieParser,csrfTokenProvider, the patchedres.jsonerror formatter, and an unauthenticatedGET /api/healthprobe. - Route files under
routes/validate input, enforce ownership throughreq.user.id, and use service modules for business logic. WebAuthn and TOTP challenges use a 5–15 minute TTL inwebauthn_challengesandtotp_challenges. - React 19 under
client/calls/api/*throughclient/api.tswithcredentials: includeand CSRF headers on mutating methods; the CSRF token is fetched once from/api/auth/csrf-tokenand cached in memory.
2. Project Layout
server.js— Express entry point and route mounting.routes/— HTTP API handlers:auth.js— login, logout, logout-all, password change, TOTP setup/enable/disable/challenge/status, WebAuthn status, login history, CSRF token endpoint, mode, acknowledge-version/privacy.authOidc.js— OIDC login and callback.bills.js— bills CRUD, auto-mark paid, history ranges, soft-delete, recently-deleted restore.payments.js— payments CRUD, status matching, snowball handling, bank-override accounting, bulk/quick.categories.js— category CRUD, sort order, spending/bill split, category groups.tracker.js— monthly tracker data, bucket resolution, cycle handling, drift, autopay suggestions, amount suggestions, safe-to-spend, three-month averages.summary.js— summary stats, income, starting amounts, bank-tracking overlay, skip override.analytics.js— expense reports, category breakdown, month-window filtering.settings.js— user settings, admin config, notification settings.notifications.js— notification management (admin SMTP + push).profile.js— user profile, notification settings, change-password, export URLs, import history, login history.import.js— CSV / Excel / user-SQLite / OFX import workflows.export.js— CSV, Excel, SQLite export.status.js— system status, health, runtime.dataSources.js— data sources CRUD with sync status (manual / file_import / provider_sync).transactions.js— transaction CRUD, match/ignore/commit actions, manual entry, pending flag, geolocation.matches.js— match suggestions, rejection tracking.snowball.js— snowball projection, settings, debt order, plan lifecycle (create / list / active / pause / resume / complete / abandon).subscriptions.js— user subscriptions, recommendations (list / decline / match-bill / create), transaction matches, catalog links, custom descriptors.spending.js— categorized spending summary, transactions, budgets (PUT / copy / month-rollover), category rules, income.calendarFeed.js— public iCalendar feed (GET /feed.ics) per-user token-gated.admin.js— users (incl. username change), backups, cleanup, auth-mode, bank-sync-config, migrations rollback.about.js,aboutAdmin.js— public + admin markdown about view.privacy.js— public privacy page.version.js— public version, history, update-status.
services/— business logic modules. See §4.middleware/— auth guards, CSRF, rate limits, security headers, error formatting.db/schema.sql— base SQLite schema.db/database.js— DB connection, migrations runner, defaults, settings, rollback support.db/migrations/versionedMigrations.js— ordered, dependency-aware migrationsv0.2→v1.06.db/migrations/legacyReconcileMigrations.js— column-level reconciliation for unversioned columns that pre-date the migration framework.db/subscriptionCatalogSeed.js— top-200 US subscriptions dataset used byv0.65/v0.69/v0.95.workers/dailyWorker.js— scheduled notification/cleanup worker entry.client/— React 19 SPA in TypeScript (see §7).dist/— generated Vite build output served by Express.Dockerfile,docker-compose.yml,docker-entrypoint.sh— container deployment.tsconfig.json— TypeScript config (strict +noUncheckedIndexedAccess,allowJs).eslint.config.mjs— flat ESLint config,react-hooks+react-refresh+typescript-eslint.vite.config.mjs— Vite + React Compiler + PWA + Vitest config.
3. Backend Entry Point and Middleware
server.js
Server defaults:
- Port:
PORT || 3000. - Bind host:
BIND_HOST || '0.0.0.0'. - Static frontend:
dist/. - Optional CORS: enabled when
CORS_ORIGINis set; comma-separated allowlist withcredentials: true. TRUST_PROXY(env): when set totrue/yes/ontrusts 1 hop; numeric → trust that many hops; otherwise the raw value (e.g.loopback, CIDR). Unset/false = no proxy trust. Required behind nginx/Traefik soreq.ipandreq.securereflect the real client.- Session cleanup: runs on startup and every
SESSION_CLEANUP_INTERVAL_MS || 86400000ms. - Admin seed:
INIT_ADMIN_USER || admin;INIT_ADMIN_PASSor generated/default behavior indb/database.js. - Environment variables
INIT_ADMIN_USERandINIT_ADMIN_PASS(orINIT_REGULAR_USER+INIT_REGULAR_PASS) skip the first-login flow entirely by pre-seeding users withfirst_login=0flags viasetup/firstRun.js. - Optional regular-user seed:
INIT_REGULAR_USER+INIT_REGULAR_PASS; password must be at least 8 chars. - First-run flow: if
userCount === 0after migrations,setup/firstRun.run(db)is invoked. - After
app.listen,cleanupExpiredSessionsis scheduled,bankSyncWorker.start()runs, the backup scheduler is started, andworkers/dailyWorker.start()schedules the daily jobs.
Global middleware order:
securityHeaders- optional
cors(only ifCORS_ORIGIN) express.json({ limit: '100kb' })— import routes override this per-endpointcookieParser()csrfTokenProvidererrorFormatter(patchesres.json)GET /api/health— unauthenticated liveness probe- mounted API routers with route-level rate-limit/auth/CSRF middleware
GET /login.html→ 302/login,express.static(DIST)- SPA fallback
GET *servingdist/index.htmlafter ensuring a CSRF token cookie - final JSON error handler for malformed JSON / body-size / runtime errors
Authentication middleware
middleware/requireAuth.js exports:
requireAuth: attachesreq.userfrombt_session; in single-user mode attaches the configured active regular user without a session.requireUser: permitsuserandadminroles but blocks the default admin account from tracker access.requireAdmin: requiresreq.user.role === 'admin'.
CSRF middleware
middleware/csrf.js:
- Cookie name:
CSRF_COOKIE_NAME || bt_csrf_token. - Header name:
x-csrf-token. - Defaults:
CSRF_HTTP_ONLY === trueonly when explicitly configured,CSRF_SAME_SITE || strict,CSRF_SECURE !== false. - The SPA expects
CSRF_HTTP_ONLY=falsesoclient/api.tscan read the token cookie and sendx-csrf-token; do not enable httpOnly CSRF cookies unless token delivery changes. - The SPA also calls
GET /api/auth/csrf-token(handled byroutes/auth.js) on first request and caches the token in memory; the cookie is the source of truth and the body mirrors it. csrfTokenProvidersets a token cookie on responses.csrfMiddlewarevalidates mutating requests unlessreq.csrfSkipis set. Token may come from header, query, or body and must match cookie.- Failures return 403 and audit
csrf.failure.
Rate limits
Defined in middleware/rateLimiter.js:
- Login: 10 / 15 min.
- Login-by-username (extra layer): separate limiter, applied alongside
loginLimiter. - Password changes: 5 / 15 min.
- Import: 20 / 15 min.
- Export: 30 / 15 min.
- Admin actions: 30 / 15 min.
- OIDC: 20 / 15 min.
- Backup operations: 5 / 60 min.
- Demo-data clear: 3 / 15 min.
A skipRateLimitIfNoUsers(limiter) wrapper is applied to /api/auth/login so a brand-new install with zero users is not locked out by the login limiter.
Rate-limit responses are JSON: { "error": "..." }.
Security headers
middleware/securityHeaders.js sets CSP with a per-request nonce plus common hardening headers. Static SPA scripts/styles must comply with the generated CSP.
Error formatting
middleware/errorFormatter.js standardizes route responses into JSON with error, code, and optional field. Common statuses: 400 validation, 401 auth, 403 forbidden, 404 not found, 409 conflict, 429 rate limit, 500 server error.
4. Services
services/authService.js
- Cookie:
bt_session. - Session lifetime: 7 days. Session tokens are hashed at rest in
sessions(column populated inv0.94); the cookie value is a random opaque ID and is looked up by hash. - Password hashing: bcrypt, cost 12.
- Functions:
login(username, password)— verifies active local user, rejects OIDC-only accounts, cleans expired sessions for that user, creates a new session, updateslast_login_at, and returns{sessionId, user}ornull.createSession(userId)— creates a session for an OIDC-provisioned or existing local user after validating that the user is active.logout(sessionId)— deletes one session.getSessionUser(sessionId)— returns public user fields when the session exists, is unexpired, and belongs to an active user.rotateSessionId(oldSessionId, userId)— validates the current session, deletes it, and inserts a replacement session in a transaction. Password changes use this to prevent session fixation.invalidateOtherSessions(userId, keepSessionId)— deletes all sessions for a user except the supplied current session; whenkeepSessionIdisnull, deletes every session for that user.pruneExpiredSessions()— deletes expired sessions.publicUser(user)— strips password/session secrets and normalizes booleans.
Password changes through /api/auth/change-password update the password hash, clear must_change_password, stamp last_password_change_at, invalidate all other sessions, rotate the current session ID when a valid session cookie is present, set a replacement bt_session cookie, and audit password.change.
/api/auth/logout-all calls invalidateOtherSessions(req.user.id, null), deletes the current cookie session if present, audits logout.all, clears bt_session, and returns {success:true}.
services/totpService.js
TOTP / RFC-6238 authenticator-app 2FA:
newSecret()—otplib20-char base32 secret.generateQrCode(secret, username)— returns{uri, qr_data_url}for the SPA to display.verifyToken(encryptedSecret, token)— decrypts the per-user secret, strips whitespace, callsverifySync.- Setup flow:
getTotpSetup→ user scans QR →enableTotp(plainSecret, token)validates a sample token, encrypts and stores the secret, returns 8 recovery codes. disableTotp(userId)— clearstotp_enabled,totp_secret, recovery codes.- Recovery codes are stored hashed in
users.totp_recovery_codes(JSON). totpStatus(userId)returns{enabled, hasSecret, recoveryCodesRemaining}.- Login flow:
POST /api/auth/totp/challengeaccepts{username, password}and returns a 5-minutechallenge_idstored intotp_challenges; the SPA submits it with the 6-digit code on the next request.
services/webauthnService.js
WebAuthn / FIDO2 security-key 2FA via @simplewebauthn/server:
WEBAUTHN_RP_ID(env, defaultlocalhost) andWEBAUTHN_ORIGIN(env, defaulthttp://localhost:${PORT}).createRegistrationChallenge(db, userId, username)— generates a 32-bytewebauthn_user_id(base64url), excludes already-registered credentials, stores the challenge inwebauthn_challengeswith 15-minute TTL, returns{options, challengeId}.verifyRegistration(db, userId, challengeId, response, credentialName)— verifies the registration, stores the credential (credential_id, public key, sign count, transports, backup flags, AAGUID, friendly name) and returns the credential id.createAuthenticationChallenge(db, userId)— for login: returns assertion options + challengeId.verifyAuthentication(db, userId, challengeId, response)— verifies, updates sign count, auditswebauthn.login.- Supports
attestationType: 'none',residentKey: 'preferred',userVerification: 'preferred', algorithms-7(ES256) and-257(RS256). WEBAUTHN_RP_IDmust match the browser-visible hostname, or registration/assertion will fail.
services/oidcService.js
Handles Authentik/OIDC login with openid-client:
- Reads settings first, then env fallbacks: issuer URL, client ID/secret, redirect URI, token auth method, scopes, provider name, admin group, auto-provision flag.
- The OIDC client secret is encrypted at rest (migration
v0.79) usingservices/encryptionService(HKDF-derived key fromv0.78). - Builds PKCE login state in
oidc_states. - Discovers provider and caches OIDC client for 1 hour.
- Exchanges callback code, verifies nonce/state, maps claims to local user.
- Role mapping uses configured admin group; default role is
user. - Client secret is write-only through admin settings.
services/encryptionService.js
assertEncryptionReady()— throws if no master key is configured.encryptSecret(plain)/decryptSecret(cipher)— symmetric encryption with HKDF-derived key.- The key derivation was upgraded from SHA-256 to HKDF in
v0.78; migrationsv0.77(SMTP password) andv0.79(OIDC secret) wrapped any pre-existing plaintexts. reEncryptWithEnvKey()is invoked by the migration runner when an admin setsENCRYPTION_KEYafter install; called from the runtime error handler when a legacy ciphertext is detected.
services/loginFingerprint.js
- Captures
ip,user_agent,browser,os,device_type,device_fingerprinton every login. - The
ipanduser_agentcolumns are encrypted at rest (migrationv0.84). success(v0.85) tracks failed-attempt history separately from successful logins.- A
session_fingerprintis stored on each successful login so the SPA can detect a different device for the same session and trigger a re-auth challenge.
services/backupService.js
- Backup directory:
BACKUP_PATH || backups/. - Valid backup IDs match managed prefixes only:
bill-tracker-backup,pre-restore,imported-backup,scheduled-backup. - Creates SQLite backups with checksum and metadata.
- Validates uploads by SQLite
integrity_checkand optional SHA-256 checksum. - Restore creates a pre-restore backup before swapping DB.
- Path traversal is prevented by ID regex and
path.relativechecks.
services/backupScheduler.js
- Validates daily/weekly schedule,
HH:MMtime, retention count 1-365. - Stores schedule settings in
settings. - Uses
node-cron, supports run-now, reload, next-run status, and retention cleanup.
services/cleanupService.js
Cleanup tasks:
- Expired import sessions.
- Stale temp SQLite export files in OS temp dir.
- Orphan
.partial/.uploadbackup files. - Optional old import-history pruning.
Settings are stored in settings; run results are stored as JSON.
services/notificationService.js
- Builds SMTP transport from global notification settings (SMTP password is encrypted at rest, migration
v0.77). - Sends test email to an admin-provided address.
- Runs due-bill notifications for 3 days, 1 day, due today, and overdue.
- De-duplicates sends via
notificationsunique key. - Recipients come from user notification settings when enabled/allowed or global recipient settings.
- Push notifications: ntfy, Gotify, Discord webhook, Telegram bot — configured per-user in
userscolumns (migrationv0.80); dispatched alongside or in place of SMTP when configured.
services/spreadsheetImportService.js
- Accepts XLSX buffers only, max 10 MB, max 5,000 rows.
- Detects monthly sheets, headers, categories, bills, amounts, and ambiguous rows.
- Creates
import_sessionspreview records with 24-hour TTL. - Apply step creates/updates user-owned categories, bills, payments, monthly state, and import history.
services/ofxImportService.js
- OFX / QFX bank-statement import (separate from CSV/Excel). Uses
node-ofx-parser; falls back to manual buffer parsing when the package is not installed. - Produces
import_sessionspreviews and commits totransactionswithsource_type='file_import'. - 24-hour TTL on preview sessions; results write to
import_history.
services/userDbImportService.js
- Accepts user SQLite export files up to 50 MB.
- Requires export metadata and known tables.
- Sanitizes categories, bills, payments, monthly state, and starting amounts.
- Preview stores an import session; apply maps export IDs to current user-owned IDs.
services/transactionService.js
Transaction data source and transaction row helpers:
ensureManualDataSource(db, userId)creates/retrieves a user-specific manual data source (type='manual', provider='manual', name='Manual Entry').decorateDataSource(row)removesencrypted_secret, addssource_labelandsource_type_label.decorateTransaction(row)addssource_label,source_type_label, and embeddeddata_sourceobject with safe fields.getSourceTypeLabel(type)returns labels:manual→ Manual,file_import→ File import,provider_sync→ Provider sync.sourceLabel(source)constructs human-readable source labels for manual entries or provider names.transactionsschema carries apendingflag (v1.01) for SimpleFIN pending transactions.
services/transactionMatchService.js + services/transactionMatchState.js
matchTransactionToBill(transactionId, billId, userId)— links a transaction to a bill, creates a correspondingpaymentsrow withpayment_source='provider_sync', runs accounting override logic.unmatchTransaction(transactionId, userId)— reverses the link and deletes the auto-created payment (or marks itaccounting_excluded).ignoreTransaction/unignoreTransaction— set/match-status transitions.transactionMatchState.jsis the state-machine helper that powers match/unmatch/ignore/unignore.
services/matchSuggestionService.js
listMatchSuggestions(userId, transactionId, billId, limit, offset)— returns scored suggestions.rejectMatchSuggestion(userId, transactionId, billId)— records the rejection inmatch_suggestion_rejections.suggestionCounts(userId)— dashboard counts.- Rejections are stored in
match_suggestion_rejections(created inv0.62) so a transaction/bill pair is never re-suggested after rejection. - After sync,
autoMatchForUser(userId)runs merchant-rule + merchant-store-match + match-suggestion passes in order.
services/csvTransactionImportService.js
CSV import workflow for transactions:
- Parses CSV with quoted-field support and quote doubling.
previewCsvTransactions(userId, buffer, options)returns headers, sample rows, suggested field mapping, errors, and creates a 24-hour TTL import session (max 25k rows).suggestMapping(headers)auto-detects field mappings from header names againstposted_date,transacted_at,amount,description,payee,memo,category,account,transaction_type,currency, etc.commitCsvTransactions(userId, importSessionId, mapping)imports rows into thetransactionstable withsource_type='file_import', auto-creates a CSV data source and financial accounts per unique account name.- Stable deduplication via SHA-256 hash:
csv:id:prefix for explicit transaction IDs,csv:hash:prefix from date+amount+description+payee+account. - Imports record to
import_historywith counts and details. FIELD_LABELSmaps field keys to user-friendly labels for validation messages.
services/bankSyncService.js + services/simplefinService.js + services/bankSyncConfigService.js + services/bankSyncWorker.js
- SimpleFIN bridge (
simplefinService.js): wraps the SimpleFIN protocol —claimSetupToken,fetchAccountsAndTransactions,normalizeAccount,normalizeTransaction,sanitizeErrorMessage.assertEncryptionReadyis invoked before any secret operation. - Per-user sync (
bankSyncService.js): upsertsfinancial_accountsrows keyed by(data_source_id, provider_account_id); upsertstransactionskeyed by(data_source_id, provider_transaction_id). Honors themonitoredflag (v0.64) so the user can exclude a credit-card account from bill matching. Applies merchant rules, spending-category rules, merchant-store matches, thenautoMatchForUserin sequence. - Config (
bankSyncConfigService.js):getBankSyncConfig(userId),SYNC_DAYS_EFFECTIVE,SYNC_DAYS_DEFAULT. Per-user default-lookback window. - Worker (
bankSyncWorker.js): scheduled background sync, started fromserver.jsafterapp.listen.bankSyncConfigServiceadmin endpoints live at/api/admin/bank-sync-config.
services/billMerchantRuleService.js
- Persistent merchant→bill auto-match rules (table created in
v0.67). applyMerchantRules(transactions, userId)— returns transactions withmatched_bill_idandmatch_status='matched'filled in.auto_attribute_late(v0.83) marks rules whose bills always post after month-end, so amount-suggestion math skips the “looks like last month’s bill” signal.
services/merchantStoreMatchService.js
- 5k merchant/store matching pack seeded by migration
v1.05. applyMerchantStoreMatches(transactions, userId)— broad merchant-to-store normalization for spending categorization.
services/advisoryFilterService.js
- 5k advisory non-bill patterns + bill-like override terms seeded by migration
v0.68. - Used to suppress “this looks like a bill but isn’t” false positives in tracker suggestions.
services/subscriptionService.js + services/amountSuggestionService.js
subscriptionService.js— manages the user’s subscription bills, recommendations, declined hints (v0.66), feedback (v0.97), and the top-200 catalog seeding. ExposesnormalizeMerchantused by migrationv0.90to re-normalize stored rules.amountSuggestionService.js—computeAmountSuggestion(per-bill) andcomputeAmountSuggestionsBatch(all-bills, used by the Tracker P1 fix to kill the N+1).- The batched suggestion is byte-identical to the per-bill function; pinned by
tests/amountSuggestionService.test.js.
services/snowballService.js + services/calendarFeedService.js + services/driftService.js + services/statusRuntime.js
snowballService.js— Debt snowball/avalanche calculations, Ramsey mode support, order updates, plan lifecycle math.calendarFeedService.js— generates iCalendar (.ics) text for upcoming bills, gated bycalendar_tokens(v1.00).driftService.js— detects when an autopay/posted amount drifts from the bill’s expected amount; surfaces the “drift_snoozed_until” field (v0.71).statusRuntime.js—recordError(err, req), runtime worker state, health JSON consumed by/api/status.
services/spendingService.js + services/userSettings.js + services/userDataService.js + services/eraseUserData.js (in utils/tests) + services/billsService.js + services/aprService.js
spendingService.js— spending categories, rules (v0.87), budgets, default category seeding, income breakdown,applySpendingCategoryRulesused by the bank-sync pipeline.userSettings.js—getUserSettings(userId)returns a typed settings object; consumed by the bank-sync pipeline.userDataService.js— user-scoped read helpers (e.g.getCategoriesForUser).billsService.js— bill CRUD helpers, soft-delete +RecentlyDeletedBillsrestore support.aprService.js— annual-percentage-rate / monthly interest accrual used byv0.93(interest_accrued_month,interest_delta).
services/paymentAccountingService.js + services/paymentValidation.js
paymentAccountingService.js— source-of-truth for “manual vs. bank” payments. The bank-backed predicate, the accounting-active SQL fragment, and the core invariant: a bank payment overrides a provisional manual payment so it isn’t double-counted, restores the balance, and the manual payment counts again with its balance re-applied when the override is removed. Pinned bytests/paymentAccountingService.test.js.paymentValidation.js—PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync', 'transaction_match'](auto_match was normalized to provider_sync inv0.82),validatePaymentSource(),validatePaymentInput().
services/auditService.js
Writes audit_log rows for security-sensitive events such as login, logout, password changes, role changes, CSRF failures, user seed flag resets, migration runs, migration rollback attempts, WebAuthn/TOTP events, bank-sync admin changes, and 2FA enable/disable.
db/database.js does not import logAudit at module load time. It uses a lazy getLogAudit() helper so migration code can write audit rows without creating a circular dependency: auditService imports database.js, and database.js needs audit logging during migrations. If the lazy require fails, the helper degrades to a no-op audit function while console logging still records migration progress/errors.
5. API Reference
All routes are prefixed with /api unless stated otherwise. Most mutating endpoints require CSRF token header x-csrf-token. Auth is cookie-based (bt_session). User routes are scoped to the authenticated user unless noted. GET /api/health is the unauthenticated liveness probe and is the only public non-meta endpoint.
Response conventions:
- Success: JSON object/array unless endpoint downloads a file.
- Validation:
400 {error, code?, field?}. - Unauthenticated:
401. - Forbidden:
403. - Missing resource:
404. - Conflict:
409(also used forDUPLICATE_SUSPECTEDonPOST /payments— see §5.5). - Rate-limited:
429. - Server error (Track D): the global 500 handler in
server.jsemits{ error: 'Internal server error', code: 'INTERNAL_ERROR' }so an unexpected server error carries the same{error, code}field the client can switch on as every structured 4xx.
5.1 Auth (/api/auth)
POST /auth/login—{username, password}. Setsbt_session. Rate-limited (skipped if zero users). Skips CSRF (no session yet).POST /auth/logout— clearsbt_session.POST /auth/logout-all— invalidates every session for the user.GET /auth/me— public user object (single-user mode supplies user without session).GET /auth/mode— auth mode, local-login flag, OIDC public info, single-user status.POST /auth/restore-multi-user-mode— restores multi-user mode where allowed.POST /auth/acknowledge-privacy— updates first-login/privacy acknowledgement flags.POST /auth/acknowledge-version— stampsusers.last_seen_version.POST /auth/change-password—{current_password, new_password}. Password-limited, CSRF-skip. Rotates session id, invalidates others.GET /auth/csrf-token— returns{token}mirroring the cookie; SPA caches in memory.GET /auth/has-users— whether non-default users exist.GET /auth/users— admin-only safe user list.POST /auth/users— admin-only create.GET /auth/login-history— recent login/failed-login history for the current user (encrypted columns decrypted in-process).GET /auth/webauthn/status—{enabled, credentials:[{id, name, createdAt, lastUsedAt}]}.- TOTP (
/api/auth/totp):POST /auth/totp/challenge—{username, password}→{challenge_id}(5-min TTL).GET /auth/totp/setup— admin-aware: returns{secret, uri, qr_data_url}for a new enrolment.POST /auth/totp/enable—{secret, token}→ verifies, encrypts, stores, returns recovery codes.POST /auth/totp/disable— clears secret + recovery codes.GET /auth/totp/status—{enabled, hasSecret, recoveryCodesRemaining}.
5.2 OIDC Auth (/api/auth/oidc)
OIDC rate limiter applies.
GET /auth/oidc/login?redirect_to=/path— public when OIDC active; builds PKCE state and redirects.GET /auth/oidc/callback?code=&state=— validates state/nonce, exchanges code, provisions/fetches user, creates session, redirects to saved path or app root. Error redirects use query errors such asaccess_deniedorauthentication_failed.
5.3 Admin (/api/admin)
Mounted under /api/admin; all require requireAuth + requireAdmin + adminActionLimiter. Backup subroutes also use backupOperationLimiter.
GET /admin/has-users—{has_users:boolean}.GET /admin/users— safe users ordered by default-admin / role / username.POST /admin/users—{username, password}.PUT /admin/users/:id/password—{password}. Invalidates target sessions; requires password change.PUT /admin/users/:id/role—{role:"admin"|"user"}. Cannot change own role; cannot remove last admin.PUT /admin/users/:id/active—{active:boolean}. Cannot deactivate self.PUT /admin/users/:id/username— change a user’s username (admin only).DELETE /admin/users/:id— transaction deletes import sessions/history, sessions, and the user.- Backups:
GET/POST /admin/backups,GET /admin/backups/settings,PUT /admin/backups/settings,POST /admin/backups/run-scheduled-now,POST /admin/backups/import(raw SQLite, max 100 MB, optionalX-Checksum-Sha256),GET /admin/backups/:id/download,POST /admin/backups/:id/restore,DELETE /admin/backups/:id. - Cleanup:
GET/PUT /admin/cleanup,POST /admin/cleanup/run. - Auth mode:
GET /admin/auth-mode,POST /admin/auth-mode/oidc-test,PUT /admin/auth-mode. - Bank sync config:
GET /admin/bank-sync-config,PUT /admin/bank-sync-config(default-lookback days, monitored-account default, etc.). - Migrations:
POST /admin/migrations/rollbackwith{version}. MapsNOT_APPLIED→ 404,ROLLBACK_NOT_SUPPORTED→ 422, other failures → 500.
5.4 Bills (/api/bills)
User/admin tracker access. Standard bill fields plus cycle_type, cycle_day, is_subscription, subscription_type, catalog_id, autopay_verified_at, inactive_reason, drift_snoozed_until, sort_order, interest_accrued_month, current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt, auto_mark_paid, deleted_at.
Endpoints (subset): GET /bills?inactive=true, GET /bills/:id, POST /bills, PUT /bills/:id, DELETE /bills/:id (hard delete; cascades payments, monthly state, history ranges, merchant rules; returns warning), GET/PUT /bills/:id/monthly-state?year=&month=, GET /bills/:id/payments, POST /bills/:id/toggle-paid, GET/POST/PUT/DELETE /bills/:id/history-ranges, GET /bills/recently-deleted (admin), POST /bills/:id/restore.
5.5 Payments (/api/payments)
User/admin tracker access. Soft delete via deleted_at. payment_source ∈ {manual, file_import, provider_sync, transaction_match}. Hard-dedupe unique index on transaction_id (v0.61).
Endpoints: GET /payments?bill_id=&year=&month=, GET /payments/:id, POST /payments, POST /payments/quick, POST /payments/bulk (max 50), PUT /payments/:id, DELETE /payments/:id, POST /payments/:id/restore.
Track A money-integrity notes:
POST /paymentsbody now accepts an optionalallow_duplicate: trueflag. When omitted and a live payment already matches the same(bill_id, paid_date, amount), the route returns409 { error, code: 'DUPLICATE_SUSPECTED' }instead of silently deduping — silently dropping a legitimately-identical second payment would be a money-integrity bug. The client usesclient/lib/paymentActions.ts → createPaymentOrConfirmto surface a sonner "Add anyway?" toast; confirming resends withallow_duplicate: true.- Every balance-mutation runs in a single transaction so a partial failure cannot leave
bills.current_balancehalf-updated relative to the new payment. - New tests:
tests/paymentsRoute.test.js(route + transactional behavior) andtests/reconciliation.test.js(cross-surface reconciliation harness).
5.6 Categories (/api/categories)
User/admin tracker access. Categories have a spending_enabled flag (v0.88) and a sort_order (v0.75). Categories can be grouped via category_groups (v1.06).
Endpoints: GET /categories (seeds defaults, includes spending_enabled + counts), POST /categories, PUT /categories/:id, DELETE /categories/:id, plus category-group endpoints under /api/categories/groups (list/create/update/delete/reorder).
5.7 Tracker and Calendar (/api/tracker, /api/calendar)
GET /tracker?year=&month=— returnsyear,month, trackerrows, totals, starting-amount info, previous-month paid total, three-month averages/trends, drift / autopay / amount-suggestion objects, safe-to-spend (cashflow timeline),generated_at.GET /tracker/upcoming?days=30— upcoming active bills.GET /tracker/health— dedicated health endpoint for the home page (subset of/tracker).GET /calendar?year=&month=— month days with payment entries, bills/due-status entries, totals.GET /calendar/feed.ics?token=...— public iCalendar feed of upcoming bills, gated bycalendar_tokens(v1.00).
5.8 Summary and Starting Amounts (/api/summary, /api/monthly-starting-amounts)
GET /summary?year=&month=— returns{year, month, income, expenses, starting_amounts, previous_month, summary, chart, bank_tracking, generated_at}.PUT /summary/income—{year, month, amount, label?}.GET /monthly-starting-amounts?year=&month=/PUT /monthly-starting-amounts— bucket response.
5.9 Snowball (/api/snowball)
GET /snowball— current user's debt bills pre-sorted bysnowball_order.GET /snowball/settings/PATCH /snowball/settings— extra payment, ramsey mode, ready-current, ready-emergency-fund.GET /snowball/projection—{snowball, avalanche, minimum_only, comparison}with enriched debt arrays (APR snapshots, interest-accrual month).PATCH /snowball/order—[{id, snowball_order}]batch update.- Plans (lifecycle,
v0.73):POST /snowball/plans,GET /snowball/plans,GET /snowball/plans/active,PATCH /snowball/plans/:id,POST /snowball/plans/:id/pause|resume|complete|abandon.
5.10 Spending (/api/spending)
GET /spending/summary?year=&month=— monthly spending summary, by category / by bill.GET /spending/transactions— paginated, filterable.PATCH /spending/transactions/:id/category— manual reassignment.GET /spending/budgets/PUT /spending/budgets/POST /spending/budgets/copy— monthly budgets with month-rollover.GET /spending/category-rules/POST /spending/category-rules/DELETE /spending/category-rules/:id— automation rules seeded inv0.87.GET /spending/income— per-user monthly income.
5.11 Subscriptions (/api/subscriptions)
GET /subscriptions— user’s subscription bills.GET /subscriptions/recommendations— bank-derived candidates (declined hints suppressed).POST /subscriptions/recommendations/decline— record a decline.POST /subscriptions/recommendations/match-bill— link a recommendation to an existing bill.POST /subscriptions/recommendations/create— create a new bill from a recommendation.GET /subscriptions/transaction-matches— candidate transactions for the user’s subscriptions.GET /subscriptions/catalog— top-200 catalog with bank descriptors.PUT /subscriptions/:id/catalog-link/POST /subscriptions/catalog/:catalogId/descriptors/DELETE /subscriptions/catalog/descriptors/:id— manage custom bank descriptors.PATCH /subscriptions/:id— update subscription fields (price, cycle, descriptor, etc.).
5.12 Analytics (/api/analytics)
GET /analytics/summary?year=&month=&months=&category_id=&bill_id=&include_inactive=&include_skipped=— monthly spending, expected vs actual, category totals, bill totals, filters,generated_at.
5.13 Settings (/api/settings)
GET /settings/PUT /settings— user-visible settings.POST /settings/seed-demo-data— seed demo data.
5.14 Notifications (/api/notifications)
Mounted under /api/notifications; requireAuth at mount.
GET/PUT /notifications/admin— SMTP + push (ntfy / Gotify / Discord / Telegram) global settings.POST /notifications/test—{to}.GET/PUT /notifications/me— per-user notification email, push channels, lead times.
5.15 Profile (/api/profile)
GET /profile— safe profile, notification settings, export URLs, import-history URL.PATCH /profile—{display_name}.GET/PATCH /profile/settings— per-user notification prefs.POST /profile/change-password— rotates session, invalidates others.GET /profile/exports/GET /profile/import-history.
5.16 User Demo Data (/api/user)
POST /user/seed-demo-data,POST /user/clear-demo-data(rate-limited; deletesis_seeded=1bills/categories for current user).
5.17 Import (/api/import, /api/imports)
requireAuth + requireUser + importLimiter at mount. Both prefixes are accepted.
POST /import/spreadsheet/preview(XLSX, max 10 MB, optionalparse_all_sheets,year,month).POST /import/spreadsheet/apply.POST /import/user-db/preview(SQLite, max 50 MB) /apply.POST /import/csv/preview/POST /import/csv/commit.POST /import/ofx/preview/POST /import/ofx/commit(OFX/QFX bank statements).GET /import/history.
5.18 Data Sources (/api/data-sources)
GET /data-sources?type=&status=—type∈manual, file_import, provider_sync;status∈active, inactive, error. Returns sources withaccount_count,transaction_count, sync info.
5.19 Transactions (/api/transactions)
GET /transactions?limit=&offset=&match_status=&ignored=&source_type=&start_date=&end_date=&q=&data_source_id=&account_id=&matched_bill_id=. Free-text search across description/payee/memo/category.POST /transactions/manual— manual entry, returns created row withsource_type='manual'.PUT /transactions/:id— partial update.DELETE /transactions/:id— soft-unmatch then hard delete.POST /transactions/:id/match/unmatch/ignore/unignore.- The
pendingflag (v1.01) marks SimpleFIN pending transactions separately from posted ones.
5.20 Match Suggestions (/api/matches)
GET /matches/suggestions?transaction_id=&bill_id=&limit=&offset=.POST /matches/:id/reject— records rejection so the pair is never re-suggested.
5.21 Export (/api/export)
GET /export?year=YYYY&format=csv|xlsx— payment/bill history for the year.GET /export/user-excel— user workbook.GET /export/user-db— portable SQLite with metadata.
5.22 Status, About, Version, Privacy, Health
GET /status— admin-only: app version, uptime, runtime worker state, DB health, SMTP/push config status, backup status, current-month tracker health, recent errors.GET /about— public;{version, about}.GET /about-admin— admin-only, file allowlistFUTURE.md/DEVELOPMENT_LOG.md.GET /version— public; package version + latest structured notes fromHISTORY.md.GET /version/history— raw history text.GET /version/update-status— async, checks upstream for new releases (usesservices/updateCheckService).GET /privacy— public privacy text.GET /health— public liveness probe{ok:true}.
6. Database Reference
SQLite uses WAL mode and foreign keys. Base schema is in db/schema.sql; db/database.js applies migrations to reach the current schema.
Tables and columns
users
id INTEGER PRIMARY KEYusername TEXT NOT NULL UNIQUE COLLATE NOCASEpassword_hash TEXT NOT NULLrole TEXT NOT NULL DEFAULT 'user'(adminoruser)must_change_password INTEGER NOT NULL DEFAULT 0first_login INTEGER NOT NULL DEFAULT 1created_at TEXT DEFAULT datetime('now')updated_at TEXT DEFAULT datetime('now')notification_email TEXTnotifications_enabled INTEGER NOT NULL DEFAULT 0notify_3d INTEGER NOT NULL DEFAULT 1notify_1d INTEGER NOT NULL DEFAULT 1notify_due INTEGER NOT NULL DEFAULT 1notify_overdue INTEGER NOT NULL DEFAULT 1display_name TEXTlast_password_change_at TEXTauth_provider TEXT NOT NULL DEFAULT 'local'external_subject TEXTemail TEXTlast_login_at TEXTactive INTEGER NOT NULL DEFAULT 1is_default_admin INTEGER NOT NULL DEFAULT 0snowball_extra_payment REAL(v0.48)last_seen_version TEXT(v0.50) — release-notes notification stampwebauthn_user_id TEXT(v0.92);webauthn_enabled INTEGER NOT NULL DEFAULT 0(v0.92)totp_enabled INTEGER NOT NULL DEFAULT 0(v0.86);totp_secret(encrypted,v0.86);totp_recovery_codes(JSON array of hashes,v0.86)- Push notification columns (
v0.80): ntfy / Gotify / Discord / Telegram (topic + URL + enabled flag each) geolocation_enabled INTEGER NOT NULL DEFAULT 0(v1.02; was a global admin setting)
sessions
id TEXT PRIMARY KEY— session token hash, not the raw cookie value (v0.94). The cookie carries a random opaque ID; the DB stores its SHA-256 so a leaked DB does not yield usable session tokens.user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEexpires_at TEXT NOT NULLcreated_at TEXT DEFAULT datetime('now')(v0.43)
webauthn_credentials (v0.92)
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEcredential_id TEXT NOT NULL UNIQUEpublic_key TEXT NOT NULLsign_count INTEGER NOT NULL DEFAULT 0transports TEXT(JSON)backup_eligible INTEGER NOT NULL DEFAULT 0backup_state INTEGER NOT NULL DEFAULT 0credential_name TEXTaaguid TEXTlast_used_at TEXTcreated_at TEXT NOT NULL DEFAULT (datetime('now'))
webauthn_challenges (v0.92)
id TEXT PRIMARY KEYuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEchallenge_type TEXT NOT NULL CHECK ('registration' OR 'authentication')challenge TEXT NOT NULLexpires_at TEXT NOT NULL(15-min TTL)
totp_challenges (v0.86)
id TEXT PRIMARY KEYuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEchallenge TEXT NOT NULLexpires_at TEXT NOT NULL(5-min TTL)
categories
id INTEGER PRIMARY KEYuser_id INTEGER REFERENCES users(id) ON DELETE CASCADEname TEXT NOT NULLcreated_at TEXT DEFAULT datetime('now')updated_at TEXT DEFAULT datetime('now')is_seeded INTEGER NOT NULL DEFAULT 0deleted_at TEXT(v0.54)sort_order INTEGER(v0.75) — persistent orderspending_enabled INTEGER NOT NULL DEFAULT 0(v0.88) — separates bill vs spending categoriesgroup_id INTEGER REFERENCES category_groups(id) ON DELETE SET NULL(v1.06)
category_groups (v1.06)
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEname TEXT NOT NULLsort_order INTEGERcreated_at,updated_at- Unique:
(user_id, name)
bills
id INTEGER PRIMARY KEYuser_id INTEGER REFERENCES users(id) ON DELETE CASCADEname TEXT NOT NULLcategory_id INTEGER REFERENCES categories(id) ON DELETE SET NULLdue_day INTEGER NOT NULL CHECK 1-31override_due_date TEXTbucket TEXT CHECK ('1st','15th')expected_amount REAL NOT NULL DEFAULT 0(dollars; converted to integer cents byv1.03)interest_rate REAL CHECK null or 0-100billing_cycle TEXT DEFAULT 'monthly' CHECK ('monthly','quarterly','annually','irregular')(canonicalized inv0.76)autopay_enabled INTEGER NOT NULL DEFAULT 0autodraft_status TEXT NOT NULL DEFAULT 'none' CHECK ('none','pending','assumed_paid','confirmed')website TEXTusername TEXTaccount_info TEXThas_2fa INTEGER NOT NULL DEFAULT 0active INTEGER NOT NULL DEFAULT 1notes TEXTcreated_at TEXT DEFAULT datetime('now')updated_at TEXT DEFAULT datetime('now')history_visibility TEXT NOT NULL DEFAULT 'default'is_seeded INTEGER NOT NULL DEFAULT 0cycle_type TEXT NOT NULL DEFAULT 'monthly'cycle_day TEXTcurrent_balance REAL(dollars; converted to integer cents byv1.03)minimum_payment REAL(dollars; converted to integer cents byv1.03)snowball_order INTEGERsnowball_include INTEGER NOT NULL DEFAULT 0snowball_exempt INTEGER NOT NULL DEFAULT 0auto_mark_paid INTEGER NOT NULL DEFAULT 0deleted_at TEXTis_subscription INTEGER NOT NULL DEFAULT 0,subscription_type TEXT(v0.63)interest_accrued_month TEXT(v0.93) — calendar month of last applied interestdrift_snoozed_until TEXT(v0.71) — suppresses drift notifications until this datesort_order INTEGER(v0.72) — persistent tracker ordercatalog_id INTEGER REFERENCES subscription_catalog(id) ON DELETE SET NULL(v0.96)autopay_verified_at TEXT,inactive_reason TEXT(v0.99) — autopay trust / lifecycle fields
payments
id INTEGER PRIMARY KEYbill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADEamount REAL NOT NULL(dollars; converted to integer cents byv1.03)paid_date TEXT NOT NULLmethod TEXTnotes TEXTbalance_delta REAL(dollars; converted to integer cents byv1.03;v0.49)payment_source TEXT NOT NULL DEFAULT 'manual'(v0.59;auto_matchnormalized toprovider_syncinv0.82)transaction_id INTEGER(v0.59; unique-per-active index added inv0.61)interest_delta REAL(dollars; converted to integer cents byv1.03;v0.93)accounting_excluded INTEGER NOT NULL DEFAULT 0(v0.98) — bank-override metadata for provisional manual paymentscreated_at TEXT DEFAULT datetime('now')updated_at TEXT DEFAULT datetime('now')deleted_at TEXT
monthly_bill_state
id INTEGER PRIMARY KEYbill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADEyear INTEGER NOT NULL CHECK 2000-2100month INTEGER NOT NULL CHECK 1-12actual_amount REAL(dollars; converted to integer cents byv1.03)notes TEXTis_skipped INTEGER NOT NULL DEFAULT 0snoozed_until TEXT(v0.70) — overdue command-center snoozecreated_at TEXT DEFAULT datetime('now')updated_at TEXT DEFAULT datetime('now')- Unique:
(bill_id, year, month)
monthly_income
id INTEGER PRIMARY KEYuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEyear INTEGER NOT NULLmonth INTEGER NOT NULLlabel TEXT NOT NULL DEFAULT 'Salary'amount REAL NOT NULL DEFAULT 0(dollars; converted to integer cents byv1.03)created_at TEXT DEFAULT datetime('now')updated_at TEXT DEFAULT datetime('now')- Unique:
(user_id, year, month)
monthly_starting_amounts
id INTEGER PRIMARY KEYuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEyear INTEGER NOT NULLmonth INTEGER NOT NULLfirst_amount REAL NOT NULL DEFAULT 0fifteenth_amount REAL NOT NULL DEFAULT 0other_amount REAL NOT NULL DEFAULT 0notes TEXTcreated_at TEXT DEFAULT datetime('now')updated_at TEXT DEFAULT datetime('now')- Unique:
(user_id, year, month)
bill_history_ranges
id INTEGER PRIMARY KEYbill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADEstart_year INTEGER NOT NULLstart_month INTEGER NOT NULLend_year INTEGERend_month INTEGERlabel TEXTdescription TEXT NOT NULLapplied_at TEXT NOT NULL DEFAULT datetime('now')
data_sources
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEtype TEXT NOT NULL(manual,file_import,provider_sync)provider TEXTname TEXT NOT NULLstatus TEXT NOT NULL DEFAULT 'active'(active,inactive,error)config_json TEXTencrypted_secret TEXTlast_sync_at TEXTlast_error TEXTcreated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMPupdated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP- Unique partial index:
(user_id, type, provider)WHEREtype='manual' AND provider='manual'
financial_accounts
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEdata_source_id INTEGER REFERENCES data_sources(id) ON DELETE SET NULLprovider_account_id TEXTname TEXT NOT NULLorg_name TEXTaccount_type TEXTcurrency TEXTbalance INTEGERavailable_balance INTEGERraw_data TEXTcreated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMPupdated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP- Unique:
(data_source_id, provider_account_id)
transactions
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEdata_source_id INTEGER REFERENCES data_sources(id) ON DELETE SET NULLaccount_id INTEGER REFERENCES financial_accounts(id) ON DELETE SET NULLprovider_transaction_id TEXTsource_type TEXT NOT NULL(manual,file_import,provider_sync)transaction_type TEXTposted_date TEXTtransacted_at TEXTamount INTEGER NOT NULLcurrency TEXTdescription TEXTpayee TEXTmemo TEXTcategory TEXTraw_data TEXTmatched_bill_id INTEGER REFERENCES bills(id) ON DELETE SET NULLmatch_status TEXT NOT NULL DEFAULT 'unmatched'(unmatched,matched,ignored)ignored INTEGER NOT NULL DEFAULT 0spending_category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL(v0.87)pending INTEGER NOT NULL DEFAULT 0(v1.01) — SimpleFIN pending transactionscreated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMPupdated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP- Unique partial index:
(data_source_id, provider_transaction_id)WHEREprovider_transaction_id IS NOT NULL - Indexes:
(user_id, posted_date, transacted_at),(user_id, match_status, ignored),(account_id),(matched_bill_id)
Indexes
Important indexes include:
idx_bills_active(active)idx_bills_user_active(user_id, active)idx_bills_user_name(user_id, name)idx_bills_user_deleted(user_id, deleted_at)(v0.91)idx_bills_user_sort(user_id, active, sort_order, due_day)(v0.72)idx_categories_user_name(user_id, name)idx_categories_user_name_unique(user_id, name COLLATE NOCASE)idx_categories_user_deleted(user_id, deleted_at)(v0.91)idx_categories_user_sort(user_id, sort_order, name)(v0.75)idx_payments_bill_id(bill_id)idx_payments_paid_date(paid_date)idx_payments_bill_date_del(bill_id, paid_date, deleted_at)idx_payments_deleted(deleted_at)idx_payments_method(method)idx_payments_transaction_active— unique-per-active ontransaction_id(v0.61)idx_sessions_user_id(user_id)idx_sessions_expires(expires_at)idx_monthly_bill_state_lookup(bill_id, year, month)idx_monthly_income_user_month(user_id, year, month)idx_monthly_starting_amounts_user(user_id)idx_monthly_starting_amounts_user_month(user_id, year, month)idx_notifications_lookup(bill_id, user_id, year, month)idx_import_sessions_user(user_id),idx_import_sessions_expires(expires_at)idx_import_history_user(user_id),idx_import_history_imported_at(imported_at)idx_oidc_states_expires(expires_at)idx_bill_history_ranges_bill(bill_id)idx_audit_log_user(user_id, created_at),idx_audit_log_action(action, created_at)idx_data_sources_user_type(user_id, type, status)idx_data_sources_user_manual(user_id, type, provider)WHEREtype='manual' AND provider='manual'idx_financial_accounts_user_source(user_id, data_source_id)idx_transactions_user_date(user_id, posted_date, transacted_at)idx_transactions_user_match(user_id, match_status, ignored)idx_transactions_account(account_id)idx_transactions_matched_bill(matched_bill_id)idx_transactions_provider_dedupe(data_source_id, provider_transaction_id)WHEREprovider_transaction_id IS NOT NULLidx_bill_merchant_rules_user_bill(user_id, bill_id)(v0.81)
import_sessions
id TEXT PRIMARY KEYuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEcreated_at TEXT NOT NULLexpires_at TEXT NOT NULLpreview_json TEXT NOT NULL
import_history
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEimported_at TEXT NOT NULLsource_filename TEXTfile_type TEXT DEFAULT 'csv_transactions'rows_parsed INTEGER DEFAULT 0rows_created INTEGER DEFAULT 0rows_updated INTEGER DEFAULT 0rows_skipped INTEGER DEFAULT 0rows_errored INTEGER DEFAULT 0options_json TEXTsummary_json TEXTcreated_at TEXT DEFAULT (datetime('now'))
autopay_suggestion_dismissals
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEbill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADEyear INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100)month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12)dismissed_at TEXT NOT NULL DEFAULT (datetime('now'))- Unique:
(user_id, bill_id, year, month)
bill_templates
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEname TEXT NOT NULLdata TEXT NOT NULLcreated_at TEXT DEFAULT (datetime('now'))updated_at TEXT DEFAULT (datetime('now'))- Unique:
(user_id, name)
match_suggestion_rejections
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEtransaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADEbill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADErejected_at TEXT NOT NULL DEFAULT (datetime('now'))- Unique:
(user_id, transaction_id, bill_id)
user_login_history
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADElogged_in_at TEXT NOT NULL DEFAULT (datetime('now'))ip_address TEXT(encrypted at rest,v0.84)user_agent TEXT(encrypted at rest,v0.84)browser TEXTos TEXTdevice_type TEXTdevice_fingerprint TEXTlocation TEXT(v0.84) — IP-geolocation city/region/country when geolocation opt-in is onsuccess INTEGER NOT NULL DEFAULT 1(v0.85) — failed attempts when 0session_fingerprint TEXT(v0.85) — for current-session detection
subscription_catalog (v0.65, expanded v0.69, v0.95)
id INTEGER PRIMARY KEY AUTOINCREMENTname TEXT NOT NULLcategory TEXTsubcategory TEXT(v0.95)starting_monthly_usd REAL(v0.95)bank_descriptors TEXT(JSON array; populated byv0.95from a 2026 researched dataset)created_at,updated_at
declined_subscription_hints (v0.66)
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEdescriptor TEXT NOT NULLsource TEXTdeclined_at TEXT NOT NULL DEFAULT (datetime('now'))- Unique:
(user_id, descriptor, source)
bill_merchant_rules (v0.67)
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEbill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADEnormalized_merchant TEXT NOT NULLmatch_mode TEXT NOT NULL DEFAULT 'contains'(e.g.contains,equals,regex)priority INTEGER NOT NULL DEFAULT 0auto_attribute_late INTEGER NOT NULL DEFAULT 0(v0.83)created_at,updated_at- Composite index
(user_id, bill_id)for fastEXISTSlookups (v0.81)
user_catalog_descriptors (v0.96)
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEcatalog_id INTEGER NOT NULL REFERENCES subscription_catalog(id) ON DELETE CASCADEdescriptor TEXT NOT NULLcreated_at TEXT NOT NULL DEFAULT (datetime('now'))
subscription_recommendation_feedback (v0.97)
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADErecommendation_key TEXT NOT NULLverdict TEXT NOT NULL(accept|decline|mute)created_at TEXT NOT NULL DEFAULT (datetime('now'))
snowball_plans (v0.73)
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEname TEXT NOT NULLstrategy TEXT NOT NULL(snowball|avalanche|minimum_only)extra_payment REAL NOT NULL DEFAULT 0status TEXT NOT NULL DEFAULT 'active'(active|paused|completed|abandoned)created_at,updated_at,completed_at- Lifecycle transitions are recorded with audit + plan-row updates;
pause/resumeflipstatus.
calendar_tokens (v1.00)
id INTEGER PRIMARY KEY AUTOINCREMENTuser_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADEtoken TEXT NOT NULL UNIQUE(random opaque)label TEXTcreated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMPlast_used_at TEXTrevoked_at TEXT
spending_rules / spending_budgets (v0.87)
spending_rules: per-user regex/keyword rules mapping a transaction pattern to aspending_category_id. Auto-applied during bank sync byapplySpendingCategoryRules.spending_budgets: per-user monthly budget rows; month-rollover uses the copy endpoint.
merchant_store_matches (seeded by v1.05)
- Static 5k merchant/store matching pack; used by
applyMerchantStoreMatchesduring bank sync to normalize merchant strings.
advisory_non_bill_filters (seeded by v0.68)
- 5k advisory non-bill patterns + bill-like override terms.
Migration system
db/database.js:
- Reads
db/schema.sqlininitSchema(). - Creates
schema_migrations. - Detects and reconciles legacy databases via
db/migrations/legacyReconcileMigrations.js. - Applies ordered migrations from
db/migrations/versionedMigrations.jsonly when not already recorded. - Validates dependency chains (
dependsOn: ['v0.71']) before applying dependent migrations. - Uses a column whitelist for dynamic
ALTER TABLEstatements. - Wraps versioned migrations in transactions, with special handling for
v0.40because it uses PRAGMA-driven table rebuild work. - Supports rollback SQL for selected migrations through
ROLLBACK_SQL_MAPandrollbackMigration(version).
Current migration set (v0.2 → v1.06):
v0.2payments soft delete.v0.3tracker payment compound index.v0.4monthly bill state.v0.13user profile columns.v0.14bill history visibility.v0.14.4bill interest rate.v0.15import sessions/history.v0.17OIDC/external identity columns and state table.v0.18.1monthly income.v0.18.2monthly starting amounts.v0.18.3other starting amount bucket.v0.38per-user import audit history.v0.40ownership for bills/categories.v0.41seeded demo-data flags.v0.42bill history ranges.v0.43sessioncreated_at.v0.44performance indexes.v0.45audit log.v0.46billcycle_typeandcycle_day.v0.47bills:current_balance,minimum_payment,snowball_order,snowball_include,snowball_exempt,auto_mark_paid,deleted_atcolumns.v0.48users:snowball_extra_paymentcolumn.v0.49payments:balance_deltacolumn for debt payoff tracking.v0.50users:last_seen_versionfor release-notes notifications.v0.51user_login_history table.v0.52–v0.53ad-hoc columns (legacy reconcile).v0.54bills/categories: soft-delete columns (deleted_at).v0.55autopay:auto_mark_paidand suggestion dismissals table.v0.56bills: saved bill templates table.v0.57match_suggestion_rejections table (replaced byv0.62).v0.58import_sessions and import_history tables.v0.59payments:payment_sourceandtransaction_idcolumns.v0.60data_sources, financial_accounts, transactions tables.v0.61payments: one active payment per linked transaction (unique index ontransaction_id).v0.62match_suggestion_rejections table (canonical).v0.63bills: subscription metadata fields (is_subscription,subscription_type).v0.64financial_accounts:monitoredflag for bill matching; transactions: partial unique index on(data_source_id, provider_transaction_id)and supporting indexes.v0.65subscription_catalog: top-200 known subscription services.v0.66declined_subscription_hints: per-user dismissed recommendation store.v0.67bill_merchant_rules: persistent merchant→bill auto-match rules.v0.68advisory_non_bill_filters: 5k advisory patterns + bill-like override terms.v0.69subscription_catalog v2: 90 new services + category fixes.v0.70monthly_bill_state:snoozed_untilfor overdue command center.v0.71bills:drift_snoozed_until; users:notify_amount_change.v0.72bills: persistent tracker sort order.v0.73snowball_plans table for plan lifecycle + history.v0.74subscription_catalog: Claude.ai Anthropic matching.v0.75categories: persistent sort order.v0.76bills: canonical billing schedule cleanup.v0.77encrypt SMTP password at rest.v0.78re-encrypt secrets from SHA-256 to HKDF key derivation.v0.79encrypt OIDC client secret at rest.v0.80users: push notification columns (ntfy / Gotify / Discord / Telegram).v0.81bill_merchant_rules: composite index on(user_id, bill_id).v0.82payments: normaliseauto_matchsource toprovider_sync.v0.83bill_merchant_rules:auto_attribute_lateflag.v0.84user_login_history: encrypt ip/useragent at rest + addlocation+ keep 10 records.v0.85user_login_history: failed-attempt tracking +session_fingerprint.v0.86users: TOTP/authenticator 2FA columns +totp_challengestable.v0.87spending: category assignment on transactions + rules + budgets + default categories.v0.88categories:spending_enabledflag to separate bill vs spending categories.v0.89categories: seed spending defaults for users who had existing categories beforev0.87.v0.90re-normalize merchant rules after&fix; ensure rejection expiry column.v0.91performance: composite indexes onuser_id+deleted_atfor categories, bills, payments.v0.92auth: WebAuthn/FIDO2 security key support —webauthn_credentials+webauthn_challengestables.v0.93bills:interest_accrued_month; payments:interest_delta; transactions: stable provider key + dedupe index.v0.94security: session token hashing + geolocation opt-in setting (seeded intosettings).v0.95subscription_catalog: bank descriptors + pricing from 2026 researched dataset.v0.96bills:catalog_idFK;user_catalog_descriptorsfor custom bank descriptors.v0.97subscription recommendation feedback: per-user learning signals.v0.98payments:accounting_excludedfor bank-override metadata.v0.99bills: autopay trust indicators (autopay_verified_at,inactive_reason) + lifecycle fields; payments: autopay failure flag.v1.00calendar feed subscription tokens (calendar_tokens).v1.01transactions:pendingflag for SimpleFIN pending transactions.v1.02users: per-user geolocation opt-in (was global admin setting).v1.03money columns: dollars (REAL) → integer cents forbills.expected_amount/current_balance/minimum_payment,payments.amount/balance_delta/interest_delta,monthly_bill_state.actual_amount,monthly_income.amount, plus relevant index updates.v1.04bill_templates.dataJSON: money fields dollars → integer cents (column-onlyv1.03does not touch the JSON blob;serializeTemplateDatareads cents).v1.05merchant_store_matches: 5k merchant/store matching pack for bank transaction categorization.v1.06category_groups: organize spending categories into named groups.- Unversioned user notification columns are also reconciled by
legacyReconcileMigrations.js.
Migration logging is both console-based and audit-backed:
runMigrations()logs start, dependency status, transaction begin/commit, per-migration elapsed time, skips for already-applied migrations, failures with rollback messages, and total elapsed time.- Audit events use the lazy
getLogAudit()helper to avoid theauditService -> database.js -> auditServicecircular dependency. - Audit actions include
migration.start,migration.complete, andmigration.failure. - Rollback paths audit
migration.rollbackandmigration.rollback.failure.
Rollback support is defined by ROLLBACK_SQL_MAP in db/database.js and currently covers v0.44 through v1.04 (the most recent migrations v1.05 and v1.06 are not yet rollbackable):
v0.44— drops selected performance indexes:idx_bills_user_name,idx_payments_method,idx_monthly_starting_amounts_user, andidx_import_history_imported_at.v0.45— dropsidx_audit_log_user,idx_audit_log_action, and theaudit_logtable.v0.46— dropsbills.cycle_dayandbills.cycle_type.v0.47— dropscurrent_balance,minimum_payment,snowball_order,snowball_include,snowball_exempt,auto_mark_paid,deleted_atcolumns from bills.v0.48— dropssnowball_extra_paymentcolumn from users.v0.49— dropsbalance_deltacolumn from payments.v0.50— dropslast_seen_versioncolumn from users.v0.51— dropsuser_login_historytable.v0.54— removes soft-delete columns (deleted_at) from bills and categories.v0.55— drops autopay suggestion dismissals table.v0.56— dropsbill_templatestable.v0.57— dropsmatch_suggestion_rejectionstable.v0.58— dropsimport_sessionsandimport_historytables.v0.59— dropspayment_sourceandtransaction_idcolumns from payments.v0.60— dropsdata_sources,financial_accounts, andtransactionstables.v0.61— drops unique index ontransaction_idfrom payments.v0.62— dropsmatch_suggestion_rejectionstable.v0.63— drops partial unique index ondata_sources.v0.64— drops partial unique index and indexes ontransactions.v0.72— dropsbills.sort_orderandidx_bills_user_sort.v0.75— dropscategories.sort_orderandidx_categories_user_sort.v0.76— dropsbills.billing_cycle(canonical cleanup).v0.77–v0.79— revert SMTP/OIDC secret encryption (re-store plaintext).v0.81— dropsidx_bill_merchant_rules_user_bill.v0.82— no-op rollback (data migration).v0.83— dropsbill_merchant_rules.auto_attribute_late.v0.84–v0.85— revertuser_login_historyencryption and dropsuccess/session_fingerprint/location.v0.86— dropsusers.totp_*columns andtotp_challenges.v0.87— dropstransactions.spending_category_id,spending_rules,spending_budgets, related defaults.v0.88— dropscategories.spending_enabled.v0.89— data-only (seeding); no schema rollback.v0.90— data-only (re-normalization); no schema rollback.v0.91— dropsidx_categories_user_deleted,idx_bills_user_deleted,idx_payments_user_deleted.v0.92— dropswebauthn_credentials,webauthn_challenges, and thewebauthn_*columns onusers.v0.93— dropsbills.interest_accrued_month,payments.interest_delta, transactions dedupe index + indexes.v0.94— reverts session token hashing (drops the hash; cookie value is now the raw ID again); removesgeolocation_enabledsetting row.v0.95— dropssubscription_catalog.bank_descriptors/subcategory/starting_monthly_usd.v0.96— dropsbills.catalog_idanduser_catalog_descriptorstable.v0.97— dropssubscription_recommendation_feedbacktable.v0.98— dropspayments.accounting_excluded.v0.99— dropsbills.autopay_verified_at/inactive_reason(and any payments autopay-failure column added in the same migration).v1.00— dropscalendar_tokenstable.v1.01— dropstransactions.pending.v1.02— dropsusers.geolocation_enabledand re-seeds the global admin setting key.v1.03— reverts the integer-cents conversion (back to dollar REALs) for the listed columns.v1.04— revertsbill_templates.datamoney fields back to dollars.
rollbackMigration(version) requires an initialized database, verifies the version exists in schema_migrations, looks up rollback SQL in ROLLBACK_SQL_MAP, executes all rollback statements inside a transaction, deletes the migration record, logs elapsed time, audits success, and returns {success:true, version, description, elapsed_ms}. If the migration is not recorded, it throws NOT_APPLIED. If no rollback definition exists, it throws ROLLBACK_NOT_SUPPORTED. Execution failures roll back the transaction and are audited as migration.rollback.failure.
The admin API exposes rollback through POST /api/admin/migrations/rollback. The route requires admin auth through the /api/admin mount. It maps NOT_APPLIED to HTTP 404, ROLLBACK_NOT_SUPPORTED to HTTP 422, and unexpected rollback failures to HTTP 500.
The lazy audit helper in db/database.js is:
let _logAudit = null;
function getLogAudit() {
if (!_logAudit) {
try { _logAudit = require('../services/auditService').logAudit; } catch { _logAudit = () => {}; }
}
return _logAudit;
}
Use this pattern for database-layer audit calls instead of a top-level require('../services/auditService').
7. Frontend Reference
Frontend stack
- React
^19.2.7(withbabel-plugin-react-compilerfor automatic memoization) - TypeScript
^6.0.3withstrict: true+noUncheckedIndexedAccess: true;allowJs: true,checkJs: falsefor the gradual JS → TS migration - Vite
^5.4.10with@vitejs/plugin-reactandvite-plugin-pwa(autoUpdate service worker, manifest, runtime caching for tracker/bills/calendar/summary/analytics/snowball/categories) - React Router
^6.26.2 - TanStack Query
^5.100.9+@tanstack/react-query-devtools+@tanstack/react-virtualfor the virtualized Subscriptions list - Tailwind CSS
^3.4.14(content glob:./client/**/*.{js,jsx,ts,tsx}— must includets,tsxor.tsx-only classes generate no CSS) - shadcn/ui component primitives, backed by Radix UI where applicable
- Sonner toast notifications via
sonner framer-motion^12.40.0for transitionsreact-markdown,remark-gfm,rehype-sanitizefor markdown renderingvitest^4.1.8+@testing-library/reactfor client unit tests (server tests stay onnode --test)
client/main.tsx
Creates the React root and wraps App with global providers. Theme is provided by client/contexts/ThemeContext.tsx.
client/App.tsx
- Creates a TanStack
QueryClientwith:- Global
QueryCache.onError— toasts asonnererror when a background refetch fails while stale data is on screen. Pages render inline errors on the initial load so the toast only fires for refetches. staleTime: 2 * 60 * 1000,retry: 1,refetchOnWindowFocus: false.
- Global
- Uses lazy loading and
SuspensewithPageLoaderfor most pages. - Wraps route elements in
ErrorBoundary; pages further wrap inPageTransition. - Exposes
ReactQueryDevtools. - Provides skip link for keyboard users.
- Mounts the
CommandPaletteandReleaseNotesDialogglobally. RequireAuth({children, role}) behavior:- Shows loading while auth state is
undefined. - Single-user mode bypasses the auth gate for
role: 'user'. - Redirects unauthenticated users to
/login(preservesstate.from). - Default admin cannot access user routes — redirected to
/admin. - Role mismatches redirect to
/admin(for admin role) or/(for user role).
- Shows loading while auth state is
AdminShellwraps admin routes so the user-shell nav does not appear inside admin.
Routes (all rendered through Routes with ErrorBoundary, lazy + Suspense for non-critical pages):
/login→LoginPage(public; supports local + OIDC + TOTP challenge)/about→AboutPage(public)/privacy→PrivacyPage(public)/release-notes→ReleaseNotesPage(public)/admin→AdminPage(admin only)/admin/about→ admin shell +AboutPage admin(admin only)/admin/roadmap→ admin shell +RoadmapPage(admin only)/admin/status→ admin shell +StatusPage(admin only)/status→ redirects admin to/admin/status/(index under user layout) →TrackerPage/calendar→CalendarPage/summary→SummaryPage/bills→BillsPage/subscriptions→SubscriptionsPage(virtualized list, optimistic reorder, drag-reorder)/subscriptions/catalog→SubscriptionCatalogPage(admin view of the catalog)/categories→CategoriesPage/health→HealthPage(bill + payment health snapshot)/analytics→AnalyticsPage/settings→SettingsPage/snowball→SnowballPage(projection + plans)/payoff→PayoffPage(payoff scenarios)/spending→SpendingPage(categorized transactions, rules, budgets, income)/bank-transactions→BankTransactionsPage(the bank ledger,Cents-based)/data→DataPage(export, CSV/OFX/XLSX/SQLite imports, bank sync, transaction matching)/profile→ProfilePage*→NotFoundPage
API client
client/api.ts (TypeScript; converted from api.js in the TypeScript migration):
_fetch<T>(method, path, body, opts)calls/api${path}with JSON headers andcredentials: include.- Reads CSRF token from the
bt_csrf_tokencookie or fetches once from/api/auth/csrf-tokenand caches the token in memory; sendsx-csrf-tokenonPOST/PUT/PATCH/DELETE. - Non-OK responses throw an
Errorwhosestatus/data/details/codematch the server's structurederrorFormatter. ApiErrorinterface carries those fields;TogglePaidResultandPaymentRecordprovide typed payment responses;QueryParamstypes the query-string builder.- Exposes grouped functions for
auth,admin,notifications,profile,tracker,calendar,summary,bills,payments,categories,settings,analytics,status,version/about,import,export,data-sources,transactions,matches,snowball,subscriptions,spending,monthlyStartingAmounts,csvImport,matchSuggestions,bankTransactionsLedger,matchTransaction,unmatchTransaction,ignoreTransaction,unignoreTransaction,applyTransactionMerchantMatch,autoCategorizeTransactions,categoryGroups,copySpendingBudgets,spendingIncome,spendingCategoryRules,createPaymentOrConfirm(viaclient/lib/paymentActions.ts). - File download/upload endpoints use raw
fetchbecause responses/bodies are blobs or octet streams. - All 16 import sites that referenced
@/api.jswere normalized to@/apiso Vite/TS resolve the.ts. - Track C response typing — see §7 Domain types for the full list. In short, the high-traffic domains (
auth,bills,payments,categories,tracker,snowball,subscriptions,spending,bank-ledger) all return typed responses viaget<T>/post<T>; the remaining single-consumer non-money casts (adminUsers, version/about/privacy/health/importHistory) are deliberately left as-is. - Server error-shape consistency (Track D). The global 500 handler in
server.jsemits{ error, code: 'INTERNAL_ERROR' }so an unexpected server error carries the same{error, code}field the client can switch on as every structured 4xx. See §5 Response conventions.
Domain types — client/types.ts
- Branded
DollarsandCentsmoney types (fromclient/lib/money.ts). User(moved from@/hooks/useAuthin Track C; re-exported fromuseAuthfor the 7 importers).Bill,Payment,Category,TrackerResponse(withsummary/bank_tracking/cashflow/rows).DriftBill,AutopaySuggestion,AmountSuggestion,AutopayStats.BankTransaction(integer cents on the wire).BankLedgerTransaction extends BankTransaction(Track C: bank-ledger domain types) — preserves theCentsbrand.BankAccount,BankLedger,BankLedgerSummary,BankCategoryBreakdown,AutoCategorizePreview,AutoCategorizeChange.SnowballProjection(Track C) +SnowballProjectionDetail/AvalancheProjectionDetail/SnowballDebtProjection.SnowballSettings(Track C).Subscription,SubscriptionTopType,SubscriptionSummary,SubscriptionsResponse,CatalogMatch,ExistingBillMatch,Recommendation(Track C: subscriptions domain types) +RecommendationEvidence/RecommendationTransaction/SubscriptionTx.SpendingCategoryEntry,SpendingSummary,SpendingTransaction,SpendingTransactionsResponse,SpendingIncomeTx,SpendingIncomeResponse,SpendingRule,CategoryGroup,CopyBudgetsResponse(Track C: spending domain types).TimelineBillfor the Safe-to-Spend SVG timeline.- Money fields are typed as
Dollarsfor bill/payment/tracker/summary amounts and asCentsfor raw bank transactions — so the unit mixup bug class is unrepresentable in typed code. - A
money.type-test.tsnever-imported guard uses@ts-expect-errorto assert each unit mixup is a real compile error, sotsc --noEmitfails loudly if the branding ever regresses. - Single-consumer non-money casts left in place (deliberate Track C stop):
adminUserswith its page-localAdminUser, and the one-offversion/about/privacy/health/importHistorydoc shapes. Central types add ~nothing for a single reader; typing them is the low-value tail.
Auth state
client/hooks/useAuth.tsx:
AuthContexttyped (acreateContext(null)would have madeuseContextreturnnever).Userinterface lives inclient/types.ts(moved there in Track C;useAuthre-exports it for the 7 importers that previously imported it from@/hooks/useAuth).- Maintains
user,singleUserMode,loading. - Calls
api.authMode()andapi.me()on startup;api.me()is nowget<User>(typed). - Exposes
logout(),logoutAll(),refresh(). singleUserModeis sourced from/auth/mode;RequireAuthconsults it.
Query hooks
client/hooks/useQueries.ts (TypeScript; converted from useQueries.js):
useTracker(year, month),useTrackerHealth(year, month),usePrefetchTracker()— warms the adjacent-month cache on hover/focus.useBills(),useCategories(),useSnowball*,useSpending*,useSummary,useBankLedger,useCategoriesWithSpending,useSubscriptions,useSubscriptionCatalog,useSubscriptionRecommendations,useMatchSuggestions,useTransactions,useTransactionsCategory,useTransactionCategories,useBankDataSources,useMatchBillDialog,useBankAccountDataSources,useBankSyncConfig,useAdminUserList,useAdminOIDCTest,useAdminBackups,useAdminCleanup,useAdminMigrations,useAdminAuthMode,useAdminNotifications,useReleaseNotes,useLoginHistory,useSnowballPlans,useWebAuthnStatus,useTOTPStatus,useUpcomingBills.- All hooks typed (params +
QueryParams-typed query builders) and exported fromapi.ts. - React Query v5: caches use
gcTime(notcacheTime, which the v5 rename silently ignored). All mutation hooks invalidate / set query data viasetQueryData<T>(key, updater). - Mutations:
useQuickPay(shared across desktop + mobile tracker rows;isPendingreplaces manual busy flags; tracker/badge caches invalidate on settle),useTogglePaid,useMarkPaid,useMatch,useUnmatch,useIgnore,useUnignore,useCategorizeTransaction,useBillCreate,useBillUpdate,useBillDelete,useBillReorder,useBudgetPut,useBudgetCopy,useCategoryRuleCreate,useCategoryRuleDelete,usePlanCreate,usePlanUpdate,usePlanPause,usePlanResume,usePlanComplete,usePlanAbandon,useSubscriptionLink,useSubscriptionDecline,useSubscriptionCreate,useSubscriptionUpdate,useImportCsv,useImportOfx,useBankSyncNow, etc. useAutoSave<T>(value, options)is a generic debounced-save hook with anAutoSaveStatusunion (idle|pending|saving|saved|error).usePaymentActions.ts— shared quick-pay/toggle-paid mutation hooks; typed payloads withDollarson money amounts.useSearchPanelPreference.ts— typed[boolean, setter]tuple.
Pages
LoginPage.tsx— local login, OIDC login button, TOTP challenge, WebAuthn assertion, recovery code entry.TrackerPage.tsx— monthly tracker, payment interactions, upcoming bills, starting-amount awareness, safe-to-spend, drift/autopay/amount-suggestion badges, bulk/session logout, month-nav prefetch.CalendarPage.tsx— calendar grid backed by/calendar.SummaryPage.tsx— monthly plan, income, starting amounts, expenses, chart, bank-tracking overlay, skip-override.BillsPage.tsx— bill CRUD, categories, monthly state/history range controls, soft-delete + recently-deleted restore, merchant rules manager, bill historical-import dialog.CategoriesPage.tsx— category list/create/update/delete, group/category split, sort order, spending flag.AnalyticsPage.tsx— analytics summary filters and charts.SettingsPage.tsx— user settings and demo data seed.DataPage.tsx— export, spreadsheet import, user DB import, import history, CSV transaction import with preview + commit flow (ImportTransactionCsvSection), OFX import, bank sync.ProfilePage.tsx— display name, notification preferences, password change, TOTP setup, WebAuthn enrollment, login history, export/import-history links.AdminPage.tsx— users, auth mode/OIDC, WebAuthn, TOTP, backups, cleanup, notifications, migrations, admin settings, bank-sync config.StatusPage.tsx— admin system status.AboutPage.tsx— public or admin markdown/about view; admin mode uses/about-admin; markdown is sanitized.RoadmapPage.tsx— admin roadmap.ReleaseNotesPage.tsx— release history display; theReleaseNotesDialogis shown to the user on login when theirlast_seen_versionis older than the current package version.PrivacyPage.tsx— public privacy text.SubscriptionsPage.tsx(1814 lines) —useOptimistic<Subscription[]>reducer, virtualizer'sFlatItemdiscriminated union, drag-reorder typed via the sharedDragProps/MoveControls.SubscriptionCatalogPage.tsx— admin view of the top-200 catalog with bank descriptors.SnowballPage.tsx— projection, settings, plans lifecycle.PayoffPage.tsx— payoff scenarios.SpendingPage.tsx— categorized transactions, rules, monthly budgets with month-rollover, income.BankTransactionsPage.tsx— theCents-based bank ledger, with a localTxstructurally compatible withBankTransactionso it still satisfiesMatchBillDialog.HealthPage.tsx— bill + payment health snapshot.NotFoundPage.tsx— 404 fallback.
Components
- Layout:
Layout,Sidebar,BrandBlock,NavPill,CommandPalette,PageTransition. - Admin: full
components/admin/directory — users, auth methods, OIDC, WebAuthn, TOTP, bank-sync, backup manager, cleanup, notifications, migrations, settings cards. - Bill modal: full
components/bill-modal/directory plusBillModal.tsx(1092-line add/edit form, reached from 9 entry points). - Tracker: full
components/tracker/directory (18 files — the home page) wherefmt(row.expected_amount)type-checks againstDollars. - Transactions:
components/transactions/directory. - Snowball:
components/snowball/directory (plan list, projection, settings). - Data: full
components/data/directory (14 files) — the Data page's SimpleFIN connect/sync workbench, transaction matching, and CSV / OFX / XLSX-spreadsheet / SQLite import flows, withCents-branded bank-transaction amounts flowing through. - Domain UI:
BillModal,BillsTableInner,MobileBillRow,MobileTrackerRow,StatusBadge,SummaryCard,MarkdownText,ReleaseNotesDialog,IncomeBreakdownModal,BillHistoricalImportDialog,BillRulesManager,BillMerchantRules,RecentlyDeletedBillsDialog,CalendarFeedManager,SearchFilterPanel,SubscriptionCatalogSection. - Reliability:
ErrorBoundary(typed class component),PageLoader,PageTransition. - UI primitives (
components/ui/):alert-dialog,badge,button,card,checkbox,collapsible,confirm-dialog,dialog,dropdown-menu,input,input-dialog,label,save-status,select,separator,skeleton,sonner,switch,table,tabs,theme-toggle,tooltip— all.tsx.
client/lib/
money.ts— brandedCents/Dollarstypes,asCents/asDollars/centsToDollars/dollarsToCents, formatters. AformatUSD(dollars)andformatCentsUSD(cents)split mirrors the server'sformatUSD/formatCentsUSDsplit. Inputs are coerced defensively so null /''/ undefined / NaN never render as$NaN.utils.ts—cn,fmt, date/format helpers,errMessage(unknown, fallback)for narrowingunknowncaught errors into strings.fmtinheritsformatUSD's branded-dollars input.trackerUtils.ts— row/status/sort helpers,TrackerRowdomain interface,TrackerStatusunion.billDrafts.ts—SourceBill/Template/Categoryshapes formakeBillDraft.billingSchedule.ts—Scheduleunion and helpers.cashflowUtils.ts— typed SVG timeline geometry for the Safe-to-Spend card.trackerTableColumns.ts— typed column descriptors.reorder.ts— genericmoveInArray<T>.version.ts— typed release-notes shapes; the Vite-injected__APP_VERSION__is declared inline.paymentActions.ts—createPaymentOrConfirm(payload, onSuccess)(Track A). The deliberate manual-add path treats the server's409 DUPLICATE_SUSPECTEDas a question rather than an error: losing a legitimately-identical second payment (same bill, date, amount) is itself a money-integrity bug, so the server returns 409 instead of silently deduping and the helper surfaces a sonner "Add anyway?" toast whose confirm action resends withallow_duplicate: true.onSuccessruns after any real creation (immediate or confirmed retry). Non-duplicate errors re-throw for the caller's existingcatch.
Frontend build & lint
npm run build—vite build(output:dist/,emptyOutDir: true).npm run typecheck—tsc --noEmit -p tsconfig.json. Wired intonpm run ci.npm run lint—eslint client(flat config;react-hooks/rules-of-hooks,react-hooks/exhaustive-deps,react-refresh/only-export-components, typescript-eslint parser for.ts/.tsx).npm run test— servernode --test tests/*.test.js.npm run test:client—vitest run(client unit tests).npm run test:all— both.npm run test:e2e— Playwright (chromium desktop + mobile);prepare-db.jsreseeds the test DB.npm run check:server—node --checkon every server JS file.npm run check—check:server+build.npm run ci—lint+typecheck+check:server+test:all+build.
Frontend security notes
- CSRF header is sent on
POST/PUT/PATCH/DELETE; the token is fetched once from/api/auth/csrf-tokenand cached in memory. - Auth is cookie/session based; no tokens are stored in localStorage. Session token hashing (
v0.94) means a leaked DB does not yield usable session IDs. - Admin routes are client-guarded and server-guarded; the default admin is redirected away from user routes.
- Markdown rendering uses
rehype-sanitize; CSP headers inmiddleware/securityHeaders.jscover the built assets. - Error boundaries prevent route crashes from taking down the whole SPA.
- PWA:
vite-plugin-pwaregisters a service worker (autoUpdate) with manifest + icons. Runtime caching usesNetworkFirstfor/api/(tracker|bills|calendar|summary|analytics|snowball|categories)with a 5-second network timeout, 30-entry max, 5-minute max-age.
8. Infrastructure and Deployment
package.json
Version: 0.40.0. "type": "commonjs".
Scripts:
npm run dev:api—node --watch server.js.npm run dev:ui— Vite dev server.npm run dev—concurrentlyruns API and UI.npm run build—vite build(output:dist/,emptyOutDir: true).npm run check:server—node --checkevery server JS file underserver.js,db,middleware,routes,services,utils.npm run lint—eslint client(flat config;react-hooks+react-refresh+typescript-eslint).npm run typecheck—tsc --noEmit -p tsconfig.json.npm run check—check:server+build.npm run test—node --test tests/*.test.js(server).npm run test:client—vitest run(client).npm run test:all— both.npm run test:e2e/test:e2e:ui/test:e2e:update/test:e2e:probe— Playwright (chromium desktop + mobile + axe), withe2e/setup/prepare-db.jsreseeding the test DB.npm run smoke:prod—bash scripts/prod-smoke.sh.npm run ci—lint+typecheck+check:server+test:all+build. Runs in Forgejo Actions on every push and PR (.forgejo/workflows/ci.yml, containernode:22-bookworm).npm start—node server.js.
Key runtime dependencies:
- Express 4, cookie-parser, cors, express-rate-limit.
- better-sqlite3 12.
- bcryptjs.
- openid-client 5.
- @simplewebauthn/server 13, @simplewebauthn/browser 13.
- otplib 13, qrcode 1.
- nodemailer 8.
- node-cron 4.
- React 19, React DOM 19, React Router 6, TanStack Query 5, TanStack Virtual 3, framer-motion 12.
- shadcn/ui component primitives, Radix UI primitives, lucide-react, Tailwind utilities, Sonner toasts.
- xlsx for spreadsheet import/export.
Key dev dependencies:
- typescript 6, @types/react 19, @types/react-dom 19, typescript-eslint 8.
- eslint 9, @eslint/js, eslint-plugin-react-hooks 5, eslint-plugin-react-refresh 0.4, globals 17.
- vite 5, @vitejs/plugin-react 4, vite-plugin-pwa 1, babel-plugin-react-compiler 1.
- vitest 4, @testing-library/react 16, @axe-core/playwright 4, @playwright/test 1.
- tailwindcss 3, autoprefixer 10, postcss 8.
Dockerfile
Multi-stage build:
- Builder:
node:22-alpine, installs build depspython3 make g++, runsnpm install, copies source, runsnpm run build. - Runtime:
node:22-alpine, installsbash nano su-exec, creates non-rootbilluser, copies built app, creates/data/db,/data/backups,/app/backups, sets ownership and restrictive permissions.
Runtime environment:
NODE_ENV=productionPORT=3000DB_PATH=/data/db/bills.dbBACKUP_PATH=/data/backups
Exposes port 3000, declares volume /data, entrypoint docker-entrypoint.sh, command node server.js.
docker-compose.yml
Service: bill-tracker.
- Image:
dream.scheller.ltd/null/billtracker:latest. - Container name:
bill-tracker. - Ports: host
3030to container3000. - Volume:
/portainer/hosting/bill-tracker/data:/data. - Restart:
unless-stopped. - Environment includes
INIT_ADMIN_USER,INIT_ADMIN_PASS, and CSRF cookie settings (CSRF_HTTP_ONLY: "false",CSRF_SAME_SITE: "strict",CSRF_SECURE: "true",CSRF_COOKIE_NAME: "bt_csrf_token").
Important deployment note: the compose file currently sets CSRF_SECURE: "true"; for plain HTTP development this prevents CSRF cookies from being sent by browsers. Use HTTPS or override to false only in local/dev.
CI (.forgejo/workflows/ci.yml)
Forgejo Actions runs on every push and PR: actions/checkout@v4 on node:22-bookworm (matches Dockerfile runtime so better-sqlite3 prebuilds resolve identically), then npm ci and npm run ci. The workflow is the only place a build is fully re-verified end-to-end (lint + typecheck + server tests + client tests + build).
Vite (vite.config.mjs)
@vitejs/plugin-reactwithbabel-plugin-react-compilerenabled (React 19 auto-memoization). The codebase is rules-of-hooks clean (enforced byeslint-plugin-react-hooks).vite-plugin-pwawithregisterType: 'autoUpdate', manifest, icons inclient/public/img/, runtime caching for/api/(tracker|bills|calendar|summary|analytics|snowball|categories)viaNetworkFirst(5s network timeout, 30-entry max, 5-min max-age).__APP_VERSION__is injected at build time frompackage.json; the SPA reads it throughclient/lib/version.ts.- Manual chunk splitting:
vendor-react(react / react-dom / react-router-dom),vendor-radix(dialog / select / dropdown-menu / tabs / tooltip / alert-dialog),vendor-motion(framer-motion),vendor-query(@tanstack/react-query + @tanstack/react-virtual). resolve.alias['@']→client/, matches the Vite alias and thetsconfig.jsonpathsmapping.server.port: 5173, proxies/apitohttp://localhost:${API_PORT || PORT || 3000}.test.environment: 'node'; tests opt into jsdom via@vitest-environmentper-file.test.include: ['client/**/*.test.{js,jsx}']; note: client tests on.tsare currently run via thetests/*.test.jsserver suite (which still exercises the same behaviors). The Vitestincludeis restricted to.js/.jsxto keep current test coverage green during the gradual TS migration.
TypeScript (tsconfig.json)
target: ES2022,lib: [ES2023, DOM, DOM.Iterable].module: ESNext,moduleResolution: bundler,jsx: react-jsx.paths: { "@/*": ["./client/*"] },resolveJsonModule,esModuleInterop,isolatedModules,skipLibCheck,noEmit.allowJs: true,checkJs: false— legacy.js/.jsxkeep resolving and running untouched; only.ts/.tsxare type-checked. Convert file-by-file.strict: true,noUncheckedIndexedAccess: true.types: ["vite/client"]— providesimport.meta.envand__APP_VERSION__typings.include: ["client/**/*"],exclude: ["node_modules", "dist", "client/**/*.test.*"].
ESLint (eslint.config.mjs)
Flat config scoped to the React client. The point is enforcement of the two rules that catch real React bugs — rules-of-hooks (conditional hooks) and exhaustive-deps (stale closures) — which nothing previously checked. Server code (CommonJS Node) is out of scope here; npm run check:server covers it.
- JS/JSX block (
client/**/*.{js,jsx}):js.configs.recommended+react-hooks+react-refresh.react-hooks/rules-of-hooks: error,react-hooks/exhaustive-deps: warn,react-refresh/only-export-components: warn(withallowConstantExport: true). - TS/TSX block (
client/**/*.{ts,tsx}): same react rules via thetypescript-eslintparser.no-undefandno-unused-varsoff (TypeScript handles them);@typescript-eslint/no-unused-vars: warnwith^[A-Z_]ignore pattern. no-empty: warnwithallowEmptyCatch: true.- Ignores:
dist/**,node_modules/**,coverage/**,client/**/*.test.*.
9. Auth and Security Flows
Local login
- Client calls
GET /auth/modeto determine local/OIDC visibility. - Client submits
POST /auth/login. - Server checks
local_login_enabled, validates credentials through bcrypt (cost 12), rejects inactive users, cleans expired sessions, creates a 7-day session, hashes the session token for storage (v0.94), and sets thebt_sessioncookie with the raw opaque ID. - Server sets
bt_sessioncookie usingcookieOpts(req). - Client calls
GET /auth/meto populate auth state.
TOTP 2FA
- User enables TOTP via
GET /auth/totp/setup→ scans QR code →POST /auth/totp/enablewith{secret, token}. Server encrypts the secret withservices/encryptionServiceand stores 8 recovery codes (hashed). - On subsequent logins, after a successful
POST /auth/login, the server returns{ totp_required: true, challenge_id }. The SPA prompts for the 6-digit code and submits it (or a recovery code) with the challenge id. POST /auth/totp/challengeaccepts{username, password}and returns a 5-minutechallenge_id; the SPA submits it with the token on the next request.disableTotp(userId)clears the encrypted secret and recovery codes.
WebAuthn 2FA
- User enrolls a security key:
GET /auth/webauthn/status(or the admin equivalent) returns current credentials. The SPA calls an internal helper that triggers a registration ceremony; the server stores the credential inwebauthn_credentials(AAGUID, transports, backup flags, friendly name). - On login, the SPA requests an assertion via
webauthn_loginflow;createAuthenticationChallengereturns options + a 15-minutechallenge_id;verifyAuthenticationupdates the sign count and auditswebauthn.login. WEBAUTHN_RP_ID(env, defaultlocalhost) andWEBAUTHN_ORIGIN(env, defaulthttp://localhost:${PORT}) must match the browser-visible hostname, or registration/assertion will fail.
OIDC login
- Client navigates to
/api/auth/oidc/login. - Server verifies active OIDC config (the OIDC client secret is decrypted from
settings.oidc_client_secret, encrypted at rest byv0.79), creates PKCE state, redirects to provider. - Provider returns to
/api/auth/oidc/callback. - Server validates state/nonce, exchanges code, maps/provisions user, creates local session cookie (token hashed in
sessions.id), redirects back into SPA.
Password change
- Current password and matching new password are required.
- New password must be at least 8 chars.
- Server updates hash, clears
must_change_password, setslast_password_change_at. - Other sessions are invalidated and current session is rotated when possible.
Login history + device fingerprint
- Every login (successful or failed) writes a
user_login_historyrow. ip_addressanduser_agentare encrypted at rest (v0.84).location(IP-geolocation city/region/country) is captured only when the user hasusers.geolocation_enabled = 1(v1.02).success(v0.85) tracks failed-attempt history separately.session_fingerprintlets the SPA detect a different device for the same session and trigger a re-auth challenge.
Authorization
- All user data routes enforce owner scope by
req.user.idin SQL. - Admin-only routes require
requireAdminon server. - Default admin cannot use tracker routes.
- Role changes invalidate target sessions.
- Deactivation invalidates target sessions.
Secret encryption at rest
services/encryptionService.jsprovidesencryptSecret/decryptSecret/assertEncryptionReadyusing a master key derived with HKDF (v0.78; the prior SHA-256 derivation was migrated in-place).- Encrypted at rest:
oidc_client_secret(v0.79), SMTP password (v0.77),data_sources.encrypted_secret(SimpleFIN access URLs),user_login_history.ip_address/user_agent(v0.84),users.totp_secret(v0.86). reEncryptWithEnvKey()is invoked by the migration runner when an admin setsENCRYPTION_KEYafter install; called from the runtime error handler when a legacy ciphertext is detected.
Backup safety
- Managed filename regex and path checks prevent traversal.
- Uploads are written to temp paths first, validated by SQLite
integrity_check+ optional SHA-256 checksum, then moved. - Restore creates a pre-restore backup.
Import safety
- Spreadsheet import accepts only XLSX and validates size, sheets, rows, cells, headers, and decisions.
- User DB import validates SQLite magic, size, required metadata/tables, and maps all data to current user ownership.
- CSV import preview/apply is split; ambiguous rows are surfaced in
errorsfor the user to resolve. - OFX/QFX import is rate-limited via the import limiter; previews have a 24-hour TTL.
Bank sync safety
- SimpleFIN access URLs are encrypted at rest (
data_sources.encrypted_secret). assertEncryptionReadyis called before any secret operation.- The
monitoredflag (v0.64) lets users exclude a credit-card account from bill matching. - Transactions are deduplicated by
provider_transaction_id(unique partial index). - Account balance updates run in transactions; partial failures roll back.
10. Operational Notes
Startup behavior
- DB path is
DB_PATHordb/bills.db. - DB open logging prints only
path.basename(DB_PATH)instead of the full database path, so startup logs identify the file without exposing the full filesystem location. - SQLite WAL and foreign keys are enabled.
- Schema and migrations run automatically (
db/migrations/versionedMigrations.js+db/migrations/legacyReconcileMigrations.js). - Default categories/settings are seeded (incl. spending defaults for new users in
v0.87). - Expired sessions are purged at startup; a periodic
SESSION_CLEANUP_INTERVAL_MScleanup interval is scheduled. bankSyncWorker.start()is started;backupScheduleris started;workers/dailyWorker.start()runs the daily jobs.- First-run flow: if
userCount === 0after migrations,setup/firstRun.run(db)is invoked.
Environment-seeded regular users use INIT_REGULAR_USER and INIT_REGULAR_PASS. New seeded users are inserted with first_login = 0 and must_change_password = 0. Existing seeded regular users have their password hash updated and both flags reset to 0, then the server audits seed.flag_reset with the username, reset flags, and source: "server-seed". This lets ENV-managed users skip first-login/privacy/password-change gates after seed refreshes.
Environment variables
Common variables used by current code:
PORT— server port (default 3000)BIND_HOST— server bind (default0.0.0.0)DB_PATHBACKUP_PATHINIT_ADMIN_USERINIT_ADMIN_PASSINIT_REGULAR_USERINIT_REGULAR_PASSSESSION_CLEANUP_INTERVAL_MSTRUST_PROXY—true/1/ numeric /loopback/ CIDR; required behind nginx/TraefikCORS_ORIGIN— comma-separated allowlistCOOKIE_SECUREHTTPSCSRF_HTTP_ONLYCSRF_SAME_SITECSRF_SECURECSRF_COOKIE_NAMEOIDC_ISSUER_URLOIDC_CLIENT_IDOIDC_CLIENT_SECRET— encrypted at rest (v0.79); the settings-table copy is the source of truthOIDC_REDIRECT_URIOIDC_TOKEN_AUTH_METHODWEBAUTHN_RP_ID— defaultlocalhostWEBAUTHN_ORIGIN— defaulthttp://localhost:${PORT}ENCRYPTION_KEY— master key for at-rest encryption (HKDF,v0.78)API_PORT— Vite dev-server proxy target (defaults toPORT || 3000)DATA_IMPORT_ENABLED— gates CSV import (defaults to true)
Most notification, OIDC, backup, cleanup, and auth-mode settings are also stored in the settings table and managed from Admin UI.
Known code characteristics to preserve
- Use transactions for multi-step destructive or bulk DB changes.
- Keep user-owned SQL scoped by
req.user.id. - Keep admin lockout protection before changing login methods.
- Do not expose
password_hash, session IDs (raw or hashed), OIDC client secret, encrypted secret blobs, or internal backup paths in API responses. - Keep import preview/apply separated so users can resolve ambiguous spreadsheet / CSV / OFX / user-DB data before DB writes.
- Branded
Dollars/Centstypes inclient/lib/money.tsare the source of truth for client money handling. A bare number at afmt()/formatUSD/formatCentsUSDboundary is a compile error in typed code. Themoney.type-test.tsguard uses@ts-expect-errorso a regression in the branding failstsc --noEmit. - Tailwind's
contentglob must includets,tsx(./client/**/*.{js,jsx,ts,tsx}). Without it, classes used only in.tsxfiles generate no CSS and produce a runtime-only failure that the e2e probe (not typecheck/build/tests) is the only gate that catches. - DB path support:
db/database.jsusespath.basename(DB_PATH)in logging to anonymize the DB path while still providing useful diagnostic information. Absolute and relative paths are both supported. - React Compiler (
babel-plugin-react-compiler) requires a rules-of-hooks-clean codebase; do not introduce conditional hooks. - React Query v5: use
gcTime, notcacheTime(silently ignored in v5).
11. Verification Checklist Used for This Reference
Reviewed current code sources:
server.js(includingTRUST_PROXYhandling, health probe, route mount order, post-listenworker startup).- All 26 route files under
routes/. - All 41 service files under
services/(incl.bankSync*,simplefinService,ofxImportService,merchantStoreMatchService,advisoryFilterService,totpService,webauthnService,loginFingerprint,driftService,aprService,paymentAccountingService,encryptionService,calendarFeedService). - All middleware files (
csrf,errorFormatter,rateLimiter,requireAuth,securityHeaders). db/schema.sql,db/database.js,db/migrations/versionedMigrations.js,db/migrations/legacyReconcileMigrations.js,db/subscriptionCatalogSeed.js.- Actual initialized SQLite schema via
better-sqlite3introspection (PRAGMA table_info,PRAGMA index_list). client/main.tsx,client/App.tsx,client/api.ts,client/types.ts,client/contexts/ThemeContext.tsx.client/hooks/useAuth.tsx,client/hooks/useQueries.ts,client/hooks/usePaymentActions.ts,client/hooks/useAutoSave.ts,client/hooks/useSearchPanelPreference.ts.- Page inventory under
client/pages/(24 files) and component inventory underclient/components/(incl.admin/,bill-modal/,tracker/,transactions/,snowball/,data/,layout/,ui/). client/lib/(money.ts,utils.ts,trackerUtils.ts,billDrafts.ts,billingSchedule.ts,cashflowUtils.ts,trackerTableColumns.ts,reorder.ts,version.ts).package.json(version 0.40.0;"type": "commonjs"),tsconfig.json,eslint.config.mjs,vite.config.mjs.Dockerfile(node:22-alpine),docker-compose.yml,docker-entrypoint.sh,.forgejo/workflows/ci.yml.
End-to-end checks run against the codebase:
find client -name '*.jsx'returns 0 — the JSX → TSX migration is complete.find client -name '*.ts'andfind client -name '*.tsx'together cover every previously.jsxfile incomponents/,pages/,hooks/,contexts/, plus the API client andlib/.greponpackage.jsonfor React 19, TypeScript 6, Vite 5, TanStack Query 5,@simplewebauthn/*13,otplib13.npm run typecheckwas green at the time of the v0.41.0 entry inHISTORY.md(0 errors); 48 client unit tests pass; 17/17 e2e probe (every page renders, all API paths respond).node --checkon every server JS file underserver.js,db,middleware,routes,services,utils(npm run check:server).
Track A/B/C/D additions (targeting v0.41.0, un-pushed as of this update):
6084896(Track A) —fix(money): make every payment balance-mutation atomicdd5bf92(Track A) —fix(money): manual payment path flags suspected dupes (409) w/o losing legit onesc223f62(Track B) —test(money): cross-surface reconciliation harnessb267599(Track C) —refactor(ts): type the bank-ledger API responses8265b4a(Track C) —refactor(ts): type the spending API responses6bb8c63(Track C) —refactor(ts): type the subscriptions API responses65a477c(Track C) —refactor(ts): type snowball projection + settings responses1df4b1b(Track C/D) —refactor(ts): move User to @/types + type auth endpoints; add code to 500
Diff stats across the 8 commits: 19 files changed, 813 insertions(+), 415 deletions(-). Notable: client/types.ts grew by 240 lines; new file client/lib/paymentActions.ts (~40 lines, createPaymentOrConfirm); client/api.ts re-exports 9 new typed methods; routes/payments.js grew by ~150 lines for the transactional + duplicate-detection logic; server.js gained a single line (code: 'INTERNAL_ERROR').
The previous manual (v0.28.1) contained stale route/page descriptions and missed major features added between v0.28 and v0.40 (subscriptions, spending, snowball plans, SimpleFIN, WebAuthn, TOTP, push notifications, category groups, calendar feed, money-cents migration in progress, React 18 → 19 + TypeScript migration, branded money types). This version replaces that with a current-state engineering reference.