feat(tracker): price-change drift detection with amber insight panel

Detects when a bill's recent payments have diverged from its configured
expected amount for 2+ consecutive months and surfaces it in a new
collapsible amber panel on the Tracker page.

- Migration v0.71: adds `drift_snoozed_until` to bills and
  `notify_amount_change` to users
- New `driftService.getDriftReport()`: computes per-bill payment median
  over last 3 months, flags drift above a user-configurable threshold
  (default 5%, minimum $1 delta)
- New `GET /api/bills/drift-report` and `POST /api/bills/:id/snooze-drift`
  routes (registered before `/:id` to avoid routing conflict)
- `runDriftNotifications()` added to daily worker — sends amber digest
  email per user listing all changed bills with old → new amounts
- `notify_amount_change` wired through profile and notifications routes
- `DriftInsightPanel`: collapsible amber panel with per-bill
  strikethrough old → new amount, ±% badge, TrendingUp/TrendingDown
  icons, "Update to $X.XX" (with undo toast) and "Dismiss" (30 days)
  actions; teal palette for price decreases
- `drift_threshold_pct` setting added to SettingsPage Billing Behavior
- "Notify on price changes" toggle added to ProfilePage notifications

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
null 2026-05-30 14:33:55 -05:00
parent d0b318c9d2
commit 5182754e0f
15 changed files with 530 additions and 28 deletions

View File

@ -178,6 +178,8 @@ export const api = {
createBillHistoryRange: (id, data) => post(`/bills/${id}/history-ranges`, data),
updateBillHistoryRange: (id, rangeId, data) => put(`/bills/${id}/history-ranges/${rangeId}`, data),
deleteBillHistoryRange: (id, rangeId) => del(`/bills/${id}/history-ranges/${rangeId}`),
driftReport: () => get('/bills/drift-report'),
snoozeBillDrift: (id) => post(`/bills/${id}/snooze-drift`, {}),
billTemplates: () => get('/bills/templates'),
saveBillTemplate: (data) => post('/bills/templates', data),
deleteBillTemplate: (id) => del(`/bills/templates/${id}`),

View File

@ -0,0 +1,189 @@
import React, { useState } from 'react';
import { TrendingUp, TrendingDown, ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { cn, fmt } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
function DriftRow({ row, refresh }) {
const [loading, setLoading] = useState(false);
async function handleUpdate() {
setLoading(true);
const oldAmount = row.expected_amount;
try {
await api.updateBill(row.id, { expected_amount: row.recent_amount });
toast.success(`"${row.name}" updated to ${fmt(row.recent_amount)}`, {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.updateBill(row.id, { expected_amount: oldAmount });
toast.success('Reverted');
refresh();
} catch {
toast.error('Failed to revert');
}
},
},
});
refresh();
} catch {
toast.error('Failed to update bill');
} finally {
setLoading(false);
}
}
async function handleDismiss() {
setLoading(true);
try {
await api.snoozeBillDrift(row.id);
toast.success('Hidden for 30 days');
refresh();
} catch {
toast.error('Failed to dismiss');
} finally {
setLoading(false);
}
}
const isUp = row.direction === 'up';
const sign = isUp ? '+' : '';
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 sm:flex-nowrap">
{/* Bill info */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-medium text-foreground">{row.name}</span>
{row.category_name && (
<Badge variant="secondary" className="shrink-0 px-1.5 py-0 text-[10px]">
{row.category_name}
</Badge>
)}
</div>
<div className="flex items-center gap-1.5">
{isUp
? <TrendingUp className="h-3 w-3 text-amber-500 dark:text-amber-400" />
: <TrendingDown className="h-3 w-3 text-teal-500 dark:text-teal-400" />
}
<span className={cn(
'text-xs font-medium',
isUp ? 'text-amber-600 dark:text-amber-400' : 'text-teal-600 dark:text-teal-400'
)}>
{sign}{row.drift_pct}% over {row.months_sampled} months
</span>
</div>
</div>
{/* Amount change */}
<div className="flex shrink-0 items-center gap-1.5">
<span className="font-mono text-xs text-muted-foreground line-through">
{fmt(row.expected_amount)}
</span>
<span className="text-muted-foreground"></span>
<span className={cn(
'font-mono text-sm font-semibold',
isUp ? 'text-amber-500 dark:text-amber-400' : 'text-teal-500 dark:text-teal-400'
)}>
{fmt(row.recent_amount)}
</span>
<span className={cn(
'rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none',
isUp
? 'bg-amber-500/15 text-amber-700 dark:text-amber-300'
: 'bg-teal-500/15 text-teal-700 dark:text-teal-300'
)}>
{sign}{row.drift_pct}%
</span>
</div>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
<Button
size="sm"
variant="outline"
className={cn(
'h-7 gap-1.5 px-2.5 text-xs',
isUp
? 'border-amber-400/40 text-amber-600 hover:border-amber-400/70 hover:bg-amber-500/[0.08] hover:text-amber-500 dark:text-amber-400'
: 'border-teal-400/40 text-teal-600 hover:border-teal-400/70 hover:bg-teal-500/[0.08] hover:text-teal-500 dark:text-teal-400'
)}
disabled={loading}
onClick={handleUpdate}
>
Update to {fmt(row.recent_amount)}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2.5 text-xs text-muted-foreground hover:text-foreground"
disabled={loading}
onClick={handleDismiss}
>
Dismiss
</Button>
</div>
</div>
);
}
export default function DriftInsightPanel({ driftBills, refresh }) {
const [isOpen, setIsOpen] = useState(true);
if (!driftBills?.length) return null;
const totalNetDelta = driftBills.reduce((sum, b) => sum + (b.recent_amount - b.expected_amount), 0);
const sign = totalNetDelta >= 0 ? '+' : '';
const netColor = totalNetDelta >= 0 ? 'text-amber-500 dark:text-amber-400' : 'text-teal-500 dark:text-teal-400';
const hasIncrease = driftBills.some(b => b.direction === 'up');
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="rounded-xl border border-amber-400/30 bg-amber-500/[0.06] shadow-sm overflow-hidden dark:bg-amber-400/[0.05]">
{/* Header */}
<CollapsibleTrigger asChild>
<button className="flex w-full items-center justify-between px-4 py-3 transition-colors hover:bg-amber-500/[0.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40 focus-visible:ring-inset">
<div className="flex items-center gap-2.5">
{hasIncrease
? <TrendingUp className="h-4 w-4 shrink-0 text-amber-400" />
: <TrendingDown className="h-4 w-4 shrink-0 text-teal-400" />
}
<span className="text-sm font-semibold text-foreground">
{driftBills.length === 1
? '1 bill changed price'
: `${driftBills.length} bills changed price`}
</span>
<span className={cn('font-mono text-sm', netColor)}>
{sign}{fmt(Math.abs(totalNetDelta))}/mo net
</span>
</div>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform duration-200',
isOpen && 'rotate-180'
)}
/>
</button>
</CollapsibleTrigger>
{/* Bill rows */}
<CollapsibleContent>
<div className="divide-y divide-border/40 px-4 pb-2">
{driftBills.map(row => (
<DriftRow
key={row.id}
row={row}
refresh={refresh}
/>
))}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
}

