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:
parent
6746d42380
commit
0db5a77adc
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 = `(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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, '&').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 = `<!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 };
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue