diff --git a/routes/bills.js b/routes/bills.js index 27bdb6a..1122cc8 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -17,7 +17,7 @@ const { standardizeError } = require('../middleware/errorFormatter'); const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts'); const { addMerchantRule, syncBillPaymentsFromSimplefin, merchantMatches } = require('../services/billMerchantRuleService.cts'); const { normalizeMerchant } = require('../services/subscriptionService'); -const { decorateTransaction } = require('../services/transactionService'); +const { decorateTransaction } = require('../services/transactionService.cts'); const { accountingActiveSql, applyBankPaymentAsSourceOfTruth, diff --git a/routes/calendar.js b/routes/calendar.js index 8733fe7..0bb1089 100644 --- a/routes/calendar.js +++ b/routes/calendar.js @@ -12,7 +12,7 @@ const { previewFeed, regenerateToken, revokeToken, -} = require('../services/calendarFeedService'); +} = require('../services/calendarFeedService.cts'); const { localDateString } = require('../utils/dates.mts'); const { roundMoney, sumMoney, fromCents } = require('../utils/money.mts'); diff --git a/routes/calendarFeed.js b/routes/calendarFeed.js index 9bb3796..0d81217 100644 --- a/routes/calendarFeed.js +++ b/routes/calendarFeed.js @@ -6,7 +6,7 @@ const { buildCalendarFeed, getTokenRecord, markTokenUsed, -} = require('../services/calendarFeedService'); +} = require('../services/calendarFeedService.cts'); // GET /api/calendar/feed.ics?token=... // Public by design: calendar clients cannot use the app's session cookies. diff --git a/routes/dataSources.js b/routes/dataSources.js index a51e925..b04137e 100644 --- a/routes/dataSources.js +++ b/routes/dataSources.js @@ -3,7 +3,7 @@ const router = require('express').Router(); const { getDb } = require('../db/database'); const { standardizeError } = require('../middleware/errorFormatter'); -const { decorateDataSource, ensureManualDataSource } = require('../services/transactionService'); +const { decorateDataSource, ensureManualDataSource } = require('../services/transactionService.cts'); const { connectSimplefin, syncDataSource, backfillDataSource, disconnectDataSource } = require('../services/bankSyncService'); const { sanitizeErrorMessage } = require('../services/simplefinService'); const { getBankSyncConfig } = require('../services/bankSyncConfigService.cts'); diff --git a/routes/matches.js b/routes/matches.js index 87ac30b..a3bdc5c 100644 --- a/routes/matches.js +++ b/routes/matches.js @@ -5,7 +5,7 @@ const { applyBankPaymentAsSourceOfTruth, reactivatePaymentsOverriddenBy } = requ const { listMatchSuggestions, rejectMatchSuggestion, -} = require('../services/matchSuggestionService'); +} = require('../services/matchSuggestionService.cts'); const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService.cts'); const { markMatched, markUnmatched } = require('../services/transactionMatchState.cts'); const { serializePayment } = require('../services/paymentValidation.cts'); diff --git a/routes/snowball.js b/routes/snowball.js index 9b99454..d2b4aea 100644 --- a/routes/snowball.js +++ b/routes/snowball.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); const { getDb } = require('../db/database'); const { standardizeError } = require('../middleware/errorFormatter'); -const { calculateSnowball, calculateAvalanche } = require('../services/snowballService'); +const { calculateSnowball, calculateAvalanche } = require('../services/snowballService.cts'); const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService.cts'); const { serializeBill } = require('../services/billsService'); const { toCents, fromCents } = require('../utils/money.mts'); diff --git a/routes/transactions.js b/routes/transactions.js index 16f9542..cc658b4 100644 --- a/routes/transactions.js +++ b/routes/transactions.js @@ -5,7 +5,7 @@ const { decorateTransaction, ensureManualDataSource, getTransactionForUser, -} = require('../services/transactionService'); +} = require('../services/transactionService.cts'); const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService.cts'); const { getBankSyncConfig } = require('../services/bankSyncConfigService.cts'); const { markUnmatched } = require('../services/transactionMatchState.cts'); @@ -14,13 +14,13 @@ const { matchTransactionToBill, unignoreTransaction, unmatchTransaction, -} = require('../services/transactionMatchService'); +} = require('../services/transactionMatchService.cts'); const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService.cts'); const { findMerchantMatch, applyMerchantStoreMatches, findOrCreateCategory, -} = require('../services/merchantStoreMatchService'); +} = require('../services/merchantStoreMatchService.cts'); const { categorizeTransaction } = require('../services/spendingService.cts'); const { todayLocal } = require('../utils/dates.mts'); const { roundMoney } = require('../utils/money.mts'); diff --git a/services/bankSyncService.js b/services/bankSyncService.js index 93aaef8..13e97af 100644 --- a/services/bankSyncService.js +++ b/services/bankSyncService.js @@ -9,11 +9,11 @@ const { sanitizeErrorMessage, } = require('./simplefinService'); const { getBankSyncConfig, SYNC_DAYS_EFFECTIVE, SYNC_DAYS_DEFAULT } = require('./bankSyncConfigService.cts'); -const { decorateDataSource } = require('./transactionService'); +const { decorateDataSource } = require('./transactionService.cts'); const { applyMerchantRules } = require('./billMerchantRuleService.cts'); const { applySpendingCategoryRules } = require('./spendingService.cts'); -const { applyMerchantStoreMatches } = require('./merchantStoreMatchService'); -const { autoMatchForUser } = require('./matchSuggestionService'); +const { applyMerchantStoreMatches } = require('./merchantStoreMatchService.cts'); +const { autoMatchForUser } = require('./matchSuggestionService.cts'); const { getUserSettings } = require('./userSettings'); function sinceEpochDays(days) { diff --git a/services/calendarFeedService.js b/services/calendarFeedService.cts similarity index 79% rename from services/calendarFeedService.js rename to services/calendarFeedService.cts index d3cb8de..89bb453 100644 --- a/services/calendarFeedService.js +++ b/services/calendarFeedService.cts @@ -1,15 +1,20 @@ 'use strict'; +import type { Db } from '../types/db'; +import type { Req } from '../types/http'; + const crypto = require('crypto'); const { getDb } = require('../db/database'); const { normalizeCycleType, resolveDueDate } = require('./statusService.cts'); -const { fromCents } = require('../utils/money.mts'); +const { fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts'); + +type Row = Record; const PRODID = '-//Bill Tracker//Calendar Feed//EN'; const FEED_PAST_MONTHS = 12; const FEED_FUTURE_MONTHS = 24; -const WEEKDAY_CODES = { +const WEEKDAY_CODES: Record = { sunday: 'SU', monday: 'MO', tuesday: 'TU', @@ -19,7 +24,7 @@ const WEEKDAY_CODES = { saturday: 'SA', }; -function ensureCalendarTokenSchema(db = getDb()) { +function ensureCalendarTokenSchema(db: Db = getDb()): void { db.exec(` CREATE TABLE IF NOT EXISTS calendar_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -36,16 +41,16 @@ function ensureCalendarTokenSchema(db = getDb()) { `); } -function generateToken() { +function generateToken(): string { return crypto.randomBytes(32).toString('base64url'); } -function sanitizeLabel(label) { +function sanitizeLabel(label: unknown): string { const value = String(label || 'Bill Tracker Calendar').trim(); return value.slice(0, 80) || 'Bill Tracker Calendar'; } -function getActiveToken(userId, db = getDb()) { +function getActiveToken(userId: number, db: Db = getDb()): Row | null { ensureCalendarTokenSchema(db); return db.prepare(` SELECT id, user_id, token, label, active, last_used_at, created_at, revoked_at @@ -56,7 +61,7 @@ function getActiveToken(userId, db = getDb()) { `).get(userId) || null; } -function createToken(userId, label = 'Bill Tracker Calendar', db = getDb()) { +function createToken(userId: number, label = 'Bill Tracker Calendar', db: Db = getDb()): Row { ensureCalendarTokenSchema(db); const token = generateToken(); const result = db.prepare(` @@ -70,13 +75,13 @@ function createToken(userId, label = 'Bill Tracker Calendar', db = getDb()) { `).get(result.lastInsertRowid); } -function getOrCreateToken(userId, db = getDb()) { +function getOrCreateToken(userId: number, db: Db = getDb()): Row { return getActiveToken(userId, db) || createToken(userId, 'Bill Tracker Calendar', db); } -function regenerateToken(userId, db = getDb()) { +function regenerateToken(userId: number, db: Db = getDb()): Row { ensureCalendarTokenSchema(db); - let next; + let next: Row; db.transaction(() => { db.prepare(` UPDATE calendar_tokens @@ -85,10 +90,10 @@ function regenerateToken(userId, db = getDb()) { `).run(userId); next = createToken(userId, 'Bill Tracker Calendar', db); })(); - return next; + return next!; } -function revokeToken(userId, db = getDb()) { +function revokeToken(userId: number, db: Db = getDb()): void { ensureCalendarTokenSchema(db); db.prepare(` UPDATE calendar_tokens @@ -97,7 +102,7 @@ function revokeToken(userId, db = getDb()) { `).run(userId); } -function getTokenRecord(token, db = getDb()) { +function getTokenRecord(token: unknown, db: Db = getDb()): Row | null { ensureCalendarTokenSchema(db); if (!token || typeof token !== 'string') return null; return db.prepare(` @@ -107,22 +112,22 @@ function getTokenRecord(token, db = getDb()) { `).get(token) || null; } -function markTokenUsed(tokenId, db = getDb()) { +function markTokenUsed(tokenId: number, db: Db = getDb()): void { ensureCalendarTokenSchema(db); db.prepare('UPDATE calendar_tokens SET last_used_at = datetime(\'now\') WHERE id = ?').run(tokenId); } -function originFromRequest(req) { +function originFromRequest(req: Req): string { const host = req.get?.('x-forwarded-host') || req.get?.('host') || 'localhost'; const proto = req.get?.('x-forwarded-proto') || req.protocol || 'http'; return `${String(proto).split(',')[0]}://${String(host).split(',')[0]}`; } -function feedUrlForToken(req, token) { +function feedUrlForToken(req: Req, token: string): string { return `${originFromRequest(req)}/api/calendar/feed.ics?token=${encodeURIComponent(token)}`; } -function ymd(date) { +function ymd(date: Date): string { return [ date.getUTCFullYear(), String(date.getUTCMonth() + 1).padStart(2, '0'), @@ -130,27 +135,27 @@ function ymd(date) { ].join('-'); } -function icsDate(dateString) { +function icsDate(dateString: unknown): string { return String(dateString).replaceAll('-', ''); } -function utcStamp(date = new Date()) { +function utcStamp(date: Date = new Date()): string { return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); } -function addDays(dateString, days) { +function addDays(dateString: unknown, days: number): string { const [year, month, day] = String(dateString).split('-').map(Number); const date = new Date(Date.UTC(year, month - 1, day)); date.setUTCDate(date.getUTCDate() + days); return ymd(date); } -function addMonths(date, months) { +function addMonths(date: Date, months: number): Date { return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1)); } -function monthIterator(startDate, endDate) { - const months = []; +function monthIterator(startDate: Date, endDate: Date): { year: number; month: number }[] { + const months: { year: number; month: number }[] = []; let cursor = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), 1)); const end = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), 1)); while (cursor <= end) { @@ -161,7 +166,7 @@ function monthIterator(startDate, endDate) { } // RFC 5545 §3.3.11 TEXT: escape backslash, semicolon, comma, and line breaks. -function escapeText(value) { +function escapeText(value: unknown): string { return String(value ?? '') .replace(/\\/g, '\\\\') .replace(/\r\n|\r|\n/g, '\\n') @@ -170,11 +175,11 @@ function escapeText(value) { } // RFC 5545 §3.1 content lines: fold at 75 octets, not JavaScript characters. -function foldLine(line) { +function foldLine(line: unknown): string { const value = String(line); if (Buffer.byteLength(value, 'utf8') <= 75) return value; - const lines = []; + const lines: string[] = []; let current = ''; let limit = 75; @@ -193,23 +198,23 @@ function foldLine(line) { return lines.map((part, index) => (index === 0 ? part : ` ${part}`)).join('\r\n'); } -function weekdayForCycleDay(value) { +function weekdayForCycleDay(value: unknown): string { const normalized = String(value || 'monday').trim().toLowerCase(); return WEEKDAY_CODES[normalized] || 'MO'; } -function monthForCycleDay(value) { - const parsed = parseInt(value, 10); +function monthForCycleDay(value: unknown): number { + const parsed = parseInt(value as string, 10); return Number.isInteger(parsed) && parsed >= 1 && parsed <= 12 ? parsed : 1; } -function dayForBill(bill) { +function dayForBill(bill: Row): number { const parsed = parseInt(bill?.due_day, 10); return Number.isInteger(parsed) && parsed >= 1 && parsed <= 31 ? parsed : 1; } // RRULE values follow RFC 5545 §3.3.10 and mirror the app's five cycle types. -function rruleForCycle(bill = {}) { +function rruleForCycle(bill: Row = {}): string { const cycleType = normalizeCycleType(bill); if (cycleType === 'weekly') { return `FREQ=WEEKLY;BYDAY=${weekdayForCycleDay(bill.cycle_day)}`; @@ -226,17 +231,17 @@ function rruleForCycle(bill = {}) { return `FREQ=MONTHLY;BYMONTHDAY=${dayForBill(bill)}`; } -function eventUid(bill, dueDate) { +function eventUid(bill: Row, dueDate: string): string { return `bill-tracker-bill-${bill.id}-${dueDate}@bill-tracker`; } -function eventSummary(bill, detailLevel = 'standard') { +function eventSummary(bill: Row, detailLevel = 'standard'): string { if (detailLevel === 'private') return 'Bill due'; if (detailLevel === 'full') return `${bill.name} due - $${(fromCents(bill.expected_amount) || 0).toFixed(2)}`; return `${bill.name} due`; } -function eventDescription(bill, dueDate, detailLevel = 'standard') { +function eventDescription(bill: Row, dueDate: string, detailLevel = 'standard'): string { const lines = ['Bill Tracker reminder']; if (detailLevel !== 'private') lines.push(`Bill: ${bill.name}`); if (detailLevel === 'full') lines.push(`Expected amount: $${(fromCents(bill.expected_amount) || 0).toFixed(2)}`); @@ -246,7 +251,7 @@ function eventDescription(bill, dueDate, detailLevel = 'standard') { return lines.join('\n'); } -function buildEvent({ bill, dueDate, detailLevel = 'standard', dtstamp = utcStamp() }) { +function buildEvent({ bill, dueDate, detailLevel = 'standard', dtstamp = utcStamp() }: { bill: Row; dueDate: string; detailLevel?: string; dtstamp?: string }): string[] { const dtEnd = addDays(dueDate, 1); const lines = [ 'BEGIN:VEVENT', @@ -264,7 +269,7 @@ function buildEvent({ bill, dueDate, detailLevel = 'standard', dtstamp = utcStam return lines; } -function loadActiveBillsForFeed(userId, db = getDb()) { +function loadActiveBillsForFeed(userId: number, db: Db = getDb()): Row[] { return db.prepare(` SELECT b.*, c.name AS category_name FROM bills b @@ -276,14 +281,14 @@ function loadActiveBillsForFeed(userId, db = getDb()) { `).all(userId); } -function buildFeedEvents(userId, options = {}, db = getDb()) { +function buildFeedEvents(userId: number, options: Row = {}, db: Db = getDb()): any[] { const now = options.now || new Date(); const start = options.startDate || addMonths(now, -(options.pastMonths ?? FEED_PAST_MONTHS)); const end = options.endDate || addMonths(now, options.futureMonths ?? FEED_FUTURE_MONTHS); const detailLevel = options.detailLevel || 'standard'; const dtstamp = options.dtstamp || utcStamp(now); - const events = []; - const seen = new Set(); + const events: any[] = []; + const seen = new Set(); for (const bill of loadActiveBillsForFeed(userId, db)) { for (const { year, month } of monthIterator(start, end)) { @@ -300,7 +305,7 @@ function buildFeedEvents(userId, options = {}, db = getDb()) { return events; } -function buildIcsCalendar({ name = 'Bill Tracker', events = [] } = {}) { +function buildIcsCalendar({ name = 'Bill Tracker', events = [] }: { name?: string; events?: any[] } = {}): string { const lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', @@ -319,7 +324,7 @@ function buildIcsCalendar({ name = 'Bill Tracker', events = [] } = {}) { return `${lines.map(foldLine).join('\r\n')}\r\n`; } -function buildCalendarFeed(userId, options = {}, db = getDb()) { +function buildCalendarFeed(userId: number, options: Row = {}, db: Db = getDb()) { const events = buildFeedEvents(userId, options, db); return { ics: buildIcsCalendar({ name: options.name || 'Bill Tracker', events }), @@ -327,7 +332,7 @@ function buildCalendarFeed(userId, options = {}, db = getDb()) { }; } -function previewFeed(userId, options = {}, db = getDb()) { +function previewFeed(userId: number, options: Row = {}, db: Db = getDb()) { const events = buildFeedEvents(userId, { ...options, pastMonths: 0, futureMonths: 6 }, db); return events.slice(0, options.limit || 10).map(event => ({ uid: event.uid, diff --git a/services/csvTransactionImportService.js b/services/csvTransactionImportService.js index 0bb2e09..4eb1fd6 100644 --- a/services/csvTransactionImportService.js +++ b/services/csvTransactionImportService.js @@ -2,7 +2,7 @@ const crypto = require('crypto'); const { getDb } = require('../db/database'); -const { decorateTransaction, ensureManualDataSource } = require('./transactionService'); +const { decorateTransaction, ensureManualDataSource } = require('./transactionService.cts'); const SESSION_TTL_MS = 24 * 60 * 60 * 1000; const MAX_ROWS = 25000; diff --git a/services/matchSuggestionService.js b/services/matchSuggestionService.cts similarity index 80% rename from services/matchSuggestionService.js rename to services/matchSuggestionService.cts index 42e6193..cfdff99 100644 --- a/services/matchSuggestionService.js +++ b/services/matchSuggestionService.cts @@ -1,19 +1,23 @@ 'use strict'; +import type { Db } from '../types/db'; + const { getDb } = require('../db/database'); const { getCycleRange, resolveDueDate } = require('./statusService.cts'); -const { decorateTransaction } = require('./transactionService'); -const { fromCents } = require('../utils/money.mts'); +const { decorateTransaction } = require('./transactionService.cts'); +const { fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts'); -function suggestionError(status, message, code, field = null) { - const err = new Error(message); +type Row = Record; + +function suggestionError(status: number, message: string, code: string, field: string | null = null): Error { + const err = new Error(message) as any; err.status = status; err.code = code; err.field = field; return err; } -function normalizeId(value, field) { +function normalizeId(value: unknown, field: string): number { const id = typeof value === 'number' ? value : Number(value); if (!Number.isSafeInteger(id) || id <= 0) { throw suggestionError(400, `${field} must be a positive integer`, 'VALIDATION_ERROR', field); @@ -21,11 +25,11 @@ function normalizeId(value, field) { return id; } -function suggestionId(transactionId, billId) { +function suggestionId(transactionId: any, billId: any): string { return `${transactionId}:${billId}`; } -function parseSuggestionId(id) { +function parseSuggestionId(id: unknown): { transactionId: number; billId: number } { const match = /^(\d+):(\d+)$/.exec(String(id || '').trim()); if (!match) { throw suggestionError(400, 'Suggestion id must be transactionId:billId', 'VALIDATION_ERROR', 'id'); @@ -36,36 +40,36 @@ function parseSuggestionId(id) { }; } -function textKey(value) { +function textKey(value: unknown): string { return String(value || '') .toLowerCase() .replace(/[^a-z0-9]+/g, ' ') .trim(); } -function transactionDate(transaction) { +function transactionDate(transaction: Row): string | null { const date = transaction.posted_date || String(transaction.transacted_at || '').slice(0, 10); return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null; } -function dateParts(date) { +function dateParts(date: string): { year: number; month: number } { const [year, month] = String(date).split('-').map(Number); return { year, month }; } -function diffDays(a, b) { +function diffDays(a: string, b: string): number | null { const left = new Date(`${a}T00:00:00Z`).getTime(); const right = new Date(`${b}T00:00:00Z`).getTime(); if (!Number.isFinite(left) || !Number.isFinite(right)) return null; return Math.abs(Math.round((left - right) / 86400000)); } -function amountDollars(transaction) { +function amountDollars(transaction: Row): number { const cents = Number(transaction.amount); return Number.isFinite(cents) ? Math.abs(cents) / 100 : 0; } -function addAmountScore(score, reasons, transaction, bill) { +function addAmountScore(score: number, reasons: string[], transaction: Row, bill: Row): number { const txAmount = amountDollars(transaction); const expected = fromCents(bill.expected_amount) || 0; if (txAmount <= 0 || expected <= 0) return score; @@ -91,7 +95,7 @@ function addAmountScore(score, reasons, transaction, bill) { return score; } -function addDateScore(score, reasons, transaction, bill) { +function addDateScore(score: number, reasons: string[], transaction: Row, bill: Row): number { const postedDate = transactionDate(transaction); if (!postedDate) return score; @@ -117,17 +121,17 @@ function addDateScore(score, reasons, transaction, bill) { } // Word-boundary comparison — same logic as billMerchantRuleService.merchantMatches() -function wordBoundaryIncludes(a, b) { +function wordBoundaryIncludes(a: string, b: string): boolean { if (!a || !b) return false; if (a === b) return true; try { - const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const wb = s => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`); + const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const wb = (s: string) => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`); return wb(b).test(a) || wb(a).test(b); } catch { return false; } } -function addNameScore(score, reasons, transaction, bill) { +function addNameScore(score: number, reasons: string[], transaction: Row, bill: Row): number { const billName = textKey(bill.name); if (!billName) return score; @@ -150,7 +154,7 @@ function addNameScore(score, reasons, transaction, bill) { return score; } -function addPriorMatchScore(score, reasons, transaction, bill, priorMatchKeys) { +function addPriorMatchScore(score: number, reasons: string[], transaction: Row, bill: Row, priorMatchKeys: Set): number { const payee = textKey(transaction.payee); const description = textKey(transaction.description); if ( @@ -163,7 +167,7 @@ function addPriorMatchScore(score, reasons, transaction, bill, priorMatchKeys) { return score; } -function hasPaymentInTransactionCycle(db, bill, transaction) { +function hasPaymentInTransactionCycle(db: Db, bill: Row, transaction: Row): boolean { const postedDate = transactionDate(transaction); if (!postedDate) return false; const { year, month } = dateParts(postedDate); @@ -180,8 +184,8 @@ function hasPaymentInTransactionCycle(db, bill, transaction) { `).get(bill.id, range.start, range.end); } -function loadCandidateTransactions(db, userId, transactionId = null) { - const params = [userId]; +function loadCandidateTransactions(db: Db, userId: number, transactionId: number | null = null): any[] { + const params: any[] = [userId]; const where = [ 't.user_id = ?', 't.ignored = 0', @@ -215,7 +219,7 @@ function loadCandidateTransactions(db, userId, transactionId = null) { `).all(...params).map(decorateTransaction); } -function loadBills(db, userId) { +function loadBills(db: Db, userId: number): Row[] { return db.prepare(` SELECT b.*, c.name AS category_name FROM bills b @@ -227,17 +231,17 @@ function loadBills(db, userId) { `).all(userId); } -function loadRejections(db, userId) { +function loadRejections(db: Db, userId: number): Set { const rows = db.prepare(` SELECT transaction_id, bill_id FROM match_suggestion_rejections WHERE user_id = ? AND rejected_at > datetime('now', '-90 days') `).all(userId); - return new Set(rows.map(row => suggestionId(row.transaction_id, row.bill_id))); + return new Set(rows.map((row: Row) => suggestionId(row.transaction_id, row.bill_id))); } -function loadPriorMatchKeys(db, userId) { +function loadPriorMatchKeys(db: Db, userId: number): Set { const rows = db.prepare(` SELECT matched_bill_id, payee, description FROM transactions @@ -246,7 +250,7 @@ function loadPriorMatchKeys(db, userId) { AND match_status = 'matched' AND ignored = 0 `).all(userId); - const keys = new Set(); + const keys = new Set(); for (const row of rows) { const payee = textKey(row.payee); const description = textKey(row.description); @@ -256,8 +260,8 @@ function loadPriorMatchKeys(db, userId) { return keys; } -function scoreSuggestion(transaction, bill, priorMatchKeys) { - const reasons = []; +function scoreSuggestion(transaction: Row, bill: Row, priorMatchKeys: Set): { score: number; reasons: string[] } { + const reasons: string[] = []; let score = 0; score = addAmountScore(score, reasons, transaction, bill); score = addDateScore(score, reasons, transaction, bill); @@ -266,8 +270,8 @@ function scoreSuggestion(transaction, bill, priorMatchKeys) { return { score: Math.min(score, 100), reasons }; } -function listMatchSuggestions(userId, options = {}) { - const db = getDb(); +function listMatchSuggestions(userId: number, options: Row = {}): any[] { + const db: Db = getDb(); const rawTransactionId = options.transactionId ?? options.transaction_id; const transactionId = rawTransactionId ? normalizeId(rawTransactionId, 'transaction_id') @@ -278,7 +282,7 @@ function listMatchSuggestions(userId, options = {}) { const bills = loadBills(db, userId); const rejections = loadRejections(db, userId); const priorMatchKeys = loadPriorMatchKeys(db, userId); - const suggestions = []; + const suggestions: any[] = []; for (const transaction of transactions) { for (const bill of bills) { @@ -312,8 +316,8 @@ function listMatchSuggestions(userId, options = {}) { .slice(0, limit); } -function rejectMatchSuggestion(userId, id) { - const db = getDb(); +function rejectMatchSuggestion(userId: number, id: unknown) { + const db: Db = getDb(); const parsed = parseSuggestionId(id); const transaction = db.prepare('SELECT id FROM transactions WHERE id = ? AND user_id = ?').get(parsed.transactionId, userId); @@ -345,8 +349,8 @@ function rejectMatchSuggestion(userId, id) { // Called by the background sync worker after each successful source sync. // Score of 80+ requires at minimum exact amount + date proximity + name signal, // so false-positive risk is low. -function autoMatchForUser(userId) { - const { matchTransactionToBill } = require('./transactionMatchService'); +function autoMatchForUser(userId: number): number { + const { matchTransactionToBill } = require('./transactionMatchService.cts'); const suggestions = listMatchSuggestions(userId, { limit: 50 }); let matched = 0; for (const s of suggestions) { diff --git a/services/merchantStoreMatchService.js b/services/merchantStoreMatchService.cts similarity index 80% rename from services/merchantStoreMatchService.js rename to services/merchantStoreMatchService.cts index 33dbb2e..f1e7a46 100644 --- a/services/merchantStoreMatchService.js +++ b/services/merchantStoreMatchService.cts @@ -1,27 +1,31 @@ 'use strict'; +import type { Db } from '../types/db'; + const { categorizeTransaction } = require('./spendingService.cts'); +type Row = Record; + // Mirrors the pack's match_order_recommendation: regional descriptor variants // (most specific — tied to a city/county) are checked first, then online // billing descriptors (PAYPAL/STRIPE/APPLE.COM/BILL/etc.), then canonical // national merchants as the fallback. -const ENTRY_KIND_ORDER = { +const ENTRY_KIND_ORDER: Record = { regional_descriptor_variant: 0, online_billing_descriptor_variant: 1, canonical_merchant: 2, }; -let _entries = null; +let _entries: any[] | null = null; -function maxPatternLength(patterns) { +function maxPatternLength(patterns: string[]): number { return patterns.reduce((max, p) => Math.max(max, p.length), 0); } // Lazily load and pre-sort all merchant_store_matches rows. Cached for the // lifetime of the process — this is static reference data seeded once by the // v1.05 migration. -function loadMerchantMatchEntries(db) { +function loadMerchantMatchEntries(db: Db): any[] { if (_entries) return _entries; const rows = db.prepare(` @@ -31,7 +35,7 @@ function loadMerchantMatchEntries(db) { FROM merchant_store_matches `).all(); - _entries = rows.map(row => ({ + _entries = rows.map((row: Row) => ({ ...row, match_patterns: JSON.parse(row.match_patterns || '[]'), negative_patterns: JSON.parse(row.negative_patterns || '[]'), @@ -51,7 +55,7 @@ function loadMerchantMatchEntries(db) { // Apply the pack's normalization rules: uppercase, "&" -> "AND", strip // apostrophes/punctuation, collapse whitespace. match_patterns in the pack // are pre-normalized to this same shape (e.g. "THE CHILDREN S PLACE"). -function normalizeForMatch(value) { +function normalizeForMatch(value: unknown): string { return String(value || '') .toUpperCase() .replace(/&/g, ' AND ') @@ -63,14 +67,14 @@ function normalizeForMatch(value) { // Find the best merchant/store match for a raw transaction description. // Returns { entry_id, canonical_name, display_name, category, scope, priority } or null. -function findMerchantMatch(db, description) { +function findMerchantMatch(db: Db, description: unknown): Row | null { const normalized = normalizeForMatch(description); if (!normalized) return null; const entries = loadMerchantMatchEntries(db); for (const entry of entries) { - if (entry.negative_patterns.some(p => normalized.includes(p))) continue; - if (entry.match_patterns.some(p => p && normalized.includes(p))) { + if (entry.negative_patterns.some((p: string) => normalized.includes(p))) continue; + if (entry.match_patterns.some((p: string) => p && normalized.includes(p))) { return { entry_id: entry.id, canonical_name: entry.canonical_name, @@ -86,7 +90,7 @@ function findMerchantMatch(db, description) { // Find-or-create a spending category by name for this user, matching the // COLLATE NOCASE convention used by ensureUserDefaultCategories (db/database.js). -function findOrCreateCategory(db, userId, name) { +function findOrCreateCategory(db: Db, userId: number, name: string): number | bigint { const existing = db.prepare('SELECT id FROM categories WHERE user_id = ? AND name = ? COLLATE NOCASE') .get(userId, name); if (existing) return existing.id; @@ -98,12 +102,9 @@ function findOrCreateCategory(db, userId, name) { // and categorize them (writing spending_category_rules so future syncs hit // the cheaper rule path instead of rescanning the full pack). // -// With { dryRun: true }, computes the same matches without writing anything -// (no categories created, no transactions updated) — used to preview an -// auto-categorize run before applying it. -// -// Returns { updated: number, categories: [{ name, count }], changes: [{ transaction_id, display_name, category }] }. -function applyMerchantStoreMatches(db, userId, { dryRun = false } = {}) { +// With { dryRun: true }, computes the same matches without writing anything. +// Returns { updated, categories: [{name,count}], changes: [{transaction_id,display_name,category}] }. +function applyMerchantStoreMatches(db: Db, userId: number, { dryRun = false }: { dryRun?: boolean } = {}) { const txRows = db.prepare(` SELECT id, payee, description, memo FROM transactions @@ -114,10 +115,10 @@ function applyMerchantStoreMatches(db, userId, { dryRun = false } = {}) { AND spending_category_id IS NULL `).all(userId); - if (txRows.length === 0) return { updated: 0, categories: [], changes: [] }; + if (txRows.length === 0) return { updated: 0, categories: [] as any[], changes: [] as any[] }; - const categoryCounts = new Map(); // category name -> count - const changes = []; + const categoryCounts = new Map(); // category name -> count + const changes: any[] = []; let updated = 0; const apply = () => { diff --git a/services/ofxImportService.js b/services/ofxImportService.js index de4b7ca..0a3c04b 100644 --- a/services/ofxImportService.js +++ b/services/ofxImportService.js @@ -10,7 +10,7 @@ // so dedupe scope, the import_sessions table, and import_history are identical. const { getDb } = require('../db/database'); -const { decorateTransaction, ensureManualDataSource } = require('./transactionService'); +const { decorateTransaction, ensureManualDataSource } = require('./transactionService.cts'); const { saveImportSession, loadImportSession, diff --git a/services/snowballService.js b/services/snowballService.cts similarity index 83% rename from services/snowballService.js rename to services/snowballService.cts index a274a57..2774247 100644 --- a/services/snowballService.js +++ b/services/snowballService.cts @@ -1,5 +1,6 @@ +import type { Dollars } from '../utils/money.mts'; -const { roundMoney, sumMoney } = require('../utils/money.mts'); +const { roundMoney, sumMoney } = require('../utils/money.mts') as typeof import('../utils/money.mts'); /** * Debt payoff calculators — Snowball and Avalanche methods. * @@ -9,13 +10,36 @@ const { roundMoney, sumMoney } = require('../utils/money.mts'); * Both share the same month-by-month simulation loop; only the initial order differs. */ +type Row = Record; + +interface ActiveDebt { + id: any; + name: any; + balance: number; + minPayment: number; + monthlyRate: number; + payoffMonth: number | null; + totalInterest: number; +} +interface SkippedDebt { id: any; name: any; reason: string } +interface DebtProjection { + months_to_freedom: number | null; + total_interest_paid: number; + payoff_date: string | null; + payoff_display: string | null; + debts: any[]; + skipped: SkippedDebt[]; + extra_payment: number; + capped: boolean; +} + // ── Private simulation engine ───────────────────────────────────────────────── -function _simulate(orderedDebts, extraPayment, startDate) { +function _simulate(orderedDebts: Row[], extraPayment: number, startDate: Date): DebtProjection { const extra = Math.max(0, Number(extraPayment) || 0); - const active = []; - const skipped = []; + const active: ActiveDebt[] = []; + const skipped: SkippedDebt[] = []; for (const d of orderedDebts) { const bal = Number(d.current_balance); @@ -93,12 +117,12 @@ function _simulate(orderedDebts, extraPayment, startDate) { const baseYear = startDate.getFullYear(); const baseMo = startDate.getMonth(); - function monthLabel(m) { + function monthLabel(m: number): string { const d = new Date(baseYear, baseMo + m, 1); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; } - function monthDisplay(m) { + function monthDisplay(m: number): string { const d = new Date(baseYear, baseMo + m, 1); return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long' }); } @@ -140,7 +164,7 @@ function _simulate(orderedDebts, extraPayment, startDate) { * Snowball: attack the smallest balance first (fast wins, motivational). * Debts must already be in snowball order (sorted by current_balance ASC by the caller). */ -function calculateSnowball(debts, extraPayment = 0, startDate = new Date()) { +function calculateSnowball(debts: Row[], extraPayment = 0, startDate: Date = new Date()): DebtProjection { return _simulate(debts, extraPayment, startDate); } @@ -148,7 +172,7 @@ function calculateSnowball(debts, extraPayment = 0, startDate = new Date()) { * Avalanche: attack the highest interest rate first (minimises total interest paid). * Re-sorts debts internally — caller does not need to pre-sort. */ -function calculateAvalanche(debts, extraPayment = 0, startDate = new Date()) { +function calculateAvalanche(debts: Row[], extraPayment = 0, startDate: Date = new Date()): DebtProjection { const sorted = [...debts].sort((a, b) => { const ra = Number(a.interest_rate) || 0; const rb = Number(b.interest_rate) || 0; @@ -159,7 +183,7 @@ function calculateAvalanche(debts, extraPayment = 0, startDate = new Date()) { return _simulate(sorted, extraPayment, startDate); } -function round2(n) { +function round2(n: number): Dollars { return roundMoney(n); } diff --git a/services/transactionMatchService.js b/services/transactionMatchService.cts similarity index 82% rename from services/transactionMatchService.js rename to services/transactionMatchService.cts index 25496ba..ecd0d0f 100644 --- a/services/transactionMatchService.js +++ b/services/transactionMatchService.cts @@ -1,5 +1,7 @@ 'use strict'; +import type { Db } from '../types/db'; + const { getDb } = require('../db/database'); const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { @@ -9,22 +11,24 @@ const { const { decorateTransaction, getTransactionForUser, -} = require('./transactionService'); +} = require('./transactionService.cts'); const { serializePayment } = require('./paymentValidation.cts'); const { markMatched, markUnmatched, markIgnored } = require('./transactionMatchState.cts'); +type Row = Record; + const MATCH_PAYMENT_SOURCE = 'transaction_match'; const MATCH_PAYMENT_METHOD = 'transaction_match'; -function matchError(status, message, code, field = null) { - const err = new Error(message); +function matchError(status: number, message: string, code: string, field: string | null = null): Error { + const err = new Error(message) as any; err.status = status; err.code = code; err.field = field; return err; } -function normalizeId(value, field) { +function normalizeId(value: unknown, field: string): number { const id = typeof value === 'number' ? value : Number(value); if (!Number.isSafeInteger(id) || id <= 0) { throw matchError(400, `${field} must be a positive integer`, 'VALIDATION_ERROR', field); @@ -32,7 +36,7 @@ function normalizeId(value, field) { return id; } -function getOwnedTransaction(db, userId, transactionId) { +function getOwnedTransaction(db: Db, userId: number, transactionId: unknown): Row { const id = normalizeId(transactionId, 'transaction_id'); const transaction = getTransactionForUser(db, userId, id); if (!transaction) { @@ -41,7 +45,7 @@ function getOwnedTransaction(db, userId, transactionId) { return transaction; } -function getOwnedBill(db, userId, billId) { +function getOwnedBill(db: Db, userId: number, billId: unknown): Row { const id = normalizeId(billId, 'bill_id'); const bill = db.prepare(` SELECT * @@ -54,7 +58,7 @@ function getOwnedBill(db, userId, billId) { return bill; } -function paymentDateForTransaction(transaction) { +function paymentDateForTransaction(transaction: Row): string { const date = transaction.posted_date || String(transaction.transacted_at || '').slice(0, 10); if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { throw matchError( @@ -67,7 +71,7 @@ function paymentDateForTransaction(transaction) { return date; } -function paymentAmountForTransaction(transaction) { +function paymentAmountForTransaction(transaction: Row): number { const cents = Number(transaction.amount); if (!Number.isSafeInteger(cents) || cents === 0) { throw matchError( @@ -80,7 +84,7 @@ function paymentAmountForTransaction(transaction) { return Math.round(Math.abs(cents)); // tx.amount and payments.amount are both cents } -function getActivePaymentForTransaction(db, userId, transactionId) { +function getActivePaymentForTransaction(db: Db, userId: number, transactionId: number): Row | undefined { return db.prepare(` SELECT p.* FROM payments p @@ -94,7 +98,7 @@ function getActivePaymentForTransaction(db, userId, transactionId) { `).get(transactionId, userId); } -function getPaymentForResponse(db, userId, paymentId) { +function getPaymentForResponse(db: Db, userId: number, paymentId: number | bigint | null): Row | null { if (!paymentId) return null; return db.prepare(` SELECT p.* @@ -105,7 +109,7 @@ function getPaymentForResponse(db, userId, paymentId) { `).get(paymentId, userId) || null; } -function restorePaymentBalance(db, payment) { +function restorePaymentBalance(db: Db, payment: Row): void { if (!payment || payment.balance_delta == null) return; const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id); if (bill?.current_balance == null) return; @@ -122,14 +126,14 @@ function restorePaymentBalance(db, payment) { `).run(restored, payment.interest_delta != null ? 1 : 0, bill.id); } -function applyPaymentBalance(db, bill, amount) { +function applyPaymentBalance(db: Db, bill: Row, amount: number): { balance_delta: any; interest_delta: any } { const freshBill = db.prepare('SELECT * FROM bills WHERE id = ?').get(bill.id) || bill; const balCalc = computeBalanceDelta(freshBill, amount); applyBalanceDelta(db, bill.id, balCalc); return { balance_delta: balCalc?.balance_delta ?? null, interest_delta: balCalc?.interest_delta ?? null }; } -function updatePaymentBalanceDeltas(db, paymentId, deltas) { +function updatePaymentBalanceDeltas(db: Db, paymentId: number | bigint, deltas: { balance_delta: any; interest_delta: any }): void { db.prepare(` UPDATE payments SET balance_delta = ?, @@ -139,12 +143,12 @@ function updatePaymentBalanceDeltas(db, paymentId, deltas) { `).run(deltas.balance_delta, deltas.interest_delta, paymentId); } -function buildMatchPaymentNotes(transaction, bill) { +function buildMatchPaymentNotes(transaction: Row, bill: Row): string { const label = transaction.payee || transaction.description || `transaction ${transaction.id}`; return `Matched transaction to ${bill.name}: ${label}`.slice(0, 500); } -function createOrUpdateMatchPayment(db, userId, transaction, bill) { +function createOrUpdateMatchPayment(db: Db, userId: number, transaction: Row, bill: Row): number | bigint { const amount = paymentAmountForTransaction(transaction); const paidDate = paymentDateForTransaction(transaction); const notes = buildMatchPaymentNotes(transaction, bill); @@ -209,7 +213,7 @@ function createOrUpdateMatchPayment(db, userId, transaction, bill) { return result.lastInsertRowid; } -function unlinkPaymentForTransaction(db, userId, transactionId) { +function unlinkPaymentForTransaction(db: Db, userId: number, transactionId: number): Row | null { const existingPayment = getActivePaymentForTransaction(db, userId, transactionId); if (!existingPayment) return null; @@ -232,7 +236,7 @@ function unlinkPaymentForTransaction(db, userId, transactionId) { return { ...serializePayment(existingPayment), unlinked: true }; } -function responseForTransaction(db, userId, transactionId, paymentId = null, extra = {}) { +function responseForTransaction(db: Db, userId: number, transactionId: number, paymentId: number | bigint | null = null, extra: Row = {}) { return { success: true, transaction: decorateTransaction(getTransactionForUser(db, userId, transactionId)), @@ -245,8 +249,8 @@ function responseForTransaction(db, userId, transactionId, paymentId = null, ext // merchant→bill rule so future synced transactions from the same merchant // auto-match. Left false for background auto-matching to avoid compounding // a wrong auto-match into a permanent rule. -function matchTransactionToBill(userId, transactionId, billId, opts = {}) { - const db = getDb(); +function matchTransactionToBill(userId: number, transactionId: unknown, billId: unknown, opts: { learnMerchant?: boolean } = {}) { + const db: Db = getDb(); const tx = db.transaction(() => { const transaction = getOwnedTransaction(db, userId, transactionId); if (transaction.ignored || transaction.match_status === 'ignored') { @@ -269,8 +273,8 @@ function matchTransactionToBill(userId, transactionId, billId, opts = {}) { return tx(); } -function unmatchTransaction(userId, transactionId) { - const db = getDb(); +function unmatchTransaction(userId: number, transactionId: unknown) { + const db: Db = getDb(); const tx = db.transaction(() => { const transaction = getOwnedTransaction(db, userId, transactionId); const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id); @@ -283,8 +287,8 @@ function unmatchTransaction(userId, transactionId) { return tx(); } -function ignoreTransaction(userId, transactionId) { - const db = getDb(); +function ignoreTransaction(userId: number, transactionId: unknown) { + const db: Db = getDb(); const tx = db.transaction(() => { const transaction = getOwnedTransaction(db, userId, transactionId); const removedPayment = unlinkPaymentForTransaction(db, userId, transaction.id); @@ -297,8 +301,8 @@ function ignoreTransaction(userId, transactionId) { return tx(); } -function unignoreTransaction(userId, transactionId) { - const db = getDb(); +function unignoreTransaction(userId: number, transactionId: unknown) { + const db: Db = getDb(); const tx = db.transaction(() => { const transaction = getOwnedTransaction(db, userId, transactionId); diff --git a/services/transactionService.js b/services/transactionService.cts similarity index 85% rename from services/transactionService.js rename to services/transactionService.cts index ccc4768..cad57b2 100644 --- a/services/transactionService.js +++ b/services/transactionService.cts @@ -1,20 +1,24 @@ -const SOURCE_TYPE_LABELS = { +import type { Db } from '../types/db'; + +type Row = Record; + +const SOURCE_TYPE_LABELS: Record = { manual: 'Manual', file_import: 'File import', provider_sync: 'Provider sync', }; -function sourceTypeLabel(type) { - return SOURCE_TYPE_LABELS[type] || String(type || 'Unknown'); +function sourceTypeLabel(type: unknown): string { + return SOURCE_TYPE_LABELS[type as string] || String(type || 'Unknown'); } -function sourceLabel(source = {}) { +function sourceLabel(source: Row = {}): string { if (source.type === 'manual' || source.provider === 'manual') return source.name || 'Manual Entry'; if (source.name && source.provider) return `${source.name} (${source.provider})`; return source.name || source.provider || sourceTypeLabel(source.type); } -function decorateDataSource(row) { +function decorateDataSource(row: Row | null): Row | null { if (!row) return null; const safe = { ...row }; delete safe.encrypted_secret; @@ -25,7 +29,7 @@ function decorateDataSource(row) { }; } -function decorateTransaction(row) { +function decorateTransaction(row: Row | null): Row | null { if (!row) return null; const source = row.data_source_id ? { id: row.data_source_id, @@ -44,7 +48,7 @@ function decorateTransaction(row) { }; } -function ensureManualDataSource(db, userId) { +function ensureManualDataSource(db: Db, userId: number): Row { const existing = db.prepare(` SELECT * FROM data_sources @@ -71,7 +75,7 @@ function ensureManualDataSource(db, userId) { return db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(result.lastInsertRowid, userId); } -function getTransactionForUser(db, userId, id) { +function getTransactionForUser(db: Db, userId: number, id: number): Row | undefined { return db.prepare(` SELECT t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id, diff --git a/tests/calendarFeedService.test.js b/tests/calendarFeedService.test.js index 171603d..e66a7ac 100644 --- a/tests/calendarFeedService.test.js +++ b/tests/calendarFeedService.test.js @@ -15,7 +15,7 @@ const { escapeText, foldLine, rruleForCycle, -} = require('../services/calendarFeedService'); +} = require('../services/calendarFeedService.cts'); const { getDb, closeDb } = require('../db/database'); function createUser(db, username = 'calendar-user') { diff --git a/tests/snowballMath.test.js b/tests/snowballMath.test.js index 770649e..87493f2 100644 --- a/tests/snowballMath.test.js +++ b/tests/snowballMath.test.js @@ -5,7 +5,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { calculateSnowball, calculateAvalanche } = require('../services/snowballService'); +const { calculateSnowball, calculateAvalanche } = require('../services/snowballService.cts'); const { monthlyInterest, monthsToPayoff, amortizationSchedule, calculateMinimumOnly, debtAprSnapshot, diff --git a/tests/transactionMatchService.test.js b/tests/transactionMatchService.test.js index 1b36703..f07f36b 100644 --- a/tests/transactionMatchService.test.js +++ b/tests/transactionMatchService.test.js @@ -8,19 +8,19 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-transaction-match-test-${pro process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database'); -const { ensureManualDataSource } = require('../services/transactionService'); +const { ensureManualDataSource } = require('../services/transactionService.cts'); const { getTracker } = require('../services/trackerService'); const { listMatchSuggestions, rejectMatchSuggestion, suggestionId, -} = require('../services/matchSuggestionService'); +} = require('../services/matchSuggestionService.cts'); const { ignoreTransaction, matchTransactionToBill, unignoreTransaction, unmatchTransaction, -} = require('../services/transactionMatchService'); +} = require('../services/transactionMatchService.cts'); function createUser(db, suffix) { return db.prepare(`