View File

@ -41,3 +41,12 @@ export function useOverdueCount() {
refetchIntervalInBackground: false, // only when tab is active
});
}
// Drift / price-change report — refreshed on demand, not auto-polled
export function useDriftReport() {
return useQuery({
queryKey: ['drift-report'],
queryFn: () => api.driftReport(),
staleTime: 1000 * 60 * 10,
refetchOnWindowFocus: false,
});
}

View File

@ -339,6 +339,7 @@ function NotificationPreferences({ settings, onSaved }) {
notify_1_day: !!(form.notify_1_day ?? form.notify_1d),
notify_due: !!(form.notify_due ?? form.notify_day_of),
notify_overdue: !!(form.notify_overdue ?? form.notify_daily_overdue),
notify_amount_change: !!(form.notify_amount_change ?? true),
};
payload.enabled = payload.notifications_enabled;
payload.notify_3d = payload.notify_3_day;
@ -372,6 +373,7 @@ function NotificationPreferences({ settings, onSaved }) {
<CheckRow id="n-1" label="Notify 1 day before" checked={payload.notify_1_day} onChange={v => set('notify_1_day', v)} disabled={!payload.notifications_enabled} />
<CheckRow id="n-due" label="Notify due date" checked={payload.notify_due} onChange={v => set('notify_due', v)} disabled={!payload.notifications_enabled} />
<CheckRow id="n-overdue" label="Notify overdue" checked={payload.notify_overdue} onChange={v => set('notify_overdue', v)} disabled={!payload.notifications_enabled} />
<CheckRow id="n-amount" label="Notify on price changes" checked={payload.notify_amount_change} onChange={v => set('notify_amount_change', v)} disabled={!payload.notifications_enabled} />
</div>
</div>
<div className="px-6 py-4 border-t border-border/50 flex justify-end">

View File

@ -215,6 +215,7 @@ export default function SettingsPage() {
currency: 'USD',
date_format: 'MM/DD/YYYY',
grace_period_days: 3,
drift_threshold_pct: '5',
};
const [settings, setSettings] = useState(DEFAULTS);
@ -242,6 +243,7 @@ export default function SettingsPage() {
currency: settings.currency,
date_format: settings.date_format,
grace_period_days: settings.grace_period_days,
drift_threshold_pct: settings.drift_threshold_pct,
});
toast.success('Settings saved.');
} catch (err) {
@ -337,6 +339,23 @@ export default function SettingsPage() {
<span className="text-sm text-muted-foreground">days</span>
</div>
</SettingRow>
<SettingRow
label="Price change sensitivity"
description="Flag a bill when recent payments differ from the expected amount by at least this percentage."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={25}
step={1}
value={settings.drift_threshold_pct ?? '5'}
onChange={(e) => set('drift_threshold_pct', e.target.value)}
className="w-20 font-mono"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
</SettingRow>
</SectionCard>

View File

@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom';
import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2, Plus, Search, X, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { useTracker } from '@/hooks/useQueries';
import { useTracker, useDriftReport } from '@/hooks/useQueries';
import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
@ -29,6 +29,7 @@ import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
import PaymentModal from '@/components/tracker/PaymentModal';
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
import DriftInsightPanel from '@/components/tracker/DriftInsightPanel';
const MONTHS = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
@ -1830,6 +1831,7 @@ export default function TrackerPage() {
// Use React Query for data fetching
const { data, isLoading: loading, isError, error, refetch } = useTracker(year, month);
const { data: driftData, refetch: refetchDrift } = useDriftReport();
useEffect(() => {
const querySearch = searchParams.get('search') || '';
@ -2068,6 +2070,14 @@ export default function TrackerPage() {
/>
)}
{/* ── Drift / Price-Change Insights ── */}
{!isError && !loading && (driftData?.bills?.length ?? 0) > 0 && (
<DriftInsightPanel
driftBills={driftData.bills}
refresh={() => { refetch(); refetchDrift(); }}
/>
)}
{/* ── Fetch error state ── */}
{isError && (
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-destructive/20 bg-destructive/5">

View File

@ -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');
}
}
];

View File

@ -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'))
);

View File

@ -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();

View File

@ -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,
);

View File

@ -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,
);

