0 && row.total_paid >= threshold;
- const isPaid = row.status === 'paid' || row.status === 'autodraft' || isPaidByThreshold;
const isSkipped = !!row.is_skipped;
+ const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped;
+ const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
+ const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
const effectiveStatus = isSkipped
? 'skipped'
: (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
@@ -1115,13 +1484,14 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
: row.status;
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0);
+ const summary = paymentSummary(row, threshold);
async function handleQuickPay() {
const val = parseFloat(amountRef.current?.value);
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
try {
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
- toast.success('Marked as paid');
+ toast.success('Payment added');
refresh();
} catch (err) {
toast.error(err.message);
@@ -1167,6 +1537,32 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
performTogglePaid();
}
+ async function handleConfirmSuggestion() {
+ setSuggestionLoading(true);
+ try {
+ const result = await api.confirmAutopaySuggestion(row.id, { year, month });
+ toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded');
+ refresh();
+ } catch (err) {
+ toast.error(err.message || 'Failed to confirm autopay suggestion');
+ } finally {
+ setSuggestionLoading(false);
+ }
+ }
+
+ async function handleDismissSuggestion() {
+ setSuggestionLoading(true);
+ try {
+ await api.dismissAutopaySuggestion(row.id, { year, month });
+ toast.success('Autopay suggestion dismissed');
+ refresh();
+ } catch (err) {
+ toast.error(err.message || 'Failed to dismiss autopay suggestion');
+ } finally {
+ setSuggestionLoading(false);
+ }
+ }
+
return (
<>
+
+
setPaymentLedgerOpen(true)} compact />
+
+
@@ -1261,17 +1661,33 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
Date
- {row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}
+
- {!isPaid && !isSkipped && (
+ {hasAutopaySuggestion && (
+
+ )}
+ {!isPaid && !isSkipped && !hasAutopaySuggestion && (
- Pay
+ Add
)}
@@ -1302,6 +1718,16 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
/>
)}
+ {paymentLedgerOpen && (
+
setPaymentLedgerOpen(false)}
+ onSaved={refresh}
+ />
+ )}
+
{showMbs && (
))
+ ) : rows.length === 0 ? (
+
+ No bills match this bucket and filter set.
+
) : (
rows.map((r, i) => (
))
+ ) : rows.length === 0 ? (
+
+
+ No bills match this bucket and filter set.
+
+
) : (
rows.map((r, i) => (
{
+ const querySearch = searchParams.get('search') || '';
+ if (querySearch) setSearch(querySearch);
+ }, [searchParams]);
+
function navigate(delta) {
setMonth(m => {
const nm = m + delta;
@@ -1542,8 +1995,71 @@ export default function TrackerPage() {
const rows = data?.rows || [];
const summary = data?.summary || {};
- const first = rows.filter(r => r.bucket === '1st');
- const second = rows.filter(r => r.bucket === '15th');
+ const toggleFilter = (key) => setFilters(prev => ({ ...prev, [key]: !prev[key] }));
+ const setFilterValue = (key, value) => setFilters(prev => ({ ...prev, [key]: value }));
+ const hasFilters = !!(
+ search.trim()
+ || filters.category !== FILTER_ALL
+ || filters.cycle !== FILTER_ALL
+ || filters.autopay
+ || filters.firstBucket
+ || filters.fifteenthBucket
+ || filters.unpaid
+ || filters.overdue
+ || filters.debt
+ );
+ const resetFilters = () => {
+ setSearch('');
+ setFilters({
+ category: FILTER_ALL,
+ cycle: FILTER_ALL,
+ autopay: false,
+ firstBucket: false,
+ fifteenthBucket: false,
+ unpaid: false,
+ overdue: false,
+ debt: false,
+ });
+ };
+ const categoryOptions = useMemo(() => {
+ const map = new Map();
+ rows.forEach(row => {
+ if (row.category_id && row.category_name) map.set(String(row.category_id), row.category_name);
+ });
+ return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name));
+ }, [rows]);
+ const cycleOptions = useMemo(() => (
+ Array.from(new Set(rows.map(row => row.billing_cycle || 'monthly'))).sort()
+ ), [rows]);
+ const filteredRows = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ return rows.filter(row => {
+ const effectiveStatus = rowEffectiveStatus(row);
+ if (filters.category !== FILTER_ALL && String(row.category_id ?? '') !== filters.category) return false;
+ if (filters.cycle !== FILTER_ALL && String(row.billing_cycle || 'monthly') !== filters.cycle) return false;
+ if (filters.autopay && !row.autopay_enabled) return false;
+ if (filters.debt && !rowIsDebt(row)) return false;
+ if (filters.unpaid && (row.is_skipped || rowIsPaid(row))) return false;
+ if (filters.overdue && !(effectiveStatus === 'late' || effectiveStatus === 'missed')) return false;
+ if (filters.firstBucket && !filters.fifteenthBucket && row.bucket !== '1st') return false;
+ if (filters.fifteenthBucket && !filters.firstBucket && row.bucket !== '15th') return false;
+
+ if (!q) return true;
+ const haystack = [
+ row.name,
+ row.category_name,
+ row.notes,
+ row.monthly_notes,
+ row.billing_cycle,
+ row.bucket,
+ row.status,
+ amountSearchText(row.expected_amount, row.actual_amount, row.total_paid, row.balance, row.current_balance, row.minimum_payment),
+ ].filter(Boolean).join(' ').toLowerCase();
+ return haystack.includes(q);
+ });
+ }, [filters, rows, search]);
+ const first = filteredRows.filter(r => r.bucket === '1st');
+ const second = filteredRows.filter(r => r.bucket === '15th');
return (
@@ -1588,6 +2104,63 @@ export default function TrackerPage() {
+
+
+
+
+
+
+
+
+ toggleFilter('unpaid')}>Unpaid
+ toggleFilter('overdue')}>Overdue
+ toggleFilter('autopay')}>Autopay
+ toggleFilter('firstBucket')}>1st bucket
+ toggleFilter('fifteenthBucket')}>15th bucket
+ toggleFilter('debt')}>Debt
+
+ {filteredRows.length} of {rows.length} shown
+
+
+
+
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
{loading ? (
@@ -1671,10 +2244,17 @@ export default function TrackerPage() {
{/* Edit Bill modal — opened by clicking a bill name in any tracker row */}
{editBillData && (
setEditBillData(null)}
onSave={() => { setEditBillData(null); refetch(); }}
+ onDuplicate={bill => setEditBillData({
+ bill: null,
+ initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }),
+ categories: editBillData.categories,
+ })}
/>
)}
diff --git a/db/.restore-1777763192032-96266b49.sqlite-shm b/db/.restore-1777763192032-96266b49.sqlite-shm
deleted file mode 100644
index fe9ac28..0000000
Binary files a/db/.restore-1777763192032-96266b49.sqlite-shm and /dev/null differ
diff --git a/db/.restore-1777763192032-96266b49.sqlite-wal b/db/.restore-1777763192032-96266b49.sqlite-wal
deleted file mode 100644
index e69de29..0000000
diff --git a/db/database.js b/db/database.js
index 60c2173..36fc16c 100644
--- a/db/database.js
+++ b/db/database.js
@@ -796,6 +796,58 @@ function reconcileLegacyMigrations() {
}
console.log('[migration] bills/categories deleted_at columns added');
}
+ },
+ {
+ version: 'v0.57',
+ description: 'autopay: suggestions and auto-mark paid',
+ check: function() {
+ const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
+ const hasDismissals = !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='autopay_suggestion_dismissals'").get();
+ return billCols.includes('auto_mark_paid') && hasDismissals;
+ },
+ run: function() {
+ const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
+ if (!billCols.includes('auto_mark_paid')) {
+ db.exec('ALTER TABLE bills ADD COLUMN auto_mark_paid INTEGER NOT NULL DEFAULT 0');
+ }
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS autopay_suggestion_dismissals (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
+ year 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)
+ );
+ CREATE INDEX IF NOT EXISTS idx_autopay_suggestion_dismissals_user_month
+ ON autopay_suggestion_dismissals(user_id, year, month);
+ `);
+ console.log('[migration] autopay auto_mark_paid and suggestion dismissals ensured');
+ }
+ },
+ {
+ version: 'v0.58',
+ description: 'bills: saved bill templates',
+ check: function() {
+ return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_templates'").get();
+ },
+ run: function() {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS bill_templates (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ data TEXT NOT NULL,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now')),
+ UNIQUE(user_id, name)
+ );
+ CREATE INDEX IF NOT EXISTS idx_bill_templates_user_name
+ ON bill_templates(user_id, name);
+ `);
+ console.log('[migration] bill_templates table ensured');
+ }
}
];
@@ -1441,6 +1493,52 @@ function runMigrations() {
}
console.log('[migration] bills/categories deleted_at columns added');
}
+ },
+ {
+ version: 'v0.57',
+ description: 'autopay: suggestions and auto-mark paid',
+ dependsOn: ['v0.56'],
+ run: function() {
+ const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
+ if (!billCols.includes('auto_mark_paid')) {
+ db.exec('ALTER TABLE bills ADD COLUMN auto_mark_paid INTEGER NOT NULL DEFAULT 0');
+ }
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS autopay_suggestion_dismissals (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
+ year 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)
+ );
+ CREATE INDEX IF NOT EXISTS idx_autopay_suggestion_dismissals_user_month
+ ON autopay_suggestion_dismissals(user_id, year, month);
+ `);
+ console.log('[migration] autopay auto_mark_paid and suggestion dismissals ensured');
+ }
+ },
+ {
+ version: 'v0.58',
+ description: 'bills: saved bill templates',
+ dependsOn: ['v0.57'],
+ run: function() {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS bill_templates (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ data TEXT NOT NULL,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now')),
+ UNIQUE(user_id, name)
+ );
+ CREATE INDEX IF NOT EXISTS idx_bill_templates_user_name
+ ON bill_templates(user_id, name);
+ `);
+ console.log('[migration] bill_templates table ensured');
+ }
}
];
@@ -1861,6 +1959,21 @@ const ROLLBACK_SQL_MAP = {
'ALTER TABLE categories DROP COLUMN deleted_at',
'ALTER TABLE bills DROP COLUMN deleted_at',
]
+ },
+ 'v0.57': {
+ description: 'autopay suggestions and auto-mark paid',
+ sql: [
+ 'DROP INDEX IF EXISTS idx_autopay_suggestion_dismissals_user_month',
+ 'DROP TABLE IF EXISTS autopay_suggestion_dismissals',
+ 'ALTER TABLE bills DROP COLUMN auto_mark_paid',
+ ]
+ },
+ 'v0.58': {
+ description: 'saved bill templates',
+ sql: [
+ 'DROP INDEX IF EXISTS idx_bill_templates_user_name',
+ 'DROP TABLE IF EXISTS bill_templates',
+ ]
}
};
diff --git a/db/schema.sql b/db/schema.sql
index 961c67e..5da3c1f 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS bills (
billing_cycle TEXT DEFAULT 'monthly' CHECK(billing_cycle IN ('monthly', 'quarterly', 'annually', 'irregular')),
autopay_enabled INTEGER NOT NULL DEFAULT 0,
autodraft_status TEXT NOT NULL DEFAULT 'none' CHECK(autodraft_status IN ('none', 'pending', 'assumed_paid', 'confirmed')),
+ auto_mark_paid INTEGER NOT NULL DEFAULT 0,
website TEXT,
username TEXT,
account_info TEXT,
@@ -131,3 +132,29 @@ CREATE TABLE IF NOT EXISTS monthly_bill_state (
CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup
ON monthly_bill_state(bill_id, year, month);
+
+CREATE TABLE IF NOT EXISTS autopay_suggestion_dismissals (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
+ year 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)
+);
+
+CREATE INDEX IF NOT EXISTS idx_autopay_suggestion_dismissals_user_month
+ ON autopay_suggestion_dismissals(user_id, year, month);
+
+CREATE TABLE IF NOT EXISTS bill_templates (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ data TEXT NOT NULL,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now')),
+ UNIQUE(user_id, name)
+);
+
+CREATE INDEX IF NOT EXISTS idx_bill_templates_user_name
+ ON bill_templates(user_id, name);
diff --git a/routes/admin.js b/routes/admin.js
index 89a2443..ca0df83 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
-const { getDb, getSetting, setSetting, rollbackMigration } = require('../db/database');
+const { getDb, rollbackMigration } = require('../db/database');
const { hashPassword } = require('../services/authService');
const {
createBackup,
@@ -351,132 +351,12 @@ router.post('/cleanup/run', backupOperationLimiter, async (req, res) => {
// ── Auth-mode helpers ─────────────────────────────────────────────────────────
const {
- getAdminOidcSettings,
- getOidcConfigStatus,
- invalidateClientCache,
+ applyAuthModeSettings,
+ buildAuthModeStatus,
+ buildSubmittedOidcConfig,
testOidcConfiguration,
} = require('../services/oidcService');
-function trimOrEmpty(value) {
- if (value === undefined || value === null) return '';
- return String(value).trim();
-}
-
-function boolSetting(value, fallback) {
- if (value === undefined) return fallback;
- if (typeof value === 'string') return value === 'true';
- return !!value;
-}
-
-function computeSubmittedOidcConfigured(body) {
- const current = getAdminOidcSettings();
- const next = {
- issuer: body.oidc_issuer_url !== undefined
- ? trimOrEmpty(body.oidc_issuer_url)
- : current.oidc_issuer_url,
- clientId: body.oidc_client_id !== undefined
- ? trimOrEmpty(body.oidc_client_id)
- : current.oidc_client_id,
- redirectUri: body.oidc_redirect_uri !== undefined
- ? trimOrEmpty(body.oidc_redirect_uri)
- : current.oidc_redirect_uri,
- clientSecret: current.oidc_client_secret_set ? 'set' : '',
- };
-
- if (body.oidc_client_secret_clear === true) {
- next.clientSecret = process.env.OIDC_CLIENT_SECRET ? 'set' : '';
- }
- if (trimOrEmpty(body.oidc_client_secret)) {
- next.clientSecret = 'set';
- }
-
- return !!(next.issuer && next.clientId && next.clientSecret && next.redirectUri);
-}
-
-function buildSubmittedOidcConfig(body) {
- const current = getAdminOidcSettings();
- const status = getOidcConfigStatus();
-
- const issuerUrl = body.oidc_issuer_url !== undefined
- ? trimOrEmpty(body.oidc_issuer_url)
- : current.oidc_issuer_url;
- const clientId = body.oidc_client_id !== undefined
- ? trimOrEmpty(body.oidc_client_id)
- : current.oidc_client_id;
- const redirectUri = body.oidc_redirect_uri !== undefined
- ? trimOrEmpty(body.oidc_redirect_uri)
- : current.oidc_redirect_uri;
- const tokenAuthMethod = body.oidc_token_auth_method !== undefined
- ? trimOrEmpty(body.oidc_token_auth_method)
- : current.oidc_token_auth_method;
- const scopes = body.oidc_scopes !== undefined
- ? trimOrEmpty(body.oidc_scopes)
- : current.oidc_scopes;
- const providerName = body.oidc_provider_name !== undefined
- ? trimOrEmpty(body.oidc_provider_name)
- : current.oidc_provider_name;
-
- let clientSecret = status.oidc_client_secret_set ? '__saved__' : '';
- if (body.oidc_client_secret_clear === true) clientSecret = process.env.OIDC_CLIENT_SECRET || '';
- if (trimOrEmpty(body.oidc_client_secret)) clientSecret = trimOrEmpty(body.oidc_client_secret);
-
- if (!issuerUrl || !clientId || !clientSecret || !redirectUri) return null;
-
- return {
- enabled: true,
- issuerUrl,
- clientId,
- clientSecret: clientSecret === '__saved__'
- ? (getSetting('oidc_client_secret') || process.env.OIDC_CLIENT_SECRET || '')
- : clientSecret,
- tokenEndpointAuthMethod: tokenAuthMethod === 'client_secret_post'
- ? 'client_secret_post'
- : 'client_secret_basic',
- redirectUri,
- scopes: (scopes || 'openid email profile groups').split(/\s+/).filter(Boolean),
- adminGroup: body.oidc_admin_group !== undefined ? trimOrEmpty(body.oidc_admin_group) : current.oidc_admin_group,
- defaultRole: 'user',
- autoProvision: body.oidc_auto_provision !== undefined ? !!body.oidc_auto_provision : current.oidc_auto_provision,
- providerName: providerName || 'authentik',
- };
-}
-
-function buildAuthModeStatus() {
- const oidcConfigured = getOidcConfigStatus().oidc_configured;
- const localEnabled = getSetting('local_login_enabled') !== 'false';
- const oidcEnabled = getSetting('oidc_login_enabled') === 'true';
- const oidcAdminGroup = getAdminOidcSettings().oidc_admin_group;
-
- // Disabling local is only safe if OIDC is configured, enabled, and has an admin path.
- const canDisableLocal = oidcConfigured && oidcEnabled && !!oidcAdminGroup;
-
- const warnings = [];
- if (!localEnabled && !oidcConfigured) {
- warnings.push('Local login is disabled but OIDC is not configured; users may be locked out.');
- }
- if (!localEnabled && !oidcEnabled) {
- warnings.push('No login method is enabled. Re-enable local login or configure OIDC.');
- }
- if (oidcEnabled && !oidcConfigured) {
- warnings.push('authentik/OIDC login is enabled but configuration is incomplete, so the login button will stay hidden.');
- }
- if (!localEnabled && !oidcAdminGroup) {
- warnings.push('Local login is disabled but no OIDC admin group is configured.');
- }
-
- return {
- auth_mode: getSetting('auth_mode') || 'multi',
- default_user_id: getSetting('default_user_id') || null,
- local_login_enabled: localEnabled,
- oidc_login_enabled: oidcEnabled,
- oidc_configured: oidcConfigured,
- ...getOidcConfigStatus(),
- ...getAdminOidcSettings(),
- can_disable_local: canDisableLocal,
- warnings,
- };
-}
-
// GET /api/admin/auth-mode
router.get('/auth-mode', (req, res) => {
res.json(buildAuthModeStatus());
@@ -495,106 +375,11 @@ router.post('/auth-mode/oidc-test', async (req, res) => {
// Accepts legacy auth_mode/default_user_id fields plus new auth method settings.
// Validates lockout protection before saving.
router.put('/auth-mode', (req, res) => {
- const {
- auth_mode, default_user_id,
- local_login_enabled, oidc_login_enabled, oidc_enabled,
- oidc_provider_name, oidc_issuer_url, oidc_client_id, oidc_client_secret,
- oidc_client_secret_clear, oidc_token_auth_method, oidc_redirect_uri, oidc_scopes,
- oidc_auto_provision, oidc_admin_group, oidc_default_role,
- } = req.body;
-
- // ── Legacy single/multi mode (unchanged behavior) ─────────────────────────
- if (auth_mode !== undefined) {
- if (!['multi', 'single'].includes(auth_mode))
- return res.status(400).json({ error: 'auth_mode must be "multi" or "single"' });
- if (auth_mode === 'single') {
- if (!default_user_id) return res.status(400).json({ error: 'default_user_id is required for single mode' });
- const u = getDb().prepare("SELECT id FROM users WHERE id=? AND role='user'").get(default_user_id);
- if (!u) return res.status(404).json({ error: 'User not found or not a regular user' });
- setSetting('default_user_id', default_user_id);
- }
- setSetting('auth_mode', auth_mode);
+ try {
+ res.json(applyAuthModeSettings(req.body || {}));
+ } catch (err) {
+ res.status(err.status || 500).json({ error: err.status ? err.message : 'Failed to update authentication settings' });
}
-
- // ── Auth method toggles ───────────────────────────────────────────────────
- const oidcConfigured = computeSubmittedOidcConfigured(req.body || {});
- const nextLocal = boolSetting(local_login_enabled, getSetting('local_login_enabled') !== 'false');
- const requestedOidc = oidc_login_enabled !== undefined ? oidc_login_enabled : oidc_enabled;
- const nextOidc = boolSetting(requestedOidc, getSetting('oidc_login_enabled') === 'true');
- const nextAdminGroup = oidc_admin_group !== undefined
- ? trimOrEmpty(oidc_admin_group)
- : getAdminOidcSettings().oidc_admin_group;
-
- // Lockout protection: cannot disable both login methods
- if (!nextLocal && !nextOidc) {
- return res.status(400).json({ error: 'Cannot disable all login methods. At least one must remain enabled.' });
- }
-
- // Lockout protection: cannot disable local login unless OIDC has a working admin path.
- if (!nextLocal && !oidcConfigured) {
- return res.status(400).json({
- error: 'Cannot disable local login until authentik/OIDC is fully configured.',
- });
- }
- if (!nextLocal && !nextOidc) {
- return res.status(400).json({
- error: 'Cannot disable local login without OIDC login enabled.',
- });
- }
- if (!nextLocal && !nextAdminGroup) {
- return res.status(400).json({
- error: 'Cannot disable local login until an OIDC admin group is configured.',
- });
- }
-
- // Cannot enable OIDC login if required provider settings are incomplete
- if (nextOidc && !oidcConfigured) {
- return res.status(400).json({
- error: 'Cannot enable OIDC login until issuer URL, client ID, client secret, and redirect URI are configured.',
- });
- }
-
- if (local_login_enabled !== undefined) setSetting('local_login_enabled', nextLocal ? 'true' : 'false');
- if (oidc_login_enabled !== undefined) setSetting('oidc_login_enabled', nextOidc ? 'true' : 'false');
-
- // OIDC provider settings. Client secret is write-only from the Admin API.
- if (oidc_provider_name !== undefined) {
- const name = String(oidc_provider_name).slice(0, 100).trim();
- if (name) setSetting('oidc_provider_name', name);
- }
- if (oidc_issuer_url !== undefined) setSetting('oidc_issuer_url', trimOrEmpty(oidc_issuer_url).slice(0, 500));
- if (oidc_client_id !== undefined) setSetting('oidc_client_id', trimOrEmpty(oidc_client_id).slice(0, 500));
- if (oidc_token_auth_method !== undefined) {
- const method = oidc_token_auth_method === 'client_secret_post' ? 'client_secret_post' : 'client_secret_basic';
- setSetting('oidc_token_auth_method', method);
- }
- if (oidc_redirect_uri !== undefined) setSetting('oidc_redirect_uri', trimOrEmpty(oidc_redirect_uri).slice(0, 500));
- if (oidc_scopes !== undefined) {
- const scopes = trimOrEmpty(oidc_scopes).split(/\s+/).filter(Boolean).join(' ') || 'openid email profile groups';
- setSetting('oidc_scopes', scopes.slice(0, 500));
- }
- if (oidc_client_secret_clear === true) setSetting('oidc_client_secret', '');
- if (trimOrEmpty(oidc_client_secret)) {
- setSetting('oidc_client_secret', trimOrEmpty(oidc_client_secret).slice(0, 1000));
- }
- if (oidc_auto_provision !== undefined) setSetting('oidc_auto_provision', !!oidc_auto_provision ? 'true' : 'false');
- if (oidc_admin_group !== undefined) setSetting('oidc_admin_group', String(oidc_admin_group).slice(0, 200).trim());
- if (oidc_default_role !== undefined) {
- setSetting('oidc_default_role', 'user');
- }
-
- if (
- oidc_issuer_url !== undefined ||
- oidc_client_id !== undefined ||
- oidc_client_secret !== undefined ||
- oidc_client_secret_clear === true ||
- oidc_token_auth_method !== undefined ||
- oidc_redirect_uri !== undefined
- ) {
- invalidateClientCache();
- }
-
- res.json({ success: true, ...buildAuthModeStatus() });
});
// ── Migration Rollback ────────────────────────────────────────────────────────
diff --git a/routes/analytics.js b/routes/analytics.js
index 4ac2de7..8b66bb9 100644
--- a/routes/analytics.js
+++ b/routes/analytics.js
@@ -1,288 +1,12 @@
const express = require('express');
const router = express.Router();
-const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter');
-
-function parseInteger(value, fallback) {
- if (value === undefined || value === null || value === '') return fallback;
- const parsed = Number(value);
- return Number.isInteger(parsed) ? parsed : NaN;
-}
-
-function monthKey(year, month) {
- return `${year}-${String(month).padStart(2, '0')}`;
-}
-
-function monthLabel(year, month) {
- return new Date(Date.UTC(year, month - 1, 1)).toLocaleString('en-US', {
- month: 'short',
- year: '2-digit',
- timeZone: 'UTC',
- });
-}
-
-function addMonths(year, month, delta) {
- const date = new Date(Date.UTC(year, month - 1 + delta, 1));
- return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 };
-}
-
-function monthEndDate(year, month) {
- const day = new Date(Date.UTC(year, month, 0)).getUTCDate();
- return `${monthKey(year, month)}-${String(day).padStart(2, '0')}`;
-}
-
-function buildMonths(endYear, endMonth, count) {
- return Array.from({ length: count }, (_, index) => {
- const value = addMonths(endYear, endMonth, index - count + 1);
- return {
- ...value,
- key: monthKey(value.year, value.month),
- label: monthLabel(value.year, value.month),
- start: `${monthKey(value.year, value.month)}-01`,
- end: monthEndDate(value.year, value.month),
- };
- });
-}
-
-function validateSummaryQuery(query) {
- const now = new Date();
- const year = parseInteger(query.year, now.getFullYear());
- const month = parseInteger(query.month, now.getMonth() + 1);
- const months = parseInteger(query.months, 12);
- const categoryId = parseInteger(query.category_id, null);
- const billId = parseInteger(query.bill_id, null);
- const includeInactive = query.include_inactive === 'true';
- const includeSkipped = query.include_skipped !== 'false';
-
- if (!Number.isInteger(year) || year < 2000 || year > 2100) {
- return { error: 'year must be a 4-digit integer between 2000 and 2100' };
- }
- if (!Number.isInteger(month) || month < 1 || month > 12) {
- return { error: 'month must be an integer between 1 and 12' };
- }
- if (!Number.isInteger(months) || months < 1 || months > 36) {
- return { error: 'months must be an integer between 1 and 36' };
- }
- if (categoryId !== null && (!Number.isInteger(categoryId) || categoryId < 1)) {
- return { error: 'category_id must be a positive integer' };
- }
- if (billId !== null && (!Number.isInteger(billId) || billId < 1)) {
- return { error: 'bill_id must be a positive integer' };
- }
-
- return { year, month, months, categoryId, billId, includeInactive, includeSkipped };
-}
-
-function isMonthInPast(year, month) {
- const now = new Date();
- const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
- const targetMonthStart = new Date(year, month - 1, 1);
- return targetMonthStart < currentMonthStart;
-}
-
-function buildBillWhere({ userId, categoryId, billId, includeInactive }) {
- const clauses = ['b.user_id = ?', 'b.deleted_at IS NULL'];
- const params = [userId];
- if (!includeInactive) clauses.push('b.active = 1');
- if (categoryId) {
- clauses.push('b.category_id = ?');
- params.push(categoryId);
- }
- if (billId) {
- clauses.push('b.id = ?');
- params.push(billId);
- }
- return { where: clauses.join(' AND '), params };
-}
+const { getAnalyticsSummary } = require('../services/analyticsService');
router.get('/summary', (req, res) => {
- const parsed = validateSummaryQuery(req.query);
- if (parsed.error) return res.status(400).json(standardizeError(parsed.error, 'VALIDATION_ERROR', 'month'));
-
- const db = getDb();
- const userId = req.user.id;
- const rangeMonths = buildMonths(parsed.year, parsed.month, parsed.months);
- const startDate = rangeMonths[0].start;
- const endDate = rangeMonths[rangeMonths.length - 1].end;
- const billWhere = buildBillWhere({ ...parsed, userId });
-
- const categories = db.prepare(`
- SELECT id, name
- FROM categories
- WHERE user_id = ?
- AND deleted_at IS NULL
- ORDER BY name COLLATE NOCASE
- `).all(userId);
-
- const bills = db.prepare(`
- SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at,
- c.name AS category_name
- FROM bills b
- LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
- WHERE ${billWhere.where}
- ORDER BY b.name COLLATE NOCASE
- `).all(...billWhere.params);
-
- if (!bills.length) {
- return res.json({
- range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate },
- filters: {
- category_id: parsed.categoryId,
- bill_id: parsed.billId,
- include_inactive: parsed.includeInactive,
- include_skipped: parsed.includeSkipped,
- },
- categories,
- bills: [],
- monthly_spending: [],
- expected_vs_actual: [],
- category_spend: [],
- heatmap: { months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })), rows: [] },
- generated_at: new Date().toISOString(),
- });
- }
-
- const billIds = bills.map(b => b.id);
- const placeholders = billIds.map(() => '?').join(',');
-
- // Batch fetch all payments for the date range
- let paymentRows = [];
- if (billIds.length > 0) {
- paymentRows = db.prepare(`
- SELECT p.bill_id,
- substr(p.paid_date, 1, 7) AS month_key,
- SUM(p.amount) AS total
- FROM payments p
- JOIN bills b ON b.id = p.bill_id
- WHERE b.user_id = ?
- AND b.deleted_at IS NULL
- AND p.bill_id IN (${placeholders})
- AND p.paid_date BETWEEN ? AND ?
- AND p.deleted_at IS NULL
- GROUP BY p.bill_id, substr(p.paid_date, 1, 7)
- `).all(userId, ...billIds, startDate, endDate);
- }
-
- // Batch fetch all monthly bill states for the date range
- let stateRows = [];
- if (billIds.length > 0) {
- stateRows = db.prepare(`
- SELECT m.bill_id, m.year, m.month, m.actual_amount, m.is_skipped
- FROM monthly_bill_state m
- JOIN bills b ON b.id = m.bill_id
- WHERE b.user_id = ?
- AND b.deleted_at IS NULL
- AND m.bill_id IN (${placeholders})
- AND (m.year * 100 + m.month) BETWEEN ? AND ?
- `).all(
- userId,
- ...billIds,
- rangeMonths[0].year * 100 + rangeMonths[0].month,
- rangeMonths[rangeMonths.length - 1].year * 100 + rangeMonths[rangeMonths.length - 1].month,
- );
- }
-
- const paymentByBillMonth = new Map(paymentRows.map(row => [`${row.bill_id}:${row.month_key}`, Number(row.total) || 0]));
- const stateByBillMonth = new Map(stateRows.map(row => [`${row.bill_id}:${monthKey(row.year, row.month)}`, row]));
-
- const monthly_spending = rangeMonths.map(m => {
- const total = bills.reduce((sum, bill) => sum + (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0), 0);
- return { month: m.key, label: m.label, total: Number(total.toFixed(2)) };
- }).filter(row => row.total > 0);
-
- const expected_vs_actual = rangeMonths.map(m => {
- let expected = 0;
- let actual = 0;
- let skipped_count = 0;
- for (const bill of bills) {
- const state = stateByBillMonth.get(`${bill.id}:${m.key}`);
- const skipped = !!state?.is_skipped;
- if (skipped) skipped_count += 1;
- if (!skipped || parsed.includeSkipped) {
- actual += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
- }
- if (!skipped) {
- expected += state?.actual_amount ?? bill.expected_amount ?? 0;
- }
- }
- return {
- month: m.key,
- label: m.label,
- expected: Number(expected.toFixed(2)),
- actual: Number(actual.toFixed(2)),
- skipped_count,
- };
- }).filter(row => row.expected > 0 || row.actual > 0 || row.skipped_count > 0);
-
- const categoryMap = new Map();
- for (const bill of bills) {
- const categoryId = bill.category_id || null;
- const key = categoryId == null ? 'uncategorized' : String(categoryId);
- const existing = categoryMap.get(key) || {
- category_id: categoryId,
- category_name: bill.category_name || 'Uncategorized',
- total: 0,
- };
- for (const m of rangeMonths) {
- existing.total += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
- }
- categoryMap.set(key, existing);
- }
- const category_spend = Array.from(categoryMap.values())
- .map(row => ({ ...row, total: Number(row.total.toFixed(2)) }))
- .filter(row => row.total > 0)
- .sort((a, b) => b.total - a.total);
-
- const heatmapRows = bills.map(bill => {
- const cells = rangeMonths.map(m => {
- const paid = (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0) > 0;
- const state = stateByBillMonth.get(`${bill.id}:${m.key}`);
- const skipped = !!state?.is_skipped;
- let status = 'no_data';
- if (skipped) status = 'skipped';
- else if (paid) status = 'paid';
- else if (isMonthInPast(m.year, m.month)) status = 'missed';
- return {
- month: m.key,
- label: m.label,
- status,
- amount_paid: Number((paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0).toFixed(2)),
- };
- });
- return {
- bill_id: bill.id,
- bill_name: bill.name,
- category_name: bill.category_name || 'Uncategorized',
- active: !!bill.active,
- cells: parsed.includeSkipped ? cells : cells.filter(cell => cell.status !== 'skipped'),
- };
- });
-
- res.json({
- range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate },
- filters: {
- category_id: parsed.categoryId,
- bill_id: parsed.billId,
- include_inactive: parsed.includeInactive,
- include_skipped: parsed.includeSkipped,
- },
- categories,
- bills: bills.map(b => ({
- id: b.id,
- name: b.name,
- category_id: b.category_id,
- category_name: b.category_name || 'Uncategorized',
- active: !!b.active,
- })),
- monthly_spending,
- expected_vs_actual,
- category_spend,
- heatmap: {
- months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })),
- rows: heatmapRows,
- },
- generated_at: new Date().toISOString(),
- });
+ const result = getAnalyticsSummary(req.user.id, req.query);
+ if (result.error) return res.status(400).json(standardizeError(result.error, 'VALIDATION_ERROR', 'month'));
+ res.json(result);
});
module.exports = router;
diff --git a/routes/bills.js b/routes/bills.js
index cc35de3..68d9a3b 100644
--- a/routes/bills.js
+++ b/routes/bills.js
@@ -1,32 +1,19 @@
const express = require('express');
const router = express.Router();
const { getDb, ensureUserDefaultCategories } = require('../db/database');
-const { VALID_VISIBILITY, getValidCycleTypes, parseDueDay, parseInterestRate, validateCycleDay, validateBillData, computeBalanceDelta } = require('../services/billsService');
+const {
+ auditBillsForUser,
+ categoryBelongsToUser,
+ insertBill,
+ parseTemplateData,
+ sanitizeTemplateData,
+ validateBillData,
+ computeBalanceDelta,
+} = require('../services/billsService');
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
const { standardizeError } = require('../middleware/errorFormatter');
const { validatePaymentInput } = require('../services/paymentValidation');
-function hasText(value) {
- return typeof value === 'string' && value.trim().length > 0;
-}
-
-function isDebtBill(bill) {
- const category = String(bill.category_name || '').toLowerCase();
- return Number(bill.current_balance) > 0
- || bill.minimum_payment != null
- || ['credit card', 'credit cards', 'loan', 'loans', 'debt'].some(token => category.includes(token));
-}
-
-function issue(bill, field, severity, suggestion) {
- return {
- bill_id: bill.id,
- bill_name: bill.name,
- field,
- severity,
- suggestion,
- };
-}
-
// ── GET /api/bills ────────────────────────────────────────────────────────────
router.get('/', (req, res) => {
const db = getDb();
@@ -52,61 +39,106 @@ router.get('/audit', (req, res) => {
const db = getDb();
ensureUserDefaultCategories(req.user.id);
const includeInactive = req.query.inactive === 'true';
- const bills = db.prepare(`
- SELECT b.id, b.name, b.category_id, b.due_day, b.active, b.autopay_enabled,
- b.website, b.username, b.account_info, b.current_balance,
- b.minimum_payment, b.interest_rate, c.name AS category_name
- FROM bills b
- LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
- WHERE b.user_id = ?
- AND b.deleted_at IS NULL
- ${includeInactive ? '' : 'AND b.active = 1'}
- ORDER BY b.active DESC, b.due_day ASC, b.name ASC
+ res.json(auditBillsForUser(db, req.user.id, includeInactive));
+});
+
+// ── GET /api/bills/templates ─────────────────────────────────────────────────
+router.get('/templates', (req, res) => {
+ const db = getDb();
+ const rows = db.prepare(`
+ SELECT id, name, data, created_at, updated_at
+ FROM bill_templates
+ WHERE user_id = ?
+ ORDER BY name COLLATE NOCASE ASC
`).all(req.user.id);
- const auditedBills = bills.map((bill) => {
- const issues = [];
- const dueDay = Number(bill.due_day);
- const debt = isDebtBill(bill);
- const balance = Number(bill.current_balance);
+ res.json(rows.map(row => ({
+ ...row,
+ data: parseTemplateData(row.data),
+ })));
+});
- if (!Number.isInteger(dueDay) || dueDay < 1 || dueDay > 31) {
- issues.push(issue(bill, 'due_day', 'error', 'Add a due day between 1 and 31 so tracker periods and reminders can place this bill correctly.'));
- }
- if (!bill.category_id || !bill.category_name) {
- issues.push(issue(bill, 'category_id', 'warning', 'Choose a category so summaries, filters, and snowball debt detection stay accurate.'));
- }
- if (debt && !(Number(bill.minimum_payment) > 0)) {
- issues.push(issue(bill, 'minimum_payment', 'error', 'Add the required minimum payment so debt snowball projections can calculate payoff order and dates.'));
- }
- if (bill.autopay_enabled && !hasText(bill.website) && !hasText(bill.account_info)) {
- issues.push(issue(bill, 'autopay_enabled', 'warning', 'Add a website or account note so autopay bills still have enough reference information when something needs attention.'));
- }
- if (Number.isFinite(balance) && balance > 0 && bill.interest_rate == null) {
- issues.push(issue(bill, 'interest_rate', 'warning', 'Add the APR so snowball and amortization estimates include interest instead of assuming 0%.'));
- }
+// ── POST /api/bills/templates ────────────────────────────────────────────────
+router.post('/templates', (req, res) => {
+ const db = getDb();
+ const name = String(req.body.name || '').trim();
+ if (name.length < 2) {
+ return res.status(400).json(standardizeError('Template name must be at least 2 characters', 'VALIDATION_ERROR', 'name'));
+ }
- return {
- id: bill.id,
- name: bill.name,
- active: !!bill.active,
- category_name: bill.category_name,
- due_day: bill.due_day,
- is_debt: debt,
- issues,
- };
+ const data = sanitizeTemplateData(req.body.data || {});
+ if (Object.keys(data).length === 0) {
+ return res.status(400).json(standardizeError('Template data is required', 'VALIDATION_ERROR', 'data'));
+ }
+ const validation = validateBillData(data);
+ if (validation.errors.length > 0) {
+ const firstError = validation.errors[0];
+ return res.status(400).json(standardizeError(firstError.message, 'VALIDATION_ERROR', `data.${firstError.field}`));
+ }
+ if (!categoryBelongsToUser(db, validation.normalized.category_id, req.user.id)) {
+ return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'data.category_id'));
+ }
+ const normalizedData = sanitizeTemplateData(validation.normalized);
+
+ const result = db.prepare(`
+ INSERT INTO bill_templates (user_id, name, data, updated_at)
+ VALUES (?, ?, ?, datetime('now'))
+ ON CONFLICT(user_id, name) DO UPDATE SET
+ data = excluded.data,
+ updated_at = datetime('now')
+ `).run(req.user.id, name, JSON.stringify(normalizedData));
+
+ const template = db.prepare(`
+ SELECT id, name, data, created_at, updated_at
+ FROM bill_templates
+ WHERE user_id = ? AND name = ?
+ `).get(req.user.id, name);
+
+ res.status(result.changes > 0 ? 201 : 200).json({
+ ...template,
+ data: parseTemplateData(template.data),
});
+});
- const issues = auditedBills.flatMap(bill => bill.issues);
- res.json({
- bills: auditedBills.filter(bill => bill.issues.length > 0),
- summary: {
- audited_bills: bills.length,
- issue_count: issues.length,
- error_count: issues.filter(item => item.severity === 'error').length,
- warning_count: issues.filter(item => item.severity === 'warning').length,
- },
- });
+// ── DELETE /api/bills/templates/:templateId ──────────────────────────────────
+router.delete('/templates/:templateId', (req, res) => {
+ const db = getDb();
+ const templateId = parseInt(req.params.templateId, 10);
+ if (!Number.isInteger(templateId)) {
+ return res.status(400).json(standardizeError('template_id must be an integer', 'VALIDATION_ERROR', 'template_id'));
+ }
+ const result = db.prepare('DELETE FROM bill_templates WHERE id = ? AND user_id = ?').run(templateId, req.user.id);
+ if (result.changes === 0) return res.status(404).json(standardizeError('Template not found', 'NOT_FOUND', 'template_id'));
+ res.json({ success: true });
+});
+
+// ── POST /api/bills/:id/duplicate ────────────────────────────────────────────
+router.post('/:id/duplicate', (req, res) => {
+ const db = getDb();
+ const body = req.body || {};
+ const billId = parseInt(req.params.id, 10);
+ if (!Number.isInteger(billId)) {
+ return res.status(400).json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
+ }
+ const source = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
+ if (!source) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
+
+ const draft = {
+ ...sanitizeTemplateData(source),
+ ...sanitizeTemplateData(body),
+ name: String(body.name || `${source.name} (Copy)`).trim(),
+ };
+ const validation = validateBillData(draft);
+ if (validation.errors.length > 0) {
+ const firstError = validation.errors[0];
+ return res.status(400).json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
+ }
+ const { normalized } = validation;
+ if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
+ return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
+ }
+
+ res.status(201).json(insertBill(db, req.user.id, normalized));
});
// ── GET /api/bills/:id/monthly-state?year=&month= ─────────────────────────────
@@ -208,14 +240,25 @@ router.get('/:id', (req, res) => {
// ── POST /api/bills ───────────────────────────────────────────────────────────
router.post('/', (req, res) => {
const db = getDb();
- const {
- name, category_id, due_day, override_due_date, expected_amount, interest_rate,
- billing_cycle, autopay_enabled, autodraft_status, website, username,
- account_info, has_2fa, notes, history_visibility, cycle_type, cycle_day,
- } = req.body;
+ const body = req.body || {};
+ let payload = body;
+
+ if (body.source_bill_id !== undefined && body.source_bill_id !== null && body.source_bill_id !== '') {
+ const sourceBillId = parseInt(body.source_bill_id, 10);
+ if (!Number.isInteger(sourceBillId)) {
+ return res.status(400).json(standardizeError('source_bill_id must be an integer', 'VALIDATION_ERROR', 'source_bill_id'));
+ }
+ const source = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(sourceBillId, req.user.id);
+ if (!source) return res.status(404).json(standardizeError('Source bill not found', 'NOT_FOUND', 'source_bill_id'));
+ payload = {
+ ...sanitizeTemplateData(source),
+ ...sanitizeTemplateData(body),
+ name: String(body.name || `${source.name} (Copy)`).trim(),
+ };
+ }
// Validate and normalize bill data
- const validation = validateBillData(req.body);
+ const validation = validateBillData(payload);
if (validation.errors.length > 0) {
const firstError = validation.errors[0];
return res.status(400).json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
@@ -224,46 +267,11 @@ router.post('/', (req, res) => {
const { normalized } = validation;
// Validate category_id exists for this user
- if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(normalized.category_id, req.user.id)) {
+ if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
}
- const result = db.prepare(`
- INSERT INTO bills
- (user_id, name, category_id, due_day, override_due_date, bucket, expected_amount,
- interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username,
- account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day,
- current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
- `).run(
- req.user.id,
- normalized.name,
- normalized.category_id,
- normalized.due_day,
- normalized.override_due_date,
- normalized.bucket,
- normalized.expected_amount,
- normalized.interest_rate,
- normalized.billing_cycle,
- normalized.autopay_enabled,
- normalized.autodraft_status,
- normalized.website,
- normalized.username,
- normalized.account_info,
- normalized.has_2fa,
- normalized.notes,
- normalized.history_visibility,
- normalized.cycle_type,
- normalized.cycle_day,
- normalized.current_balance,
- normalized.minimum_payment,
- normalized.snowball_order,
- normalized.snowball_include,
- normalized.snowball_exempt,
- );
-
- const created = db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid);
- res.status(201).json(created);
+ res.status(201).json(insertBill(db, req.user.id, normalized));
});
// ── PUT /api/bills/:id ────────────────────────────────────────────────────────
@@ -282,14 +290,14 @@ router.put('/:id', (req, res) => {
const { normalized } = validation;
// Validate category_id exists for this user if changed
- if (normalized.category_id && !db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(normalized.category_id, req.user.id)) {
+ if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
return res.status(400).json(standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'));
}
db.prepare(`
UPDATE bills SET
name = ?, category_id = ?, due_day = ?, override_due_date = ?, bucket = ?,
- expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?,
+ expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?, auto_mark_paid = ?,
website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?,
history_visibility = ?, cycle_type = ?, cycle_day = ?,
current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?, snowball_exempt = ?,
@@ -306,6 +314,7 @@ router.put('/:id', (req, res) => {
normalized.billing_cycle,
normalized.autopay_enabled,
normalized.autodraft_status,
+ normalized.auto_mark_paid,
normalized.website,
normalized.username,
normalized.account_info,
@@ -392,7 +401,7 @@ router.post('/:id/toggle-paid', (req, res) => {
const billId = parseInt(req.params.id, 10);
// Get bill - always scope to the requesting user
- const bill = db.prepare('SELECT id, expected_amount, user_id, due_day, current_balance, interest_rate FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
+ const bill = db.prepare('SELECT id, expected_amount, user_id, due_day, current_balance, interest_rate, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
@@ -478,7 +487,6 @@ router.post('/:id/toggle-paid', (req, res) => {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
.run(balCalc.new_balance, billId);
}
-
res.status(201).json({
success: true,
isPaid: true,
diff --git a/routes/payments.js b/routes/payments.js
index d7f276e..7c576d8 100644
--- a/routes/payments.js
+++ b/routes/payments.js
@@ -4,9 +4,47 @@ const router = require('express').Router();
const { getDb } = require('../db/database');
const { computeBalanceDelta } = require('../services/billsService');
const { validatePaymentInput } = require('../services/paymentValidation');
+const { resolveDueDate } = require('../services/statusService');
const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments
+function parseYearMonth(body) {
+ const year = parseInt(body.year, 10);
+ const month = parseInt(body.month, 10);
+ if (!Number.isInteger(year) || year < 2000 || year > 2100) {
+ return { error: standardizeError('year must be a 4-digit integer between 2000 and 2100', 'VALIDATION_ERROR', 'year') };
+ }
+ if (!Number.isInteger(month) || month < 1 || month > 12) {
+ return { error: standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month') };
+ }
+ return { year, month };
+}
+
+function getAutopaySuggestionContext(db, userId, billId, year, month) {
+ const bill = db.prepare(`
+ SELECT *
+ FROM bills
+ WHERE id = ? AND user_id = ? AND deleted_at IS NULL
+ `).get(billId, userId);
+ if (!bill) return { error: standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'), status: 404 };
+ if (!bill.autopay_enabled || bill.autodraft_status !== 'assumed_paid') {
+ return { error: standardizeError('Bill is not eligible for autopay suggestions', 'VALIDATION_ERROR', 'bill_id'), status: 400 };
+ }
+
+ const state = db.prepare(`
+ SELECT actual_amount, is_skipped
+ FROM monthly_bill_state
+ WHERE bill_id = ? AND year = ? AND month = ?
+ `).get(bill.id, year, month);
+ if (state?.is_skipped) {
+ return { error: standardizeError('Skipped bills cannot be suggested for payment', 'VALIDATION_ERROR', 'bill_id'), status: 400 };
+ }
+
+ const dueDate = resolveDueDate(bill, year, month);
+ const amount = state?.actual_amount ?? bill.expected_amount;
+ return { bill, dueDate, amount };
+}
+
// GET /api/payments?bill_id=&year=&month=
router.get('/', (req, res) => {
const db = getDb();
@@ -66,12 +104,19 @@ router.post('/', (req, res) => {
}
const payment = validation.normalized;
- if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id))
+ const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(payment.bill_id, req.user.id);
+ if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
+ const balCalc = computeBalanceDelta(bill, payment.amount);
const result = db.prepare(
- 'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
- ).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null);
+ 'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
+ ).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null, balCalc?.balance_delta ?? null);
+
+ if (balCalc) {
+ db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
+ .run(balCalc.new_balance, bill.id);
+ }
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
});
@@ -113,11 +158,99 @@ router.post('/quick', (req, res) => {
.run(balCalc.new_balance, bill.id);
}
- if (bill.autopay_enabled) {
- db.prepare("UPDATE bills SET autodraft_status='confirmed', updated_at=datetime('now') WHERE id=?").run(bill.id);
+ res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
+});
+
+// POST /api/payments/autopay-suggestions/:billId/confirm
+router.post('/autopay-suggestions/:billId/confirm', (req, res) => {
+ const db = getDb();
+ const ym = parseYearMonth(req.body);
+ if (ym.error) return res.status(400).json(ym.error);
+
+ const billId = parseInt(req.params.billId, 10);
+ if (!Number.isInteger(billId)) {
+ return res.status(400).json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
}
- res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
+ const context = getAutopaySuggestionContext(db, req.user.id, billId, ym.year, ym.month);
+ if (context.error) return res.status(context.status).json(context.error);
+ const { bill, dueDate, amount } = context;
+ if (dueDate > new Date().toISOString().slice(0, 10)) {
+ return res.status(400).json(standardizeError('Autopay suggestion is not due yet', 'VALIDATION_ERROR', 'paid_date'));
+ }
+ const paymentValidation = validatePaymentInput(
+ { amount, paid_date: dueDate },
+ { requireBillId: false },
+ );
+ if (paymentValidation.error) {
+ return res.status(400).json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
+ }
+ const suggestedPayment = paymentValidation.normalized;
+
+ const existing = db.prepare(`
+ SELECT p.*
+ FROM payments p
+ JOIN bills b ON b.id = p.bill_id
+ WHERE p.bill_id = ?
+ AND b.user_id = ?
+ AND p.deleted_at IS NULL
+ AND strftime('%Y', p.paid_date) = ?
+ AND strftime('%m', p.paid_date) = ?
+ ORDER BY p.paid_date DESC
+ LIMIT 1
+ `).get(bill.id, req.user.id, String(ym.year), String(ym.month).padStart(2, '0'));
+
+ if (existing) {
+ db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
+ .run(req.user.id, bill.id, ym.year, ym.month);
+ return res.json({ created: false, payment: existing });
+ }
+
+ const balCalc = computeBalanceDelta(bill, suggestedPayment.amount);
+ const result = db.prepare(`
+ INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `).run(
+ bill.id,
+ suggestedPayment.amount,
+ suggestedPayment.paid_date,
+ 'autopay',
+ 'Confirmed autopay suggestion',
+ balCalc?.balance_delta ?? null,
+ );
+
+ if (balCalc) {
+ db.prepare("UPDATE bills SET current_balance = ?, updated_at=datetime('now') WHERE id=?")
+ .run(balCalc.new_balance, bill.id);
+ }
+ db.prepare('DELETE FROM autopay_suggestion_dismissals WHERE user_id = ? AND bill_id = ? AND year = ? AND month = ?')
+ .run(req.user.id, bill.id, ym.year, ym.month);
+
+ res.status(201).json({ created: true, payment: db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid) });
+});
+
+// POST /api/payments/autopay-suggestions/:billId/dismiss
+router.post('/autopay-suggestions/:billId/dismiss', (req, res) => {
+ const db = getDb();
+ const ym = parseYearMonth(req.body);
+ if (ym.error) return res.status(400).json(ym.error);
+
+ const billId = parseInt(req.params.billId, 10);
+ if (!Number.isInteger(billId)) {
+ return res.status(400).json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
+ }
+ if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id)) {
+ return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
+ }
+
+ db.prepare(`
+ INSERT INTO autopay_suggestion_dismissals (user_id, bill_id, year, month, dismissed_at)
+ VALUES (?, ?, ?, ?, datetime('now'))
+ ON CONFLICT(user_id, bill_id, year, month)
+ DO UPDATE SET dismissed_at = datetime('now')
+ `).run(req.user.id, billId, ym.year, ym.month);
+
+ res.json({ success: true });
});
// POST /api/payments/bulk — record multiple payments in one request
@@ -217,16 +350,39 @@ router.put('/:id', (req, res) => {
return res.status(400).json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
}
+ const nextAmount = validation.normalized.amount ?? existing.amount;
+ const nextPaidDate = validation.normalized.paid_date ?? existing.paid_date;
+ let nextBalanceDelta = existing.balance_delta;
+
+ const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(existing.bill_id, req.user.id);
+ if (bill) {
+ let restoredBalance = bill.current_balance;
+ if (existing.balance_delta != null && bill.current_balance != null) {
+ restoredBalance = Math.max(0, Math.round((bill.current_balance - existing.balance_delta) * 100) / 100);
+ }
+
+ const balCalc = computeBalanceDelta({ ...bill, current_balance: restoredBalance }, nextAmount);
+ nextBalanceDelta = balCalc?.balance_delta ?? null;
+ if (balCalc) {
+ db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
+ .run(balCalc.new_balance, existing.bill_id);
+ } else if (existing.balance_delta != null && restoredBalance != null) {
+ db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
+ .run(restoredBalance, existing.bill_id);
+ }
+ }
+
db.prepare(`
UPDATE payments SET
- amount = ?, paid_date = ?, method = ?, notes = ?,
+ amount = ?, paid_date = ?, method = ?, notes = ?, balance_delta = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(
- validation.normalized.amount ?? existing.amount,
- validation.normalized.paid_date ?? existing.paid_date,
+ nextAmount,
+ nextPaidDate,
method !== undefined ? (method || null) : existing.method,
notes !== undefined ? (notes || null) : existing.notes,
+ nextBalanceDelta,
req.params.id,
);
diff --git a/routes/tracker.js b/routes/tracker.js
index 392c8da..d283687 100644
--- a/routes/tracker.js
+++ b/routes/tracker.js
@@ -1,319 +1,17 @@
const express = require('express');
-const router = express.Router();
-const { getDb } = require('../db/database');
-const { buildTrackerRow, getCycleRange, resolveDueDate } = require('../services/statusService');
-const { getUserSettings } = require('../services/userSettings');
+const router = express.Router();
+const { getTracker, getUpcomingBills } = require('../services/trackerService');
// GET /api/tracker?year=2026&month=5
router.get('/', (req, res) => {
- const db = getDb();
- const now = new Date();
- const year = parseInt(req.query.year || now.getFullYear(), 10);
- const month = parseInt(req.query.month || now.getMonth() + 1, 10);
-
- if (isNaN(year) || year < 2000 || year > 2100)
- return res.status(400).json({ error: 'year must be a 4-digit integer between 2000 and 2100' });
- if (isNaN(month) || month < 1 || month > 12)
- return res.status(400).json({ error: 'month must be an integer between 1 and 12' });
-
- const todayStr = now.toISOString().slice(0, 10);
- const userSettings = getUserSettings(req.user.id);
- const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
-
- const { start, end } = getCycleRange(year, month);
-
- // Calculate previous month (with year wrapping)
- const prevMonth = month === 1 ? 12 : month - 1;
- const prevYear = month === 1 ? year - 1 : year;
- const prevMonthRange = getCycleRange(prevYear, prevMonth);
-
- // Calculate 3-month range for trend analysis
- const threeMonthsAgo = (() => {
- let y = year, m = month - 2;
- while (m <= 0) { m += 12; y -= 1; }
- return { year: y, month: m };
- })();
-
- const bills = db.prepare(`
- SELECT b.*, c.name AS category_name
- FROM bills b
- LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
- WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
- ORDER BY b.due_day ASC, b.name ASC
- `).all(req.user.id);
-
- // Batch fetch all monthly bill states for current month
- const billIds = bills.map(bill => bill.id);
- const placeholders = billIds.map(() => '?').join(',');
-
- let monthlyStates = {};
- if (billIds.length > 0) {
- const monthlyStateQuery = `
- SELECT bill_id, actual_amount, notes, is_skipped
- FROM monthly_bill_state
- WHERE bill_id IN (${placeholders}) AND year = ? AND month = ?
- `;
- const monthlyStateRows = db.prepare(monthlyStateQuery).all(...billIds, year, month);
- monthlyStates = Object.fromEntries(monthlyStateRows.map(row => [row.bill_id, row]));
- }
-
- // Batch fetch all payments for current month
- let allPayments = {};
- if (billIds.length > 0) {
- const paymentsQuery = `
- SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
- FROM payments
- WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
- AND deleted_at IS NULL
- ORDER BY paid_date DESC
- `;
- const paymentRows = db.prepare(paymentsQuery).all(...billIds, start, end);
-
- // Group payments by bill_id
- allPayments = {};
- paymentRows.forEach(row => {
- if (!allPayments[row.bill_id]) {
- allPayments[row.bill_id] = [];
- }
- allPayments[row.bill_id].push(row);
- });
- }
-
- // Batch fetch all previous month payments
- let prevMonthPayments = {};
- if (billIds.length > 0) {
- const prevPaymentsQuery = `
- SELECT bill_id, SUM(amount) as total_paid
- FROM payments
- WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
- AND deleted_at IS NULL
- GROUP BY bill_id
- `;
- const prevPaymentRows = db.prepare(prevPaymentsQuery).all(...billIds, prevMonthRange.start, prevMonthRange.end);
- prevMonthPayments = Object.fromEntries(prevPaymentRows.map(row => [row.bill_id, row.total_paid]));
- }
-
- const rows = bills.map(bill => {
- // Get payments for this bill
- const payments = allPayments[bill.id] || [];
-
- const row = buildTrackerRow(bill, payments, year, month, todayStr, rowOptions);
-
- // Overlay monthly state overrides
- const mbs = monthlyStates[bill.id];
- row.actual_amount = mbs?.actual_amount ?? null;
- row.monthly_notes = mbs?.notes ?? null;
- row.is_skipped = !!(mbs?.is_skipped);
-
- // Get previous month paid amount
- row.previous_month_paid = prevMonthPayments[bill.id] || 0;
-
- return row;
- });
-
- const totalOverdue = rows
- .filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed'))
- .reduce((s, r) => s + r.balance, 0);
-
- const activeRows = rows.filter(r => !r.is_skipped);
-
- // Get starting amounts for this month
- const startingAmounts = db.prepare(`
- SELECT COALESCE(first_amount, 0) AS first_amount,
- COALESCE(fifteenth_amount, 0) AS fifteenth_amount,
- COALESCE(other_amount, 0) AS other_amount,
- COALESCE(first_amount, 0) + COALESCE(fifteenth_amount, 0) + COALESCE(other_amount, 0) AS combined_amount
- FROM monthly_starting_amounts
- WHERE user_id = ? AND year = ? AND month = ?
- `).get(req.user.id, year, month);
-
- const dayOfMonth = now.getDate();
- const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
- const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
- const periodPaid = periodRows.reduce((s, r) => s + r.total_paid, 0);
- const periodOutstandingBalance = periodRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
- const periodStartingAmount = activeRemainingPeriod === '1st'
- ? (startingAmounts?.first_amount || 0)
- : (startingAmounts?.fifteenth_amount || 0);
- const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
-
- const totalStarting = startingAmounts?.combined_amount || 0;
- const hasStartingAmounts = !!startingAmounts;
- const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
- const activeTotalExpected = activeRows.reduce((s, r) => s + r.expected_amount, 0);
- const activeOutstandingBalance = activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
-
- // Calculate previous month total
- const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0);
-
- // Calculate 3-month trend data
- const threeMonthStart = getCycleRange(threeMonthsAgo.year, threeMonthsAgo.month).start;
- const currentMonthEnd = end;
-
- // Get all payments for the last 3 months for this user
- // Join through bills to get user_id since payments table doesn't have user_id
- const threeMonthPayments = db.prepare(`
- SELECT SUM(p.amount) as total_paid, strftime('%Y-%m', p.paid_date) as month_key
- FROM payments p
- JOIN bills b ON p.bill_id = b.id
- WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ?
- AND p.deleted_at IS NULL
- GROUP BY strftime('%Y-%m', p.paid_date)
- `).all(req.user.id, threeMonthStart, currentMonthEnd);
-
- // Create a map of month payments for easier access
- const monthlyPaymentsMap = new Map();
- threeMonthPayments.forEach(payment => {
- monthlyPaymentsMap.set(payment.month_key, payment.total_paid);
- });
-
- // Calculate payments for each of the last 3 months
- const months = [];
- for (let i = 2; i >= 0; i--) {
- const date = new Date(year, month - 1 - i);
- const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
- months.push({
- year: date.getFullYear(),
- month: date.getMonth() + 1,
- key: monthKey,
- payment: parseFloat(monthlyPaymentsMap.get(monthKey) || 0)
- });
- }
-
- // Calculate 3-month average
- const threeMonthTotal = months.reduce((sum, m) => sum + m.payment, 0);
- const threeMonthAvg = threeMonthTotal / 3;
-
- // Calculate current month paid (sum of all bills)
- const currentMonthPaid = activeTotalPaid;
-
- // Calculate percentage change
- let percentChange = 0;
- let direction = 'flat';
-
- if (threeMonthAvg > 0) {
- percentChange = ((currentMonthPaid - threeMonthAvg) / threeMonthAvg) * 100;
-
- // Determine direction based on percentage change
- if (percentChange > 2) {
- direction = 'up';
- } else if (percentChange < -2) {
- direction = 'down';
- } else {
- direction = 'flat';
- }
- }
-
- // Ensure percentChange is a number with 1 decimal place
- percentChange = parseFloat(percentChange.toFixed(1));
-
- res.json({
- year, month, today: todayStr,
- summary: {
- total_expected: activeTotalExpected,
- total_starting: totalStarting,
- has_starting_amounts: hasStartingAmounts,
- total_paid: activeTotalPaid,
- remaining: hasStartingAmounts ? periodStartingAmount - periodPaid : periodOutstandingBalance,
- total_remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : activeOutstandingBalance,
- remaining_period: activeRemainingPeriod,
- remaining_label: periodLabel,
- remaining_hint: hasStartingAmounts
- ? `${periodLabel}: ${periodStartingAmount.toFixed(2)} starting minus ${periodPaid.toFixed(2)} paid`
- : `${periodLabel}: unpaid bills due in this period`,
- overdue: totalOverdue,
- count_paid: activeRows.filter(r => r.status === 'paid').length,
- count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,
- count_late: activeRows.filter(r => r.status === 'late' || r.status === 'missed').length,
- count_autodraft: activeRows.filter(r => r.status === 'autodraft').length,
- previous_month_total: previousMonthTotal,
- trend: {
- three_month_avg: parseFloat(threeMonthAvg.toFixed(2)),
- current_month_paid: parseFloat(currentMonthPaid.toFixed(2)),
- percent_change: percentChange,
- direction: direction
- }
- },
- rows,
- });
+ const result = getTracker(req.user.id, req.query);
+ if (result.error) return res.status(result.status || 400).json({ error: result.error });
+ res.json(result);
});
// GET /api/tracker/upcoming?days=30
-// Returns active bills with a due date in the next N days, sorted by due_date asc.
router.get('/upcoming', (req, res) => {
- const db = getDb();
- const days = Math.max(1, Math.min(parseInt(req.query.days || '30', 10) || 30, 365));
- const now = new Date();
- const todayStr = now.toISOString().slice(0, 10);
- const userSettings = getUserSettings(req.user.id);
- const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
-
- const year = now.getFullYear();
- const month = now.getMonth() + 1;
- const { start, end } = getCycleRange(year, month);
-
- const bills = db.prepare(`
- SELECT b.*, c.name AS category_name
- FROM bills b
- LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
- WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
- `).all(req.user.id);
-
- const cutoff = new Date(now);
- cutoff.setDate(cutoff.getDate() + days);
- const cutoffStr = cutoff.toISOString().slice(0, 10);
-
- // Get all bill IDs for batch processing
- const billIds = bills.map(bill => bill.id);
-
- // Batch fetch all payments for all bills in the date range
- let allPayments = {};
- if (billIds.length > 0) {
- const placeholders = billIds.map(() => '?').join(',');
- const paymentsQuery = `
- SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
- FROM payments
- WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
- AND deleted_at IS NULL
- ORDER BY paid_date DESC
- `;
- const paymentRows = db.prepare(paymentsQuery).all(...billIds, start, end);
-
- // Group payments by bill_id
- allPayments = {};
- paymentRows.forEach(row => {
- if (!allPayments[row.bill_id]) {
- allPayments[row.bill_id] = [];
- }
- allPayments[row.bill_id].push(row);
- });
- }
-
- const upcoming = [];
-
- for (const bill of bills) {
- const dueDate = resolveDueDate(bill, year, month);
- if (dueDate < todayStr || dueDate > cutoffStr) continue;
-
- // Get payments for this bill from the batched results
- const payments = allPayments[bill.id] || [];
-
- const row = buildTrackerRow(bill, payments, year, month, todayStr, rowOptions);
- if (row.status === 'paid') continue; // skip already paid
-
- upcoming.push({
- id: bill.id,
- name: bill.name,
- category_name: bill.category_name,
- due_date: dueDate,
- expected_amount: bill.expected_amount,
- status: row.status,
- days_until_due: Math.floor((new Date(dueDate) - now) / 86400000),
- });
- }
-
- upcoming.sort((a, b) => a.due_date.localeCompare(b.due_date));
- res.json({ days, today: todayStr, upcoming });
+ res.json(getUpcomingBills(req.user.id, req.query));
});
module.exports = router;
diff --git a/services/analyticsService.js b/services/analyticsService.js
new file mode 100644
index 0000000..72317de
--- /dev/null
+++ b/services/analyticsService.js
@@ -0,0 +1,289 @@
+'use strict';
+
+const { getDb } = require('../db/database');
+
+function parseInteger(value, fallback) {
+ if (value === undefined || value === null || value === '') return fallback;
+ const parsed = Number(value);
+ return Number.isInteger(parsed) ? parsed : NaN;
+}
+
+function monthKey(year, month) {
+ return `${year}-${String(month).padStart(2, '0')}`;
+}
+
+function monthLabel(year, month) {
+ return new Date(Date.UTC(year, month - 1, 1)).toLocaleString('en-US', {
+ month: 'short',
+ year: '2-digit',
+ timeZone: 'UTC',
+ });
+}
+
+function addMonths(year, month, delta) {
+ const date = new Date(Date.UTC(year, month - 1 + delta, 1));
+ return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 };
+}
+
+function monthEndDate(year, month) {
+ const day = new Date(Date.UTC(year, month, 0)).getUTCDate();
+ return `${monthKey(year, month)}-${String(day).padStart(2, '0')}`;
+}
+
+function buildMonths(endYear, endMonth, count) {
+ return Array.from({ length: count }, (_, index) => {
+ const value = addMonths(endYear, endMonth, index - count + 1);
+ return {
+ ...value,
+ key: monthKey(value.year, value.month),
+ label: monthLabel(value.year, value.month),
+ start: `${monthKey(value.year, value.month)}-01`,
+ end: monthEndDate(value.year, value.month),
+ };
+ });
+}
+
+function validateSummaryQuery(query, now = new Date()) {
+ const year = parseInteger(query.year, now.getFullYear());
+ const month = parseInteger(query.month, now.getMonth() + 1);
+ const months = parseInteger(query.months, 12);
+ const categoryId = parseInteger(query.category_id, null);
+ const billId = parseInteger(query.bill_id, null);
+ const includeInactive = query.include_inactive === 'true';
+ const includeSkipped = query.include_skipped !== 'false';
+
+ if (!Number.isInteger(year) || year < 2000 || year > 2100) {
+ return { error: 'year must be a 4-digit integer between 2000 and 2100' };
+ }
+ if (!Number.isInteger(month) || month < 1 || month > 12) {
+ return { error: 'month must be an integer between 1 and 12' };
+ }
+ if (!Number.isInteger(months) || months < 1 || months > 36) {
+ return { error: 'months must be an integer between 1 and 36' };
+ }
+ if (categoryId !== null && (!Number.isInteger(categoryId) || categoryId < 1)) {
+ return { error: 'category_id must be a positive integer' };
+ }
+ if (billId !== null && (!Number.isInteger(billId) || billId < 1)) {
+ return { error: 'bill_id must be a positive integer' };
+ }
+
+ return { year, month, months, categoryId, billId, includeInactive, includeSkipped };
+}
+
+function isMonthInPast(year, month) {
+ const now = new Date();
+ const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
+ const targetMonthStart = new Date(year, month - 1, 1);
+ return targetMonthStart < currentMonthStart;
+}
+
+function buildBillWhere({ userId, categoryId, billId, includeInactive }) {
+ const clauses = ['b.user_id = ?', 'b.deleted_at IS NULL'];
+ const params = [userId];
+ if (!includeInactive) clauses.push('b.active = 1');
+ if (categoryId) {
+ clauses.push('b.category_id = ?');
+ params.push(categoryId);
+ }
+ if (billId) {
+ clauses.push('b.id = ?');
+ params.push(billId);
+ }
+ return { where: clauses.join(' AND '), params };
+}
+
+function emptySummary(parsed, rangeMonths, startDate, endDate, categories) {
+ return {
+ range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate },
+ filters: {
+ category_id: parsed.categoryId,
+ bill_id: parsed.billId,
+ include_inactive: parsed.includeInactive,
+ include_skipped: parsed.includeSkipped,
+ },
+ categories,
+ bills: [],
+ monthly_spending: [],
+ expected_vs_actual: [],
+ category_spend: [],
+ heatmap: { months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })), rows: [] },
+ generated_at: new Date().toISOString(),
+ };
+}
+
+function getAnalyticsSummary(userId, query = {}) {
+ const parsed = validateSummaryQuery(query);
+ if (parsed.error) return parsed;
+
+ const db = getDb();
+ const rangeMonths = buildMonths(parsed.year, parsed.month, parsed.months);
+ const startDate = rangeMonths[0].start;
+ const endDate = rangeMonths[rangeMonths.length - 1].end;
+ const billWhere = buildBillWhere({ ...parsed, userId });
+
+ const categories = db.prepare(`
+ SELECT id, name
+ FROM categories
+ WHERE user_id = ?
+ AND deleted_at IS NULL
+ ORDER BY name COLLATE NOCASE
+ `).all(userId);
+
+ const bills = db.prepare(`
+ SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at,
+ c.name AS category_name
+ FROM bills b
+ LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id AND c.deleted_at IS NULL
+ WHERE ${billWhere.where}
+ ORDER BY b.name COLLATE NOCASE
+ `).all(...billWhere.params);
+
+ if (!bills.length) {
+ return emptySummary(parsed, rangeMonths, startDate, endDate, categories);
+ }
+
+ const billIds = bills.map(b => b.id);
+ const placeholders = billIds.map(() => '?').join(',');
+
+ const paymentRows = db.prepare(`
+ SELECT p.bill_id,
+ substr(p.paid_date, 1, 7) AS month_key,
+ SUM(p.amount) AS total
+ FROM payments p
+ JOIN bills b ON b.id = p.bill_id
+ WHERE b.user_id = ?
+ AND b.deleted_at IS NULL
+ AND p.bill_id IN (${placeholders})
+ AND p.paid_date BETWEEN ? AND ?
+ AND p.deleted_at IS NULL
+ GROUP BY p.bill_id, substr(p.paid_date, 1, 7)
+ `).all(userId, ...billIds, startDate, endDate);
+
+ const stateRows = db.prepare(`
+ SELECT m.bill_id, m.year, m.month, m.actual_amount, m.is_skipped
+ FROM monthly_bill_state m
+ JOIN bills b ON b.id = m.bill_id
+ WHERE b.user_id = ?
+ AND b.deleted_at IS NULL
+ AND m.bill_id IN (${placeholders})
+ AND (m.year * 100 + m.month) BETWEEN ? AND ?
+ `).all(
+ userId,
+ ...billIds,
+ rangeMonths[0].year * 100 + rangeMonths[0].month,
+ rangeMonths[rangeMonths.length - 1].year * 100 + rangeMonths[rangeMonths.length - 1].month,
+ );
+
+ const paymentByBillMonth = new Map(paymentRows.map(row => [`${row.bill_id}:${row.month_key}`, Number(row.total) || 0]));
+ const stateByBillMonth = new Map(stateRows.map(row => [`${row.bill_id}:${monthKey(row.year, row.month)}`, row]));
+
+ const monthly_spending = rangeMonths.map(m => {
+ const total = bills.reduce((sum, bill) => sum + (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0), 0);
+ return { month: m.key, label: m.label, total: Number(total.toFixed(2)) };
+ }).filter(row => row.total > 0);
+
+ const expected_vs_actual = rangeMonths.map(m => {
+ let expected = 0;
+ let actual = 0;
+ let skipped_count = 0;
+ for (const bill of bills) {
+ const state = stateByBillMonth.get(`${bill.id}:${m.key}`);
+ const skipped = !!state?.is_skipped;
+ if (skipped) skipped_count += 1;
+ if (!skipped || parsed.includeSkipped) {
+ actual += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
+ }
+ if (!skipped) {
+ expected += state?.actual_amount ?? bill.expected_amount ?? 0;
+ }
+ }
+ return {
+ month: m.key,
+ label: m.label,
+ expected: Number(expected.toFixed(2)),
+ actual: Number(actual.toFixed(2)),
+ skipped_count,
+ };
+ }).filter(row => row.expected > 0 || row.actual > 0 || row.skipped_count > 0);
+
+ const categoryMap = new Map();
+ for (const bill of bills) {
+ const categoryId = bill.category_id || null;
+ const key = categoryId == null ? 'uncategorized' : String(categoryId);
+ const existing = categoryMap.get(key) || {
+ category_id: categoryId,
+ category_name: bill.category_name || 'Uncategorized',
+ total: 0,
+ };
+ for (const m of rangeMonths) {
+ existing.total += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
+ }
+ categoryMap.set(key, existing);
+ }
+ const category_spend = Array.from(categoryMap.values())
+ .map(row => ({ ...row, total: Number(row.total.toFixed(2)) }))
+ .filter(row => row.total > 0)
+ .sort((a, b) => b.total - a.total);
+
+ const heatmapRows = bills.map(bill => {
+ const cells = rangeMonths.map(m => {
+ const paid = (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0) > 0;
+ const state = stateByBillMonth.get(`${bill.id}:${m.key}`);
+ const skipped = !!state?.is_skipped;
+ let status = 'no_data';
+ if (skipped) status = 'skipped';
+ else if (paid) status = 'paid';
+ else if (isMonthInPast(m.year, m.month)) status = 'missed';
+ return {
+ month: m.key,
+ label: m.label,
+ status,
+ amount_paid: Number((paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0).toFixed(2)),
+ };
+ });
+ return {
+ bill_id: bill.id,
+ bill_name: bill.name,
+ category_name: bill.category_name || 'Uncategorized',
+ active: !!bill.active,
+ cells: parsed.includeSkipped ? cells : cells.filter(cell => cell.status !== 'skipped'),
+ };
+ });
+
+ return {
+ range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate },
+ filters: {
+ category_id: parsed.categoryId,
+ bill_id: parsed.billId,
+ include_inactive: parsed.includeInactive,
+ include_skipped: parsed.includeSkipped,
+ },
+ categories,
+ bills: bills.map(b => ({
+ id: b.id,
+ name: b.name,
+ category_id: b.category_id,
+ category_name: b.category_name || 'Uncategorized',
+ active: !!b.active,
+ })),
+ monthly_spending,
+ expected_vs_actual,
+ category_spend,
+ heatmap: {
+ months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })),
+ rows: heatmapRows,
+ },
+ generated_at: new Date().toISOString(),
+ };
+}
+
+module.exports = {
+ addMonths,
+ buildMonths,
+ getAnalyticsSummary,
+ monthEndDate,
+ monthKey,
+ monthLabel,
+ validateSummaryQuery,
+};
diff --git a/services/billsService.js b/services/billsService.js
index 07647e0..7200dc1 100644
--- a/services/billsService.js
+++ b/services/billsService.js
@@ -1,5 +1,151 @@
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
+const TEMPLATE_FIELDS = [
+ 'name', 'category_id', 'due_day', 'override_due_date', 'bucket', 'expected_amount',
+ 'interest_rate', 'billing_cycle', 'cycle_type', 'cycle_day', 'autopay_enabled',
+ 'autodraft_status', 'auto_mark_paid', 'website', 'username', 'account_info',
+ 'has_2fa', 'notes', 'current_balance', 'minimum_payment', 'snowball_order',
+ 'snowball_include', 'snowball_exempt', 'history_visibility',
+];
+
+function hasText(value) {
+ return typeof value === 'string' && value.trim().length > 0;
+}
+
+function isDebtBill(bill) {
+ const category = String(bill.category_name || '').toLowerCase();
+ return Number(bill.current_balance) > 0
+ || bill.minimum_payment != null
+ || ['credit card', 'credit cards', 'loan', 'loans', 'debt'].some(token => category.includes(token));
+}
+
+function billAuditIssue(bill, field, severity, suggestion) {
+ return {
+ bill_id: bill.id,
+ bill_name: bill.name,
+ field,
+ severity,
+ suggestion,
+ };
+}
+
+function sanitizeTemplateData(data = {}) {
+ return TEMPLATE_FIELDS.reduce((out, field) => {
+ if (data[field] !== undefined) out[field] = data[field];
+ return out;
+ }, {});
+}
+
+function parseTemplateData(raw) {
+ try {
+ return sanitizeTemplateData(JSON.parse(raw || '{}'));
+ } catch {
+ return {};
+ }
+}
+
+function categoryBelongsToUser(db, categoryId, userId) {
+ if (!categoryId) return true;
+ return !!db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(categoryId, userId);
+}
+
+function insertBill(db, userId, normalized) {
+ const result = db.prepare(`
+ INSERT INTO bills
+ (user_id, name, category_id, due_day, override_due_date, bucket, expected_amount,
+ interest_rate, billing_cycle, autopay_enabled, autodraft_status, auto_mark_paid, website, username,
+ account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day,
+ current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
+ `).run(
+ userId,
+ normalized.name,
+ normalized.category_id,
+ normalized.due_day,
+ normalized.override_due_date,
+ normalized.bucket,
+ normalized.expected_amount,
+ normalized.interest_rate,
+ normalized.billing_cycle,
+ normalized.autopay_enabled,
+ normalized.autodraft_status,
+ normalized.auto_mark_paid,
+ normalized.website,
+ normalized.username,
+ normalized.account_info,
+ normalized.has_2fa,
+ normalized.notes,
+ normalized.history_visibility,
+ normalized.cycle_type,
+ normalized.cycle_day,
+ normalized.current_balance,
+ normalized.minimum_payment,
+ normalized.snowball_order,
+ normalized.snowball_include,
+ normalized.snowball_exempt,
+ );
+
+ return db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid);
+}
+
+function auditBillsForUser(db, userId, includeInactive = false) {
+ const bills = db.prepare(`
+ SELECT b.id, b.name, b.category_id, b.due_day, b.active, b.autopay_enabled,
+ b.website, b.username, b.account_info, b.current_balance,
+ b.minimum_payment, b.interest_rate, c.name AS category_name
+ FROM bills b
+ LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
+ WHERE b.user_id = ?
+ AND b.deleted_at IS NULL
+ ${includeInactive ? '' : 'AND b.active = 1'}
+ ORDER BY b.active DESC, b.due_day ASC, b.name ASC
+ `).all(userId);
+
+ const auditedBills = bills.map((bill) => {
+ const issues = [];
+ const dueDay = Number(bill.due_day);
+ const debt = isDebtBill(bill);
+ const balance = Number(bill.current_balance);
+
+ if (!Number.isInteger(dueDay) || dueDay < 1 || dueDay > 31) {
+ issues.push(billAuditIssue(bill, 'due_day', 'error', 'Add a due day between 1 and 31 so tracker periods and reminders can place this bill correctly.'));
+ }
+ if (!bill.category_id || !bill.category_name) {
+ issues.push(billAuditIssue(bill, 'category_id', 'warning', 'Choose a category so summaries, filters, and snowball debt detection stay accurate.'));
+ }
+ if (debt && !(Number(bill.minimum_payment) > 0)) {
+ issues.push(billAuditIssue(bill, 'minimum_payment', 'error', 'Add the required minimum payment so debt snowball projections can calculate payoff order and dates.'));
+ }
+ if (bill.autopay_enabled && !hasText(bill.website) && !hasText(bill.account_info)) {
+ issues.push(billAuditIssue(bill, 'autopay_enabled', 'warning', 'Add a website or account note so autopay bills still have enough reference information when something needs attention.'));
+ }
+ if (Number.isFinite(balance) && balance > 0 && bill.interest_rate == null) {
+ issues.push(billAuditIssue(bill, 'interest_rate', 'warning', 'Add the APR so snowball and amortization estimates include interest instead of assuming 0%.'));
+ }
+
+ return {
+ id: bill.id,
+ name: bill.name,
+ active: !!bill.active,
+ category_name: bill.category_name,
+ due_day: bill.due_day,
+ is_debt: debt,
+ issues,
+ };
+ });
+
+ const issues = auditedBills.flatMap(bill => bill.issues);
+ return {
+ bills: auditedBills.filter(bill => bill.issues.length > 0),
+ summary: {
+ audited_bills: bills.length,
+ issue_count: issues.length,
+ error_count: issues.filter(item => item.severity === 'error').length,
+ warning_count: issues.filter(item => item.severity === 'warning').length,
+ },
+ };
+}
+
// Helper function to get default cycle day based on cycle type
function getDefaultCycleDay(cycleType) {
switch (cycleType) {
@@ -124,6 +270,9 @@ function validateBillData(data, existingBill = null) {
// autodraft_status
normalized.autodraft_status = data.autodraft_status !== undefined ? (data.autodraft_status || 'none') : (existingBill?.autodraft_status || 'none');
+ // auto_mark_paid
+ normalized.auto_mark_paid = data.auto_mark_paid !== undefined ? (data.auto_mark_paid ? 1 : 0) : (existingBill?.auto_mark_paid || 0);
+
// website
normalized.website = data.website !== undefined ? (data.website || null) : (existingBill?.website || null);
@@ -274,11 +423,17 @@ function computeBalanceDelta(bill, paymentAmount) {
module.exports = {
VALID_VISIBILITY,
+ TEMPLATE_FIELDS,
+ auditBillsForUser,
+ categoryBelongsToUser,
getValidCycleTypes,
getDefaultCycleDay,
+ insertBill,
+ parseTemplateData,
validateCycleDay,
parseDueDay,
parseInterestRate,
+ sanitizeTemplateData,
validateBillData,
validateCycleDayOnly,
computeBalanceDelta,
diff --git a/services/oidcService.js b/services/oidcService.js
index 1d528ef..abbe798 100644
--- a/services/oidcService.js
+++ b/services/oidcService.js
@@ -51,7 +51,7 @@
const crypto = require('crypto');
const { Issuer } = require('openid-client');
-const { getDb, getSetting } = require('../db/database');
+const { getDb, getSetting, setSetting } = require('../db/database');
// ── Configuration ─────────────────────────────────────────────────────────────
@@ -155,6 +155,217 @@ function getAdminOidcSettings() {
};
}
+function trimOrEmpty(value) {
+ if (value === undefined || value === null) return '';
+ return String(value).trim();
+}
+
+function boolSetting(value, fallback) {
+ if (value === undefined) return fallback;
+ if (typeof value === 'string') return value === 'true';
+ return !!value;
+}
+
+function serviceError(message, status = 400) {
+ return Object.assign(new Error(message), { status });
+}
+
+function computeSubmittedOidcConfigured(body = {}) {
+ const current = getAdminOidcSettings();
+ const next = {
+ issuer: body.oidc_issuer_url !== undefined
+ ? trimOrEmpty(body.oidc_issuer_url)
+ : current.oidc_issuer_url,
+ clientId: body.oidc_client_id !== undefined
+ ? trimOrEmpty(body.oidc_client_id)
+ : current.oidc_client_id,
+ redirectUri: body.oidc_redirect_uri !== undefined
+ ? trimOrEmpty(body.oidc_redirect_uri)
+ : current.oidc_redirect_uri,
+ clientSecret: current.oidc_client_secret_set ? 'set' : '',
+ };
+
+ if (body.oidc_client_secret_clear === true) {
+ next.clientSecret = process.env.OIDC_CLIENT_SECRET ? 'set' : '';
+ }
+ if (trimOrEmpty(body.oidc_client_secret)) {
+ next.clientSecret = 'set';
+ }
+
+ return !!(next.issuer && next.clientId && next.clientSecret && next.redirectUri);
+}
+
+function buildSubmittedOidcConfig(body = {}) {
+ const current = getAdminOidcSettings();
+ const status = getOidcConfigStatus();
+
+ const issuerUrl = body.oidc_issuer_url !== undefined
+ ? trimOrEmpty(body.oidc_issuer_url)
+ : current.oidc_issuer_url;
+ const clientId = body.oidc_client_id !== undefined
+ ? trimOrEmpty(body.oidc_client_id)
+ : current.oidc_client_id;
+ const redirectUri = body.oidc_redirect_uri !== undefined
+ ? trimOrEmpty(body.oidc_redirect_uri)
+ : current.oidc_redirect_uri;
+ const tokenAuthMethod = body.oidc_token_auth_method !== undefined
+ ? trimOrEmpty(body.oidc_token_auth_method)
+ : current.oidc_token_auth_method;
+ const scopes = body.oidc_scopes !== undefined
+ ? trimOrEmpty(body.oidc_scopes)
+ : current.oidc_scopes;
+ const providerName = body.oidc_provider_name !== undefined
+ ? trimOrEmpty(body.oidc_provider_name)
+ : current.oidc_provider_name;
+
+ let clientSecret = status.oidc_client_secret_set ? '__saved__' : '';
+ if (body.oidc_client_secret_clear === true) clientSecret = process.env.OIDC_CLIENT_SECRET || '';
+ if (trimOrEmpty(body.oidc_client_secret)) clientSecret = trimOrEmpty(body.oidc_client_secret);
+
+ if (!issuerUrl || !clientId || !clientSecret || !redirectUri) return null;
+
+ return {
+ enabled: true,
+ issuerUrl,
+ clientId,
+ clientSecret: clientSecret === '__saved__'
+ ? (getSetting('oidc_client_secret') || process.env.OIDC_CLIENT_SECRET || '')
+ : clientSecret,
+ tokenEndpointAuthMethod: tokenAuthMethod === 'client_secret_post'
+ ? 'client_secret_post'
+ : 'client_secret_basic',
+ redirectUri,
+ scopes: (scopes || 'openid email profile groups').split(/\s+/).filter(Boolean),
+ adminGroup: body.oidc_admin_group !== undefined ? trimOrEmpty(body.oidc_admin_group) : current.oidc_admin_group,
+ defaultRole: 'user',
+ autoProvision: body.oidc_auto_provision !== undefined ? !!body.oidc_auto_provision : current.oidc_auto_provision,
+ providerName: providerName || 'authentik',
+ };
+}
+
+function buildAuthModeStatus() {
+ const oidcConfigured = getOidcConfigStatus().oidc_configured;
+ const localEnabled = getSetting('local_login_enabled') !== 'false';
+ const oidcEnabled = getSetting('oidc_login_enabled') === 'true';
+ const oidcAdminGroup = getAdminOidcSettings().oidc_admin_group;
+ const canDisableLocal = oidcConfigured && oidcEnabled && !!oidcAdminGroup;
+
+ const warnings = [];
+ if (!localEnabled && !oidcConfigured) {
+ warnings.push('Local login is disabled but OIDC is not configured; users may be locked out.');
+ }
+ if (!localEnabled && !oidcEnabled) {
+ warnings.push('No login method is enabled. Re-enable local login or configure OIDC.');
+ }
+ if (oidcEnabled && !oidcConfigured) {
+ warnings.push('authentik/OIDC login is enabled but configuration is incomplete, so the login button will stay hidden.');
+ }
+ if (!localEnabled && !oidcAdminGroup) {
+ warnings.push('Local login is disabled but no OIDC admin group is configured.');
+ }
+
+ return {
+ auth_mode: getSetting('auth_mode') || 'multi',
+ default_user_id: getSetting('default_user_id') || null,
+ local_login_enabled: localEnabled,
+ oidc_login_enabled: oidcEnabled,
+ oidc_configured: oidcConfigured,
+ ...getOidcConfigStatus(),
+ ...getAdminOidcSettings(),
+ can_disable_local: canDisableLocal,
+ warnings,
+ };
+}
+
+function applyAuthModeSettings(body = {}) {
+ const {
+ auth_mode, default_user_id,
+ local_login_enabled, oidc_login_enabled, oidc_enabled,
+ oidc_provider_name, oidc_issuer_url, oidc_client_id, oidc_client_secret,
+ oidc_client_secret_clear, oidc_token_auth_method, oidc_redirect_uri, oidc_scopes,
+ oidc_auto_provision, oidc_admin_group, oidc_default_role,
+ } = body;
+
+ if (auth_mode !== undefined) {
+ if (!['multi', 'single'].includes(auth_mode)) {
+ throw serviceError('auth_mode must be "multi" or "single"');
+ }
+ if (auth_mode === 'single') {
+ if (!default_user_id) throw serviceError('default_user_id is required for single mode');
+ const u = getDb().prepare("SELECT id FROM users WHERE id=? AND role='user'").get(default_user_id);
+ if (!u) throw serviceError('User not found or not a regular user', 404);
+ }
+ }
+
+ const oidcConfigured = computeSubmittedOidcConfigured(body);
+ const nextLocal = boolSetting(local_login_enabled, getSetting('local_login_enabled') !== 'false');
+ const requestedOidc = oidc_login_enabled !== undefined ? oidc_login_enabled : oidc_enabled;
+ const nextOidc = boolSetting(requestedOidc, getSetting('oidc_login_enabled') === 'true');
+ const nextAdminGroup = oidc_admin_group !== undefined
+ ? trimOrEmpty(oidc_admin_group)
+ : getAdminOidcSettings().oidc_admin_group;
+
+ if (!nextLocal && !nextOidc) {
+ throw serviceError('Cannot disable all login methods. At least one must remain enabled.');
+ }
+ if (!nextLocal && !oidcConfigured) {
+ throw serviceError('Cannot disable local login until authentik/OIDC is fully configured.');
+ }
+ if (!nextLocal && !nextAdminGroup) {
+ throw serviceError('Cannot disable local login until an OIDC admin group is configured.');
+ }
+ if (nextOidc && !oidcConfigured) {
+ throw serviceError('Cannot enable OIDC login until issuer URL, client ID, client secret, and redirect URI are configured.');
+ }
+
+ if (auth_mode !== undefined) {
+ if (auth_mode === 'single') setSetting('default_user_id', default_user_id);
+ setSetting('auth_mode', auth_mode);
+ }
+ if (local_login_enabled !== undefined) setSetting('local_login_enabled', nextLocal ? 'true' : 'false');
+ if (oidc_login_enabled !== undefined || oidc_enabled !== undefined) {
+ setSetting('oidc_login_enabled', nextOidc ? 'true' : 'false');
+ }
+
+ if (oidc_provider_name !== undefined) {
+ const name = String(oidc_provider_name).slice(0, 100).trim();
+ if (name) setSetting('oidc_provider_name', name);
+ }
+ if (oidc_issuer_url !== undefined) setSetting('oidc_issuer_url', trimOrEmpty(oidc_issuer_url).slice(0, 500));
+ if (oidc_client_id !== undefined) setSetting('oidc_client_id', trimOrEmpty(oidc_client_id).slice(0, 500));
+ if (oidc_token_auth_method !== undefined) {
+ const method = oidc_token_auth_method === 'client_secret_post' ? 'client_secret_post' : 'client_secret_basic';
+ setSetting('oidc_token_auth_method', method);
+ }
+ if (oidc_redirect_uri !== undefined) setSetting('oidc_redirect_uri', trimOrEmpty(oidc_redirect_uri).slice(0, 500));
+ if (oidc_scopes !== undefined) {
+ const scopes = trimOrEmpty(oidc_scopes).split(/\s+/).filter(Boolean).join(' ') || 'openid email profile groups';
+ setSetting('oidc_scopes', scopes.slice(0, 500));
+ }
+ if (oidc_client_secret_clear === true) setSetting('oidc_client_secret', '');
+ if (trimOrEmpty(oidc_client_secret)) {
+ setSetting('oidc_client_secret', trimOrEmpty(oidc_client_secret).slice(0, 1000));
+ }
+ if (oidc_auto_provision !== undefined) setSetting('oidc_auto_provision', !!oidc_auto_provision ? 'true' : 'false');
+ if (oidc_admin_group !== undefined) setSetting('oidc_admin_group', String(oidc_admin_group).slice(0, 200).trim());
+ if (oidc_default_role !== undefined) {
+ setSetting('oidc_default_role', 'user');
+ }
+
+ if (
+ oidc_issuer_url !== undefined ||
+ oidc_client_id !== undefined ||
+ oidc_client_secret !== undefined ||
+ oidc_client_secret_clear === true ||
+ oidc_token_auth_method !== undefined ||
+ oidc_redirect_uri !== undefined
+ ) {
+ invalidateClientCache();
+ }
+
+ return { success: true, ...buildAuthModeStatus() };
+}
+
/**
* Returns whether OIDC login is both configured and enabled by admin.
*/
@@ -492,6 +703,10 @@ async function findOrProvisionUser(claims, config) {
}
module.exports = {
+ applyAuthModeSettings,
+ buildAuthModeStatus,
+ buildSubmittedOidcConfig,
+ computeSubmittedOidcConfigured,
getOidcConfig,
getOidcConfigStatus,
getAdminOidcSettings,
diff --git a/services/spreadsheetImportService.js b/services/spreadsheetImportService.js
index 4dacabd..5824138 100644
--- a/services/spreadsheetImportService.js
+++ b/services/spreadsheetImportService.js
@@ -1362,6 +1362,25 @@ function resolveMonth(decision, previewRow, sessionData) {
return decision.month ?? previewRow?.detected_month ?? sessionData.default_month ?? null;
}
+function nullableString(value) {
+ if (value == null) return null;
+ const text = String(value).trim();
+ return text === '' ? null : text;
+}
+
+function nullableNumber(value) {
+ if (value == null) return null;
+ const num = Number(value);
+ return Number.isFinite(num) ? num : null;
+}
+
+function amountsEqual(a, b) {
+ const left = nullableNumber(a);
+ const right = nullableNumber(b);
+ if (left == null || right == null) return left === right;
+ return Math.abs(left - right) < 0.005;
+}
+
function upsertMonthlyState(db, billId, year, month, amount, notes, isSkipped, allowOverwrite) {
const existing = db.prepare(`
SELECT id, actual_amount, notes, is_skipped
@@ -1377,8 +1396,8 @@ function upsertMonthlyState(db, billId, year, month, amount, notes, isSkipped, a
return { result: 'created' };
}
- const amountConflict = (amount !== null && existing.actual_amount !== null && existing.actual_amount !== amount);
- const notesConflict = (notes !== null && existing.notes !== null && existing.notes !== notes);
+ const amountConflict = (amount != null && existing.actual_amount !== null && !amountsEqual(existing.actual_amount, amount));
+ const notesConflict = (notes != null && existing.notes !== null && nullableString(existing.notes) !== nullableString(notes));
if ((amountConflict || notesConflict) && !allowOverwrite) {
return { result: 'skipped_conflict', note: 'Monthly state already exists with different values — use overwrite:true to replace' };
@@ -1386,7 +1405,16 @@ function upsertMonthlyState(db, billId, year, month, amount, notes, isSkipped, a
const newAmount = allowOverwrite ? amount : (existing.actual_amount !== null ? existing.actual_amount : amount);
const newNotes = allowOverwrite ? notes : (existing.notes !== null ? existing.notes : notes);
- const newSkipped = isSkipped !== null ? isSkipped : existing.is_skipped;
+ const newSkipped = isSkipped != null ? isSkipped : existing.is_skipped;
+
+ const noChange =
+ amountsEqual(existing.actual_amount, newAmount)
+ && nullableString(existing.notes) === nullableString(newNotes)
+ && Number(existing.is_skipped ?? 0) === Number(newSkipped ?? 0);
+
+ if (noChange && !allowOverwrite) {
+ return { result: 'skipped_duplicate', note: 'Monthly state already exists with the same values' };
+ }
db.prepare(`
UPDATE monthly_bill_state
@@ -1570,9 +1598,16 @@ function applyOneDecision(db, userId, decision, previewRow, sessionData, allowOv
const st = upsertMonthlyState(db, billId, year, month, amountToStore, noteToStore, isSkipped, allowOverwrite);
- if (st.result === 'skipped_conflict') { summary.skipped++; }
- else if (st.result === 'created') { summary.created++; }
- else { summary.updated++; }
+ if (st.result === 'skipped_conflict') {
+ summary.skipped++;
+ } else if (st.result === 'skipped_duplicate') {
+ summary.skipped++;
+ summary.duplicates++;
+ } else if (st.result === 'created') {
+ summary.created++;
+ } else {
+ summary.updated++;
+ }
const detail = { row_id, action, result: st.result, bill_id: billId };
if (st.note) detail.note = st.note;
diff --git a/services/statusService.js b/services/statusService.js
index a733538..7b43d14 100644
--- a/services/statusService.js
+++ b/services/statusService.js
@@ -52,10 +52,7 @@ function calculateStatus(bill, payments, dueDate, today, options = {}) {
const safePayments = Array.isArray(payments) ? payments : [];
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
- // A recorded payment is the user's confirmation that this cycle is handled.
- // Expected amounts are estimates, so a lower actual payment must not leave a Pay
- // button visible and invite duplicate payments.
- if (safePayments.length > 0 || totalPaid >= bill.expected_amount) return 'paid';
+ if (totalPaid >= bill.expected_amount) return 'paid';
if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') {
return 'autodraft';
@@ -107,7 +104,11 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
status,
autopay_enabled: !!bill.autopay_enabled,
autodraft_status: bill.autodraft_status,
+ auto_mark_paid: !!bill.auto_mark_paid,
billing_cycle: bill.billing_cycle,
+ current_balance: bill.current_balance ?? null,
+ minimum_payment: bill.minimum_payment ?? null,
+ interest_rate: bill.interest_rate ?? null,
payments: safePayments,
};
}
diff --git a/services/trackerService.js b/services/trackerService.js
new file mode 100644
index 0000000..4055b11
--- /dev/null
+++ b/services/trackerService.js
@@ -0,0 +1,366 @@
+'use strict';
+
+const { getDb } = require('../db/database');
+const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService');
+const { getUserSettings } = require('./userSettings');
+const { computeBalanceDelta } = require('./billsService');
+
+function validateTrackerMonth(query = {}, now = new Date()) {
+ const year = parseInt(query.year || now.getFullYear(), 10);
+ const month = parseInt(query.month || now.getMonth() + 1, 10);
+
+ if (Number.isNaN(year) || year < 2000 || year > 2100) {
+ return { error: 'year must be a 4-digit integer between 2000 and 2100', status: 400 };
+ }
+ if (Number.isNaN(month) || month < 1 || month > 12) {
+ return { error: 'month must be an integer between 1 and 12', status: 400 };
+ }
+ return { year, month };
+}
+
+function previousMonthFor(year, month) {
+ return {
+ year: month === 1 ? year - 1 : year,
+ month: month === 1 ? 12 : month - 1,
+ };
+}
+
+function monthOffset(year, month, offset) {
+ let y = year;
+ let m = month + offset;
+ while (m <= 0) { m += 12; y -= 1; }
+ while (m > 12) { m -= 12; y += 1; }
+ return { year: y, month: m };
+}
+
+function groupPaymentsByBill(paymentRows) {
+ const allPayments = {};
+ paymentRows.forEach(row => {
+ if (!allPayments[row.bill_id]) {
+ allPayments[row.bill_id] = [];
+ }
+ allPayments[row.bill_id].push(row);
+ });
+ return allPayments;
+}
+
+function fetchActiveBills(db, userId, orderBy = 'b.due_day ASC, b.name ASC') {
+ return db.prepare(`
+ SELECT b.*, c.name AS category_name
+ FROM bills b
+ LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
+ WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
+ ORDER BY ${orderBy}
+ `).all(userId);
+}
+
+function fetchMonthlyStates(db, billIds, year, month) {
+ if (billIds.length === 0) return {};
+ const placeholders = billIds.map(() => '?').join(',');
+ const rows = db.prepare(`
+ SELECT bill_id, actual_amount, notes, is_skipped
+ FROM monthly_bill_state
+ WHERE bill_id IN (${placeholders}) AND year = ? AND month = ?
+ `).all(...billIds, year, month);
+ return Object.fromEntries(rows.map(row => [row.bill_id, row]));
+}
+
+function fetchPaymentsByBill(db, billIds, start, end) {
+ if (billIds.length === 0) return {};
+ const placeholders = billIds.map(() => '?').join(',');
+ const rows = db.prepare(`
+ SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
+ FROM payments
+ WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
+ AND deleted_at IS NULL
+ ORDER BY paid_date DESC
+ `).all(...billIds, start, end);
+ return groupPaymentsByBill(rows);
+}
+
+function fetchPreviousMonthPaid(db, billIds, range) {
+ if (billIds.length === 0) return {};
+ const placeholders = billIds.map(() => '?').join(',');
+ const rows = db.prepare(`
+ SELECT bill_id, SUM(amount) as total_paid
+ FROM payments
+ WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
+ AND deleted_at IS NULL
+ GROUP BY bill_id
+ `).all(...billIds, range.start, range.end);
+ return Object.fromEntries(rows.map(row => [row.bill_id, row.total_paid]));
+}
+
+function fetchDismissedSuggestions(db, userId, billIds, year, month) {
+ if (billIds.length === 0) return new Set();
+ const rows = db.prepare(`
+ SELECT bill_id
+ FROM autopay_suggestion_dismissals
+ WHERE user_id = ? AND year = ? AND month = ?
+ `).all(userId, year, month);
+ return new Set(rows.map(row => row.bill_id));
+}
+
+function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr, dismissedSuggestions) {
+ const dueDate = resolveDueDate(bill, year, month);
+ const suggestedAmount = Number(mbs?.actual_amount ?? bill.expected_amount);
+ const hasSuggestedAmount = Number.isFinite(suggestedAmount) && suggestedAmount > 0;
+ const isEligible = !!(
+ bill.autopay_enabled &&
+ bill.autodraft_status === 'assumed_paid' &&
+ hasSuggestedAmount &&
+ dueDate <= todayStr &&
+ !mbs?.is_skipped &&
+ payments.length === 0
+ );
+
+ if (!isEligible) return null;
+
+ if (bill.auto_mark_paid) {
+ const existingPayment = db.prepare(`
+ SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
+ FROM payments
+ WHERE bill_id = ?
+ AND deleted_at IS NULL
+ AND strftime('%Y', paid_date) = ?
+ AND strftime('%m', paid_date) = ?
+ ORDER BY paid_date DESC
+ LIMIT 1
+ `).get(bill.id, String(year), String(month).padStart(2, '0'));
+
+ if (existingPayment) {
+ payments.push(existingPayment);
+ return null;
+ }
+
+ const balCalc = computeBalanceDelta(bill, suggestedAmount);
+ const result = db.prepare(`
+ INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `).run(
+ bill.id,
+ suggestedAmount,
+ dueDate,
+ 'autopay',
+ 'Auto-marked paid on due date',
+ balCalc?.balance_delta ?? null,
+ );
+
+ if (balCalc) {
+ db.prepare("UPDATE bills SET current_balance = ?, updated_at=datetime('now') WHERE id=?")
+ .run(balCalc.new_balance, bill.id);
+ bill.current_balance = balCalc.new_balance;
+ }
+ payments.push(db.prepare(`
+ SELECT bill_id, id, amount, paid_date, method, notes, created_at, updated_at
+ FROM payments
+ WHERE id = ?
+ `).get(result.lastInsertRowid));
+ return null;
+ }
+
+ if (dismissedSuggestions.has(bill.id)) return null;
+ return {
+ bill_id: bill.id,
+ amount: suggestedAmount,
+ paid_date: dueDate,
+ method: 'autopay',
+ };
+}
+
+function buildThreeMonthTrend(db, userId, year, month, end, currentMonthPaid) {
+ const threeMonthsAgo = monthOffset(year, month, -2);
+ const threeMonthStart = getCycleRange(threeMonthsAgo.year, threeMonthsAgo.month).start;
+ const rows = db.prepare(`
+ SELECT SUM(p.amount) as total_paid, strftime('%Y-%m', p.paid_date) as month_key
+ FROM payments p
+ JOIN bills b ON p.bill_id = b.id
+ WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.paid_date BETWEEN ? AND ?
+ AND p.deleted_at IS NULL
+ GROUP BY strftime('%Y-%m', p.paid_date)
+ `).all(userId, threeMonthStart, end);
+
+ const monthlyPaymentsMap = new Map();
+ rows.forEach(payment => {
+ monthlyPaymentsMap.set(payment.month_key, payment.total_paid);
+ });
+
+ const months = [];
+ for (let i = 2; i >= 0; i--) {
+ const date = new Date(year, month - 1 - i);
+ const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
+ months.push({
+ year: date.getFullYear(),
+ month: date.getMonth() + 1,
+ key: monthKey,
+ payment: parseFloat(monthlyPaymentsMap.get(monthKey) || 0),
+ });
+ }
+
+ const threeMonthAvg = months.reduce((sum, m) => sum + m.payment, 0) / 3;
+ let percentChange = 0;
+ let direction = 'flat';
+ if (threeMonthAvg > 0) {
+ percentChange = ((currentMonthPaid - threeMonthAvg) / threeMonthAvg) * 100;
+ if (percentChange > 2) direction = 'up';
+ else if (percentChange < -2) direction = 'down';
+ }
+
+ return {
+ three_month_avg: parseFloat(threeMonthAvg.toFixed(2)),
+ current_month_paid: parseFloat(currentMonthPaid.toFixed(2)),
+ percent_change: parseFloat(percentChange.toFixed(1)),
+ direction,
+ };
+}
+
+function getTracker(userId, query = {}, now = new Date()) {
+ const parsed = validateTrackerMonth(query, now);
+ if (parsed.error) return parsed;
+
+ const db = getDb();
+ const { year, month } = parsed;
+ const todayStr = now.toISOString().slice(0, 10);
+ const userSettings = getUserSettings(userId);
+ const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
+ const { start, end } = getCycleRange(year, month);
+ const previousMonth = previousMonthFor(year, month);
+ const prevMonthRange = getCycleRange(previousMonth.year, previousMonth.month);
+
+ const bills = fetchActiveBills(db, userId);
+ const billIds = bills.map(bill => bill.id);
+ const monthlyStates = fetchMonthlyStates(db, billIds, year, month);
+ const allPayments = fetchPaymentsByBill(db, billIds, start, end);
+ const prevMonthPayments = fetchPreviousMonthPaid(db, billIds, prevMonthRange);
+ const dismissedSuggestions = fetchDismissedSuggestions(db, userId, billIds, year, month);
+
+ const rows = bills.map(bill => {
+ const payments = allPayments[bill.id] || [];
+ const mbs = monthlyStates[bill.id];
+ const autopaySuggestion = applyAutopaySuggestions(
+ db,
+ bill,
+ payments,
+ mbs,
+ year,
+ month,
+ todayStr,
+ dismissedSuggestions,
+ );
+
+ const billForStatus = mbs?.actual_amount != null
+ ? { ...bill, expected_amount: mbs.actual_amount }
+ : bill;
+ const row = buildTrackerRow(billForStatus, payments, year, month, todayStr, rowOptions);
+ row.expected_amount = bill.expected_amount;
+ row.actual_amount = mbs?.actual_amount ?? null;
+ row.monthly_notes = mbs?.notes ?? null;
+ row.is_skipped = !!(mbs?.is_skipped);
+ if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
+ row.previous_month_paid = prevMonthPayments[bill.id] || 0;
+ return row;
+ });
+
+ const activeRows = rows.filter(r => !r.is_skipped);
+ const startingAmounts = db.prepare(`
+ SELECT COALESCE(first_amount, 0) AS first_amount,
+ COALESCE(fifteenth_amount, 0) AS fifteenth_amount,
+ COALESCE(other_amount, 0) AS other_amount,
+ COALESCE(first_amount, 0) + COALESCE(fifteenth_amount, 0) + COALESCE(other_amount, 0) AS combined_amount
+ FROM monthly_starting_amounts
+ WHERE user_id = ? AND year = ? AND month = ?
+ `).get(userId, year, month);
+
+ const dayOfMonth = now.getDate();
+ const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
+ const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
+ const periodPaid = periodRows.reduce((s, r) => s + r.total_paid, 0);
+ const periodOutstandingBalance = periodRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
+ const periodStartingAmount = activeRemainingPeriod === '1st'
+ ? (startingAmounts?.first_amount || 0)
+ : (startingAmounts?.fifteenth_amount || 0);
+ const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
+
+ const totalStarting = startingAmounts?.combined_amount || 0;
+ const hasStartingAmounts = !!startingAmounts;
+ const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
+ const activeTotalExpected = activeRows.reduce((s, r) => s + r.expected_amount, 0);
+ const activeOutstandingBalance = activeRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
+ const totalOverdue = rows
+ .filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed'))
+ .reduce((s, r) => s + r.balance, 0);
+ const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0);
+
+ return {
+ year,
+ month,
+ today: todayStr,
+ summary: {
+ total_expected: activeTotalExpected,
+ total_starting: totalStarting,
+ has_starting_amounts: hasStartingAmounts,
+ total_paid: activeTotalPaid,
+ remaining: hasStartingAmounts ? periodStartingAmount - periodPaid : periodOutstandingBalance,
+ total_remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : activeOutstandingBalance,
+ remaining_period: activeRemainingPeriod,
+ remaining_label: periodLabel,
+ remaining_hint: hasStartingAmounts
+ ? `${periodLabel}: ${periodStartingAmount.toFixed(2)} starting minus ${periodPaid.toFixed(2)} paid`
+ : `${periodLabel}: unpaid bills due in this period`,
+ overdue: totalOverdue,
+ count_paid: activeRows.filter(r => r.status === 'paid').length,
+ count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,
+ count_late: activeRows.filter(r => r.status === 'late' || r.status === 'missed').length,
+ count_autodraft: activeRows.filter(r => r.status === 'autodraft').length,
+ previous_month_total: previousMonthTotal,
+ trend: buildThreeMonthTrend(db, userId, year, month, end, activeTotalPaid),
+ },
+ rows,
+ };
+}
+
+function getUpcomingBills(userId, query = {}, now = new Date()) {
+ const db = getDb();
+ const days = Math.max(1, Math.min(parseInt(query.days || '30', 10) || 30, 365));
+ const todayStr = now.toISOString().slice(0, 10);
+ const userSettings = getUserSettings(userId);
+ const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
+ const year = now.getFullYear();
+ const month = now.getMonth() + 1;
+ const { start, end } = getCycleRange(year, month);
+ const bills = fetchActiveBills(db, userId, 'b.id ASC');
+ const billIds = bills.map(bill => bill.id);
+ const allPayments = fetchPaymentsByBill(db, billIds, start, end);
+
+ const cutoff = new Date(now);
+ cutoff.setDate(cutoff.getDate() + days);
+ const cutoffStr = cutoff.toISOString().slice(0, 10);
+ const upcoming = [];
+
+ for (const bill of bills) {
+ const dueDate = resolveDueDate(bill, year, month);
+ if (dueDate < todayStr || dueDate > cutoffStr) continue;
+
+ const row = buildTrackerRow(bill, allPayments[bill.id] || [], year, month, todayStr, rowOptions);
+ if (row.status === 'paid') continue;
+
+ upcoming.push({
+ id: bill.id,
+ name: bill.name,
+ category_name: bill.category_name,
+ due_date: dueDate,
+ expected_amount: bill.expected_amount,
+ status: row.status,
+ days_until_due: Math.floor((new Date(dueDate) - now) / 86400000),
+ });
+ }
+
+ upcoming.sort((a, b) => a.due_date.localeCompare(b.due_date));
+ return { days, today: todayStr, upcoming };
+}
+
+module.exports = {
+ getTracker,
+ getUpcomingBills,
+ validateTrackerMonth,
+};