diff --git a/routes/bills.js b/routes/bills.js index 1122cc8..84747c4 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -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'); diff --git a/routes/notifications.js b/routes/notifications.js index f0d67cf..a15b3f7 100644 --- a/routes/notifications.js +++ b/routes/notifications.js @@ -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'); diff --git a/routes/payments.js b/routes/payments.js index 9379236..92cca7b 100644 --- a/routes/payments.js +++ b/routes/payments.js @@ -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'); diff --git a/routes/snowball.js b/routes/snowball.js index d2b4aea..89626bd 100644 --- a/routes/snowball.js +++ b/routes/snowball.js @@ -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 = `( diff --git a/services/authService.cts b/services/authService.cts index 172dce1..3e1d7bd 100644 --- a/services/authService.cts +++ b/services/authService.cts @@ -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] diff --git a/services/billsService.js b/services/billsService.cts similarity index 89% rename from services/billsService.js rename to services/billsService.cts index a36c1bc..71d1172 100644 --- a/services/billsService.js +++ b/services/billsService.cts @@ -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; +type FieldResult = { 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 { 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 { 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 { 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 diff --git a/services/notificationService.js b/services/notificationService.cts similarity index 81% rename from services/notificationService.js rename to services/notificationService.cts index f61942f..fb9d172 100644 --- a/services/notificationService.js +++ b/services/notificationService.cts @@ -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; // ── 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 = { upcoming: 'default', soon: 'high', today: 'high', overdue: 'urgent' }; +const PUSH_TAGS: Record = { upcoming: ['bell'], soon: ['warning'], today: ['rotating_light'], overdue: ['rotating_light','red_circle'] }; +const DISCORD_COLOR: Record = { 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 = { '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)[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; 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 = { 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 = { due_3d: `${name} is due in ${leadDaysOf(bill)} days.`, due_1d: `${name} is due tomorrow.`, due_today: `${name} is due today.`, @@ -242,18 +231,18 @@ function buildEmailHtml(bill, type, dueDate) { `; } -function esc(s) { +function esc(s: any): string { return String(s || '').replace(/&/g, '&').replace(//g, '>'); } // ── 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 = `

Bill Tracker — Test Email

@@ -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(); 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(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 }; diff --git a/services/paymentAccountingService.cts b/services/paymentAccountingService.cts index bb522bc..f2989b7 100644 --- a/services/paymentAccountingService.cts +++ b/services/paymentAccountingService.cts @@ -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 diff --git a/services/spreadsheetImportService.js b/services/spreadsheetImportService.js index 0013e1d..6ee349d 100644 --- a/services/spreadsheetImportService.js +++ b/services/spreadsheetImportService.js @@ -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 ──────────────────────────────────────────────────────────────── diff --git a/services/subscriptionService.js b/services/subscriptionService.js index e8853c8..62c30e7 100644 --- a/services/subscriptionService.js +++ b/services/subscriptionService.js @@ -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'); diff --git a/services/trackerService.js b/services/trackerService.js index f1adabd..34036ec 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -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'); diff --git a/services/transactionMatchService.cts b/services/transactionMatchService.cts index ecd0d0f..fbc2034 100644 --- a/services/transactionMatchService.cts +++ b/services/transactionMatchService.cts @@ -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, diff --git a/services/userDbImportService.js b/services/userDbImportService.js index c98d224..fbda08b 100644 --- a/services/userDbImportService.js +++ b/services/userDbImportService.js @@ -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; diff --git a/tests/billsService.test.js b/tests/billsService.test.js index 16f93d5..efe4061 100644 --- a/tests/billsService.test.js +++ b/tests/billsService.test.js @@ -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'); diff --git a/tests/notificationDelivery.test.js b/tests/notificationDelivery.test.js index bebcd2a..f3fc7ad 100644 --- a/tests/notificationDelivery.test.js +++ b/tests/notificationDelivery.test.js @@ -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 = ''; for (const type of ['due_3d', 'due_1d', 'due_today', 'overdue']) { const html = buildEmailHtml({ name: evil, expected_amount: 8500 }, type, '2026-07-15'); diff --git a/tests/notificationLeadTime.test.js b/tests/notificationLeadTime.test.js index ce62f8e..a0baba9 100644 --- a/tests/notificationLeadTime.test.js +++ b/tests/notificationLeadTime.test.js @@ -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', () => { diff --git a/workers/dailyWorker.js b/workers/dailyWorker.js index af70b23..3614c50 100644 --- a/workers/dailyWorker.js +++ b/workers/dailyWorker.js @@ -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,