refactor(server): migrate 6 mid-tier services to TypeScript (.cts)

transactionService, merchantStoreMatchService, snowballService,
transactionMatchService, calendarFeedService, matchSuggestionService.
calendarFeedService's request helpers use the shared http Req type.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-06 10:54:10 -05:00
parent a6c9b6cb1c
commit 3f891602f9
19 changed files with 200 additions and 158 deletions

View File

@ -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,

View File

@ -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');

View File

@ -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.

View File

@ -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');

View File

@ -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');

View File

@ -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');

View File

@ -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');

View File

@ -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) {

View File

@ -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<string, any>;
const PRODID = '-//Bill Tracker//Calendar Feed//EN';
const FEED_PAST_MONTHS = 12;
const FEED_FUTURE_MONTHS = 24;
const WEEKDAY_CODES = {
const WEEKDAY_CODES: Record<string, string> = {
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<string>();
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,

View File

@ -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;

View File

@ -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<string, any>;
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<string>): 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<string> {
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<string> {
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<string>();
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<string>): { 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) {

View File

@ -1,27 +1,31 @@
'use strict';
import type { Db } from '../types/db';
const { categorizeTransaction } = require('./spendingService.cts');
type Row = Record<string, any>;
// 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<string, number> = {
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<string, number>(); // category name -> count
const changes: any[] = [];
let updated = 0;
const apply = () => {

View File

@ -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,

View File

@ -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<string, any>;
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);
}

View File

@ -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<string, any>;
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);

View File

@ -1,20 +1,24 @@
const SOURCE_TYPE_LABELS = {
import type { Db } from '../types/db';
type Row = Record<string, any>;
const SOURCE_TYPE_LABELS: Record<string, string> = {
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,

View File

@ -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') {

View File

@ -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,

View File

@ -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(`