refactor(server): migrate billsService + notificationService to TypeScript (.cts)

Two of the large foundational services. billsService (validation/normalization,
balance math) is required by many others; notificationService (email + push +
drift digest) uses the shared http types indirectly.

Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-06 11:12:06 -05:00
parent 6746d42380
commit 0db5a77adc
17 changed files with 109 additions and 146 deletions

View File

@ -11,7 +11,7 @@ const {
validateBillData,
computeBalanceDelta,
applyBalanceDelta,
} = require('../services/billsService');
} = require('../services/billsService.cts');
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService.cts');
const { standardizeError } = require('../middleware/errorFormatter');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts');

View File

@ -2,8 +2,8 @@ const express = require('express');
const router = express.Router();
const { getDb, getSetting, setSetting } = require('../db/database');
const { requireAuth, requireUser, requireAdmin } = require('../middleware/requireAuth');
const { sendTestEmail } = require('../services/notificationService');
const { sendTestPush } = require('../services/notificationService')._push || {};
const { sendTestEmail } = require('../services/notificationService.cts');
const { sendTestPush } = require('../services/notificationService.cts')._push || {};
const { decryptSecret } = require('../services/encryptionService.cts');
const { encryptSecret } = require('../services/encryptionService.cts');

View File

@ -2,7 +2,7 @@ const express = require('express');
const { standardizeError } = require('../middleware/errorFormatter');
const router = require('express').Router();
const { getDb } = require('../db/database');
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService');
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService.cts');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts');
const { getCycleRange, resolveDueDate } = require('../services/statusService.cts');
const { markUnmatched } = require('../services/transactionMatchState.cts');

View File

