const nodemailer = require('nodemailer'); const { getDb, getSetting } = require('../db/database'); const { decryptSecret, encryptSecret } = require('./encryptionService'); const { accountingActiveSql } = require('./paymentAccountingService'); const { markNotificationError, markNotificationSuccess, markNotificationTestSuccess, } = require('./statusRuntime'); const { localDateString } = require('../utils/dates'); const { fromCents } = require('../utils/money'); // ── Push notification channels ──────────────────────────────────────────────── const PUSH_PRIORITY = { upcoming: 'default', soon: 'high', today: 'high', overdue: 'urgent' }; const PUSH_TAGS = { upcoming: ['bell'], soon: ['warning'], today: ['rotating_light'], overdue: ['rotating_light','red_circle'] }; const DISCORD_COLOR = { upcoming: 0x4f46e5, soon: 0xd97706, today: 0xdc7308, overdue: 0xdc2626 }; function safeDecrypt(stored) { if (!stored) return ''; try { return decryptSecret(stored); } catch { return stored; } } async function sendNtfy(url, token, title, body, urgency = 'soon') { const headers = { 'Title': title, 'Priority': PUSH_PRIORITY[urgency] || 'default', 'Tags': (PUSH_TAGS[urgency] || ['bell']).join(','), 'Content-Type': 'text/plain', }; if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(url, { method: 'POST', headers, body }); if (!res.ok) throw new Error(`ntfy returned ${res.status}`); } async function sendGotify(url, token, title, body, urgency = 'soon') { const priority = { upcoming: 4, soon: 6, today: 7, overdue: 9 }[urgency] ?? 5; const endpoint = url.replace(/\/$/, '') + '/message?token=' + encodeURIComponent(token); const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, message: body, priority }), }); if (!res.ok) throw new Error(`Gotify returned ${res.status}`); } async function sendDiscord(webhookUrl, title, body, urgency = 'soon') { const color = DISCORD_COLOR[urgency] ?? 0x4f46e5; const res = await fetch(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ embeds: [{ title, description: body, color, footer: { text: 'Bill Tracker' }, timestamp: new Date().toISOString() }], }), }); if (!res.ok) throw new Error(`Discord returned ${res.status}`); } async function sendTelegram(botToken, chatId, title, body) { const text = `*${title}*\n${body}`; const url = `https://api.telegram.org/bot${botToken}/sendMessage`; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'Markdown' }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(`Telegram error: ${err.description || res.status}`); } } // Dispatch push to whichever channel this user has configured. async function sendPushToUser(user, title, body, urgency) { if (!user.notify_push_enabled || !user.push_channel || !user.push_url) return; const url = safeDecrypt(user.push_url); const token = safeDecrypt(user.push_token); switch (user.push_channel) { case 'ntfy': return sendNtfy(url, token, title, body, urgency); case 'gotify': return sendGotify(url, token, title, body, urgency); case 'discord': return sendDiscord(url, title, body, urgency); case 'telegram': return sendTelegram(token, user.push_chat_id, title, body); default: throw new Error(`Unknown push channel: ${user.push_channel}`); } } async function sendTestPush(user) { await sendPushToUser( user, 'Bill Tracker — Test Notification', 'Your push notification channel is working correctly.', 'upcoming', ); } // NOTE: the `_push` export is attached AFTER the final `module.exports = {…}` // below — assigning it here would be clobbered by that reassignment (QA-B10-01). // ── SMTP transport ──────────────────────────────────────────────────────────── function getSmtpPassword() { const stored = getSetting('notify_smtp_password'); if (!stored) return ''; try { return decryptSecret(stored); } catch { return stored; // legacy plaintext — works until re-saved via admin UI } } function createTransport() { const host = getSetting('notify_smtp_host'); const port = parseInt(getSetting('notify_smtp_port') || '587', 10); const encryption = getSetting('notify_smtp_encryption') || 'starttls'; const username = getSetting('notify_smtp_username'); const password = getSmtpPassword(); const selfSigned = getSetting('notify_smtp_self_signed') === 'true'; if (!host) throw new Error('SMTP host is not configured'); return nodemailer.createTransport({ host, port, secure: encryption === 'ssl', auth: username ? { user: username, pass: password } : undefined, tls: { rejectUnauthorized: !selfSigned, ...(encryption === 'none' ? { ignoreTLS: true } : {}), }, }); } function senderAddress() { const name = getSetting('notify_sender_name') || 'Bill Tracker'; const address = getSetting('notify_sender_address'); if (!address) throw new Error('Sender address is not configured'); return `"${name}" <${address}>`; } // ── Email templates ─────────────────────────────────────────────────────────── const TYPE_META = { due_3d: { subject: (b) => `Reminder: ${b.name} due in 3 days`, urgency: 'upcoming' }, due_1d: { subject: (b) => `Reminder: ${b.name} due tomorrow`, urgency: 'soon' }, due_today: { subject: (b) => `Due today: ${b.name}`, urgency: 'today' }, overdue: { subject: (b) => `Overdue: ${b.name} hasn't been paid`, urgency: 'overdue' }, }; const URGENCY_COLOR = { upcoming: '#4f46e5', soon: '#d97706', today: '#dc7308', overdue: '#dc2626', }; function buildEmailHtml(bill, type, dueDate) { const meta = TYPE_META[type]; const color = URGENCY_COLOR[meta.urgency]; const amount = '$' + (fromCents(bill.expected_amount) || 0).toFixed(2); const fmt = (d) => { if (!d) return '—'; const [y, m, day] = d.split('-'); return `${parseInt(m)}/${parseInt(day)}/${y}`; }; // QA-B14-04: escape the bill name everywhere it lands in the HTML, including // these message strings (previously raw — an XSS vector via the bill name). const name = esc(bill.name); const messages = { due_3d: `${name} is due in 3 days.`, due_1d: `${name} is due tomorrow.`, due_today: `${name} is due today.`, overdue: `${name} was due on ${fmt(dueDate)} and has not been marked as paid.`, }; return `
Bill Tracker

