diff --git a/db/database.js b/db/database.js
index 02d1df9..34d8d08 100644
--- a/db/database.js
+++ b/db/database.js
@@ -38,7 +38,7 @@ const DEFAULT_CATEGORIES = [
const COLUMN_WHITELIST = new Set([
// users table columns
'active', 'is_default_admin', 'notification_email', 'notifications_enabled',
- 'notify_3d', 'notify_1d', 'notify_due', 'notify_overdue',
+ 'notify_3d', 'notify_1d', 'notify_due', 'notify_overdue', 'notify_amount_change',
'display_name', 'last_password_change_at', 'auth_provider', 'external_subject',
'email', 'last_login_at',
// payments table columns
@@ -49,7 +49,7 @@ const COLUMN_WHITELIST = new Set([
'history_visibility', 'interest_rate', 'user_id',
'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include',
'snowball_exempt', 'is_subscription', 'subscription_type', 'reminder_days_before',
- 'subscription_source', 'subscription_detected_at', 'deleted_at',
+ 'subscription_source', 'subscription_detected_at', 'deleted_at', 'drift_snoozed_until',
// sessions table columns
'created_at',
// financial_accounts table columns
@@ -2425,6 +2425,15 @@ function runMigrations() {
run: function() {
db.exec('ALTER TABLE monthly_bill_state ADD COLUMN snoozed_until TEXT');
}
+ },
+ {
+ version: 'v0.71',
+ description: 'bills: add drift_snoozed_until; users: add notify_amount_change',
+ dependsOn: ['v0.70'],
+ run: function() {
+ db.exec('ALTER TABLE bills ADD COLUMN drift_snoozed_until TEXT');
+ db.exec('ALTER TABLE users ADD COLUMN notify_amount_change INTEGER NOT NULL DEFAULT 1');
+ }
}
];
diff --git a/db/schema.sql b/db/schema.sql
index 9641315..8440757 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS bills (
subscription_detected_at TEXT,
deleted_at TEXT,
notes TEXT,
+ drift_snoozed_until TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
@@ -76,6 +77,7 @@ CREATE TABLE IF NOT EXISTS users (
must_change_password INTEGER NOT NULL DEFAULT 0,
first_login INTEGER NOT NULL DEFAULT 1,
snowball_extra_payment REAL NOT NULL DEFAULT 0,
+ notify_amount_change INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
diff --git a/routes/bills.js b/routes/bills.js
index cab8931..575ddbe 100644
--- a/routes/bills.js
+++ b/routes/bills.js
@@ -44,6 +44,31 @@ router.get('/audit', (req, res) => {
res.json(auditBillsForUser(db, req.user.id, includeInactive));
});
+// ── GET /api/bills/drift-report ──────────────────────────────────────────────
+router.get('/drift-report', (req, res) => {
+ const { getDriftReport } = require('../services/driftService');
+ try {
+ res.json(getDriftReport(req.user.id));
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to compute drift report' });
+ }
+});
+
+// ── POST /api/bills/:id/snooze-drift ─────────────────────────────────────────
+// Registered early (before /:id) but path has suffix so no conflict
+router.post('/:id/snooze-drift', (req, res) => {
+ const db = getDb();
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
+ const bill = db.prepare('SELECT id, user_id FROM bills WHERE id = ? AND deleted_at IS NULL').get(id);
+ if (!bill || bill.user_id !== req.user.id) return res.status(404).json({ error: 'Not found' });
+ const until = new Date();
+ until.setDate(until.getDate() + 30);
+ const untilStr = until.toISOString().slice(0, 10);
+ db.prepare('UPDATE bills SET drift_snoozed_until = ? WHERE id = ?').run(untilStr, id);
+ res.json({ ok: true, drift_snoozed_until: untilStr });
+});
+
// ── GET /api/bills/templates ─────────────────────────────────────────────────
router.get('/templates', (req, res) => {
const db = getDb();
diff --git a/routes/notifications.js b/routes/notifications.js
index a55ba00..c1e457f 100644
--- a/routes/notifications.js
+++ b/routes/notifications.js
@@ -58,19 +58,20 @@ router.get('/me', requireAuth, requireUser, (req, res) => {
const db = getDb();
const user = db.prepare(`
SELECT notification_email, notifications_enabled,
- notify_3d, notify_1d, notify_due, notify_overdue
+ notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change
FROM users WHERE id = ?
`).get(req.user.id);
res.json({
- smtp_enabled: getSetting('notify_smtp_enabled') === 'true',
- allow_user_config: getSetting('notify_allow_user_config') === 'true',
- notification_email: user.notification_email || '',
+ smtp_enabled: getSetting('notify_smtp_enabled') === 'true',
+ allow_user_config: getSetting('notify_allow_user_config') === 'true',
+ notification_email: user.notification_email || '',
notifications_enabled: !!user.notifications_enabled,
- notify_3d: !!user.notify_3d,
- notify_1d: !!user.notify_1d,
- notify_due: !!user.notify_due,
- notify_overdue: !!user.notify_overdue,
+ notify_3d: !!user.notify_3d,
+ notify_1d: !!user.notify_1d,
+ notify_due: !!user.notify_due,
+ notify_overdue: !!user.notify_overdue,
+ notify_amount_change: user.notify_amount_change !== 0,
});
});
@@ -79,7 +80,7 @@ router.put('/me', requireAuth, requireUser, (req, res) => {
const db = getDb();
const {
notification_email, notifications_enabled,
- notify_3d, notify_1d, notify_due, notify_overdue,
+ notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change,
} = req.body;
db.prepare(`
@@ -90,15 +91,17 @@ router.put('/me', requireAuth, requireUser, (req, res) => {
notify_1d = ?,
notify_due = ?,
notify_overdue = ?,
+ notify_amount_change = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(
- notification_email || null,
- notifications_enabled ? 1 : 0,
- notify_3d !== false ? 1 : 0,
- notify_1d !== false ? 1 : 0,
- notify_due !== false ? 1 : 0,
+ notification_email || null,
+ notifications_enabled ? 1 : 0,
+ notify_3d !== false ? 1 : 0,
+ notify_1d !== false ? 1 : 0,
+ notify_due !== false ? 1 : 0,
notify_overdue !== false ? 1 : 0,
+ notify_amount_change !== false ? 1 : 0,
req.user.id,
);
diff --git a/routes/profile.js b/routes/profile.js
index 18f10ce..e3a1785 100644
--- a/routes/profile.js
+++ b/routes/profile.js
@@ -131,7 +131,7 @@ router.get('/settings', (req, res) => {
const db = getDb();
const user = db.prepare(`
SELECT notification_email, notifications_enabled,
- notify_3d, notify_1d, notify_due, notify_overdue
+ notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change
FROM users WHERE id = ?
`).get(req.user.id);
@@ -144,6 +144,7 @@ router.get('/settings', (req, res) => {
notify_1d: !!user.notify_1d,
notify_due: !!user.notify_due,
notify_overdue: !!user.notify_overdue,
+ notify_amount_change: user.notify_amount_change !== 0,
});
});
@@ -154,7 +155,7 @@ router.patch('/settings', (req, res) => {
const db = getDb();
const {
notification_email, email, notifications_enabled,
- notify_3d, notify_1d, notify_due, notify_overdue,
+ notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change,
} = req.body;
const nextEmail = notification_email !== undefined ? notification_email : email;
@@ -170,7 +171,7 @@ router.patch('/settings', (req, res) => {
const current = db.prepare(`
SELECT notification_email, notifications_enabled,
- notify_3d, notify_1d, notify_due, notify_overdue
+ notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change
FROM users WHERE id = ?
`).get(req.user.id);
@@ -189,15 +190,17 @@ router.patch('/settings', (req, res) => {
notify_1d = ?,
notify_due = ?,
notify_overdue = ?,
+ notify_amount_change = ?,
updated_at = datetime('now')
WHERE id = ?
`).run(
emailVal,
- boolVal(notifications_enabled, current.notifications_enabled),
- boolVal(notify_3d, current.notify_3d),
- boolVal(notify_1d, current.notify_1d),
- boolVal(notify_due, current.notify_due),
- boolVal(notify_overdue, current.notify_overdue),
+ boolVal(notifications_enabled, current.notifications_enabled),
+ boolVal(notify_3d, current.notify_3d),
+ boolVal(notify_1d, current.notify_1d),
+ boolVal(notify_due, current.notify_due),
+ boolVal(notify_overdue, current.notify_overdue),
+ boolVal(notify_amount_change, current.notify_amount_change),
req.user.id,
);
diff --git a/services/driftService.js b/services/driftService.js
new file mode 100644
index 0000000..98c88d3
--- /dev/null
+++ b/services/driftService.js
@@ -0,0 +1,106 @@
+'use strict';
+
+const { getDb } = require('../db/database');
+const { getCycleRange } = require('./statusService');
+const { getUserSettings } = require('./userSettings');
+
+const MONTHS_BACK = 3;
+const MIN_PAID_MONTHS = 2;
+const MIN_ABS_DELTA = 1.00;
+
+function median(arr) {
+ if (!arr.length) return 0;
+ const sorted = [...arr].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ return sorted.length % 2 !== 0
+ ? sorted[mid]
+ : (sorted[mid - 1] + sorted[mid]) / 2;
+}
+
+function monthEnd(year, month) {
+ return new Date(Date.UTC(year, month, 0)).getUTCDate();
+}
+
+function getDriftReport(userId, now = new Date()) {
+ try {
+ const db = getDb();
+ const settings = getUserSettings(userId);
+ const thresholdPct = Math.max(1, Math.min(25,
+ parseFloat(settings.drift_threshold_pct ?? '5') || 5
+ ));
+
+ const bills = db.prepare(`
+ SELECT b.*, c.name AS category_name
+ FROM bills b
+ LEFT JOIN categories c ON c.id = b.category_id
+ WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL
+ `).all(userId);
+
+ const todayStr = now.toISOString().slice(0, 10);
+ const drifted = [];
+
+ const mbsStmt = db.prepare(
+ 'SELECT is_skipped FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?'
+ );
+ const payStmt = db.prepare(`
+ SELECT COALESCE(SUM(amount), 0) AS total
+ FROM payments
+ WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL
+ `);
+
+ for (const bill of bills) {
+ if (!bill.expected_amount || bill.expected_amount <= 0) continue;
+ if (bill.drift_snoozed_until && bill.drift_snoozed_until > todayStr) continue;
+
+ const monthTotals = [];
+
+ for (let i = 1; i <= MONTHS_BACK; i++) {
+ const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1));
+ const yr = d.getUTCFullYear();
+ const mo = d.getUTCMonth() + 1;
+
+ // Skip if bill was created after this month ended
+ const monthEndStr = `${yr}-${String(mo).padStart(2,'0')}-${String(monthEnd(yr, mo)).padStart(2,'0')}`;
+ if (bill.created_at && bill.created_at.slice(0, 10) > monthEndStr) continue;
+
+ const mbs = mbsStmt.get(bill.id, yr, mo);
+ if (mbs?.is_skipped) continue;
+
+ const range = getCycleRange(yr, mo, bill);
+ if (!range) continue;
+
+ const { total } = payStmt.get(bill.id, range.start, range.end);
+ if (total > 0) monthTotals.push(total);
+ }
+
+ if (monthTotals.length < MIN_PAID_MONTHS) continue;
+
+ const recentAmount = median(monthTotals);
+ const delta = recentAmount - bill.expected_amount;
+ const absDelta = Math.abs(delta);
+ const driftPct = (delta / bill.expected_amount) * 100;
+
+ if (absDelta < MIN_ABS_DELTA) continue;
+ if (Math.abs(driftPct) < thresholdPct) continue;
+
+ drifted.push({
+ id: bill.id,
+ name: bill.name,
+ category_name: bill.category_name ?? null,
+ expected_amount: bill.expected_amount,
+ recent_amount: Math.round(recentAmount * 100) / 100,
+ drift_pct: Math.round(driftPct * 10) / 10,
+ direction: delta > 0 ? 'up' : 'down',
+ months_sampled: monthTotals.length,
+ drift_snoozed_until: bill.drift_snoozed_until ?? null,
+ });
+ }
+
+ return { bills: drifted, threshold_pct: thresholdPct };
+ } catch (err) {
+ console.error('[driftService] getDriftReport error:', err.message);
+ return { bills: [], threshold_pct: 5, error: err.message };
+ }
+}
+
+module.exports = { getDriftReport };
diff --git a/services/notificationService.js b/services/notificationService.js
index e061254..266f82a 100644
--- a/services/notificationService.js
+++ b/services/notificationService.js
@@ -272,4 +272,123 @@ async function runNotifications() {
}
}
-module.exports = { runNotifications, sendTestEmail, createTransport };
+// ── Drift / price-change digest email ────────────────────────────────────────
+
+function fmtAmt(n) {
+ return '$' + Number(n || 0).toFixed(2);
+}
+
+function buildDriftDigestHtml(bills) {
+ const color = '#d97706'; // amber-600
+
+ const rows = bills.map(b => {
+ const sign = b.direction === 'up' ? '+' : '';
+ const arrow = b.direction === 'up' ? '▲' : '▼';
+ const arrowColor = b.direction === 'up' ? '#d97706' : '#0d9488';
+ return `
+
+ | ${esc(b.name)} |
+ ${fmtAmt(b.expected_amount)} |
+ ${fmtAmt(b.recent_amount)} |
+ ${arrow} ${sign}${b.drift_pct}% |
+
`;
+ }).join('');
+
+ return `
+
+
+
+
+
+
+ |
+ Bill Tracker
+ Price Changes Detected
+ |
+
+
+ |
+
+ The following bills have been paid at amounts different from their expected amounts for the past ${bills[0]?.months_sampled ?? 2}+ months.
+
+ |
+
+
+
+
+
+
+ | Bill |
+ Was |
+ Now ~ |
+ Change |
+
+
+ ${rows}
+
+ |
+
+
+ |
+
+ You can update the expected amounts or dismiss these alerts in Bill Tracker.
+
+ |
+
+
+ |
+
+
+`;
+}
+
+async function runDriftNotifications() {
+ if (getSetting('notify_smtp_enabled') !== 'true') return;
+ if (!getSetting('notify_smtp_host')) return;
+ if (!getSetting('notify_sender_address')) return;
+
+ const db = getDb();
+ const { getDriftReport } = require('./driftService');
+ const now = new Date();
+ const year = now.getFullYear();
+ const month = now.getMonth() + 1;
+ const today = now.toISOString().slice(0, 10);
+
+ const allowUserConfig = getSetting('notify_allow_user_config') === 'true';
+ const globalRecipient = getSetting('notify_global_recipient');
+
+ const recipients = [];
+
+ if (allowUserConfig) {
+ const users = db.prepare(
+ "SELECT * FROM users WHERE active=1 AND role='user' AND notifications_enabled=1 AND notify_amount_change=1 AND notification_email IS NOT NULL AND notification_email != ''"
+ ).all();
+ recipients.push(...users);
+ } else if (globalRecipient) {
+ recipients.push({ id: 0, notification_email: globalRecipient, notify_amount_change: 1 });
+ }
+
+ for (const recipient of recipients) {
+ try {
+ const report = getDriftReport(recipient.id, now);
+ const newBills = (report.bills || []).filter(b =>
+ !hasNotification(db, b.id, recipient.id, year, month, 'amount_change', today)
+ );
+ if (!newBills.length) continue;
+
+ await sendEmail(
+ recipient.notification_email,
+ 'Price Change Alert: Your bill amounts have changed',
+ buildDriftDigestHtml(newBills)
+ );
+
+ for (const b of newBills) {
+ recordNotification(db, b.id, recipient.id, year, month, 'amount_change', today);
+ }
+ } catch (err) {
+ console.error('[drift notifications] Error for recipient', recipient.notification_email, ':', err.message);
+ }
+ }
+}
+
+module.exports = { runNotifications, runDriftNotifications, sendTestEmail, createTransport };
diff --git a/services/userSettings.js b/services/userSettings.js
index e8475e3..af0d6a3 100644
--- a/services/userSettings.js
+++ b/services/userSettings.js
@@ -7,6 +7,7 @@ const USER_SETTING_KEYS = [
'date_format',
'grace_period_days',
'notify_days_before',
+ 'drift_threshold_pct',
];
function defaultUserSettings() {
diff --git a/workers/dailyWorker.js b/workers/dailyWorker.js
index 40a85ef..44c7cab 100644
--- a/workers/dailyWorker.js
+++ b/workers/dailyWorker.js
@@ -2,7 +2,7 @@ const cron = require('node-cron');
const { getDb, getSetting } = require('../db/database');
const { buildTrackerRow, getCycleRange } = require('../services/statusService');
const { pruneExpiredSessions } = require('../services/authService');
-const { runNotifications } = require('../services/notificationService');
+const { runNotifications, runDriftNotifications } = require('../services/notificationService');
const { runAllCleanup } = require('../services/cleanupService');
const {
markWorkerError,
@@ -49,6 +49,9 @@ async function runDailyTasks() {
pruneExpiredSessions();
await runNotifications();
+ await runDriftNotifications().catch(err => {
+ console.error('[worker] Drift notification error (non-fatal):', err.message);
+ });
// Run scheduled cleanup tasks (expired import sessions, stale temp files, etc.)
await runAllCleanup().catch(err => {