@ -4,7 +4,7 @@ const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter');
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService.cts');
const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService.cts');
const { serializeBill } = require('../services/billsService');
const { serializeBill } = require('../services/billsService.cts');
const { toCents, fromCents } = require('../utils/money.mts');
const DEBT_LIKE_CLAUSES = `(

View File

@ -295,7 +295,7 @@ function recordLogin(userId: any, ipAddress: any, userAgent: any, sessionId: any
!priorFingerprints.includes(device.device_fingerprint);
if (isNewDevice) {
try {
const { sendPushToUser } = require('./notificationService')._push;
const { sendPushToUser } = require('./notificationService.cts')._push;
const alertUser = getDb().prepare('SELECT * FROM users WHERE id = ?').get(userId);
if (alertUser?.notify_push_enabled && alertUser?.push_channel) {
const deviceDesc = [device.browser, device.os, device.device_type !== 'desktop' ? device.device_type : null]

View File

@ -1,5 +1,10 @@
import type { Db } from '../types/db';
const { monthKey } = require('../utils/dates.mts');
const { toCents, fromCents } = require('../utils/money.mts');
const { toCents, fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts');
type Row = Record<string, any>;
type FieldResult<T> = { value?: T; error?: string };
const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none'];
const TEMPLATE_FIELDS = [
@ -11,18 +16,18 @@ const TEMPLATE_FIELDS = [
'subscription_type', 'reminder_days_before', 'subscription_source', 'subscription_detected_at',
];
function hasText(value) {
function hasText(value: unknown): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
function isDebtBill(bill) {
function isDebtBill(bill: Row): boolean {
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) {
function billAuditIssue(bill: Row, field: string, severity: string, suggestion: string) {
return {
bill_id: bill.id,
bill_name: bill.name,
@ -32,14 +37,14 @@ function billAuditIssue(bill, field, severity, suggestion) {
};
}
function sanitizeTemplateData(data = {}) {
return TEMPLATE_FIELDS.reduce((out, field) => {
function sanitizeTemplateData(data: Row = {}): Row {
return TEMPLATE_FIELDS.reduce((out: Row, field: string) => {
if (data[field] !== undefined) out[field] = data[field];
return out;
}, {});
}, {} as Row);
}
function parseTemplateData(raw) {
function parseTemplateData(raw: any): Row {
try {
return sanitizeTemplateData(JSON.parse(raw || '{}'));
} catch {
@ -47,7 +52,7 @@ function parseTemplateData(raw) {
}
}
function categoryBelongsToUser(db, categoryId, userId) {
function categoryBelongsToUser(db: Db, categoryId: any, userId: number): boolean {
if (!categoryId) return true;
return !!db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(categoryId, userId);
}
@ -55,7 +60,7 @@ function categoryBelongsToUser(db, categoryId, userId) {
/**
* Converts a bill row's integer-cents money columns to dollars for API responses.
*/
function serializeBill(bill) {
function serializeBill(bill: Row | null | undefined): any {
if (!bill) return bill;
return {
...bill,
@ -65,7 +70,7 @@ function serializeBill(bill) {
};
}
function insertBill(db, userId, normalized) {
function insertBill(db: Db, userId: number, normalized: Row): Row {
const result = db.prepare(`
INSERT INTO bills
(user_id, name, category_id, due_day, override_due_date, bucket, expected_amount,
@ -110,7 +115,7 @@ function insertBill(db, userId, normalized) {
return db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid);
}
function auditBillsForUser(db, userId, includeInactive = false) {
function auditBillsForUser(db: Db, userId: number, 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,
@ -123,8 +128,8 @@ function auditBillsForUser(db, userId, includeInactive = false) {
ORDER BY b.active DESC, b.due_day ASC, b.name ASC
`).all(userId);
const auditedBills = bills.map((bill) => {
const issues = [];
const auditedBills = bills.map((bill: Row) => {
const issues: any[] = [];
const dueDay = Number(bill.due_day);
const debt = isDebtBill(bill);
const balance = Number(bill.current_balance);
@ -156,20 +161,20 @@ function auditBillsForUser(db, userId, includeInactive = false) {
};
});
const issues = auditedBills.flatMap(bill => bill.issues);
const issues = auditedBills.flatMap((bill: Row) => bill.issues);
return {
bills: auditedBills.filter(bill => bill.issues.length > 0),
bills: auditedBills.filter((bill: Row) => 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,
error_count: issues.filter((item: Row) => item.severity === 'error').length,
warning_count: issues.filter((item: Row) => item.severity === 'warning').length,
},
};
}
// Helper function to get default cycle day based on cycle type
function getDefaultCycleDay(cycleType) {
function getDefaultCycleDay(cycleType: any): string {
switch (cycleType) {
case 'monthly':
return '1'; // 1st of the month
@ -187,7 +192,7 @@ function getDefaultCycleDay(cycleType) {
}
// Validate cycle_day based on cycle_type
function validateCycleDay(cycleType, cycleDay) {
function validateCycleDay(cycleType: any, cycleDay: any): FieldResult<string> {
if (cycleDay === undefined || cycleDay === null) return { value: getDefaultCycleDay(cycleType) };
const ct = cycleType || 'monthly';
switch (ct) {
@ -213,7 +218,7 @@ function validateCycleDay(cycleType, cycleDay) {
}
}
function parseDueDay(value) {
function parseDueDay(value: any): FieldResult<number> {
const day = Number(value);
if (!Number.isInteger(day) || day < 1 || day > 31) {
return { error: 'due_day must be an integer between 1 and 31' };
@ -221,7 +226,7 @@ function parseDueDay(value) {
return { value: day };
}
function parseInterestRate(value) {
function parseInterestRate(value: any): FieldResult<number | null> {
if (value === undefined) return { value: undefined };
if (value === null) return { value: null };
if (typeof value === 'string' && value.trim() === '') return { value: null };
@ -233,18 +238,18 @@ function parseInterestRate(value) {
return { value: rate };
}
function getValidCycleTypes() {
function getValidCycleTypes(): string[] {
return ['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'];
}
function cycleTypeFromBillingCycle(billingCycle) {
function cycleTypeFromBillingCycle(billingCycle: any): string {
const value = String(billingCycle || '').toLowerCase();
if (value === 'quarterly') return 'quarterly';
if (value === 'annually' || value === 'annual') return 'annual';
return 'monthly';
}
function billingCycleForCycleType(cycleType) {
function billingCycleForCycleType(cycleType: any): string {
const value = String(cycleType || '').toLowerCase();
if (value === 'quarterly') return 'quarterly';
if (value === 'annual') return 'annually';
@ -254,11 +259,10 @@ function billingCycleForCycleType(cycleType) {
/**
* Validates and normalizes bill data for creation/update.
* Returns an object with normalized values and any validation errors.
*/
function validateBillData(data, existingBill = null) {
const errors = [];
const normalized = {};
function validateBillData(data: Row, existingBill: Row | null = null) {
const errors: any[] = [];
const normalized: Row = {};
const validCycleTypes = getValidCycleTypes();
@ -293,7 +297,6 @@ function validateBillData(data, existingBill = null) {
// expected_amount (stored as integer cents). Validate: reject non-numeric,
// negative, or absurd values (kept well under Number.MAX_SAFE_INTEGER cents).
// QA-B13-01: previously any coercible value was accepted ("abc"→$0, -100, 1e15).
const MAX_EXPECTED_CENTS = 100_000_000_00; // $100,000,000
if (data.expected_amount === undefined) {
normalized.expected_amount = existingBill?.expected_amount || 0;
@ -301,7 +304,7 @@ function validateBillData(data, existingBill = null) {
normalized.expected_amount = 0;
} else {
const cents = toCents(data.expected_amount);
if (!Number.isInteger(cents) || cents < 0 || cents > MAX_EXPECTED_CENTS) {
if (!Number.isInteger(cents) || cents! < 0 || cents! > MAX_EXPECTED_CENTS) {
errors.push({ field: 'expected_amount', message: 'expected_amount must be a number between 0 and 100000000' });
} else {
normalized.expected_amount = cents;
@ -394,7 +397,7 @@ function validateBillData(data, existingBill = null) {
normalized.current_balance = null;
} else {
const cb = toCents(data.current_balance);
if (!Number.isInteger(cb) || cb < 0) {
if (!Number.isInteger(cb) || cb! < 0) {
errors.push({ field: 'current_balance', message: 'current_balance must be a non-negative number' });
} else {
normalized.current_balance = cb;
@ -410,7 +413,7 @@ function validateBillData(data, existingBill = null) {
normalized.minimum_payment = null;
} else {
const mp = toCents(data.minimum_payment);
if (!Number.isInteger(mp) || mp < 0) {
if (!Number.isInteger(mp) || mp! < 0) {
errors.push({ field: 'minimum_payment', message: 'minimum_payment must be a non-negative number' });
} else {
normalized.minimum_payment = mp;
@ -486,20 +489,12 @@ function validateBillData(data, existingBill = null) {
/**
* Validates cycle_day for a given cycle_type without requiring the full bill data.
*/
function validateCycleDayOnly(cycleType, cycleDay) {
function validateCycleDayOnly(cycleType: any, cycleDay: any) {
return validateCycleDay(cycleType, cycleDay);
}
// Computes how a payment affects a debt bill's current_balance.
// Interest is applied at most once per calendar month: if bill.interest_accrued_month
// already equals the current month, no interest is added this call.
//
// Returns null when the bill has no trackable balance.
// Otherwise returns:
// { new_balance, balance_delta, interest_delta, interest_accrued_month }
// where interest_delta and interest_accrued_month are null when no interest
// was charged this call (so callers can use COALESCE to leave the DB column alone).
function computeBalanceDelta(bill, paymentAmount) {
function computeBalanceDelta(bill: Row, paymentAmount: any) {
const bal = Number(bill.current_balance); // cents
const rate = Number(bill.interest_rate) || 0; // percent
const amt = Number(paymentAmount); // cents
@ -523,9 +518,8 @@ function computeBalanceDelta(bill, paymentAmount) {
};
}
// Updates current_balance (and interest_accrued_month when interest was charged)
// after a payment. Uses COALESCE so a null interest_accrued_month leaves the column alone.
function applyBalanceDelta(db, billId, balCalc) {
// Updates current_balance (and interest_accrued_month when interest was charged) after a payment.
function applyBalanceDelta(db: Db, billId: any, balCalc: any): void {
if (!balCalc) return;
db.prepare(`
UPDATE bills

View File

@ -1,3 +1,5 @@
import type { Db } from '../types/db';
const nodemailer = require('nodemailer');
const { getDb, getSetting } = require('../db/database');
const { decryptSecret, encryptSecret } = require('./encryptionService.cts');
@ -7,22 +9,24 @@ const {
markNotificationSuccess,
markNotificationTestSuccess,
} = require('./statusRuntime.cts');
const { localDateString } = require('../utils/dates.mts');
const { fromCents } = require('../utils/money.mts');
const { localDateString } = require('../utils/dates.mts') as typeof import('../utils/dates.mts');
const { fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts');
type Row = Record<string, any>;
// ── 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 };
const PUSH_PRIORITY: Record<string, string> = { upcoming: 'default', soon: 'high', today: 'high', overdue: 'urgent' };
const PUSH_TAGS: Record<string, string[]> = { upcoming: ['bell'], soon: ['warning'], today: ['rotating_light'], overdue: ['rotating_light','red_circle'] };
const DISCORD_COLOR: Record<string, number> = { upcoming: 0x4f46e5, soon: 0xd97706, today: 0xdc7308, overdue: 0xdc2626 };
function safeDecrypt(stored) {
function safeDecrypt(stored: any): string {
if (!stored) return '';
try { return decryptSecret(stored); } catch { return stored; }
}
async function sendNtfy(url, token, title, body, urgency = 'soon') {
const headers = {
async function sendNtfy(url: string, token: any, title: string, body: string, urgency = 'soon') {
const headers: Record<string, string> = {
'Title': title,
'Priority': PUSH_PRIORITY[urgency] || 'default',
'Tags': (PUSH_TAGS[urgency] || ['bell']).join(','),
@ -33,8 +37,8 @@ async function sendNtfy(url, token, title, body, urgency = 'soon') {
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;
async function sendGotify(url: string, token: any, title: string, body: string, urgency = 'soon') {
const priority = (({ upcoming: 4, soon: 6, today: 7, overdue: 9 } as Record<string, number>)[urgency]) ?? 5;
const endpoint = url.replace(/\/$/, '') + '/message?token=' + encodeURIComponent(token);
const res = await fetch(endpoint, {
method: 'POST',
@ -44,7 +48,7 @@ async function sendGotify(url, token, title, body, urgency = 'soon') {
if (!res.ok) throw new Error(`Gotify returned ${res.status}`);
}
async function sendDiscord(webhookUrl, title, body, urgency = 'soon') {
async function sendDiscord(webhookUrl: string, title: string, body: string, urgency = 'soon') {
const color = DISCORD_COLOR[urgency] ?? 0x4f46e5;
const res = await fetch(webhookUrl, {
method: 'POST',
@ -58,7 +62,7 @@ async function sendDiscord(webhookUrl, title, body, urgency = 'soon') {
if (!res.ok) throw new Error(`Discord returned ${res.status}`);
}
async function sendTelegram(botToken, chatId, title, body) {
async function sendTelegram(botToken: any, chatId: any, title: string, body: string) {
const text = `*${title}*\n${body}`;
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
const res = await fetch(url, {
@ -67,13 +71,13 @@ async function sendTelegram(botToken, chatId, title, body) {
body: JSON.stringify({ chat_id: chatId, text, parse_mode: 'Markdown' }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
const err: any = 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) {
async function sendPushToUser(user: Row, title: string, body: string, urgency: any) {
if (!user.notify_push_enabled || !user.push_channel || !user.push_url) return;
const url = safeDecrypt(user.push_url);
const token = safeDecrypt(user.push_token);
@ -86,7 +90,7 @@ async function sendPushToUser(user, title, body, urgency) {
}
}
async function sendTestPush(user) {
async function sendTestPush(user: Row) {
await sendPushToUser(
user,
'Bill Tracker — Test Notification',
@ -95,12 +99,9 @@ async function sendTestPush(user) {
);
}
// 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() {
function getSmtpPassword(): string {
const stored = getSetting('notify_smtp_password');
if (!stored) return '';
try {
@ -110,7 +111,7 @@ function getSmtpPassword() {
}
}
function createTransport() {
function createTransport(): any {
const host = getSetting('notify_smtp_host');
const port = parseInt(getSetting('notify_smtp_port') || '587', 10);
const encryption = getSetting('notify_smtp_encryption') || 'starttls';
@ -132,7 +133,7 @@ function createTransport() {
});
}
function senderAddress() {
function senderAddress(): string {
const name = getSetting('notify_sender_name') || 'Bill Tracker';
const address = getSetting('notify_sender_address');
if (!address) throw new Error('Sender address is not configured');
@ -141,19 +142,11 @@ function senderAddress() {
// ── Email templates ───────────────────────────────────────────────────────────
// Per-bill reminder lead time (days before due) for the early "due soon"
// reminder. Every bill has a reminder_days_before column (default 3); honor it
// so a user who asks for "7 days before" actually gets a 7-day reminder.
function leadDaysOf(bill) {
function leadDaysOf(bill: any): number {
return Number.isInteger(bill?.reminder_days_before) ? bill.reminder_days_before : 3;
}
// Which reminder type (if any) applies to a bill that is `diffDays` calendar
// days from its due date. Pure + exported so the lead-time logic is unit-testable
// without invoking the full runner (which uses the real clock + real senders).
// The early reminder fires at the bill's own lead time, and only when that lead
// is ≥ 2 so it never collides with the 1-day or same-day reminders.
function reminderTypeFor(bill, diffDays) {
function reminderTypeFor(bill: any, diffDays: number): string | null {
const leadDays = leadDaysOf(bill);
if (diffDays >= 2 && diffDays === leadDays) return 'due_3d';
if (diffDays === 1) return 'due_1d';
@ -162,37 +155,33 @@ function reminderTypeFor(bill, diffDays) {
return null;
}
const TYPE_META = {
// The `due_3d` key is the early reminder; its lead time is the bill's own
// reminder_days_before (only fires when that is ≥ 2 — a 1-day lead is covered
// by due_1d and a 0-day lead by due_today).
const TYPE_META: Record<string, { subject: (b: any) => string; urgency: string }> = {
due_3d: { subject: (b) => `Reminder: ${b.name} due in ${leadDaysOf(b)} 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 = {
const URGENCY_COLOR: Record<string, string> = {
upcoming: '#4f46e5',
soon: '#d97706',
today: '#dc7308',
overdue: '#dc2626',
};
function buildEmailHtml(bill, type, dueDate) {
function buildEmailHtml(bill: Row, type: string, dueDate: any): string {
const meta = TYPE_META[type];
const color = URGENCY_COLOR[meta.urgency];
const amount = '$' + (fromCents(bill.expected_amount) || 0).toFixed(2);
const fmt = (d) => {
const fmt = (d: any) => {
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).
// QA-B14-04: escape the bill name everywhere it lands in the HTML.
const name = esc(bill.name);
const messages = {
const messages: Record<string, string> = {
due_3d: `<strong>${name}</strong> is due in ${leadDaysOf(bill)} days.`,
due_1d: `<strong>${name}</strong> is due <strong>tomorrow</strong>.`,
due_today: `<strong>${name}</strong> is due <strong>today</strong>.`,
@ -242,18 +231,18 @@ function buildEmailHtml(bill, type, dueDate) {
</html>`;
}
function esc(s) {
function esc(s: any): string {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ── Send ──────────────────────────────────────────────────────────────────────
async function sendEmail(to, subject, html) {
async function sendEmail(to: any, subject: string, html: string) {
const transport = createTransport();
await transport.sendMail({ from: senderAddress(), to, subject, html });
}
async function sendTestEmail(to) {
async function sendTestEmail(to: any) {
const html = `<!DOCTYPE html>
<html><body style="font-family:sans-serif;padding:32px;">
<h2 style="color:#4f46e5;">Bill Tracker Test Email</h2>
@ -271,14 +260,14 @@ async function sendTestEmail(to) {
// ── Notification tracking ─────────────────────────────────────────────────────
function hasNotification(db, billId, userId, year, month, type, date) {
function hasNotification(db: Db, billId: any, userId: any, year: number, month: number, type: string, date: string): boolean {
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) {
function recordNotification(db: Db, billId: any, userId: any, year: number, month: number, type: string, date: string): void {
try {
db.prepare(`
INSERT INTO notifications (bill_id, user_id, year, month, type, sent_date)
@ -292,7 +281,7 @@ function recordNotification(db, billId, userId, year, month, type, date) {
// ── Main notification runner (called by daily worker) ─────────────────────────
async function runNotifications() {
const db = getDb();
const db: Db = getDb();
const emailReady = getSetting('notify_smtp_enabled') === 'true'
&& !!getSetting('notify_smtp_host')
@ -313,15 +302,12 @@ async function runNotifications() {
const { getCycleRange, resolveDueDate } = require('./statusService.cts');
// 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 = [];
const recipients: any[] = [];
if (allowUserConfig) {
const users = db.prepare(
@ -329,7 +315,6 @@ async function runNotifications() {
).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,
@ -338,15 +323,12 @@ async function runNotifications() {
if (!recipients.length) return;
const errors = [];
const errors: any[] = [];
// 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 billIds = bills.map((b: Row) => b.id);
const monthStart = `${year}-${String(month).padStart(2, '0')}-01`;
const monthEnd = localDateString(new Date(year, month, 0));
const paidMap = new Map();
const paidMap = new Map<any, any>();
if (billIds.length > 0) {
const placeholders = billIds.map(() => '?').join(',');
const paidRows = db.prepare(`
@ -361,12 +343,11 @@ async function runNotifications() {
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}`));
const sentSet = new Set<string>(sentRows.map((n: Row) => `${n.bill_id}:${n.user_id}:${n.type}`));
for (const bill of bills) {
const dueDate = resolveDueDate(bill, year, month);
@ -377,29 +358,21 @@ async function runNotifications() {
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);
const diffDays = Math.round((dueDay.getTime() - todayDate.getTime()) / 86400000);
// Determine which type applies today. The early reminder fires at the bill's
// own lead time (reminder_days_before, default 3) rather than a fixed 3 days.
const type = reminderTypeFor(bill, diffDays);
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;
@ -420,7 +393,7 @@ async function runNotifications() {
try {
await sendEmail(recipient.notification_email, subject, html);
sent = true;
} catch (err) {
} catch (err: any) {
errors.push(`email/${recipient.notification_email}/${bill.name}: ${err.message}`);
}
}
@ -430,7 +403,7 @@ async function runNotifications() {
try {
await sendPushToUser(recipient, subject, pushBody, urgency);
sent = true;
} catch (err) {
} catch (err: any) {
errors.push(`push/${recipient.push_channel}/${bill.name}: ${err.message}`);
}
}
@ -453,14 +426,14 @@ async function runNotifications() {
// ── Drift / price-change digest email ────────────────────────────────────────
function fmtAmt(n) {
function fmtAmt(n: any): string {
return '$' + Number(n || 0).toFixed(2);
}
function buildDriftDigestHtml(bills) {
function buildDriftDigestHtml(bills: any[]): string {
const color = '#d97706'; // amber-600
const rows = bills.map(b => {
const rows = bills.map((b: Row) => {
const sign = b.direction === 'up' ? '+' : '';
const arrow = b.direction === 'up' ? '▲' : '▼';
const arrowColor = b.direction === 'up' ? '#d97706' : '#0d9488';
@ -526,7 +499,7 @@ async function runDriftNotifications() {
if (!getSetting('notify_smtp_host')) return;
if (!getSetting('notify_sender_address')) return;
const db = getDb();
const db: Db = getDb();
const { getDriftReport } = require('./driftService.cts');
const now = new Date();
const year = now.getFullYear();
@ -536,7 +509,7 @@ async function runDriftNotifications() {
const allowUserConfig = getSetting('notify_allow_user_config') === 'true';
const globalRecipient = getSetting('notify_global_recipient');
const recipients = [];
const recipients: any[] = [];
if (allowUserConfig) {
const users = db.prepare(
@ -550,7 +523,7 @@ async function runDriftNotifications() {
for (const recipient of recipients) {
try {
const report = getDriftReport(recipient.id, now);
const newBills = (report.bills || []).filter(b =>
const newBills = (report.bills || []).filter((b: Row) =>
!hasNotification(db, b.id, recipient.id, year, month, 'amount_change', today)
);
if (!newBills.length) continue;
@ -564,18 +537,14 @@ async function runDriftNotifications() {
for (const b of newBills) {
recordNotification(db, b.id, recipient.id, year, month, 'amount_change', today);
}
} catch (err) {
} catch (err: any) {
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).
// Push helpers, exposed for the test-push route + tests (QA-B10-01: assigned AFTER the export above).
module.exports._push = { sendNtfy, sendGotify, sendDiscord, sendTelegram, sendTestPush, sendPushToUser, encryptSecret };
module.exports._email = { buildEmailHtml, esc };
// Reminder lead-time logic, exposed for unit tests (the full runner uses the
// real clock + real senders, so the pure type selection is tested in isolation).
module.exports._reminders = { reminderTypeFor, leadDaysOf, TYPE_META };

View File

@ -2,7 +2,7 @@
import type { Db } from '../types/db';
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService.cts');
const { getCycleRange } = require('./statusService.cts');
// Dynamic better-sqlite3 rows / payloads — shape varies by query, callers read

View File

@ -14,7 +14,7 @@
const xlsx = require('xlsx');
const crypto = require('crypto');
const { getDb, ensureUserDefaultCategories } = require('../db/database');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService.cts');
const { toCents, fromCents } = require('../utils/money.mts');
// ─── Constants ────────────────────────────────────────────────────────────────

View File

@ -1,6 +1,6 @@
'use strict';
const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService');
const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService.cts');
const { localDateString, todayLocal } = require('../utils/dates.mts');
const { roundMoney, sumMoney, mulMoney, fromCents } = require('../utils/money.mts');

View File

@ -3,7 +3,7 @@
const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange, resolveDueDate, isPaidStatus } = require('./statusService.cts');
const { getUserSettings } = require('./userSettings.cts');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService.cts');
const { computeAmountSuggestionsBatch } = require('./amountSuggestionService.cts');
const { accountingActiveSql } = require('./paymentAccountingService.cts');
const { normalizeMerchant } = require('./subscriptionService');

View File

@ -3,7 +3,7 @@
import type { Db } from '../types/db';
const { getDb } = require('../db/database');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService.cts');
const {
applyBankPaymentAsSourceOfTruth,
reactivatePaymentsOverriddenBy,

View File

@ -6,7 +6,7 @@ const os = require('os');
const path = require('path');
const Database = require('better-sqlite3');
const { getDb } = require('../db/database');
const { billingCycleForCycleType, cycleTypeFromBillingCycle } = require('./billsService');
const { billingCycleForCycleType, cycleTypeFromBillingCycle } = require('./billsService.cts');
const { toCents } = require('../utils/money.mts');
const MAX_SQLITE_BYTES = 50 * 1024 * 1024;

View File

@ -7,7 +7,7 @@ const {
billingCycleForCycleType,
cycleTypeFromBillingCycle,
validateBillData,
} = require('../services/billsService');
} = require('../services/billsService.cts');
test('billing schedule helpers map canonical cycle_type to legacy billing_cycle', () => {
assert.equal(billingCycleForCycleType('monthly'), 'monthly');

View File

@ -7,7 +7,7 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { _push } = require('../services/notificationService');
const { _push } = require('../services/notificationService.cts');
const { sendNtfy, sendGotify, sendDiscord, sendPushToUser } = _push;
// Local HTTP sink: records requests, status configurable.
@ -108,7 +108,7 @@ test('dispatch: an unknown channel throws instead of silently doing nothing', as
});
test('email HTML escapes the bill name everywhere — no XSS via the name (QA-B14-04)', () => {
const { buildEmailHtml } = require('../services/notificationService')._email;
const { buildEmailHtml } = require('../services/notificationService.cts')._email;
const evil = '<img src=x onerror=alert(1)>';
for (const type of ['due_3d', 'due_1d', 'due_today', 'overdue']) {
const html = buildEmailHtml({ name: evil, expected_amount: 8500 }, type, '2026-07-15');

View File

@ -7,7 +7,7 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { _reminders, _email } = require('../services/notificationService');
const { _reminders, _email } = require('../services/notificationService.cts');
const { reminderTypeFor, leadDaysOf, TYPE_META } = _reminders;
test('leadDaysOf defaults to 3 and honors an integer reminder_days_before', () => {

View File

@ -5,7 +5,7 @@ const { getDb, getSetting } = require('../db/database');
const { buildTrackerRow, getCycleRange } = require('../services/statusService.cts');
const { pruneExpiredSessions } = require('../services/authService.cts');
const { pruneExpiredChallenges: pruneWebAuthnChallenges } = require('../services/webauthnService.cts');
const { runNotifications, runDriftNotifications } = require('../services/notificationService');
const { runNotifications, runDriftNotifications } = require('../services/notificationService.cts');
const { runAllCleanup } = require('../services/cleanupService.cts');
const {
markWorkerError,