114 KiB
Engineering Reference Manual — Bill Tracker
Status: Complete
Last Updated: 2026-05-09
Owner: Bishop
Version: 0.19.0
Table of Contents
- High Level Overview
- Frontend Documentation
- Backend Documentation
- Authentication & Authorization
- API Documentation
- Database Documentation
- Error Handling & Troubleshooting
- Code Navigation Index
- Infrastructure & Deployment
- Sequence Flows
1. High Level Overview
App Purpose
BillTracker is a self-hosted monthly bill tracking system for households and small setups. It manages:
- Recurring bills: Track due dates, expected amounts, categories, autopay, interest rates, website login info
- Monthly tracker: Record actual payments, skip bills, view spending vs expectations
- Calendar view: Visual grid showing due dates and payments
- Analytics: Charts, category spend, payment history
- User management: Admin creates users, sets roles, manages authentication
- Notifications: Email alerts for due bills (3d, 1d, today, overdue)
- Data management: Import/Export bills, full database backup/restore
Architecture Summary
Stack: Node.js + Express (backend) + React + Vite (frontend) + SQLite (database)
Layered Architecture:
┌─────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ Pages (client/pages/) • Components (client/components/) │
│ Router (client/App.jsx) • API Client (client/api.js) │
└─────────────────────────────────────────────────────────────┘
HTTP/JSON
┌─────────────────────────────────────────────────────────────┐
│ Backend (Express) │
│ Routes (routes/) • Services (services/) • Middleware │
│ Auth (authService.js) • OIDC (oidcService.js) │
└─────────────────────────────────────────────────────────────┘
SQL
┌─────────────────────────────────────────────────────────────┐
│ Database (SQLite) │
│ Schema (db/schema.sql) • Migrations (db/database.js) │
│ Users • Sessions • Bills • Payments • Categories • etc. │
└─────────────────────────────────────────────────────────────┘
Tech Stack
| Layer | Component | Version | Purpose |
|---|---|---|---|
| Runtime | Node.js | v20+ | Backend server |
| Framework | Express | ^4.18.2 | HTTP server, routing, middleware |
| Frontend | React | ^18.3.1 | UI components |
| Build | Vite | ^5.4.10 | Bundler, dev server |
| Router | react-router-dom | ^6.26.2 | Client-side routing |
| Database | better-sqlite3 | ^12.9.0 | SQLite wrapper |
| Auth | bcryptjs | ^2.4.3 | Password hashing |
| OIDC | openid-client | ^5.7.1 | Authentik integration |
| nodemailer | ^6.9.14 | SMTP email sending | |
| Scheduler | node-cron | ^3.0.3 | Background jobs |
| UI Libs | shadcn/ui | - | Component primitives |
| Styling | TailwindCSS | ^3.4.14 | Utility-first CSS |
Major Components
| Component | Location | Purpose |
|---|---|---|
| server.js | Root | Express entry, middleware setup, route mounting |
| db/database.js | db/ |
SQLite connection, migrations, settings |
| services/authService.js | services/ |
Session management, login/logout |
| services/oidcService.js | services/ |
Authentik OIDC integration |
| services/backupService.js | services/ |
Database backup/restore |
| middleware/requireAuth.js | middleware/ |
Auth guard middleware |
| middleware/csrf.js | middleware/ |
CSRF token generation/validation |
| workers/dailyWorker.js | workers/ |
Daily background tasks |
Request Lifecycle
┌─────────────────────────────────────────────────────────────┐
│ 1. Request arrives at Express (server.js) │
│ • Security headers applied (securityHeaders) │
│ • CSRF token set on response (csrfTokenProvider) │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 2. Route-level middleware chain │
│ • CSRF validation (csrfMiddleware) │
│ • Auth check (requireAuth) │
│ • User role check (requireUser/requireAdmin) │
│ • Rate limiting (loginLimiter, exportLimiter, etc.) │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 3. Route handler (routes/*.js) │
│ • Input validation │
│ • Service layer calls │
│ • Database queries │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 4. Service layer (services/*.js) │
│ • Business logic │
│ • External service calls (SMTP, OIDC) │
│ • Data transformation │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 5. Database layer (db/database.js) │
│ • SQL query execution │
│ • Schema validation (PRAGMA foreign_keys=ON) │
│ • Migration runs (if schema changes detected) │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 6. Response sent to frontend │
│ • JSON format │
│ • CSRF token in cookie │
│ • Error formatted via errorFormatter │
└─────────────────────────────────────────────────────────────┘
2. Frontend Documentation
Route Mapping
| Route | Page Component | Auth Required | Purpose |
|---|---|---|---|
/ |
Redirect | No | Redirects to login or app based on auth |
/login |
LoginPage.jsx | No | Username/password login form |
/tracker |
TrackerPage.jsx | Yes | Monthly bill tracking view |
/bills |
BillsPage.jsx | Yes | Bill CRUD interface |
/categories |
CategoriesPage.jsx | Yes | Category management |
/calendar |
CalendarPage.jsx | Yes | Monthly calendar view |
/summary |
SummaryPage.jsx | Yes | Monthly summary/spending view |
/analytics |
AnalyticsPage.jsx | Yes | Charts and analytics |
/profile |
ProfilePage.jsx | Yes | User profile, password change |
/settings |
SettingsPage.jsx | Yes | App settings (theme, format) |
/data |
DataPage.jsx | Yes | Import/export data tools |
/admin |
AdminPage.jsx | Yes (admin) | User management, backups, OIDC config |
/about |
AboutPage.jsx | No | Version info, changelog |
/status |
StatusPage.jsx | Yes (admin) | System status, worker health |
Key Frontend Files
Core Files
| File | Purpose | Key Functions |
|---|---|---|
client/main.jsx |
React entry point | Creates root, renders App |
client/App.jsx |
Router config | Defines all routes, layout wrapper |
client/api.js |
API client | get, post, put, delete, auth, CSRF |
client/hooks/useAuth.jsx |
Auth state | login, logout, user, loading |
Layout Components
| File | Purpose | Key Features |
|---|---|---|
client/components/layout/Layout.jsx |
Main layout wrapper | Sidebar, top bar, content area |
client/components/layout/Sidebar.jsx |
Navigation sidebar | Links to all pages, collapse |
client/components/layout/BrandBlock.jsx |
App branding | Logo, title, version |
client/components/layout/NavPill.jsx |
Nav item | Active state, icon |
UI Components (shadcn/ui)
| Component | File | Purpose |
|---|---|---|
| Button | client/components/ui/button.jsx |
Primary, secondary, ghost |
| Input | client/components/ui/input.jsx |
Form input |
| Card | client/components/ui/card.jsx |
Content container |
| Table | client/components/ui/table.jsx |
Data table |
| Tabs | client/components/ui/tabs.jsx |
Tab navigation |
| Dialog | client/components/ui/dialog.jsx |
Modal dialogs |
| Badge | client/components/ui/badge.jsx |
Status badges |
| Switch | client/components/ui/switch.jsx |
Toggle switch |
| Alert Dialog | client/components/ui/alert-dialog.jsx |
Confirm action dialog |
Page Components
| Page | Route | API Calls | State |
|---|---|---|---|
| LoginPage | /login |
POST /api/auth/login |
user, error |
| TrackerPage | /tracker |
GET /api/tracker, GET /api/tracker/upcoming |
data, year, month, activeBillId |
| BillsPage | /bills |
GET /api/bills, POST /api/bills, PUT /api/bills/:id, DELETE /api/bills/:id |
bills, categories, modalState |
| CategoriesPage | /categories |
GET /api/categories, POST /api/categories |
categories |
| CalendarPage | /calendar |
GET /api/bills, GET /api/tracker |
year, month, dates |
| SummaryPage | /summary |
GET /api/summary |
data, year, month |
| AnalyticsPage | /analytics |
GET /api/analytics |
filters, data |
| ProfilePage | /profile |
GET /api/user, POST /api/profile |
user, notifications |
| SettingsPage | /settings |
GET /api/settings, PUT /api/settings |
settings |
| DataPage | /data |
GET /api/import, POST /api/import, POST /api/export |
importState, exportState |
| AdminPage | /admin |
Multiple admin endpoints | users, backups, oidc |
State Management
Approach: Component-local state + React Context (ThemeContext)
Key State:
| State | Location | Purpose |
|---|---|---|
theme |
ThemeContext | Light/dark mode |
user |
useAuth hook | Current user object |
loading |
useAuth hook | Auth state |
error |
useAuth hook | Auth errors |
bills, categories, etc. |
Page components | API response data |
Validation
Frontend validation occurs before API calls:
// client/api.js - validation wrapper
function validateInput(schema, data) {
// Check required fields
// Type validation
// Range validation (numbers, dates)
}
Common validations:
| Field | Validation |
|---|---|
due_day |
Integer 1-31 |
expected_amount |
Number ≥ 0 |
interest_rate |
Number 0-100 or null |
password |
Min 8 characters (admin) |
username |
Min 3 characters (admin) |
year/month |
Valid date range |
API Client (client/api.js)
Key Functions:
// GET request with CSRF token
await apiGet('/api/bills', { year, month })
// POST with CSRF
await apiPost('/api/bills', { name, due_day, ... })
// PUT for updates
await apiPut('/api/bills/:id', { name, ... })
// DELETE
await apiDelete('/api/bills/:id')
CSRF Handling:
- Token stored in
bt_csrf_tokencookie (httpOnly) - Sent in
x-csrf-tokenheader for POST/PUT/DELETE - Auto-retrieved from cookie on each request
3. Backend Documentation
Core Backend Files
| File | Purpose | Key Functions |
|---|---|---|
server.js |
Express entry | Middleware setup, route mounting, error handling |
db/database.js |
DB connection | SQLite init, migrations, settings |
db/schema.sql |
Schema definition | All table definitions |
services/authService.js |
Auth service | Login, logout, session management |
services/oidcService.js |
OIDC service | Authentik integration |
services/backupService.js |
Backup service | SQLite backup/restore |
middleware/requireAuth.js |
Auth guards | requireAuth, requireUser, requireAdmin |
middleware/csrf.js |
CSRF protection | Token generation/validation |
middleware/rateLimiter.js |
Rate limiting | Per-endpoint limits |
middleware/securityHeaders.js |
Security headers | CSP, HSTS, XSS protection |
middleware/errorFormatter.js |
Error formatting | JSON error responses |
Route Handlers (routes/*.js)
| Route File | API Prefix | Auth | Purpose |
|---|---|---|---|
authLogin.js |
/api/auth/login |
None | Local login |
auth.js |
/api/auth |
CSRF | Logout, password change |
authOidc.js |
/api/auth/oidc |
CSRF | OIDC login/callback |
tracker.js |
/api/tracker |
Auth+User | Monthly tracking data |
bills.js |
/api/bills |
Auth+User | Bill CRUD |
payments.js |
/api/payments |
Auth+User | Payment CRUD |
categories.js |
/api/categories |
Auth+User | Category CRUD |
settings.js |
/api/settings |
Auth+User | Settings CRUD |
user.js |
/api/user |
Auth+User | User profile |
calendar.js |
/api/calendar |
Auth+User | Calendar data |
summary.js |
/api/summary |
Auth+User | Monthly summary |
monthly-starting-amounts.js |
/api/monthly-starting-amounts |
Auth+User | Starting balance |
analytics.js |
/api/analytics |
Auth+User | Analytics data |
notifications.js |
/api/notifications |
Auth+User | Notification settings |
admin.js |
/api/admin |
Auth+Admin | Admin functions |
export.js |
/api/export |
Auth+User | Data export |
import.js |
/api/import |
Auth+User | Data import |
status.js |
/api/status |
Auth+Admin | System status |
about.js |
/api/about |
None | Version info |
version.js |
/api/version |
None | Version string |
Service Layer Functions
authService.js
| Function | Purpose | Parameters | Returns |
|---|---|---|---|
login(username, password) |
Authenticate user | username, password |
{sessionId, user} or null |
logout(sessionId) |
Destroy session | sessionId |
void |
createSession(userId) |
Create session for OIDC | userId |
{sessionId, user} |
getSessionUser(sessionId) |
Validate session | sessionId |
user or null |
hashPassword(password) |
Hash password | password |
Promise<hash> |
publicUser(user) |
Strip sensitive data | user object |
Public user object |
cookieOpts(req) |
Get cookie options | req |
{httpOnly, sameSite, secure, maxAge} |
pruneExpiredSessions() |
Clean expired sessions | None | void |
rotateSessionId(oldId, userId) |
Security rotation | oldId, userId |
newId or null |
oidcService.js
| Function | Purpose | Parameters | Returns |
|---|---|---|---|
getOidcConfig() |
Get effective config | None | Config object or null |
isOidcLoginActive() |
Check if enabled | None | boolean |
createLoginState(redirectTo) |
Create PKCE state | redirectTo |
{id, nonce, codeVerifier} |
consumeLoginState(stateId) |
Validate state | stateId |
State or null |
buildAuthorizationUrl(config, state) |
Build redirect URL | config, state |
Promise<URL> |
exchangeAndVerifyTokens(config, code, stateId, savedState) |
Exchange code for tokens | config, code, stateId, savedState |
Verified claims |
findOrProvisionUser(claims, config) |
Find or create user | claims, config |
User object |
mapRoleFromClaims(claims, config) |
Map groups to role | claims, config |
'admin' or 'user' |
testOidcConfiguration(config) |
Test OIDC setup | config |
{ok, error, ...} |
getAdminOidcSettings() |
Admin settings | None | Settings object |
getPublicOidcInfo() |
Public info | None | {oidc_enabled, oidc_provider_name} |
backupService.js
| Function | Purpose | Parameters | Returns |
|---|---|---|---|
createBackup(prefix) |
Create SQLite backup | prefix |
{id, filename, size_bytes, checksum} |
restoreBackup(id) |
Restore from backup | backupId |
{restored_from, pre_restore_backup} |
deleteBackup(id) |
Delete backup | backupId |
{deleted: true, id, deleted_at} |
listBackups() |
List backups | None | Array of backup metadata |
getBackupFile(id) |
Get backup path | backupId |
{path, metadata} |
importBackupBuffer(buffer, options) |
Import backup | buffer, {expectedChecksum} |
Backup metadata |
validateSqliteDatabase(filePath) |
Validate DB file | filePath |
void or throws |
checksumFile(filePath) |
SHA-256 checksum | filePath |
hex string |
notificationService.js
| Function | Purpose | Parameters | Returns |
|---|---|---|---|
runNotifications() |
Send due bill emails | None | void |
sendTestEmail(to) |
Test SMTP config | email |
void |
createTransport() |
Create SMTP transport | None | Nodemailer transport |
cleanupService.js
| Function | Purpose | Parameters | Returns |
|---|---|---|---|
runAllCleanup() |
Run all cleanup tasks | None | {import_sessions, temp_exports, ...} |
validateAndApplySettings(settings) |
Update cleanup config | settings |
Updated config |
getCleanupStatus() |
Get cleanup status | None | {settings, last_run, last_result} |
statusService.js
| Function | Purpose | Parameters | Returns |
|---|---|---|---|
buildTrackerRow(bill, payments, year, month, today) |
Build tracker row | bill, payments, year, month, today |
Row object |
resolveDueDate(bill, year, month) |
Calculate due date | bill, year, month |
YYYY-MM-DD |
getCycleRange(year, month) |
Get date range | year, month |
{start, end} |
Middleware Chain
requireAuth.js
| Middleware | Purpose | Check |
|---|---|---|
requireAuth |
General auth | Session valid, user active |
requireUser |
User role | Role is 'user' or 'admin', not default admin |
requireAdmin |
Admin role | Role is 'admin' |
CSRF Protection
| Setting | Default | Purpose |
|---|---|---|
CSRF_HTTP_ONLY |
true |
Cookie not accessible via JS |
CSRF_SAME_SITE |
'strict' |
Same-site cookie |
CSRF_SECURE |
true |
HTTPS only |
CSRF_COOKIE_NAME |
'bt_csrf_token' |
Cookie name |
Flow:
csrfTokenProvidersets cookie on every responsecsrfMiddlewarevalidates token on POST/PUT/DELETE- Token can be in header, query, or body
Rate Limiters
| Limiter | Max | Window | Endpoints |
|---|---|---|---|
loginLimiter |
10 | 15 min | /api/auth/login |
passwordLimiter |
5 | 15 min | /api/profile, /api/admin/users/:id/password |
importLimiter |
20 | 15 min | /api/import/* |
exportLimiter |
30 | 15 min | /api/export/* |
adminActionLimiter |
30 | 15 min | /api/admin/* |
oidcLimiter |
20 | 15 min | /api/auth/oidc/* |
backupOperationLimiter |
5 | 60 min | /api/admin/backups/* |
Error Handling
errorFormatter.js
| Error Type | Status Code | Response Format |
|---|---|---|
| Validation | 400 | {error: 'Validation failed', field: 'field_name'} |
| Auth | 401 | {error: 'Not authenticated', code: 'AUTH_ERROR'} |
| Forbidden | 403 | {error: 'Access denied', code: 'FORBIDDEN'} |
| Not Found | 404 | {error: 'Not found', code: 'NOT_FOUND'} |
| Conflict | 409 | {error: 'Already exists', code: 'CONFLICT'} |
| Rate Limit | 429 | {error: 'Too many requests'} |
| Server | 500 | {error: 'Internal server error'} |
Standard Error Format:
{
error: 'Error message',
code: 'ERROR_CODE',
field: 'optional_field_name'
}
4. Authentication & Authorization
Login Flow
┌─────────────────────────────────────────────────────────────┐
│ 1. User submits login form │
│ • Username, password │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 2. POST /api/auth/login │
│ • rateLimiter (loginLimiter) │
│ • Body: {username, password} │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 3. authService.login() │
│ • Query user by username │
│ • Check active flag │
│ • Check auth_provider === 'local' │
│ • bcrypt.compare(password, password_hash) │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 4. Create session │
│ • Generate UUID for sessionId │
│ • Insert into sessions table (expires in 7 days) │
│ • Update last_login_at │
│ • Return {sessionId, user} │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 5. Set cookie │
│ • Cookie: bt_session=<sessionId> │
│ • httpOnly: true, sameSite: strict, secure: depends │
│ • Max-Age: 7 days │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 6. Return response │
│ • JSON: {user: {id, username, display_name, role, ...}} │
│ • CSRF token set on cookie │
└─────────────────────────────────────────────────────────────┘
Session/JWT Handling
Session Storage: SQLite sessions table
| Column | Type | Purpose |
|---|---|---|
id |
TEXT (UUID) | Session identifier |
user_id |
INTEGER | Reference to users.id |
expires_at |
TEXT (ISO) | Expiration timestamp |
created_at |
TEXT (ISO) | Session creation time |
Session Validation:
SELECT u.id, u.username, u.display_name, u.role, u.must_change_password,
u.active, u.is_default_admin
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.expires_at > datetime('now') AND u.active = 1
Session Duration: 7 days
Session Destruction:
- Explicit logout:
DELETE FROM sessions WHERE id = ? - Session expiry: Daily worker pruning
- User deactivation:
DELETE FROM sessions WHERE user_id = ? - Role change:
DELETE FROM sessions WHERE user_id = ?
RBAC (Role-Based Access Control)
| Role | Capabilities |
|---|---|
user |
View/modify own bills, categories, payments, settings, profile |
admin |
All user capabilities + user management, backups, OIDC config, system settings |
Admin Guard:
function requireAdmin(req, res, next) {
if (req.user?.role !== 'admin') {
return res.status(403).json({error: 'Access denied: admin account required'});
}
next();
}
Middleware Chain
Route Protection Example:
// Admin routes
app.use('/api/admin', csrfMiddleware, requireAuth, requireAdmin,
adminActionLimiter, require('./routes/admin'));
// User routes
app.use('/api/bills', csrfMiddleware, requireAuth, requireUser,
require('./routes/bills'));
// Public routes (no auth)
app.use('/api/about', require('./routes/about'));
app.use('/api/version', require('./routes/version'));
Cookie Handling
| Cookie | Name | Type | Secure | SameSite | Purpose |
|---|---|---|---|---|---|
| Session | bt_session |
httpOnly | Configurable | strict |
Auth session |
| CSRF | bt_csrf_token |
httpOnly | Configurable | strict |
CSRF token |
Cookie Options (determined at runtime):
function cookieOpts(req) {
const cookieSecure = envFlag('COOKIE_SECURE');
const httpsSecure = envFlag('HTTPS');
const secure = cookieSecure !== null
? cookieSecure
: httpsSecure !== null
? httpsSecure
: requestLooksHttps(req); // Check X-Forwarded-Proto
return {
httpOnly: true,
sameSite: 'strict',
secure,
maxAge: SESSION_DAYS * 86400 * 1000, // 7 days
path: '/',
};
}
OIDC/Authentik Flow
┌─────────────────────────────────────────────────────────────┐
│ 1. User clicks OIDC login button │
│ • Redirects to frontend login page │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 2. Frontend calls /api/auth/oidc/login │
│ • Query params: ?redirect_to=/tracker │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 3. Create login state │
│ • Generate PKCE code_verifier (32 bytes base64url) │
│ • Generate nonce (16 bytes hex) │
│ • Store in oidc_states table (expires 5 min) │
│ • Code challenge = SHA256(code_verifier) base64url │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 4. Redirect to OIDC provider │
│ • Authorization URL built with: │
│ • client_id, redirect_uri, response_type=code │
│ • state (login state ID), nonce │
│ • code_challenge, code_challenge_method=S256 │
│ • scopes: openid, email, profile, groups │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 5. User authenticates with OIDC provider │
│ • Authentik validates credentials │
│ • Provider sends user back to redirect_uri │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 6. Callback: /api/auth/oidc/callback │
│ • Query params: code, state │
│ • Rate limited (oidcLimiter) │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 7. Exchange code for tokens │
│ • POST to OIDC token endpoint │
│ • client_id + client_secret + code + redirect_uri │
│ • code_verifier for PKCE │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 8. Verify ID token │
│ • JWT signature via JWKS │
│ • Issuer validation (iss claim) │
│ • Audience validation (aud claim) │
│ • Expiry validation (exp claim) │
│ • Nonce validation (replay protection) │
│ • State validation (replay protection) │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 9. Find or provision user │
│ • Look up by sub (external_subject) │
│ • Look up by email if email_verified=true │
│ • Auto-provision if OIDC_AUTO_PROVISION=true │
│ • Map groups to role (admin if in admin_group) │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 10. Create local session │
│ • Same mechanism as local login │
│ • Set bt_session cookie │
│ • Redirect to redirect_to (or /) │
└─────────────────────────────────────────────────────────────┘
OIDC Configuration:
# Environment variables (fallback if DB settings blank)
OIDC_ISSUER_URL=https://auth.example.com/application/o/bills/.well-known/openid-configuration
OIDC_CLIENT_ID=<client-id>
OIDC_CLIENT_SECRET=<client-secret>
OIDC_REDIRECT_URI=https://bills.example.com/api/auth/oidc/callback
OIDC_SCOPES=openid email profile groups
OIDC_ADMIN_GROUP=bill-tracker-admins
OIDC_AUTO_PROVISION=true
Failure Scenarios
| Scenario | Status Code | Response | Recovery |
|---|---|---|---|
| Invalid credentials | 401 | {error: 'Invalid username or password', code: 'AUTH_ERROR'} |
Retry with correct credentials |
| Session expired | 401 | {error: 'Not authenticated', code: 'AUTH_ERROR'} |
Re-login |
| Role insufficient | 403 | {error: 'Access denied: admin account required', code: 'FORBIDDEN'} |
Login as admin |
| Rate limited | 429 | {error: 'Too many login attempts...'} |
Wait 15 minutes |
| CSRF invalid | 403 | {error: 'CSRF token validation failed', code: 'CSRF_INVALID'} |
Refresh page |
| OIDC config missing | 501 | {error: 'OIDC authentication is not configured...'} |
Configure OIDC in Admin |
| OIDC provider error | 502 | {error: 'Failed to reach the identity provider...'} |
Check OIDC provider status |
| OIDC callback expired state | Redirect + query param | /?oidc_error=invalid_or_expired_state |
Start login flow again |
Code Locations
| Component | File |
|---|---|
| Login endpoint | routes/authLogin.js |
| Auth middleware | middleware/requireAuth.js |
| CSRF middleware | middleware/csrf.js |
| Session management | services/authService.js |
| OIDC login | routes/authOidc.js |
| OIDC service | services/oidcService.js |
| OIDC tables | db/schema.sql (sessions, oidc_states) |
5. API Documentation
Authentication Endpoints
POST /api/auth/login
Purpose: Local username/password login
Request:
{
"username": "string (required)",
"password": "string (required)"
}
Response:
{
"user": {
"id": 1,
"username": "admin",
"display_name": "Administrator",
"role": "admin",
"active": true,
"is_default_admin": true,
"must_change_password": false,
"first_login": false
}
}
Rate Limit: 10 per 15 minutes per IP (bypassed if no users exist)
Errors:
| Status | Code | Message |
|---|---|---|
| 400 | VALIDATION_ERROR |
Username/password missing |
| 401 | AUTH_ERROR |
Invalid credentials |
| 403 | FORBIDDEN |
Local login disabled |
| 429 | RATE_LIMITED |
Too many attempts |
GET /api/auth/oidc/login
Purpose: Initiate OIDC login flow
Query Parameters:
redirect_to(optional): URL to redirect after login
Response: HTTP 302 redirect to OIDC provider
Errors:
| Status | Redirect | Reason |
|---|---|---|
| 501 | - | OIDC not configured |
| 502 | - | Provider unreachable |
GET /api/auth/oidc/callback
Purpose: OIDC callback handler
Query Parameters:
code: Authorization codestate: Login state IDerror(optional): Provider error
Response: HTTP 302 redirect to frontend or error page
Errors:
| Status | Redirect | Reason |
|---|---|---|
| 302 | /?oidc_error=not_configured |
OIDC disabled |
| 302 | /?oidc_error=authorization_failed |
Provider signalled error |
| 302 | /?oidc_error=invalid_callback |
Missing code or state |
| 302 | /?oidc_error=invalid_or_expired_state |
State invalid/expired |
| 302 | /?oidc_error=authentication_failed |
Token validation failure |
| 302 | /?oidc_error=access_denied |
User not in admin group (if required) |
GET /api/auth/logout
Purpose: Logout (session invalidation)
Response:
{
"success": true
}
Tracker Endpoints
GET /api/tracker
Purpose: Monthly tracker data
Query Parameters:
year(optional, default: current year)month(optional, default: current month)
Response:
{
"year": 2026,
"month": 5,
"today": "2026-05-09",
"summary": {
"total_expected": 450.00,
"total_starting": 500.00,
"has_starting_amounts": true,
"total_paid": 320.00,
"remaining": 180.00,
"overdue": 75.00,
"count_paid": 5,
"count_upcoming": 3,
"count_late": 2,
"count_autodraft": 1
},
"rows": [
{
"id": 1,
"name": "Rent",
"category_name": "Housing",
"due_date": "2026-05-01",
"expected_amount": 1200.00,
"actual_amount": 1200.00,
"total_paid": 1200.00,
"balance": 0,
"status": "paid",
"autopay_enabled": false,
"autodraft_status": "none"
},
...
]
}
GET /api/tracker/upcoming
Purpose: Bills due in next N days
Query Parameters:
days(optional, default: 30, max: 365)
Response:
{
"days": 30,
"today": "2026-05-09",
"upcoming": [
{
"id": 2,
"name": "Internet",
"category_name": "Phone & Internet",
"due_date": "2026-05-15",
"expected_amount": 60.00,
"status": "due_soon",
"days_until_due": 6
}
]
}
Bills Endpoints
GET /api/bills
Purpose: List all bills (active or all)
Query Parameters:
inactive(optional): If "true", include inactive bills
Response: Array of bill objects
GET /api/bills/:id
Purpose: Get single bill by ID
Response: Bill object
POST /api/bills
Purpose: Create new bill
Request:
{
"name": "Internet",
"category_id": 5,
"due_day": 15,
"override_due_date": null,
"expected_amount": 60.00,
"interest_rate": null,
"billing_cycle": "monthly",
"autopay_enabled": false,
"autodraft_status": "none",
"website": null,
"username": null,
"account_info": null,
"has_2fa": false,
"notes": "Fiber optic internet",
"history_visibility": "default"
}
Response: Created bill with ID
PUT /api/bills/:id
Purpose: Update bill
Request: Partial bill object
Response: Updated bill
DELETE /api/bills/:id
Purpose: Hard-delete bill (irreversible)
Response:
{
"success": true,
"deleted_bill_id": 1,
"deleted_bill_name": "Rent",
"warning": "Bill and all associated payments, monthly state, and history ranges were permanently deleted."
}
GET /api/bills/:id/payments
Purpose: List payments for a bill
Query Parameters:
page(optional, default: 1)limit(optional, default: 20, max: 100)
Response:
{
"bill_id": 1,
"bill_name": "Rent",
"total": 5,
"page": 1,
"limit": 20,
"pages": 1,
"payments": [...]
}
POST /api/bills/:id/toggle-paid
Purpose: Toggle bill as paid/unpaid
Request:
{
"amount": 1200.00,
"paid_date": "2026-05-01",
"method": "ACH",
"notes": "Rent payment"
}
Response:
{
"success": true,
"isPaid": true,
"action": "created_payment",
"payment": { ... }
}
GET /api/bills/:id/monthly-state
Purpose: Get monthly state override for a bill
Query Parameters:
year(required)month(required)
Response:
{
"bill_id": 1,
"year": 2026,
"month": 5,
"actual_amount": 1200.00,
"notes": null,
"is_skipped": false
}
PUT /api/bills/:id/monthly-state
Purpose: Set monthly state override
Request:
{
"year": 2026,
"month": 5,
"actual_amount": 1250.00,
"notes": "Rent increased",
"is_skipped": false
}
Response: Saved state
Categories Endpoints
GET /api/categories
Purpose: List all categories for user
Response: Array of category objects
POST /api/categories
Purpose: Create new category
Request:
{
"name": "Entertainment"
}
Response: Created category
Settings Endpoints
GET /api/settings
Purpose: Get all settings
Response:
{
"currency": "USD",
"date_format": "MM/DD/YYYY",
"grace_period_days": "5",
"notify_days_before": "3",
"backup_enabled": "false",
...
}
PUT /api/settings
Purpose: Update settings
Request: Partial settings object
Response: Updated settings
User Endpoints
GET /api/user
Purpose: Get current user profile
Response: User object (without password)
Calendar Endpoints
GET /api/calendar
Purpose: Calendar data for a month
Query Parameters:
year(optional)month(optional)
Response: Calendar data with bill due dates
Summary Endpoints
GET /api/summary
Purpose: Monthly spending summary
Query Parameters:
year(optional)month(optional)
Response:
{
"year": 2026,
"month": 5,
"total_expected": 450.00,
"total_actual": 425.00,
"total_paid": 425.00,
"total_starting": 500.00,
"remaining": 75.00,
"by_category": [...],
"by_bill": [...]
}
Analytics Endpoints
GET /api/analytics
Purpose: Analytics data with filters
Query Parameters:
start_date(optional, default: 30 days ago)end_date(optional, default: today)category_id(optional)bill_id(optional)
Response:
{
"start_date": "2026-04-09",
"end_date": "2026-05-09",
"total_spent": 425.00,
"expected_vs_actual": {
"expected": 450.00,
"actual": 425.00,
"difference": -25.00
},
"by_category": [...],
"payment_history": [...]
}
Profile Endpoints
GET /api/profile
Purpose: Get user profile
Response: User profile
POST /api/profile
Purpose: Update user profile
Request:
{
"display_name": "John Doe",
"notification_email": "john@example.com",
"notifications_enabled": true,
"notify_3d": true,
"notify_1d": true,
"notify_due": true,
"notify_overdue": true,
"current_password": "oldpass123",
"new_password": "newpass456"
}
Response: Updated user
Rate Limit: 5 per 15 minutes per IP
Admin Endpoints
All admin routes require requireAuth + requireAdmin + csrfMiddleware + adminActionLimiter
GET /api/admin/has-users
Purpose: Check if other users exist (lockout protection)
Response:
{
"has_users": true
}
GET /api/admin/users
Purpose: List all users
Response: Array of user objects with admin fields
POST /api/admin/users
Purpose: Create new user
Request:
{
"username": "newuser",
"password": "password123"
}
Response: Created user
Errors:
| Status | Message |
|---|---|
| 400 | Username/password too short |
| 409 | Username already taken |
PUT /api/admin/users/:id/password
Purpose: Reset user password
Request:
{
"password": "newpassword123"
}
Response:
{
"success": true
}
Effects:
- Updates password hash
- Sets
must_change_password = 1 - Invalidates all user sessions
PUT /api/admin/users/:id/role
Purpose: Promote/demote user
Request:
{
"role": "admin"
}
Response: Updated user
Validations:
- Cannot change own role
- Cannot remove last admin
- Deletes all sessions for target user
PUT /api/admin/users/:id/active
Purpose: Deactivate/reactivate user
Request:
{
"active": false
}
Response: Updated user
Effects:
- Sets
active = 0/1 - Invalidates all sessions if deactivated
DELETE /api/admin/users/:id
Purpose: Delete user (irreversible)
Response:
{
"success": true,
"deleted_user_id": 2
}
Effects:
- Deletes all user data (sessions, imports, exports, bills, categories)
Backup Endpoints
| Method | Endpoint | Purpose | Rate Limit |
|---|---|---|---|
| POST | /api/admin/backups |
Create backup | 5/60min |
| GET | /api/admin/backups |
List backups | 5/60min |
| GET | /api/admin/backups/:id/download |
Download backup | - |
| POST | /api/admin/backups/:id/restore |
Restore backup | 5/60min |
| DELETE | /api/admin/backups/:id |
Delete backup | 5/60min |
| POST | /api/admin/backups/import |
Import backup | 5/60min |
| GET | /api/admin/backups/settings |
Get backup schedule | 5/60min |
| PUT | /api/admin/backups/settings |
Update backup schedule | 5/60min |
| POST | /api/admin/backups/run-scheduled-now |
Run scheduled backup now | 5/60min |
Backup Request/Response:
// POST /api/admin/backups
// Response (201 Created)
{
"id": "bill-tracker-backup-2026-05-09-03-45-32-456Z-abcd1234.sqlite",
"filename": "bill-tracker-backup-2026-05-09-03-45-32-456Z-abcd1234.sqlite",
"type": "manual",
"created_at": "2026-05-09T03:45:32.456Z",
"modified_at": "2026-05-09T03:45:32.456Z",
"size_bytes": 200704,
"checksum": "abc123def456..."
}
Restore Request/Response:
// POST /api/admin/backups/:id/restore
// Response
{
"restored_from": "bill-tracker-backup-2026-05-08-02-00-00-000Z-1234abcd.sqlite",
"pre_restore_backup": "pre-restore-2026-05-09-03-46-00-000Z-5678efgh.sqlite",
"restored_at": "2026-05-09T03:46:00.000Z",
"restart_required": false
}
Import Backup:
- Body: Binary SQLite file
- Header:
X-Checksum-SHA256: <hash>(optional, if provided, validated) - Max size: 100MB
Cleanup Endpoints
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/admin/cleanup |
Get cleanup settings and status |
| PUT | /api/admin/cleanup |
Update cleanup settings |
| POST | /api/admin/cleanup/run |
Run cleanup immediately |
Cleanup Settings:
{
"import_sessions_enabled": true,
"temp_exports_enabled": true,
"temp_export_max_age_hours": 2,
"backup_partials_enabled": true,
"import_history_enabled": false,
"import_history_max_age_days": 365
}
Auth Mode Endpoints
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /api/admin/auth-mode |
Get auth configuration |
| PUT | /api/admin/auth-mode |
Update auth configuration |
| POST | /api/admin/auth-mode/oidc-test |
Test OIDC configuration |
Auth Mode Settings:
{
"auth_mode": "multi",
"local_login_enabled": true,
"oidc_login_enabled": false,
"oidc_configured": false,
"oidc_issuer_url_set": false,
"oidc_client_id_set": false,
"oidc_client_secret_set": false,
"oidc_redirect_uri_set": false,
"oidc_missing_fields": ["issuer URL", "client ID", "client secret", "redirect URI"],
"can_disable_local": false,
"warnings": []
}
Export Endpoints
GET /api/export
Purpose: Export user data as XLSX
Query Parameters:
export_type(optional): 'bills', 'payments', 'categories', 'full'start_date,end_date(optional): Date range
Response: XLSX file download
Rate Limit: 30 per 15 minutes per IP
Import Endpoints
GET /api/import
Purpose: Get import history and preview settings
POST /api/import
Purpose: Preview or apply import
Form Data:
file: XLSX filepreview: 'true' or 'false'
Response (preview):
{
"preview": true,
"rows_parsed": 10,
"rows_created": 8,
"rows_updated": 2,
"rows_skipped": 0,
"rows_errored": 0,
"data": [...]
}
Response (apply):
{
"preview": false,
"imported_at": "2026-05-09T03:50:00.000Z",
"rows_parsed": 10,
"rows_created": 8,
"rows_updated": 2,
"rows_skipped": 0,
"rows_errored": 0
}
Rate Limit: 20 per 15 minutes per IP
Status Endpoints
GET /api/status
Purpose: System status (admin only)
Response:
{
"version": "0.19.0",
"node_env": "production",
"db_path": "/data/bills.db",
"backup_path": "/data/backups",
"sqlite_version": "3.45.0",
"users_count": 2,
"bills_count": 15,
"last_worker_run": "2026-05-09T06:00:00.000Z",
"last_worker_status": "success",
"uptime_seconds": 86400,
"last_error": null
}
GET /api/about
Purpose: Public version info
Response:
{
"version": "0.19.0",
"name": "Bill Tracker",
"build_time": "2026-05-01T00:00:00.000Z"
}
6. Database Documentation
Schema Overview
Database: SQLite (better-sqlite3)
Schema Location: db/schema.sql
Migration Logic: db/database.js (runMigrations function)
Table Definitions
users
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PRIMARY KEY, AUTOINCREMENT | User ID |
username |
TEXT | NOT NULL, UNIQUE (CASE-insensitive) | Login username |
password_hash |
TEXT | NOT NULL | bcrypt hash of password |
role |
TEXT | NOT NULL, CHECK('admin', 'user') | Role: admin or user |
active |
INTEGER | NOT NULL, DEFAULT 1 | 1=active, 0=deactivated |
is_default_admin |
INTEGER | NOT NULL, DEFAULT 0 | 1=initial admin account |
must_change_password |
INTEGER | NOT NULL, DEFAULT 0 | 1=force password change on next login |
first_login |
INTEGER | NOT NULL, DEFAULT 1 | 1=user has never logged in |
created_at |
TEXT | DEFAULT (datetime('now')) | Account creation time |
updated_at |
TEXT | DEFAULT (datetime('now')) | Last update time |
notification_email |
TEXT | User's email for notifications | |
notifications_enabled |
INTEGER | NOT NULL, DEFAULT 0 | 1=receive email notifications |
notify_3d |
INTEGER | NOT NULL, DEFAULT 1 | Notify 3 days before due |
notify_1d |
INTEGER | NOT NULL, DEFAULT 1 | Notify 1 day before due |
notify_due |
INTEGER | NOT NULL, DEFAULT 1 | Notify on due date |
notify_overdue |
INTEGER | NOT NULL, DEFAULT 1 | Notify for overdue bills |
display_name |
TEXT | Display name (OIDC) | |
last_password_change_at |
TEXT | Last password change timestamp | |
auth_provider |
TEXT | NOT NULL, DEFAULT 'local' | 'local' or 'oidc' |
external_subject |
TEXT | OIDC sub claim | |
email |
TEXT | OIDC email claim | |
last_login_at |
TEXT | Last login timestamp |
Indexes:
idx_sessions_user_id(on sessions.user_id)idx_sessions_expires(on sessions.expires_at)
sessions
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | Session UUID |
user_id |
INTEGER | NOT NULL, REFERENCES users(id) ON DELETE CASCADE | User ID |
expires_at |
TEXT | NOT NULL | Expiration timestamp |
created_at |
TEXT | DEFAULT (datetime('now')) | Session creation time |
Indexes:
idx_sessions_user_idonuser_ididx_sessions_expiresonexpires_at
bills
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PRIMARY KEY, AUTOINCREMENT | Bill ID |
user_id |
INTEGER | NOT NULL, REFERENCES users(id) ON DELETE CASCADE | Owner user ID |
name |
TEXT | NOT NULL | Bill name |
category_id |
INTEGER | REFERENCES categories(id) ON DELETE SET NULL | Category reference |
due_day |
INTEGER | NOT NULL, CHECK(1-31) | Due day of month |
override_due_date |
TEXT | Custom due date override | |
bucket |
TEXT | CHECK('1st', '15th') | Payment bucket |
expected_amount |
REAL | NOT NULL, DEFAULT 0 | Expected monthly amount |
interest_rate |
REAL | CHECK(0-100) | APR or interest rate |
billing_cycle |
TEXT | DEFAULT 'monthly', CHECK | 'monthly', 'quarterly', 'annually', 'irregular' |
autopay_enabled |
INTEGER | NOT NULL, DEFAULT 0 | 1=autopay enabled |
autodraft_status |
TEXT | NOT NULL, DEFAULT 'none' | 'none', 'pending', 'assumed_paid', 'confirmed' |
website |
TEXT | Bill provider website | |
username |
TEXT | Bill provider username | |
account_info |
TEXT | Bill account info | |
has_2fa |
INTEGER | NOT NULL, DEFAULT 0 | 1=2FA enabled |
history_visibility |
TEXT | NOT NULL, DEFAULT 'default' | 'default', 'all', 'ranges', 'none' |
active |
INTEGER | NOT NULL, DEFAULT 1 | 1=active, 0=inactive |
notes |
TEXT | User notes | |
is_seeded |
INTEGER | NOT NULL, DEFAULT 0 | 1=demo data |
created_at |
TEXT | DEFAULT (datetime('now')) | Creation time |
updated_at |
TEXT | DEFAULT (datetime('now')) | Last update time |
Indexes:
idx_bills_activeonactiveidx_bills_user_activeonuser_id, activeidx_bills_user_idonuser_id
categories
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PRIMARY KEY, AUTOINCREMENT | Category ID |
user_id |
INTEGER | NOT NULL, REFERENCES users(id) ON DELETE CASCADE | Owner user ID |
name |
TEXT | NOT NULL | Category name |
created_at |
TEXT | DEFAULT (datetime('now')) | Creation time |
updated_at |
TEXT | DEFAULT (datetime('now')) | Last update time |
Constraints:
- Unique constraint:
UNIQUE(user_id, name COLLATE NOCASE)
Indexes:
idx_categories_user_nameonuser_id, nameidx_categories_user_name_uniqueonuser_id, name COLLATE NOCASE
payments
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PRIMARY KEY, AUTOINCREMENT | Payment ID |
bill_id |
INTEGER | NOT NULL, REFERENCES bills(id) ON DELETE CASCADE | Bill reference |
amount |
REAL | NOT NULL | Payment amount |
paid_date |
TEXT | NOT NULL | Payment date (YYYY-MM-DD) |
method |
TEXT | Payment method (cash, check, card, ACH) | |
notes |
TEXT | Payment notes | |
deleted_at |
TEXT | Soft-delete timestamp | |
created_at |
TEXT | DEFAULT (datetime('now')) | Creation time |
updated_at |
TEXT | DEFAULT (datetime('now')) | Last update time |
Indexes:
idx_payments_bill_idonbill_ididx_payments_paid_dateonpaid_dateidx_payments_bill_date_delonbill_id, paid_date, deleted_atidx_payments_deletedondeleted_at
monthly_bill_state
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PRIMARY KEY, AUTOINCREMENT | State ID |
bill_id |
INTEGER | NOT NULL, REFERENCES bills(id) ON DELETE CASCADE | Bill reference |
year |
INTEGER | NOT NULL, CHECK(2000-2100) | Year |
month |
INTEGER | NOT NULL, CHECK(1-12) | Month |
actual_amount |
REAL | Actual amount paid (override expected) | |
notes |
TEXT | Month-specific notes | |
is_skipped |
INTEGER | NOT NULL, DEFAULT 0 | 1=skip this month |
created_at |
TEXT | DEFAULT (datetime('now')) | Creation time |
updated_at |
TEXT | DEFAULT (datetime('now')) | Last update time |
Constraints:
UNIQUE(bill_id, year, month)
Indexes:
idx_monthly_bill_state_lookuponbill_id, year, month
settings
| Column | Type | Constraints | Description |
|---|---|---|---|
key |
TEXT | PRIMARY KEY | Setting key |
value |
TEXT | NOT NULL | Setting value |
updated_at |
TEXT | DEFAULT (datetime('now')) | Last update time |
notifications
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PRIMARY KEY, AUTOINCREMENT | Notification ID |
bill_id |
INTEGER | NOT NULL, REFERENCES bills(id) ON DELETE CASCADE | Bill reference |
user_id |
INTEGER | NOT NULL, REFERENCES users(id) ON DELETE CASCADE | User reference |
year |
INTEGER | NOT NULL | Year |
month |
INTEGER | NOT NULL | Month |
type |
TEXT | NOT NULL | Notification type |
sent_date |
TEXT | NOT NULL, DEFAULT (date('now')) | Date sent |
Constraints:
UNIQUE(bill_id, user_id, year, month, type, sent_date)
Indexes:
idx_notifications_lookuponbill_id, user_id, year, month
oidc_states
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | State UUID |
nonce |
TEXT | NOT NULL | Nonce for replay protection |
code_verifier |
TEXT | NOT NULL | PKCE code verifier |
redirect_to |
TEXT | Redirect URL after login | |
created_at |
TEXT | NOT NULL | Creation time |
expires_at |
TEXT | NOT NULL | Expiration time |
Indexes:
idx_oidc_states_expiresonexpires_at
bill_history_ranges
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PRIMARY KEY, AUTOINCREMENT | Range ID |
bill_id |
INTEGER | NOT NULL, REFERENCES bills(id) ON DELETE CASCADE | Bill reference |
start_year |
INTEGER | NOT NULL | Start year |
start_month |
INTEGER | NOT NULL | Start month |
end_year |
INTEGER | End year (NULL = open-ended) | |
end_month |
INTEGER | End month (NULL = open-ended) | |
label |
TEXT | Range label | |
created_at |
TEXT | DEFAULT (datetime('now')) | Creation time |
updated_at |
TEXT | DEFAULT (datetime('now')) | Last update time |
Indexes:
idx_bill_history_ranges_billonbill_id
monthly_income
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PRIMARY KEY, AUTOINCREMENT | Income record ID |
user_id |
INTEGER | NOT NULL, REFERENCES users(id) ON DELETE CASCADE | User reference |
year |
INTEGER | NOT NULL, CHECK(2000-2100) | Year |
month |
INTEGER | NOT NULL, CHECK(1-12) | Month |
label |
TEXT | NOT NULL, DEFAULT 'Salary' | Income source |
amount |
REAL | NOT NULL, DEFAULT 0 | Income amount |
created_at |
TEXT | DEFAULT (datetime('now')) | Creation time |
updated_at |
TEXT | DEFAULT (datetime('now')) | Last update time |
Constraints:
UNIQUE(user_id, year, month)
Indexes:
idx_monthly_income_user_monthonuser_id, year, month
monthly_starting_amounts
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PRIMARY KEY, AUTOINCREMENT | Amount record ID |
user_id |
INTEGER | NOT NULL, REFERENCES users(id) ON DELETE CASCADE | User reference |
year |
INTEGER | NOT NULL, CHECK(2000-2100) | Year |
month |
INTEGER | NOT NULL, CHECK(1-12) | Month |
first_amount |
REAL | NOT NULL, DEFAULT 0, CHECK ≥ 0 | Amount for 1st of month |
fifteenth_amount |
REAL | NOT NULL, DEFAULT 0, CHECK ≥ 0 | Amount for 15th of month |
other_amount |
REAL | NOT NULL, DEFAULT 0, CHECK ≥ 0 | Other amount |
notes |
TEXT | Notes | |
created_at |
TEXT | DEFAULT (datetime('now')) | Creation time |
updated_at |
TEXT | DEFAULT (datetime('now')) | Last update time |
Constraints:
UNIQUE(user_id, year, month)
Indexes:
idx_monthly_starting_amounts_user_monthonuser_id, year, month
import_sessions
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | Session UUID |
user_id |
INTEGER | NOT NULL | User reference |
created_at |
TEXT | NOT NULL | Creation time |
expires_at |
TEXT | NOT NULL | Expiration time |
preview_json |
TEXT | NOT NULL | JSON preview data |
Indexes:
idx_import_sessions_useronuser_ididx_import_sessions_expiresonexpires_at
import_history
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PRIMARY KEY, AUTOINCREMENT | History ID |
user_id |
INTEGER | NOT NULL | User reference |
imported_at |
TEXT | NOT NULL | Import timestamp |
source_filename |
TEXT | Source filename | |
file_type |
TEXT | DEFAULT 'xlsx' | File type |
sheet_name |
TEXT | Sheet name | |
rows_parsed |
INTEGER | DEFAULT 0 | Parsed rows |
rows_created |
INTEGER | DEFAULT 0 | New rows created |
rows_updated |
INTEGER | DEFAULT 0 | Existing rows updated |
rows_skipped |
INTEGER | DEFAULT 0 | Skipped rows |
rows_ambiguous |
INTEGER | DEFAULT 0 | Ambiguous rows |
rows_errored |
INTEGER | DEFAULT 0 | Errored rows |
options_json |
TEXT | Import options | |
summary_json |
TEXT | Summary JSON |
Indexes:
idx_import_history_useronuser_id
Data Flow
User-scoped Data
All user-modifiable data is scoped to user_id:
| Table | user_id Reference | onDelete |
|---|---|---|
| bills | user_id |
CASCADE |
| categories | user_id |
CASCADE |
| payments | bills.user_id | CASCADE |
| monthly_bill_state | bills.user_id (via bill_id) | CASCADE |
| monthly_income | user_id |
CASCADE |
| monthly_starting_amounts | user_id |
CASCADE |
| import_sessions | user_id |
N/A |
| import_history | user_id |
N/A |
Data Access Pattern
-- User bill list
SELECT * FROM bills WHERE user_id = ? AND active = 1 ORDER BY due_day;
-- User categories
SELECT * FROM categories WHERE user_id = ? ORDER BY name;
-- User payments (with bill info)
SELECT p.*, b.name AS bill_name, b.due_day
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ? AND p.deleted_at IS NULL;
-- User monthly state
SELECT * FROM monthly_bill_state WHERE bill_id IN (
SELECT id FROM bills WHERE user_id = ?
) AND year = ? AND month = ?;
Migration System
Migration Location: db/database.js (runMigrations function)
Migration Types:
- Column additions:
ALTER TABLE users ADD COLUMN column_name type - Table creations:
CREATE TABLE IF NOT EXISTS - Index additions:
CREATE INDEX IF NOT EXISTS - Schema rewrites: Table rename + recreate (for breaking changes)
Security Features:
- Column name whitelist validation
- SQL definition string validation
- No user input in ALTER statements
Migration Log (from db/schema.sql comments):
- v0.2:
payments.deleted_atcolumn - v0.3:
idx_payments_bill_date_delindex - v0.4:
monthly_bill_statetable - v0.13: Profile columns (
display_name,last_password_change_at) - v0.14:
bills.history_visibility,bill_history_rangestable,bills.interest_rate - v0.14.4:
bills.interest_ratecolumn - v0.15: Cleanup worker settings
- v0.17: OIDC columns (
auth_provider,external_subject,email,last_login_at) - v0.17:
oidc_statestable - v0.18: Monthly income, monthly starting amounts tables
- v0.18.2: Monthly starting amounts table
- v0.18.3:
monthly_starting_amounts.other_amountcolumn - v0.18.1: Monthly income table
- v0.38: Import history table
- v0.39: Import sessions table
- v0.40: User-scoped bills/categories
- v0.41: Seeded flags (
is_seeded)
Entity Relationship Diagram
┌─────────────────┐
│ users │
├─────────────────┤
│ id (PK) │
│ username (U) │
│ password_hash │
│ role │
│ active │
│ ... │
└────────┬────────┘
│
│ 1:N
│
│
┌────────▼────────┐ ┌─────────────────┐
│ sessions │ │ bills │
├─────────────────┤ ├─────────────────┤
│ id (PK) │ │ id (PK) │
│ user_id (FK) │ │ user_id (FK) │
│ expires_at │ │ ... │
│ created_at │ └────────┬────────┘
└─────────────────┘ │
│ 1:N
│
┌────────────┴────────────┐
│ │
┌──────────▼──────────┐ ┌──────────▼──────────┐
│ categories │ │ payments │
├─────────────────────┤ ├─────────────────────┤
│ id (PK) │ │ id (PK) │
│ user_id (FK) │ │ bill_id (FK) │
│ ... │ │ ... │
└─────────────────────┘ └──────────┬──────────┘
│
┌────────────┴────────────┐
│ │
┌──────────▼──────────┐ ┌──────────▼──────────┐
│ monthly_bill_state│ │ monthly_income │
├─────────────────────┤ ├─────────────────────┤
│ id (PK) │ │ id (PK) │
│ bill_id (FK) │ │ user_id (FK) │
│ year, month (U) │ │ year, month (U) │
│ ... │ │ ... │
└─────────────────────┘ └─────────────────────┘
7. Error Handling & Troubleshooting
Troubleshooting Matrix
| Symptom | Likely Cause | Logs to Inspect | Files to Inspect | Services Involved | Recovery Steps |
|---|---|---|---|---|---|
| Login fails | |||||
| Invalid credentials | Wrong username/password | server.js console |
routes/authLogin.js, services/authService.js |
authService | Verify credentials, check for typos |
| Session expired | Session deleted or expired | server.js console |
services/authService.js |
authService, session pruning worker | Re-login |
| Account locked | active = 0 |
server.js console |
db/schema.sql (users.active) |
AuthService | Admin sets active = 1 |
| Password mismatch | Hash changed | server.js console |
services/authService.js |
authService | Reset password via admin |
| Local login disabled | Admin disabled it | server.js console |
db/schema.sql (settings) |
Settings | Enable local login in Admin |
| Auth issues | |||||
| CSRF token invalid | Token mismatch or expired | server.js console |
middleware/csrf.js |
CSRF middleware | Refresh page |
| Role insufficient | User role check failed | server.js console |
middleware/requireAuth.js |
Auth middleware | Login as admin/user |
| OIDC callback fails | Provider error or config issue | server.js console |
routes/authOidc.js, services/oidcService.js |
oidcService | Check OIDC config in Admin |
| API failures | |||||
| 404 Not Found | Endpoint or resource missing | server.js console |
routes/*.js |
Route handlers | Verify endpoint, check resource ID |
| 400 Bad Request | Validation error | server.js console |
routes/*.js, services/*.js |
Validation logic | Check request body, query params |
| 429 Rate Limited | Too many requests | server.js console |
middleware/rateLimiter.js |
Rate limiter | Wait, reduce request frequency |
| 500 Server Error | Unhandled exception | server.js console, NODE_ENV=production |
Any | All services | Check server logs, reproduce |
| Database issues | |||||
| Database locked | WAL checkpoint or long transaction | server.js console |
db/database.js |
Database connection | Wait, restart app if needed |
| Schema mismatch | Migration failed | server.js console |
db/database.js (runMigrations) |
Database init | Check migrations, fix manually |
| Foreign key violation | Related record deleted | server.js console |
db/schema.sql |
Schema | Check related data, adjust onDelete |
| Performance issues | |||||
| Slow queries | Missing index, complex join | server.js console |
db/database.js (schema) |
Database | Add indexes, optimize queries |
| Memory leak | Session or cache buildup | server.js console |
services/*.js |
Service cleanup | Restart app, check cleanup workers |
| High CPU | Cron job, import, export | server.js console |
workers/*.js, routes/*.js |
Background jobs | Schedule jobs off-peak |
| Notification issues | |||||
| Emails not sent | SMTP not configured or failing | server.js console |
services/notificationService.js |
notificationService | Configure SMTP in Admin, test |
| Notification not recorded | Unique constraint violation | server.js console |
db/schema.sql (notifications) |
notificationService | Check notification table |
| Backup/restore issues | |||||
| Backup fails | Disk full, permission denied | server.js console |
services/backupService.js |
backupService | Check disk space, permissions |
| Restore fails | Backup corrupted, wrong DB | server.js console |
services/backupService.js |
backupService | Verify backup integrity, restore from good backup |
| Import/Export fails | Invalid file, permission | server.js console |
routes/export.js, routes/import.js |
import/export | Check file format, permissions |
| Worker failures | |||||
| Daily worker not running | Cron not scheduled or errored | server.js console |
workers/dailyWorker.js |
dailyWorker | Check cron, restart app |
| Cleanup failing | Disk space, permission | server.js console |
services/cleanupService.js |
cleanupService | Check disk space, permissions |
Common Error Codes and Solutions
| Error Code | Status | Message | Solution |
|---|---|---|---|
AUTH_ERROR |
401 | Not authenticated | Re-login |
VALIDATION_ERROR |
400 | Input validation failed | Check request body |
FORBIDDEN |
403 | Access denied | Login as correct role |
NOT_FOUND |
404 | Resource not found | Check ID |
CONFLICT |
409 | Duplicate entry | Check for existing record |
RATE_LIMITED |
429 | Too many requests | Wait or reduce requests |
CSRF_INVALID |
403 | CSRF token validation failed | Refresh page |
IMPORT_REQUEST_ERROR |
400 | Import request failed | Smaller/valid file |
IMPORT_SERVER_ERROR |
500 | Import server error | Check server logs |
Log Files and Locations
| Log Source | Location | Purpose |
|---|---|---|
| Server console | stdout/stderr | All console.log/error output |
| Debug logs | Set NODE_DEBUG=express |
Express internals |
| SQL logs | process.env.DEBUG=better-sqlite3 |
SQL queries |
Recovery Procedures
1. Admin Locked Out
Symptom: Can't login, no users with admin role
Recovery:
# Reset default admin password (if INIT_ADMIN_USER/PASS set)
docker-compose exec app node -e "
const bcrypt = require('bcryptjs');
const hash = bcrypt.hashSync('newpassword123', 12);
console.log(hash);
"
# Then run SQL: UPDATE users SET password_hash='newhash' WHERE is_default_admin=1;
2. Corrupted Database
Symptom: App fails to start, database errors in logs
Recovery:
# Restore from backup
cp /path/to/backup.sqlite /data/bills.db
# Or rebuild from export (if available)
3. Session Starvation
Symptom: All users logged out, can't re-login
Recovery:
# Prune all sessions
docker-compose exec app node -e "
const { getDb } = require('./db/database');
const db = getDb();
db.prepare('DELETE FROM sessions').run();
console.log('Sessions pruned');
"
4. Rate Limiter Stuck
Symptom: All requests return 429, even after waiting
Recovery:
# Restart the app (in-memory rate limiter)
docker-compose restart app
# Or in Node REPL:
node -e "const { resetStores } = require('./middleware/rateLimiter'); resetStores(); console.log('Limiters reset');"
5. OIDC Configuration Broken
Symptom: OIDC login failing, discovery errors
Recovery:
# Clear OIDC client cache (forces re-discovery)
node -e "
const { invalidateClientCache } = require('./services/oidcService');
invalidateClientCache();
console.log('Client cache invalidated');
"
Debug Commands
Check Database Integrity
docker-compose exec app sqlite3 /data/bills.db "PRAGMA integrity_check;"
View Active Sessions
docker-compose exec app node -e "
const { getDb } = require('./db/database');
const db = getDb();
const sessions = db.prepare('SELECT s.id, u.username, s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.expires_at > datetime(\"now\")').all();
console.log(sessions);
"
List All Users
docker-compose exec app node -e "
const { getDb } = require('./db/database');
const db = getDb();
const users = db.prepare('SELECT id, username, role, active, auth_provider FROM users').all();
console.log(users);
"
View Bill Counts by Category
docker-compose exec app node -e "
const { getDb } = require('./db/database');
const db = getDb();
const counts = db.prepare('SELECT c.name, COUNT(b.id) as count FROM categories c LEFT JOIN bills b ON b.category_id = c.id GROUP BY c.id').all();
console.table(counts);
"
8. Code Navigation Index
Feature-to-File Mapping
| Feature | Frontend Files | Backend Files | Services | Middleware | Tests |
|---|---|---|---|---|---|
| User Authentication | client/pages/LoginPage.jsx, client/hooks/useAuth.jsx, client/api.js |
routes/authLogin.js, routes/auth.js, routes/authOidc.js |
authService.js, oidcService.js |
requireAuth.js, csrf.js |
test-functional.js, scripts/test-oidc-smoke.js |
| Monthly Tracker | client/pages/TrackerPage.jsx, client/components/MobileBillRow.jsx |
routes/tracker.js |
statusService.js, statusRuntime.js |
requireAuth.js, requireUser.js |
test-functional.js |
| Bill CRUD | client/pages/BillsPage.jsx, client/components/BillModal.jsx |
routes/bills.js |
- | requireAuth.js, requireUser.js, csrf.js |
test-functional.js |
| Payment Recording | client/components/StatusBadge.jsx, client/pages/TrackerPage.jsx |
routes/payments.js, routes/bills.js (toggle-paid) |
- | requireAuth.js, requireUser.js, csrf.js |
test-functional.js |
| Categories | client/pages/CategoriesPage.jsx |
routes/categories.js |
- | requireAuth.js, requireUser.js, csrf.js |
- |
| Monthly State Overrides | client/pages/BillsPage.jsx, client/components/TrackerPage.jsx |
routes/bills.js (monthly-state) |
- | requireAuth.js, requireUser.js, csrf.js |
- |
| Calendar View | client/pages/CalendarPage.jsx |
routes/calendar.js |
statusService.js |
requireAuth.js, requireUser.js |
- |
| Summary View | client/pages/SummaryPage.jsx |
routes/summary.js |
- | requireAuth.js, requireUser.js |
- |
| Analytics | client/pages/AnalyticsPage.jsx |
routes/analytics.js |
- | requireAuth.js, requireUser.js |
- |
| User Profile | client/pages/ProfilePage.jsx |
routes/user.js, routes/profile.js |
authService.js |
requireAuth.js, requireUser.js, passwordLimiter |
- |
| App Settings | client/pages/SettingsPage.jsx |
routes/settings.js |
- | requireAuth.js, requireUser.js |
- |
| Notifications | client/pages/ProfilePage.jsx (settings) |
routes/notifications.js |
notificationService.js, statusRuntime.js |
requireAuth.js |
- |
| Data Import | client/pages/DataPage.jsx |
routes/import.js |
spreadsheetImportService.js, userDbImportService.js |
requireAuth.js, requireUser.js, importLimiter |
scripts/test-import.js |
| Data Export | client/pages/DataPage.jsx |
routes/export.js |
- | requireAuth.js, requireUser.js, exportLimiter |
- |
| Admin User Management | client/pages/AdminPage.jsx |
routes/admin.js (users) |
authService.js |
requireAuth.js, requireAdmin.js, adminActionLimiter |
- |
| Admin Backups | client/pages/AdminPage.jsx (backups tab) |
routes/admin.js (backups) |
backupService.js, backupScheduler.js |
requireAuth.js, requireAdmin.js, backupOperationLimiter |
- |
| Admin OIDC Config | client/pages/AdminPage.jsx (auth tab) |
routes/admin.js (auth-mode) |
oidcService.js |
requireAuth.js, requireAdmin.js |
scripts/test-oidc-smoke.js |
| Admin Cleanup | client/pages/AdminPage.jsx (cleanup tab) |
routes/admin.js (cleanup) |
cleanupService.js |
requireAuth.js, requireAdmin.js |
- |
| System Status | client/pages/StatusPage.jsx |
routes/status.js |
statusRuntime.js, statusService.js |
requireAuth.js, requireAdmin.js |
- |
Component File Tree
client/
├── components/
│ ├── layout/
│ │ ├── Layout.jsx # Main layout wrapper
│ │ ├── Sidebar.jsx # Navigation sidebar
│ │ ├── BrandBlock.jsx # App branding (logo, title, version)
│ │ └── NavPill.jsx # Nav link item
│ ├── ui/
│ │ ├── button.jsx # shadcn button
│ │ ├── input.jsx # shadcn input
│ │ ├── card.jsx # shadcn card
│ │ ├── table.jsx # shadcn table
│ │ ├── tabs.jsx # shadcn tabs
│ │ ├── dialog.jsx # shadcn dialog
│ │ ├── badge.jsx # shadcn badge
│ │ ├── switch.jsx # shadcn switch
│ │ ├── select.jsx # shadcn select
│ │ ├── dropdown-menu.jsx # shadcn dropdown
│ │ ├── label.jsx # shadcn label
│ │ ├── input-dialog.jsx # Custom dialog with input
│ │ ├── confirm-dialog.jsx # Confirmation dialog
│ │ ├── alert-dialog.jsx # shadcn alert dialog
│ │ ├── separator.jsx # shadcn separator
│ │ ├── tooltip.jsx # shadcn tooltip
│ │ ├── checkbox.jsx # shadcn checkbox
│ │ └── theme-toggle.jsx # Theme switcher
│ ├── BillsTableInner.jsx # Bills table component
│ ├── MobileBillRow.jsx # Mobile bill row
│ ├── MobileTrackerRow.jsx # Mobile tracker row
│ ├── StatusBadge.jsx # Payment status badge
│ ├── SummaryCard.jsx # Summary statistics card
│ ├── MarkdownText.jsx # Markdown renderer
│ ├── ReleaseNotesDialog.jsx # Release notes modal
│ └── ...
├── pages/
│ ├── LoginPage.jsx # Login page
│ ├── TrackerPage.jsx # Monthly tracker
│ ├── BillsPage.jsx # Bill CRUD
│ ├── CategoriesPage.jsx # Category management
│ ├── CalendarPage.jsx # Calendar view
│ ├── SummaryPage.jsx # Monthly summary
│ ├── AnalyticsPage.jsx # Analytics charts
│ ├── ProfilePage.jsx # User profile
│ ├── SettingsPage.jsx # App settings
│ ├── DataPage.jsx # Import/export
│ ├── AdminPage.jsx # Admin panel
│ ├── StatusPage.jsx # System status
│ ├── AboutPage.jsx # Version/info
│ └── ReleaseNotesPage.jsx # Release notes
├── hooks/
│ └── useAuth.jsx # Auth state hook
├── contexts/
│ └── ThemeContext.jsx # Theme state
├── api.js # API client
├── App.jsx # Router config
├── main.jsx # React entry
└── lib/
├── utils.js # Utility functions
└── version.js # Version constants
Service Layer Dependencies
services/
├── authService.js # Session management, login/logout
├── oidcService.js # Authentik OIDC integration
├── backupService.js # SQLite backup/restore
├── backupScheduler.js # Scheduled backups
├── notificationService.js # Email notifications
├── cleanupService.js # Cleanup tasks
├── spreadsheetImportService.js # XLSX import
├── userDbImportService.js # SQLite user import
├── statusRuntime.js # Worker/runtime status
└── statusService.js # Tracker status calculations
Middleware Chain by Route
| Route Prefix | Middleware Chain |
|---|---|
/api/auth/login |
loginLimiter |
/api/auth |
csrfMiddleware |
/api/auth/oidc |
csrfMiddleware, oidcLimiter |
/api/tracker |
csrfMiddleware, requireAuth, requireUser |
/api/bills |
csrfMiddleware, requireAuth, requireUser |
/api/payments |
csrfMiddleware, requireAuth, requireUser |
/api/categories |
csrfMiddleware, requireAuth, requireUser |
/api/settings |
csrfMiddleware, requireAuth, requireUser |
/api/user |
csrfMiddleware, requireAuth, requireUser |
/api/calendar |
csrfMiddleware, requireAuth, requireUser |
/api/summary |
csrfMiddleware, requireAuth, requireUser |
/api/monthly-starting-amounts |
csrfMiddleware, requireAuth, requireUser |
/api/analytics |
csrfMiddleware, requireAuth, requireUser |
/api/notifications |
csrfMiddleware, requireAuth |
/api/profile |
csrfMiddleware, requireAuth, requireUser, passwordLimiter |
/api/admin |
csrfMiddleware, requireAuth, requireAdmin, adminActionLimiter |
/api/export |
csrfMiddleware, requireAuth, requireUser, exportLimiter |
/api/import |
csrfMiddleware, requireAuth, requireUser, importLimiter |
/api/status |
csrfMiddleware, requireAuth, requireAdmin |
/api/about |
(none) |
/api/version |
(none) |
Test Files
| Test File | Purpose |
|---|---|
test-functional.js |
Functional tests for all features |
run-functional-test.js |
Test runner |
scripts/test-import.js |
Import functionality test |
scripts/test-oidc-smoke.js |
OIDC configuration smoke test |
scripts/test-cookie-options.js |
Cookie options test |
9. Infrastructure & Deployment
Docker Setup
Dockerfile
# Base image
FROM node:20-alpine AS base
# Install dependencies
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Build stage
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Production
FROM base AS production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/*.js ./
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/db ./db
COPY --from=builder /app/middleware ./middleware
COPY --from=builder /app/routes ./routes
COPY --from=builder /app/services ./services
COPY --from=builder /app/workers ./workers
COPY --from=builder /app/client ./client
COPY --from=builder /app/.npmrc ./
COPY --from=builder /app/postcss.config.js ./
COPY --from=builder /app/tailwind.config.js ./
COPY --from=builder /app/vite.config.js ./
COPY --from=builder /app/index.html ./
COPY --from=builder /app/.env.example ./
# Environment variables
ENV NODE_ENV=production
ENV PORT=3000
# Data directories
VOLUME ["/data", "/backups"]
# Entry point
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "server.js"]
docker-compose.yml
version: '3.8'
services:
app:
image: bill-tracker:latest
container_name: bill-tracker
ports:
- "3030:3000"
volumes:
- ./data:/data
- ./backups:/backups
environment:
- NODE_ENV=production
- PORT=3000
- DB_PATH=/data/bills.db
- BACKUP_PATH=/data/backups
- HTTPS=true
- COOKIE_SECURE=true
# OIDC (optional)
# - OIDC_ISSUER_URL=
# - OIDC_CLIENT_ID=
# - OIDC_CLIENT_SECRET=
# - OIDC_REDIRECT_URI=
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/version"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
Environment Variables
| Variable | Default | Required | Description |
|---|---|---|---|
PORT |
3000 |
No | API server port |
NODE_ENV |
production |
No | Environment mode |
DB_PATH |
db/bills.db |
No | SQLite database path |
BACKUP_PATH |
backups/ |
No | Backup directory path |
HTTPS |
false |
No | Enable HTTPS (for HSTS, secure cookies) |
COOKIE_SECURE |
false |
No | Force secure cookies |
CORS_ORIGIN |
(disabled) | No | CORS allowed origins (comma-separated) |
INIT_ADMIN_USER |
admin |
No | Initial admin username (first run only) |
INIT_ADMIN_PASS |
admin123 |
No | Initial admin password (first run only) |
OIDC_ISSUER_URL |
- | No | OIDC issuer URL (fallback) |
OIDC_CLIENT_ID |
- | No | OIDC client ID (fallback) |
OIDC_CLIENT_SECRET |
- | No | OIDC client secret (fallback) |
OIDC_REDIRECT_URI |
- | No | OIDC redirect URI (fallback) |
OIDC_SCOPES |
openid email profile groups |
No | OIDC scopes (fallback) |
OIDC_ADMIN_GROUP |
- | No | OIDC admin group name (fallback) |
OIDC_AUTO_PROVISION |
true |
No | Auto-create users from OIDC |
OIDC_PROVIDER_NAME |
authentik |
No | Provider name (fallback) |
Ports and Services
| Port | Service | Purpose |
|---|---|---|
3000 |
Express | Main API server |
3030 |
Host (Docker) | Exposed port for app |
Monitoring & Logging
Runtime Status (statusRuntime.js)
| Status Type | Key | Description |
|---|---|---|
| Worker | last_worker_run_at |
Last daily worker execution |
| Worker | last_worker_status |
success or error |
| Worker | last_worker_error |
Error message if failed |
| Notification | last_notification_send_at |
Last email send |
| Notification | last_notification_error |
Last email error |
| Runtime | last_error_at |
Last error timestamp |
| Runtime | last_error_message |
Last error message |
Health Check
# Health endpoint (public)
GET /api/version
# Docker healthcheck
wget --no-verbose --tries=1 --spider http://localhost:3000/api/version
CI/CD Pipeline
Current: Manual deployment via deploy.sh
Deploy Script:
#!/bin/bash
# deploy.sh
# Build frontend
npm run build
# Sync to server (rsync)
rsync -avz dist/ user@server:/var/www/bill-tracker/
rsync -avz node_modules/ user@server:/var/www/bill-tracker/
# Restart server
ssh user@server "pm2 restart bill-tracker"
Security Considerations
| Feature | Implementation |
|---|---|
| Password Hashing | bcrypt with cost factor 12 |
| Session Storage | SQLite with 7-day expiry |
| CSRF Protection | Double-submit cookie pattern |
| Rate Limiting | In-memory express-rate-limit |
| SQL Injection | Parameterized queries (prepared statements) |
| XSS Protection | CSP with nonce, no inline scripts in HTML emails |
| CORS | Disabled by default, configurable via env |
| HSTS | Only when HTTPS=true |
| Secure Cookies | httpOnly, sameSite=strict, secure when HTTPS |
| OIDC Security | PKCE, state/nonce, JWKS signature verification |
| Backup Security | chmod 600, SHA-256 checksums, restrictive dir (0o700) |
Database Backups
Manual:
# Create backup
curl -X POST http://localhost:3000/api/admin/backups
# List backups
curl http://localhost:3000/api/admin/backups
# Download backup
curl -o backup.sqlite http://localhost:3000/api/admin/backups/backup-id.sqlite
Scheduled (Admin panel):
- Enable:
backup_schedule_enabled - Frequency: daily/weekly
- Time: HH:MM
- Retention: N backups
Troubleshooting Commands
View Logs (Docker)
docker-compose logs -f app
Restart App (Docker)
docker-compose restart app
Rebuild Image
docker-compose build --no-cache
docker-compose up -d
Enter Container Shell
docker-compose exec app sh
Check Database Size
docker-compose exec app ls -lh /data/bills.db
10. Sequence Flows
Login Flow (Local)
┌─────────────────────────────────────────────────────────────┐
│ User │
│ 1. Opens /login.html │
│ 2. Enters username + password │
│ 3. Clicks "Login" button │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Frontend (LoginPage.jsx) │
│ 4. Calls apiPost('/api/auth/login', {username, password}) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Backend (server.js) │
│ 5. POST /api/auth/login │
│ 6. loginLimiter checks IP (skip if no users exist) │
│ 7. authLogin.js handler runs │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Service (authService.login) │
│ 8. Query user by username │
│ 9. Check user.active === 1 │
│ 10. Check auth_provider === 'local' │
│ 11. bcrypt.compare(password, password_hash) │
│ 12. If match: create session, return {sessionId, user} │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Response │
│ 13. Set cookie: bt_session=<sessionId> │
│ 14. JSON: {user: {id, username, role, ...}} │
│ 15. Set CSRF token cookie (bt_csrf_token) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Frontend (useAuth hook) │
│ 16. Stores user in context │
│ 17. Redirects to /tracker or / │
└─────────────────────────────────────────────────────────────┘
OIDC Login Flow
┌─────────────────────────────────────────────────────────────┐
│ User │
│ 1. Clicks "Login with Authentik" button │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Frontend (LoginPage.jsx) │
│ 2. Calls apiGet('/api/auth/oidc/login?redirect_to=/tracker')│
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Backend (authOidc.js) │
│ 3. GET /api/auth/oidc/login │
│ 4. isOidcLoginActive() check │
│ 5. createLoginState(redirect_to) │
│ • Generates PKCE code_verifier │
│ • Generates nonce │
│ • Stores in oidc_states (expires 5 min) │
│ 6. buildAuthorizationUrl(config, state) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Response │
│ 7. HTTP 302 redirect to OIDC provider authorization URL │
│ • Includes: client_id, redirect_uri, response_type=code │
│ • Includes: state (login state ID), nonce │
│ • Includes: code_challenge, code_challenge_method=S256 │
│ • Includes: scopes (openid, email, profile, groups) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ OIDC Provider (Authentik) │
│ 8. User authenticates (credentials) │
│ 9. Provider creates ID token │
│ 10. Redirects to redirect_uri with code + state │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Backend (authOidc.js) │
│ 11. GET /api/auth/oidc/callback │
│ 12. oidcLimiter checks IP │
│ 13. consumeLoginState(state) │
│ • Validates expiry │
│ • Returns: {nonce, code_verifier, redirect_to} │
│ 14. exchangeAndVerifyTokens(config, code, stateId, savedState) │
│ • POST to token endpoint with code + client_secret │
│ • Verifies JWT signature via JWKS │
│ • Validates: iss, aud, exp, nonce, state │
│ 15. findOrProvisionUser(claims, config) │
│ • Look up by sub (external_subject) │
│ • Look up by email if email_verified=true │
│ • Auto-provision if enabled │
│ • Map groups to role (admin if in admin_group) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Service (authService.createSession) │
│ 16. Create session for user │
│ 17. Set cookie: bt_session=<sessionId> │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Response │
│ 18. HTTP 302 redirect to redirect_to (or /) │
└─────────────────────────────────────────────────────────────┘
Authenticated API Request Flow
┌─────────────────────────────────────────────────────────────┐
│ Frontend (api.js) │
│ 1. apiGet('/api/tracker', {year, month}) │
│ 2. Retrieve CSRF token from bt_csrf_token cookie │
│ 3. Set header: x-csrf-token=<token> │
│ 4. Set header: cookie: bt_session=<sessionId> │
│ 5. Send request to backend │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Backend (server.js) │
│ 6. requestLooksHttps(req) │
│ • Checks: req.secure, x-forwarded-proto │
│ 7. csrfTokenProvider sets cookie (if not present) │
│ 8. route middleware chain runs │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Middleware (requireAuth) │
│ 9. getSessionUser(cookie.bt_session) │
│ • Query sessions + users table │
│ • Check: expires_at > now, active = 1 │
│ 10. If valid: attach req.user, next() │
│ If invalid: return 401 {error: 'Not authenticated'} │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Middleware (requireUser) │
│ 11. Check role is 'user' or 'admin' │
│ 12. Check not default admin (no tracker access) │
│ 13. next() │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Middleware (csrfMiddleware) │
│ 14. validateCsrfToken(req) │
│ • Check header x-csrf-token matches cookie │
│ • Check query.csrf_token │
│ • Check body.csrf_token │
│ 15. If valid: next() │
│ If invalid: return 403 {error: 'CSRF token validation failed'} │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Route Handler (tracker.js) │
│ 16. ensureUserDefaultCategories(req.user.id) │
│ 17. db.prepare('SELECT * FROM bills WHERE user_id = ?') │
│ 18. Build tracker rows │
│ 19. Return JSON response │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Frontend (useAuth hook) │
│ 20. Update state with response │
│ 21. Re-render component │
└─────────────────────────────────────────────────────────────┘
Background Worker Flow (Daily)
┌─────────────────────────────────────────────────────────────┐
│ Server Start (server.js) │
│ 1. main() │
│ 2. Check if users exist, create admin if needed │
│ 3. workers/dailyWorker.js start() │
└─────────────────────────────────────────────────────────────┘
│
▒
┌─────────────────────────────────────────────────────────────┐
│ Worker (dailyWorker.js) │
│ 4. markWorkerStarted(nextDailyRunIso()) │
│ 5. runDailyTasks() │
│ • Prune expired sessions │
│ • Run notifications (email) │
│ • Run cleanup (temp files, import sessions) │
│ • Auto-mark autopay bills as assumed_paid │
│ 6. markWorkerSuccess(nextDailyRunIso()) │
└─────────────────────────────────────────────────────────────┘
│
▒
┌─────────────────────────────────────────────────────────────┐
│ Cron Job (node-cron) │
│ 7. cron.schedule('0 6 * * *', ...) │
│ Runs daily at 6:00 AM │
│ 8. Same runDailyTasks() as above │
└─────────────────────────────────────────────────────────────┘
Notification Flow
┌─────────────────────────────────────────────────────────────┐
│ Daily Worker (dailyWorker.js) │
│ 1. runNotifications() │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Notification Service (notificationService.js) │
│ 2. Check SMTP enabled, host configured │
│ 3. Get recipients (users with notifications_enabled=1 OR │
│ global recipient configured) │
│ 4. For each bill: │
│ • Calculate due date for this month │
│ • Determine notification type (due_3d, due_1d, due_today,│
│ overdue) │
│ • Check if already sent today (notifications table) │
│ • Check user notification preferences │
│ • Build HTML email template │
│ • Send email via nodemailer │
│ • Record in notifications table (to prevent duplicates) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Email (Nodemailer) │
│ 5. SMTP transport sendMail() │
│ 6. Return success/error │
└─────────────────────────────────────────────────────────────┘
Version History
| Version | Date | Changes |
|---|---|---|
| 0.19.0 | 2026-05-09 | Complete Engineering Reference Manual |
| 0.18.3 | 2026-05-08 | Added monthly_starting_amounts.other_amount column |
| 0.18.2 | 2026-05-08 | Added monthly_starting_amounts table |
| 0.18.1 | 2026-05-08 | Added monthly_income table |
| 0.17 | 2026-05-08 | OIDC columns (auth_provider, external_subject, email, last_login_at), oidc_states table |
| 0.14 | 2026-05-08 | Added history_visibility, bill_history_ranges, interest_rate |
| 0.13 | 2026-05-08 | Profile columns (display_name, last_password_change_at) |
| 0.12 | 2026-05-08 | Bill history ranges table |
| 0.11 | 2026-05-08 | Import history table |
| 0.10 | 2026-05-08 | Import sessions table |
| 0.4 | 2026-05-08 | monthly_bill_state table |
| 0.2 | 2026-05-08 | payments.deleted_at column |
Quick Reference
Critical Endpoints
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| POST | /api/auth/login |
None | Local login |
| GET | /api/auth/oidc/login |
None | OIDC login start |
| GET | /api/tracker |
User | Monthly tracker data |
| GET | /api/bills |
User | List bills |
| GET | /api/admin/users |
Admin | List users |
| POST | /api/admin/backups |
Admin | Create backup |
| GET | /api/version |
None | Version info |
| GET | /api/about |
None | About info |
Critical Settings
| Key | Default | Description |
|---|---|---|
local_login_enabled |
true |
Enable username/password login |
oidc_login_enabled |
false |
Enable OIDC login |
oidc_issuer_url |
- | OIDC provider URL |
oidc_client_id |
- | OIDC client ID |
oidc_client_secret |
- | OIDC client secret |
oidc_redirect_uri |
- | OIDC redirect URI |
oidc_admin_group |
- | OIDC group for admin access |
oidc_auto_provision |
true |
Auto-create users from OIDC |
notify_smtp_enabled |
false |
Enable email notifications |
notify_smtp_host |
- | SMTP host |
notify_smtp_port |
587 |
SMTP port |
backup_enabled |
false |
Enable manual backups |
backup_schedule_enabled |
false |
Enable scheduled backups |
Database Tables Reference
| Table | Purpose |
|---|---|
users |
User accounts |
sessions |
Active sessions |
bills |
Bill records |
categories |
Bill categories |
payments |
Payment records |
monthly_bill_state |
Monthly bill overrides |
settings |
Application settings |
notifications |
Notification history |
oidc_states |
OIDC login state |
bill_history_ranges |
History visibility ranges |
monthly_income |
Monthly income records |
monthly_starting_amounts |
Starting balance records |
import_sessions |
Import preview sessions |
import_history |
Import history |
This document is the canonical reference for the Bill Tracker system.
Last updated: 2026-05-09
Author: Bishop (code reviewer and architecture validator)