${messages[type]}

Bill ${esc(bill.name)}
Due Date ${fmt(dueDate)}
Amount ${amount}

Sent by Bill Tracker · ${new Date().toLocaleDateString()}

`; } function esc(s) { return String(s || '').replace(/&/g, '&').replace(//g, '>'); } // ── Send ────────────────────────────────────────────────────────────────────── async function sendEmail(to, subject, html) { const transport = createTransport(); await transport.sendMail({ from: senderAddress(), to, subject, html }); } async function sendTestEmail(to) { const html = `

Bill Tracker — Test Email

Your SMTP configuration is working correctly.

Sent at ${new Date().toISOString()}

`; try { await sendEmail(to, 'Bill Tracker — Test Email', html); markNotificationTestSuccess(); } catch (err) { markNotificationError(err); throw err; } } // ── Notification tracking ───────────────────────────────────────────────────── function hasNotification(db, billId, userId, year, month, type, date) { return !!db.prepare(` SELECT 1 FROM notifications WHERE bill_id=? AND user_id=? AND year=? AND month=? AND type=? AND sent_date=? `).get(billId, userId, year, month, type, date); } function recordNotification(db, billId, userId, year, month, type, date) { try { db.prepare(` INSERT INTO notifications (bill_id, user_id, year, month, type, sent_date) VALUES (?, ?, ?, ?, ?, ?) `).run(billId, userId, year, month, type, date); } catch { // Unique constraint: already recorded, ignore } } // ── Main notification runner (called by daily worker) ───────────────────────── async function runNotifications() { const db = getDb(); const emailReady = getSetting('notify_smtp_enabled') === 'true' && !!getSetting('notify_smtp_host') && !!getSetting('notify_sender_address'); // Check whether any user has push notifications enabled const pushReady = !!db.prepare( "SELECT 1 FROM users WHERE notify_push_enabled=1 AND push_channel IS NOT NULL LIMIT 1" ).get(); // Nothing to do if neither email nor push is configured if (!emailReady && !pushReady) return; const now = new Date(); const year = now.getFullYear(); const month = now.getMonth() + 1; const today = localDateString(now); const { getCycleRange, resolveDueDate } = require('./statusService'); // Fetch all active bills. In global-notification mode, the single global recipient // legitimately receives every bill. In per-user mode, each recipient must only // see their own bills — the ownership filter is applied in the loop below. const bills = db.prepare('SELECT * FROM bills WHERE active = 1 AND deleted_at IS NULL').all(); const allowUserConfig = getSetting('notify_allow_user_config') === 'true'; const globalRecipient = getSetting('notify_global_recipient'); // Gather recipient list const recipients = []; if (allowUserConfig) { const users = db.prepare( "SELECT * FROM users WHERE active = 1 AND role='user' AND notifications_enabled=1 AND (notification_email IS NOT NULL AND notification_email != '' OR notify_push_enabled=1)" ).all(); recipients.push(...users); } else if (globalRecipient) { // Treat global recipient as a synthetic user-like object recipients.push({ id: 0, notification_email: globalRecipient, notify_3d: 1, notify_1d: 1, notify_due: 1, notify_overdue: 1, }); } if (!recipients.length) return; const errors = []; // Batch-fetch all payments for active bills this cycle to avoid N+1 queries. // Bills use different cycle ranges per bill, so we use a broad month window // and the per-bill cycle check happens in memory below. const billIds = bills.map(b => b.id); const monthStart = `${year}-${String(month).padStart(2, '0')}-01`; const monthEnd = localDateString(new Date(year, month, 0)); const paidMap = new Map(); if (billIds.length > 0) { const placeholders = billIds.map(() => '?').join(','); const paidRows = db.prepare(` SELECT bill_id, SUM(amount) AS paid_sum FROM payments WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL AND ${accountingActiveSql()} GROUP BY bill_id `).all(...billIds, monthStart, monthEnd); for (const row of paidRows) paidMap.set(row.bill_id, row.paid_sum); } // Batch-fetch all notifications already sent today to avoid N×M per-bill-per-recipient queries. const sentRows = db.prepare(` SELECT bill_id, user_id, type FROM notifications WHERE year = ? AND month = ? AND sent_date = ? `).all(year, month, today); const sentSet = new Set(sentRows.map(n => `${n.bill_id}:${n.user_id}:${n.type}`)); for (const bill of bills) { const dueDate = resolveDueDate(bill, year, month); if (!dueDate) continue; const totalPaid = paidMap.get(bill.id) ?? 0; const isPaid = totalPaid >= bill.expected_amount; if (isPaid) continue; const due = new Date(dueDate + 'T00:00:00'); // Compare calendar days, not timestamps, to avoid same-day bugs // (e.g., due today at midnight vs now at 3pm would give -0.625 days → floors to -1) const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate()); const diffDays = Math.round((dueDay - todayDate) / 86400000); // Determine which type applies today let type = null; if (diffDays === 3) type = 'due_3d'; else if (diffDays === 1) type = 'due_1d'; else if (diffDays === 0) type = 'due_today'; else if (diffDays < 0) type = 'overdue'; if (!type) continue; // Defensive: warn if a bill somehow has no owner if (!bill.user_id) { console.warn(`[notifications] Bill id=${bill.id} name="${bill.name}" has no user_id — skipping`); continue; } for (const recipient of recipients) { // In per-user mode, only send bills belonging to this recipient if (allowUserConfig && bill.user_id !== recipient.id) continue; // Check recipient's preferences if (type === 'due_3d' && !recipient.notify_3d) continue; if (type === 'due_1d' && !recipient.notify_1d) continue; if (type === 'due_today' && !recipient.notify_due) continue; if (type === 'overdue' && !recipient.notify_overdue) continue; if (sentSet.has(`${bill.id}:${recipient.id}:${type}`)) continue; const meta = TYPE_META[type]; const subject = meta.subject(bill); const urgency = meta.urgency; const amount = '$' + (fromCents(bill.expected_amount) || 0).toFixed(2); const pushBody = `${subject} · ${amount}`; let sent = false; // Email if (recipient.notification_email) { const html = buildEmailHtml(bill, type, dueDate); try { await sendEmail(recipient.notification_email, subject, html); sent = true; } catch (err) { errors.push(`email/${recipient.notification_email}/${bill.name}: ${err.message}`); } } // Push (ntfy / Gotify / Discord / Telegram) if (recipient.notify_push_enabled && recipient.push_channel && recipient.push_url) { try { await sendPushToUser(recipient, subject, pushBody, urgency); sent = true; } catch (err) { errors.push(`push/${recipient.push_channel}/${bill.name}: ${err.message}`); } } if (sent) { recordNotification(db, bill.id, recipient.id, year, month, type, today); sentSet.add(`${bill.id}:${recipient.id}:${type}`); } } } if (errors.length) { markNotificationError(new Error(errors.join('; '))); console.error('[notifications] Send errors:', errors); } else { markNotificationSuccess(); console.log(`[notifications] Run complete for ${today}`); } } // ── 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.

${rows}
Bill Was Now ~ Change

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 = localDateString(now); 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 }; // Push helpers, exposed for the test-push route + tests. Assigned AFTER the line // above so it isn't clobbered by the reassignment (QA-B10-01: previously set // before it, leaving `_push` undefined → "Send test push" always 500'd). module.exports._push = { sendNtfy, sendGotify, sendDiscord, sendTelegram, sendTestPush, sendPushToUser, encryptSecret }; module.exports._email = { buildEmailHtml, esc };