106
services/driftService.js Normal file
View File

@ -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 };

View File

@ -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 `
<tr>
<td style="padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:14px;">${esc(b.name)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:14px;font-family:monospace;text-decoration:line-through;color:#9ca3af;">${fmtAmt(b.expected_amount)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:14px;font-family:monospace;font-weight:600;">${fmtAmt(b.recent_amount)}</td>
<td style="padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:13px;color:${arrowColor};font-weight:700;">${arrow} ${sign}${b.drift_pct}%</td>
</tr>`;
}).join('');
return `<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:#f5f6fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px;">
<tr><td align="center">
<table width="540" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;border:1px solid #e2e4eb;overflow:hidden;">
<tr>
<td style="background:${color};padding:16px 24px;">
<p style="margin:0;color:#fff;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;">Bill Tracker</p>
<h1 style="margin:4px 0 0;color:#fff;font-size:20px;font-weight:700;">Price Changes Detected</h1>
</td>
</tr>
<tr>
<td style="padding:20px 24px 8px;">
<p style="margin:0;color:#374151;font-size:14px;">
The following bills have been paid at amounts different from their expected amounts for the past ${bills[0]?.months_sampled ?? 2}+ months.
</p>
</td>
</tr>
<tr>
<td style="padding:8px 24px 20px;">
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:6px;overflow:hidden;">
<thead>
<tr style="background:#fef9c3;">
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;">Bill</th>
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;">Was</th>
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;">Now ~</th>
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;">Change</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</td>
</tr>
<tr>
<td style="padding:0 24px 24px;">
<p style="margin:0;color:#6b7280;font-size:13px;">
You can update the expected amounts or dismiss these alerts in Bill Tracker.
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
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 };

View File

@ -7,6 +7,7 @@ const USER_SETTING_KEYS = [
'date_format',
'grace_period_days',
'notify_days_before',
'drift_threshold_pct',
];
function defaultUserSettings() {

View File

@ -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 => {