From 005b0b7b647c828fd02b089db175e6e8efcb2244 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 6 Jul 2026 10:35:29 -0500 Subject: [PATCH] refactor(server): migrate 10 services to TypeScript (.cts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues the incremental server TS migration (after utils/*.mts and paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript, run natively via Node type-stripping — no build step), type-checked by tsconfig.server.json. Migrated: totpService, amountSuggestionService, encryptionService, paymentAccountingService, statusService, aprService, driftService, analyticsService, spendingService, billMerchantRuleService. - types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib ships none), reused across the migrated modules. - Every require of a migrated service updated to the explicit .cts extension (extensionless require does not resolve .cts) across routes / services / db migrations / server.js / workers / tests — require-only changes. - package.json: check:server find pattern now includes *.cts (it silently skipped .cts before, so paymentValidation.cts had no node --check gate). Behavior-preserving (types only). Verified: typecheck:server 0, check:server 0, full server suite 226/226, real boot → /api/health 200. Co-Authored-By: Claude Opus 4.8 --- db/migrations/versionedMigrations.js | 8 +- package.json | 2 +- routes/admin.js | 2 +- routes/analytics.js | 2 +- routes/auth.js | 6 +- routes/bills.js | 10 +-- routes/calendar.js | 4 +- routes/categories.js | 2 +- routes/matches.js | 4 +- routes/monthly-starting-amounts.js | 4 +- routes/notifications.js | 4 +- routes/payments.js | 4 +- routes/profile.js | 2 +- routes/snowball.js | 2 +- routes/spending.js | 2 +- routes/status.js | 2 +- routes/subscriptions.js | 2 +- routes/summary.js | 4 +- routes/transactions.js | 4 +- server.js | 2 +- ...Service.js => amountSuggestionService.cts} | 46 +++++++---- ...alyticsService.js => analyticsService.cts} | 82 ++++++++++++------- services/{aprService.js => aprService.cts} | 64 ++++++++++----- services/authService.js | 4 +- services/bankSyncService.js | 6 +- ...Service.js => billMerchantRuleService.cts} | 56 +++++++------ services/calendarFeedService.js | 2 +- .../{driftService.js => driftService.cts} | 37 ++++++--- ...yptionService.js => encryptionService.cts} | 28 ++++--- services/matchSuggestionService.js | 2 +- services/merchantStoreMatchService.js | 2 +- services/notificationService.js | 8 +- services/oidcService.js | 2 +- ...ervice.js => paymentAccountingService.cts} | 28 ++++--- ...spendingService.js => spendingService.cts} | 72 +++++++++------- .../{statusService.js => statusService.cts} | 65 +++++++++------ services/{totpService.js => totpService.cts} | 28 ++++--- services/trackerService.js | 6 +- services/transactionMatchService.js | 4 +- tests/amountSuggestionService.test.js | 2 +- tests/analyticsService.test.js | 2 +- tests/bankSyncService.test.js | 2 +- tests/billMerchantRuleService.test.js | 2 +- tests/encryptionService.test.js | 2 +- tests/paymentAccountingService.test.js | 2 +- tests/reconciliation.test.js | 4 +- tests/snowballMath.test.js | 2 +- tests/spendingService.test.js | 2 +- tests/spendingSummary.test.js | 2 +- tests/statusService.test.js | 2 +- tests/totpService.test.js | 4 +- tests/transactionMatchService.test.js | 2 +- types/db.d.ts | 27 ++++++ workers/dailyWorker.js | 2 +- 54 files changed, 408 insertions(+), 265 deletions(-) rename services/{amountSuggestionService.js => amountSuggestionService.cts} (72%) rename services/{analyticsService.js => analyticsService.cts} (76%) rename services/{aprService.js => aprService.cts} (81%) rename services/{billMerchantRuleService.js => billMerchantRuleService.cts} (89%) rename services/{driftService.js => driftService.cts} (77%) rename services/{encryptionService.js => encryptionService.cts} (92%) rename services/{paymentAccountingService.js => paymentAccountingService.cts} (80%) rename services/{spendingService.js => spendingService.cts} (84%) rename services/{statusService.js => statusService.cts} (79%) rename services/{totpService.js => totpService.cts} (76%) create mode 100644 types/db.d.ts diff --git a/db/migrations/versionedMigrations.js b/db/migrations/versionedMigrations.js index 170ff68..68b3018 100644 --- a/db/migrations/versionedMigrations.js +++ b/db/migrations/versionedMigrations.js @@ -915,7 +915,7 @@ module.exports = function buildVersionedMigrations(deps) { dependsOn: ['v0.76'], run: function() { try { - const { decryptSecret, encryptSecret } = require('../../services/encryptionService'); + const { decryptSecret, encryptSecret } = require('../../services/encryptionService.cts'); const row = db.prepare("SELECT value FROM settings WHERE key = 'notify_smtp_password'").get(); if (row?.value) { try { @@ -937,7 +937,7 @@ module.exports = function buildVersionedMigrations(deps) { dependsOn: ['v0.77'], run: function() { try { - const { decryptSecret, encryptSecret } = require('../../services/encryptionService'); + const { decryptSecret, encryptSecret } = require('../../services/encryptionService.cts'); // Re-encrypt SimpleFIN tokens in data_sources const sources = db.prepare( @@ -973,7 +973,7 @@ module.exports = function buildVersionedMigrations(deps) { dependsOn: ['v0.78'], run: function() { try { - const { decryptSecret, encryptSecret } = require('../../services/encryptionService'); + const { decryptSecret, encryptSecret } = require('../../services/encryptionService.cts'); const row = db.prepare("SELECT value FROM settings WHERE key = 'oidc_client_secret'").get(); if (row?.value) { try { @@ -1046,7 +1046,7 @@ module.exports = function buildVersionedMigrations(deps) { description: 'user_login_history: encrypt ip/useragent at rest + add location + keep 10 records', dependsOn: ['v0.83'], run: function() { - const { encryptSecret, decryptSecret } = require('../../services/encryptionService'); + const { encryptSecret, decryptSecret } = require('../../services/encryptionService.cts'); const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name); // Add location columns diff --git a/package.json b/package.json index ea89e14..e0712c8 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev:ui": "vite", "dev": "concurrently \"npm run dev:api\" \"npm run dev:ui\"", "build": "vite build", - "check:server": "find server.js db middleware routes services utils workers \\( -name '*.js' -o -name '*.mts' -o -name '*.ts' \\) -print0 | xargs -0 -n1 node --check", + "check:server": "find server.js db middleware routes services utils workers \\( -name '*.js' -o -name '*.mts' -o -name '*.cts' -o -name '*.ts' \\) -print0 | xargs -0 -n1 node --check", "typecheck:server": "tsc --noEmit -p tsconfig.server.json", "lint": "eslint client", "typecheck": "tsc --noEmit -p tsconfig.json", diff --git a/routes/admin.js b/routes/admin.js index 6160841..2085b38 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -3,7 +3,7 @@ const router = express.Router(); const { getDb, rollbackMigration } = require('../db/database'); const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging } = require('../services/bankSyncConfigService'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker'); -const { isEnvKeyActive, keyFingerprint } = require('../services/encryptionService'); +const { isEnvKeyActive, keyFingerprint } = require('../services/encryptionService.cts'); const { hashPassword } = require('../services/authService'); const { logAudit } = require('../services/auditService'); const { diff --git a/routes/analytics.js b/routes/analytics.js index 8b66bb9..952fd44 100644 --- a/routes/analytics.js +++ b/routes/analytics.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); const { standardizeError } = require('../middleware/errorFormatter'); -const { getAnalyticsSummary } = require('../services/analyticsService'); +const { getAnalyticsSummary } = require('../services/analyticsService.cts'); router.get('/summary', (req, res) => { const result = getAnalyticsSummary(req.user.id, req.query); diff --git a/routes/auth.js b/routes/auth.js index 728982a..4ef3e6d 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -11,7 +11,7 @@ function getAppVersion() { const { getDb, getSetting, setSetting } = require('../db/database'); const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, SINGLE_COOKIE_NAME, rotateSessionId, invalidateOtherSessions, recordLogin, recordFailedLogin } = require('../services/authService'); -const { decryptSecret } = require('../services/encryptionService'); +const { decryptSecret } = require('../services/encryptionService.cts'); const { getCsrfToken } = require('../middleware/csrf'); const { requireAuth, requireAdmin } = require('../middleware/requireAuth'); const { getPublicOidcInfo } = require('../services/oidcService'); @@ -171,8 +171,8 @@ const { generateSecret, generateQrCode, verifyToken, verifyTokenRaw, generateRecoveryCodes, hashRecoveryCode, consumeRecoveryCode, createChallenge, consumeChallenge, -} = require('../services/totpService'); -const { encryptSecret: encTotpSecret } = require('../services/encryptionService'); +} = require('../services/totpService.cts'); +const { encryptSecret: encTotpSecret } = require('../services/encryptionService.cts'); // POST /api/auth/totp/challenge — second step of login when TOTP is enabled. // Takes challenge_token (from first login step) + totp_code, creates a session. diff --git a/routes/bills.js b/routes/bills.js index 88315d4..27bdb6a 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -12,16 +12,16 @@ const { computeBalanceDelta, applyBalanceDelta, } = require('../services/billsService'); -const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService'); +const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService.cts'); const { standardizeError } = require('../middleware/errorFormatter'); const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts'); -const { addMerchantRule, syncBillPaymentsFromSimplefin, merchantMatches } = require('../services/billMerchantRuleService'); +const { addMerchantRule, syncBillPaymentsFromSimplefin, merchantMatches } = require('../services/billMerchantRuleService.cts'); const { normalizeMerchant } = require('../services/subscriptionService'); const { decorateTransaction } = require('../services/transactionService'); const { accountingActiveSql, applyBankPaymentAsSourceOfTruth, -} = require('../services/paymentAccountingService'); +} = require('../services/paymentAccountingService.cts'); const { localDateString, todayLocal } = require('../utils/dates.mts'); const { roundMoney, sumMoney, toCents, fromCents } = require('../utils/money.mts'); @@ -131,7 +131,7 @@ router.get('/audit', (req, res) => { // ── GET /api/bills/drift-report ────────────────────────────────────────────── router.get('/drift-report', (req, res) => { - const { getDriftReport } = require('../services/driftService'); + const { getDriftReport } = require('../services/driftService.cts'); try { res.json(getDriftReport(req.user.id)); } catch (err) { @@ -1273,7 +1273,7 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => { const { normalizeMerchant: nm, ..._ } = { normalizeMerchant }; const rules2 = db.prepare('SELECT due_day FROM bills WHERE id = ?').get(billId); if (rules2?.due_day) { - const { lateAttributionCandidate } = require('../services/billMerchantRuleService'); + const { lateAttributionCandidate } = require('../services/billMerchantRuleService.cts'); // inline check const paid = new Date(paidDate + 'T00:00:00'); const dom = paid.getDate(); diff --git a/routes/calendar.js b/routes/calendar.js index 38ff2d5..8733fe7 100644 --- a/routes/calendar.js +++ b/routes/calendar.js @@ -2,9 +2,9 @@ const express = require('express'); const { standardizeError } = require('../middleware/errorFormatter'); const router = express.Router(); const { getDb } = require('../db/database'); -const { buildTrackerRow, getCycleRange } = require('../services/statusService'); +const { buildTrackerRow, getCycleRange } = require('../services/statusService.cts'); const { getUserSettings } = require('../services/userSettings'); -const { accountingActiveSql } = require('../services/paymentAccountingService'); +const { accountingActiveSql } = require('../services/paymentAccountingService.cts'); const { feedUrlForToken, getActiveToken, diff --git a/routes/categories.js b/routes/categories.js index 6f0f3c1..bd49835 100644 --- a/routes/categories.js +++ b/routes/categories.js @@ -2,7 +2,7 @@ const express = require('express'); const { standardizeError } = require('../middleware/errorFormatter'); const router = express.Router(); const { getDb, ensureUserDefaultCategories } = require('../db/database'); -const { accountingActiveSql } = require('../services/paymentAccountingService'); +const { accountingActiveSql } = require('../services/paymentAccountingService.cts'); // GET /api/categories router.get('/', (req, res) => { diff --git a/routes/matches.js b/routes/matches.js index 84f7ffc..cf52711 100644 --- a/routes/matches.js +++ b/routes/matches.js @@ -1,12 +1,12 @@ const router = require('express').Router(); const { standardizeError } = require('../middleware/errorFormatter'); const { getDb } = require('../db/database'); -const { applyBankPaymentAsSourceOfTruth, reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService'); +const { applyBankPaymentAsSourceOfTruth, reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService.cts'); const { listMatchSuggestions, rejectMatchSuggestion, } = require('../services/matchSuggestionService'); -const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService'); +const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService.cts'); const { markMatched, markUnmatched } = require('../services/transactionMatchState'); const { serializePayment } = require('../services/paymentValidation.cts'); const { todayLocal } = require('../utils/dates.mts'); diff --git a/routes/monthly-starting-amounts.js b/routes/monthly-starting-amounts.js index 9f50730..56ae44e 100644 --- a/routes/monthly-starting-amounts.js +++ b/routes/monthly-starting-amounts.js @@ -1,8 +1,8 @@ const express = require('express'); const router = express.Router(); const { getDb } = require('../db/database'); -const { getCycleRange } = require('../services/statusService'); -const { accountingActiveSql } = require('../services/paymentAccountingService'); +const { getCycleRange } = require('../services/statusService.cts'); +const { accountingActiveSql } = require('../services/paymentAccountingService.cts'); const { toCents, fromCents } = require('../utils/money.mts'); function parseYearMonth(source) { diff --git a/routes/notifications.js b/routes/notifications.js index 2f6464d..f0d67cf 100644 --- a/routes/notifications.js +++ b/routes/notifications.js @@ -4,8 +4,8 @@ const { getDb, getSetting, setSetting } = require('../db/database'); const { requireAuth, requireUser, requireAdmin } = require('../middleware/requireAuth'); const { sendTestEmail } = require('../services/notificationService'); const { sendTestPush } = require('../services/notificationService')._push || {}; -const { decryptSecret } = require('../services/encryptionService'); -const { encryptSecret } = require('../services/encryptionService'); +const { decryptSecret } = require('../services/encryptionService.cts'); +const { encryptSecret } = require('../services/encryptionService.cts'); // ── Admin: SMTP configuration ───────────────────────────────────────────────── diff --git a/routes/payments.js b/routes/payments.js index 6a8d12c..940787b 100644 --- a/routes/payments.js +++ b/routes/payments.js @@ -4,12 +4,12 @@ const router = require('express').Router(); const { getDb } = require('../db/database'); const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService'); const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts'); -const { getCycleRange, resolveDueDate } = require('../services/statusService'); +const { getCycleRange, resolveDueDate } = require('../services/statusService.cts'); const { markUnmatched } = require('../services/transactionMatchState'); const { markProvisionalManualPaymentsOverridden, reactivatePaymentsOverriddenBy, -} = require('../services/paymentAccountingService'); +} = require('../services/paymentAccountingService.cts'); const { todayLocal } = require('../utils/dates.mts'); const { fromCents } = require('../utils/money.mts'); diff --git a/routes/profile.js b/routes/profile.js index 2978ee2..cbbd9ef 100644 --- a/routes/profile.js +++ b/routes/profile.js @@ -10,7 +10,7 @@ const { hashPassword, invalidateOtherSessions, rotateSessionId, COOKIE_NAME, coo const { getImportHistory } = require('../services/spreadsheetImportService'); const { logAudit } = require('../services/auditService'); const { standardizeError } = require('../middleware/errorFormatter'); -const { encryptSecret, decryptSecret } = require('../services/encryptionService'); +const { encryptSecret, decryptSecret } = require('../services/encryptionService.cts'); // All profile routes require authentication — enforced in server.js. // req.user is always the signed-in user; user_id is never accepted from the body. diff --git a/routes/snowball.js b/routes/snowball.js index 7eb0df2..9b99454 100644 --- a/routes/snowball.js +++ b/routes/snowball.js @@ -3,7 +3,7 @@ const router = express.Router(); const { getDb } = require('../db/database'); const { standardizeError } = require('../middleware/errorFormatter'); const { calculateSnowball, calculateAvalanche } = require('../services/snowballService'); -const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService'); +const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService.cts'); const { serializeBill } = require('../services/billsService'); const { toCents, fromCents } = require('../utils/money.mts'); diff --git a/routes/spending.js b/routes/spending.js index ad23a4c..2ccf1bc 100644 --- a/routes/spending.js +++ b/routes/spending.js @@ -8,7 +8,7 @@ const { getSpendingBudgets, setSpendingBudget, getSpendingCategoryRules, addSpendingCategoryRule, deleteSpendingCategoryRule, getIncomeTransactions, -} = require('../services/spendingService'); +} = require('../services/spendingService.cts'); function parseYM(source) { const now = new Date(); diff --git a/routes/status.js b/routes/status.js index 0652f2f..c9763a9 100644 --- a/routes/status.js +++ b/routes/status.js @@ -9,7 +9,7 @@ const { getScheduleStatus } = require('../services/backupScheduler'); const { checkForUpdates } = require('../services/updateCheckService'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker'); const { getBankSyncConfig } = require('../services/bankSyncConfigService'); -const { accountingActiveSql } = require('../services/paymentAccountingService'); +const { accountingActiveSql } = require('../services/paymentAccountingService.cts'); const { localDateString } = require('../utils/dates.mts'); const startTime = Date.now(); diff --git a/routes/subscriptions.js b/routes/subscriptions.js index e2a43af..b3e66cd 100644 --- a/routes/subscriptions.js +++ b/routes/subscriptions.js @@ -14,7 +14,7 @@ const { recordSubscriptionFeedback, searchSubscriptionTransactions, } = require('../services/subscriptionService'); -const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService'); +const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService.cts'); router.get('/', (req, res) => { const db = getDb(); diff --git a/routes/summary.js b/routes/summary.js index 13c9c4d..2f38b2e 100644 --- a/routes/summary.js +++ b/routes/summary.js @@ -1,9 +1,9 @@ const express = require('express'); const router = express.Router(); const { getDb } = require('../db/database'); -const { getCycleRange, resolveDueDate } = require('../services/statusService'); +const { getCycleRange, resolveDueDate } = require('../services/statusService.cts'); const { getUserSettings } = require('../services/userSettings'); -const { accountingActiveSql } = require('../services/paymentAccountingService'); +const { accountingActiveSql } = require('../services/paymentAccountingService.cts'); const { toCents, fromCents } = require('../utils/money.mts'); const DEFAULT_INCOME_LABEL = 'Salary'; diff --git a/routes/transactions.js b/routes/transactions.js index 4a6f43f..0b061f4 100644 --- a/routes/transactions.js +++ b/routes/transactions.js @@ -15,13 +15,13 @@ const { unignoreTransaction, unmatchTransaction, } = require('../services/transactionMatchService'); -const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService'); +const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService.cts'); const { findMerchantMatch, applyMerchantStoreMatches, findOrCreateCategory, } = require('../services/merchantStoreMatchService'); -const { categorizeTransaction } = require('../services/spendingService'); +const { categorizeTransaction } = require('../services/spendingService.cts'); const { todayLocal } = require('../utils/dates.mts'); const { roundMoney } = require('../utils/money.mts'); diff --git a/server.js b/server.js index a8326f9..b838b7f 100644 --- a/server.js +++ b/server.js @@ -213,7 +213,7 @@ async function main() { const db = getDb(); // Migrate DB-key-encrypted secrets to env key when TOKEN_ENCRYPTION_KEY is set - const { reEncryptWithEnvKey } = require('./services/encryptionService'); + const { reEncryptWithEnvKey } = require('./services/encryptionService.cts'); try { reEncryptWithEnvKey(db); } catch (err) { diff --git a/services/amountSuggestionService.js b/services/amountSuggestionService.cts similarity index 72% rename from services/amountSuggestionService.js rename to services/amountSuggestionService.cts index 4a8de9f..2263796 100644 --- a/services/amountSuggestionService.js +++ b/services/amountSuggestionService.cts @@ -1,12 +1,24 @@ 'use strict'; -const { accountingActiveSql } = require('./paymentAccountingService'); -const { fromCents } = require('../utils/money.mts'); +import type { Dollars } from '../utils/money.mts'; +import type { Db } from '../types/db'; + +const { accountingActiveSql } = require('./paymentAccountingService.cts'); +// require(esm) of the branded money helpers; cast so fromCents keeps its +// Cents→Dollars signature (paymentValidation.cts uses the same pattern). +const { fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts'); + +interface PriorMonth { y: number; m: number; key: string } +interface AmountSuggestion { + suggestion: Dollars | null; + months_used: number; + confidence: 'high' | 'low'; +} // The 6 calendar months immediately preceding (year, month), newest first, each // as { y, m, key: 'YYYY-MM' }. -function priorMonths(year, month, count = 6) { - const list = []; +function priorMonths(year: number, month: number, count = 6): PriorMonth[] { + const list: PriorMonth[] = []; let y = year; let m = month; for (let i = 0; i < count; i++) { @@ -18,7 +30,7 @@ function priorMonths(year, month, count = 6) { } // Rolling median of a bill's monthly amounts → a suggestion object (or null). -function medianSuggestion(amounts) { +function medianSuggestion(amounts: number[]): AmountSuggestion | null { if (amounts.length === 0) return null; const sorted = [...amounts].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); @@ -37,8 +49,8 @@ function medianSuggestion(amounts) { * of the last 6 months of actual data. Prefers monthly_bill_state.actual_amount * (user-corrected values) over raw payment sums. */ -function computeAmountSuggestion(db, billId, year, month) { - const amounts = []; +function computeAmountSuggestion(db: Db, billId: number, year: number, month: number): AmountSuggestion | null { + const amounts: number[] = []; for (const { y, m } of priorMonths(year, month, 6)) { const mbs = db.prepare( @@ -72,8 +84,8 @@ function computeAmountSuggestion(db, billId, year, month) { * bill. Returns a Map. Behavior-identical to calling * computeAmountSuggestion per bill (guarded by amountSuggestionService.test.js). */ -function computeAmountSuggestionsBatch(db, billIds, year, month) { - const result = new Map(); +function computeAmountSuggestionsBatch(db: Db, billIds: number[], year: number, month: number): Map { + const result = new Map(); if (!billIds || billIds.length === 0) return result; const months = priorMonths(year, month, 6); @@ -88,7 +100,7 @@ function computeAmountSuggestionsBatch(db, billIds, year, month) { const placeholders = billIds.map(() => '?').join(','); // User-corrected monthly amounts. - const mbsMap = new Map(); // billId → { 'YYYY-MM': actual_amount } + const mbsMap = new Map>(); // billId → { 'YYYY-MM': actual_amount } const mbsRows = db.prepare(` SELECT bill_id, year, month, actual_amount FROM monthly_bill_state @@ -98,12 +110,13 @@ function computeAmountSuggestionsBatch(db, billIds, year, month) { if (r.actual_amount == null) continue; const key = `${r.year}-${String(r.month).padStart(2, '0')}`; if (!monthKeys.has(key)) continue; - if (!mbsMap.has(r.bill_id)) mbsMap.set(r.bill_id, {}); - mbsMap.get(r.bill_id)[key] = r.actual_amount; + let bucket = mbsMap.get(r.bill_id); + if (!bucket) { bucket = {}; mbsMap.set(r.bill_id, bucket); } + bucket[key] = r.actual_amount; } // Payment sums grouped by bill + month. - const payMap = new Map(); // billId → { 'YYYY-MM': total } + const payMap = new Map>(); // billId → { 'YYYY-MM': total } const payRows = db.prepare(` SELECT bill_id, strftime('%Y-%m', paid_date) AS ym, COALESCE(SUM(amount), 0) AS total FROM payments @@ -115,12 +128,13 @@ function computeAmountSuggestionsBatch(db, billIds, year, month) { `).all(...billIds, windowStart, windowEnd); for (const r of payRows) { if (!monthKeys.has(r.ym)) continue; - if (!payMap.has(r.bill_id)) payMap.set(r.bill_id, {}); - payMap.get(r.bill_id)[r.ym] = r.total; + let bucket = payMap.get(r.bill_id); + if (!bucket) { bucket = {}; payMap.set(r.bill_id, bucket); } + bucket[r.ym] = r.total; } for (const billId of billIds) { - const amounts = []; + const amounts: number[] = []; const mbsFor = mbsMap.get(billId) || {}; const payFor = payMap.get(billId) || {}; for (const { key } of months) { diff --git a/services/analyticsService.js b/services/analyticsService.cts similarity index 76% rename from services/analyticsService.js rename to services/analyticsService.cts index 61c9186..9ca4176 100644 --- a/services/analyticsService.js +++ b/services/analyticsService.cts @@ -1,21 +1,41 @@ 'use strict'; -const { getDb } = require('../db/database'); -const { accountingActiveSql } = require('./paymentAccountingService'); -const { sumMoney, fromCents } = require('../utils/money.mts'); -const { resolveDueDate } = require('./statusService'); +import type { Db } from '../types/db'; -function parseInteger(value, fallback) { +const { getDb } = require('../db/database'); +const { accountingActiveSql } = require('./paymentAccountingService.cts'); +// Aggregation output re-typed by the client; branding adds friction (Dollars|null +// through many .filter/.sort/arith) without much safety here → plain require (any). +const { sumMoney, fromCents } = require('../utils/money.mts'); +const { resolveDueDate } = require('./statusService.cts'); + +// Dynamic better-sqlite3 rows / request query. +type Row = Record; + +interface MonthInfo { year: number; month: number; key: string; label: string; start: string; end: string } +interface ParsedQuery { + year: number; + month: number; + months: number; + categoryId: number | null; + billId: number | null; + includeInactive: boolean; + includeSkipped: boolean; +} +interface QueryError { error: string } +interface BillWhereOpts { userId: number; categoryId: number | null; billId: number | null; includeInactive: boolean } + +function parseInteger(value: unknown, fallback: number | null): number | null { if (value === undefined || value === null || value === '') return fallback; const parsed = Number(value); return Number.isInteger(parsed) ? parsed : NaN; } -function monthKey(year, month) { +function monthKey(year: number, month: number): string { return `${year}-${String(month).padStart(2, '0')}`; } -function monthLabel(year, month) { +function monthLabel(year: number, month: number): string { return new Date(Date.UTC(year, month - 1, 1)).toLocaleString('en-US', { month: 'short', year: '2-digit', @@ -23,17 +43,17 @@ function monthLabel(year, month) { }); } -function addMonths(year, month, delta) { +function addMonths(year: number, month: number, delta: number): { year: number; month: number } { const date = new Date(Date.UTC(year, month - 1 + delta, 1)); return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 }; } -function monthEndDate(year, month) { +function monthEndDate(year: number, month: number): string { const day = new Date(Date.UTC(year, month, 0)).getUTCDate(); return `${monthKey(year, month)}-${String(day).padStart(2, '0')}`; } -function buildMonths(endYear, endMonth, count) { +function buildMonths(endYear: number, endMonth: number, count: number): MonthInfo[] { return Array.from({ length: count }, (_, index) => { const value = addMonths(endYear, endMonth, index - count + 1); return { @@ -46,7 +66,7 @@ function buildMonths(endYear, endMonth, count) { }); } -function validateSummaryQuery(query, now = new Date()) { +function validateSummaryQuery(query: Row, now: Date = new Date()): ParsedQuery | QueryError { const year = parseInteger(query.year, now.getFullYear()); const month = parseInteger(query.month, now.getMonth() + 1); const months = parseInteger(query.months, 12); @@ -55,13 +75,13 @@ function validateSummaryQuery(query, now = new Date()) { const includeInactive = query.include_inactive === 'true'; const includeSkipped = query.include_skipped !== 'false'; - if (!Number.isInteger(year) || year < 2000 || year > 2100) { + if (!Number.isInteger(year) || year! < 2000 || year! > 2100) { return { error: 'year must be a 4-digit integer between 2000 and 2100' }; } - if (!Number.isInteger(month) || month < 1 || month > 12) { + if (!Number.isInteger(month) || month! < 1 || month! > 12) { return { error: 'month must be an integer between 1 and 12' }; } - if (!Number.isInteger(months) || months < 1 || months > 36) { + if (!Number.isInteger(months) || months! < 1 || months! > 36) { return { error: 'months must be an integer between 1 and 36' }; } if (categoryId !== null && (!Number.isInteger(categoryId) || categoryId < 1)) { @@ -71,19 +91,19 @@ function validateSummaryQuery(query, now = new Date()) { return { error: 'bill_id must be a positive integer' }; } - return { year, month, months, categoryId, billId, includeInactive, includeSkipped }; + return { year, month, months, categoryId, billId, includeInactive, includeSkipped } as ParsedQuery; } -function isMonthInPast(year, month) { +function isMonthInPast(year: number, month: number): boolean { const now = new Date(); const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); const targetMonthStart = new Date(year, month - 1, 1); return targetMonthStart < currentMonthStart; } -function buildBillWhere({ userId, categoryId, billId, includeInactive }) { +function buildBillWhere({ userId, categoryId, billId, includeInactive }: BillWhereOpts): { where: string; params: any[] } { const clauses = ['b.user_id = ?', 'b.deleted_at IS NULL']; - const params = [userId]; + const params: any[] = [userId]; if (!includeInactive) clauses.push('b.active = 1'); if (categoryId) { clauses.push('b.category_id = ?'); @@ -96,7 +116,7 @@ function buildBillWhere({ userId, categoryId, billId, includeInactive }) { return { where: clauses.join(' AND '), params }; } -function emptySummary(parsed, rangeMonths, startDate, endDate, categories) { +function emptySummary(parsed: ParsedQuery, rangeMonths: MonthInfo[], startDate: string, endDate: string, categories: any[]) { return { range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate }, filters: { @@ -115,11 +135,11 @@ function emptySummary(parsed, rangeMonths, startDate, endDate, categories) { }; } -function getAnalyticsSummary(userId, query = {}) { +function getAnalyticsSummary(userId: number, query: Row = {}) { const parsed = validateSummaryQuery(query); - if (parsed.error) return parsed; + if ('error' in parsed) return parsed; - const db = getDb(); + const db: Db = getDb(); const rangeMonths = buildMonths(parsed.year, parsed.month, parsed.months); const startDate = rangeMonths[0].start; const endDate = rangeMonths[rangeMonths.length - 1].end; @@ -147,7 +167,7 @@ function getAnalyticsSummary(userId, query = {}) { return emptySummary(parsed, rangeMonths, startDate, endDate, categories); } - const billIds = bills.map(b => b.id); + const billIds = bills.map((b: Row) => b.id); const placeholders = billIds.map(() => '?').join(','); const paymentRows = db.prepare(` @@ -180,13 +200,13 @@ function getAnalyticsSummary(userId, query = {}) { rangeMonths[rangeMonths.length - 1].year * 100 + rangeMonths[rangeMonths.length - 1].month, ); - const paymentByBillMonth = new Map(paymentRows.map(row => [`${row.bill_id}:${row.month_key}`, Number(row.total) || 0])); - const stateByBillMonth = new Map(stateRows.map(row => [`${row.bill_id}:${monthKey(row.year, row.month)}`, row])); + const paymentByBillMonth = new Map(paymentRows.map((row: Row) => [`${row.bill_id}:${row.month_key}`, Number(row.total) || 0])); + const stateByBillMonth = new Map(stateRows.map((row: Row) => [`${row.bill_id}:${monthKey(row.year, row.month)}`, row])); const monthly_spending = rangeMonths.map(m => { - const total = sumMoney(bills, bill => paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0); + const total = sumMoney(bills, (bill: Row) => paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0); return { month: m.key, label: m.label, total: fromCents(total) }; - }).filter(row => row.total > 0); + }).filter((row: Row) => row.total > 0); const expected_vs_actual = rangeMonths.map(m => { const [mYear, mMonth] = m.key.split('-').map(Number); @@ -214,9 +234,9 @@ function getAnalyticsSummary(userId, query = {}) { actual: fromCents(actual), skipped_count, }; - }).filter(row => row.expected > 0 || row.actual > 0 || row.skipped_count > 0); + }).filter((row: Row) => row.expected > 0 || row.actual > 0 || row.skipped_count > 0); - const categoryMap = new Map(); + const categoryMap = new Map(); for (const bill of bills) { const categoryId = bill.category_id || null; const key = categoryId == null ? 'uncategorized' : String(categoryId); @@ -235,7 +255,7 @@ function getAnalyticsSummary(userId, query = {}) { .filter(row => row.total > 0) .sort((a, b) => b.total - a.total); - const heatmapRows = bills.map(bill => { + const heatmapRows = bills.map((bill: Row) => { const cells = rangeMonths.map(m => { const paid = (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0) > 0; const state = stateByBillMonth.get(`${bill.id}:${m.key}`); @@ -269,7 +289,7 @@ function getAnalyticsSummary(userId, query = {}) { include_skipped: parsed.includeSkipped, }, categories, - bills: bills.map(b => ({ + bills: bills.map((b: Row) => ({ id: b.id, name: b.name, category_id: b.category_id, diff --git a/services/aprService.js b/services/aprService.cts similarity index 81% rename from services/aprService.js rename to services/aprService.cts index f8767c8..7519914 100644 --- a/services/aprService.js +++ b/services/aprService.cts @@ -1,10 +1,38 @@ +'use strict'; -const { roundMoney, sumMoney } = require('../utils/money.mts'); +import type { Dollars } from '../utils/money.mts'; + +const { roundMoney, sumMoney } = require('../utils/money.mts') as typeof import('../utils/money.mts'); /** * APR / amortization mathematics. * All functions are pure — no DB access, no side effects. */ +// Dynamic debt/bill rows — columns read individually. +type Row = Record; + +interface ScheduleRow { month: number; payment: number; principal: number; interest: number; balance: number } +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; +} + const MAX_MONTHS = 600; // 50-year simulation cap // ── Primitives ──────────────────────────────────────────────────────────────── @@ -12,7 +40,7 @@ const MAX_MONTHS = 600; // 50-year simulation cap /** * One month of interest accrued on a balance at an annual rate. */ -function monthlyInterest(balance, annualRatePct) { +function monthlyInterest(balance: number, annualRatePct: number): number { return Math.max(0, Number(balance) || 0) * (Math.max(0, Number(annualRatePct) || 0) / 100 / 12); } @@ -21,7 +49,7 @@ function monthlyInterest(balance, annualRatePct) { * Returns null if the payment never covers the interest (debt grows forever) * or if the balance/payment are invalid. */ -function monthsToPayoff(balance, annualRatePct, monthlyPayment) { +function monthsToPayoff(balance: number, annualRatePct: number, monthlyPayment: number): number | null { const rate = Math.max(0, Number(annualRatePct) || 0) / 100 / 12; let bal = Number(balance); const pmt = Number(monthlyPayment); @@ -42,13 +70,8 @@ function monthsToPayoff(balance, annualRatePct, monthlyPayment) { /** * Full month-by-month amortization schedule for a single debt. * Each row: { month, payment, principal, interest, balance } - * - * @param {number} balance - * @param {number} annualRatePct - * @param {number} monthlyPayment - * @param {number} [maxMonths=360] Hard cap (prevents huge payloads) */ -function amortizationSchedule(balance, annualRatePct, monthlyPayment, maxMonths = 360) { +function amortizationSchedule(balance: number, annualRatePct: number, monthlyPayment: number, maxMonths = 360): ScheduleRow[] { const rate = Math.max(0, Number(annualRatePct) || 0) / 100 / 12; let bal = Number(balance); const pmt = Number(monthlyPayment); @@ -57,7 +80,7 @@ function amortizationSchedule(balance, annualRatePct, monthlyPayment, maxMonths if (!Number.isFinite(bal) || bal <= 0 || !Number.isFinite(pmt) || pmt <= 0) return []; if (rate > 0 && pmt <= bal * rate) return []; - const schedule = []; + const schedule: ScheduleRow[] = []; let month = 0; while (bal > 0.005 && month < cap) { @@ -82,9 +105,9 @@ function amortizationSchedule(balance, annualRatePct, monthlyPayment, maxMonths * * Returns the same shape as calculateSnowball so callers can compare directly. */ -function calculateMinimumOnly(debts, startDate = new Date()) { - const active = []; - const skipped = []; +function calculateMinimumOnly(debts: Row[], startDate: Date = new Date()): DebtProjection { + const active: ActiveDebt[] = []; + const skipped: SkippedDebt[] = []; for (const d of debts) { const bal = Number(d.current_balance); @@ -150,11 +173,11 @@ function calculateMinimumOnly(debts, startDate = new Date()) { 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 { return new Date(baseYear, baseMo + m, 1) .toLocaleDateString('en-US', { year: 'numeric', month: 'long' }); } @@ -192,10 +215,13 @@ function calculateMinimumOnly(debts, startDate = new Date()) { /** * Computes the current APR breakdown for a single debt based on its present balance. * Returns the metrics needed to understand how expensive the debt is right now. - * - * @param {object} bill Requires: current_balance, interest_rate, minimum_payment */ -function debtAprSnapshot(bill) { +function debtAprSnapshot(bill: Row): { + monthly_interest: number; + principal_per_min_pmt: number; + interest_pct_of_min: number | null; + annual_interest_estimate: number; +} | null { const balance = Number(bill.current_balance); const apr = Number(bill.interest_rate) || 0; const min = Number(bill.minimum_payment) || 0; @@ -217,7 +243,7 @@ function debtAprSnapshot(bill) { // ── Helpers ─────────────────────────────────────────────────────────────────── -function round2(n) { +function round2(n: number): Dollars { return roundMoney(n); } diff --git a/services/authService.js b/services/authService.js index addff6a..683f781 100644 --- a/services/authService.js +++ b/services/authService.js @@ -2,7 +2,7 @@ const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const { getDb } = require('../db/database'); const { buildDeviceFingerprint } = require('./loginFingerprint'); -const { encryptSecret, decryptSecret } = require('./encryptionService'); +const { encryptSecret, decryptSecret } = require('./encryptionService.cts'); // Store SHA-256(token) in the DB; the raw token stays only in the cookie. function hashSession(token) { @@ -75,7 +75,7 @@ async function login(username, password) { // TOTP is enabled — don't create a session yet; issue a short-lived challenge instead if (user.totp_enabled) { - const { createChallenge } = require('./totpService'); + const { createChallenge } = require('./totpService.cts'); const challengeToken = createChallenge(getDb(), user.id); return { requires_totp: true, challenge_token: challengeToken }; } diff --git a/services/bankSyncService.js b/services/bankSyncService.js index 9f65902..2ff885a 100644 --- a/services/bankSyncService.js +++ b/services/bankSyncService.js @@ -1,6 +1,6 @@ 'use strict'; -const { assertEncryptionReady, encryptSecret, decryptSecret } = require('./encryptionService'); +const { assertEncryptionReady, encryptSecret, decryptSecret } = require('./encryptionService.cts'); const { claimSetupToken, fetchAccountsAndTransactions, @@ -10,8 +10,8 @@ const { } = require('./simplefinService'); const { getBankSyncConfig, SYNC_DAYS_EFFECTIVE, SYNC_DAYS_DEFAULT } = require('./bankSyncConfigService'); const { decorateDataSource } = require('./transactionService'); -const { applyMerchantRules } = require('./billMerchantRuleService'); -const { applySpendingCategoryRules } = require('./spendingService'); +const { applyMerchantRules } = require('./billMerchantRuleService.cts'); +const { applySpendingCategoryRules } = require('./spendingService.cts'); const { applyMerchantStoreMatches } = require('./merchantStoreMatchService'); const { autoMatchForUser } = require('./matchSuggestionService'); const { getUserSettings } = require('./userSettings'); diff --git a/services/billMerchantRuleService.js b/services/billMerchantRuleService.cts similarity index 89% rename from services/billMerchantRuleService.js rename to services/billMerchantRuleService.cts index 6bb8d0d..6eb4343 100644 --- a/services/billMerchantRuleService.js +++ b/services/billMerchantRuleService.cts @@ -1,19 +1,21 @@ 'use strict'; +import type { Db } from '../types/db'; + const { normalizeMerchant } = require('./subscriptionService'); const { getUserSettings } = require('./userSettings'); -const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService'); -const { localDateString } = require('../utils/dates.mts'); -const { fromCents } = require('../utils/money.mts'); +const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService.cts'); +const { localDateString } = require('../utils/dates.mts') as typeof import('../utils/dates.mts'); +const { fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts'); // Word-boundary merchant match — requires the rule to appear as complete word(s) // within the transaction string (or vice versa), not just as a substring. // Prevents "suno" matching "sunoco", "prime" matching "prime video", etc. -function merchantMatches(txMerchant, ruleMerchant) { +function merchantMatches(txMerchant: string, ruleMerchant: string): boolean { if (!txMerchant || !ruleMerchant) return false; if (txMerchant === ruleMerchant) return true; - const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const wordBoundary = s => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`); + const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const wordBoundary = (s: string) => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`); return wordBoundary(ruleMerchant).test(txMerchant) || wordBoundary(txMerchant).test(ruleMerchant); } @@ -21,7 +23,7 @@ function merchantMatches(txMerchant, ruleMerchant) { // Detects when a payment posted just after month end but the bill was due in the // prior month. Grace window: up to `graceDays` days into the new month. // Module-scoped so both applyMerchantRules and syncBillPaymentsFromSimplefin can use it. -function lateAttributionCandidate(paidDateStr, dueDayOfMonth, graceDays = 5) { +function lateAttributionCandidate(paidDateStr: string, dueDayOfMonth: number, graceDays = 5): string | null { const paid = new Date(paidDateStr + 'T00:00:00'); const dayOfMonth = paid.getDate(); if (dayOfMonth > graceDays) return null; @@ -31,7 +33,7 @@ function lateAttributionCandidate(paidDateStr, dueDayOfMonth, graceDays = 5) { } // Persist a merchant→bill rule so future synced transactions auto-match. -function addMerchantRule(db, userId, billId, merchant) { +function addMerchantRule(db: Db, userId: number, billId: number, merchant: string): void { const normalized = normalizeMerchant(merchant); if (!normalized || normalized.length < 3) return; try { @@ -57,14 +59,14 @@ const GENERIC_MERCHANT_TOKENS = new Set([ // Derive a specific, reusable merchant string from a confirmed transaction match. // Returns null when the text is too generic to be a safe auto-match rule. -function learnableMerchantFromTransaction(transaction) { +function learnableMerchantFromTransaction(transaction: any): string | null { if (!transaction) return null; const raw = transaction.payee || transaction.description || ''; const norm = normalizeMerchant(raw); if (!norm || norm.length < 4) return null; const tokens = norm.split(' ').filter(Boolean); // Require at least one meaningful (non-generic, length >= 3) token. - const meaningful = tokens.filter(t => t.length >= 3 && !GENERIC_MERCHANT_TOKENS.has(t)); + const meaningful = tokens.filter((t: string) => t.length >= 3 && !GENERIC_MERCHANT_TOKENS.has(t)); if (meaningful.length === 0) return null; return norm; } @@ -72,7 +74,7 @@ function learnableMerchantFromTransaction(transaction) { // Learn a merchant→bill rule from an explicit user confirmation so future synced // transactions from the same merchant auto-match. Best-effort and idempotent — // never throws, returns the merchant it stored (or null when nothing was learned). -function learnMerchantRuleFromMatch(db, userId, billId, transaction) { +function learnMerchantRuleFromMatch(db: Db, userId: number, billId: number, transaction: any): string | null { try { const merchant = learnableMerchantFromTransaction(transaction); if (!merchant) return null; @@ -86,8 +88,8 @@ function learnMerchantRuleFromMatch(db, userId, billId, transaction) { // Scan all unmatched negative transactions for this user, apply any stored // merchant rules, create payments, and mark the transactions matched. // Returns { matched: number }. -function applyMerchantRules(db, userId) { - let rules; +function applyMerchantRules(db: Db, userId: number): { matched: number; matched_bills?: string[]; late_attributions?: any[] } { + let rules: any[]; try { rules = db.prepare(` SELECT bmr.bill_id, bmr.merchant, bmr.auto_attribute_late, b.name AS bill_name, b.due_day @@ -104,7 +106,7 @@ function applyMerchantRules(db, userId) { // Longer (more specific) rules win when multiple could match the same transaction rules.sort((a, b) => b.merchant.length - a.merchant.length); - let txRows; + let txRows: any[]; try { txRows = db.prepare(` SELECT t.id, t.amount, t.payee, t.description, t.memo, t.posted_date, t.transacted_at @@ -117,7 +119,7 @@ function applyMerchantRules(db, userId) { AND t.pending = 0 AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1) `).all(userId); - } catch (err) { + } catch (err: any) { console.error('[applyMerchantRules] Failed to fetch transactions:', err.message); return { matched: 0, matched_bills: [] }; } @@ -125,7 +127,7 @@ function applyMerchantRules(db, userId) { if (txRows.length === 0) return { matched: 0, matched_bills: [] }; // Global grace window — auto-apply late attribution for ALL bills within this many days - const userSettings = (() => { try { return getUserSettings(userId); } catch { return {}; } })(); + const userSettings: any = (() => { try { return getUserSettings(userId); } catch { return {}; } })(); const globalGraceDays = parseInt(userSettings.bank_late_attribution_days, 10) || 0; const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL'); @@ -140,8 +142,8 @@ function applyMerchantRules(db, userId) { `); let matched = 0; - const matchedBills = new Map(); // bill_id → bill_name for the summary - const lateAttributions = []; // payments that crossed a month boundary + const matchedBills = new Map(); // bill_id → bill_name for the summary + const lateAttributions: any[] = []; // payments that crossed a month boundary try { db.transaction(() => { @@ -211,7 +213,7 @@ function applyMerchantRules(db, userId) { } } })(); - } catch (err) { + } catch (err: any) { console.error('[applyMerchantRules] Transaction failed, no payments recorded:', err.message); return { matched: 0, matched_bills: [], late_attributions: [] }; } @@ -223,15 +225,15 @@ function applyMerchantRules(db, userId) { // merchant rules. If no rule exists yet but the bill has a detected merchant in // its notes, the rule is created on the fly. // Returns { added: number }. -function syncBillPaymentsFromSimplefin(db, userId, billId) { +function syncBillPaymentsFromSimplefin(db: Db, userId: number, billId: number): { added: number; late_attributions?: any[] } { // Load rules for this specific bill - let rules; + let rules: any[]; try { rules = db.prepare(` SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ? ORDER BY LENGTH(merchant) DESC - `).all(userId, billId).map(r => r.merchant); + `).all(userId, billId).map((r: any) => r.merchant); } catch { return { added: 0 }; } @@ -248,13 +250,13 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { rules = [extracted]; } } - } catch (err) { + } catch (err: any) { console.error('[syncBillPaymentsFromSimplefin] Failed to read bill notes:', err.message); } if (rules.length === 0) return { added: 0 }; } - let txRows; + let txRows: any[]; try { txRows = db.prepare(` SELECT t.id, t.amount, t.payee, t.description, t.memo, t.posted_date, t.transacted_at @@ -267,7 +269,7 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { AND t.pending = 0 AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1) `).all(userId); - } catch (err) { + } catch (err: any) { console.error('[syncBillPaymentsFromSimplefin] Failed to fetch transactions:', err.message); return { added: 0 }; } @@ -288,7 +290,7 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { const getPaymentId = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL'); let added = 0; - const lateAttributions = []; + const lateAttributions: any[] = []; try { db.transaction(() => { for (const tx of txRows) { @@ -340,7 +342,7 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) { } } })(); - } catch (err) { + } catch (err: any) { console.error('[syncBillPaymentsFromSimplefin] Transaction failed, no payments recorded:', err.message); return { added: 0, late_attributions: [] }; } diff --git a/services/calendarFeedService.js b/services/calendarFeedService.js index 940c034..d3cb8de 100644 --- a/services/calendarFeedService.js +++ b/services/calendarFeedService.js @@ -2,7 +2,7 @@ const crypto = require('crypto'); const { getDb } = require('../db/database'); -const { normalizeCycleType, resolveDueDate } = require('./statusService'); +const { normalizeCycleType, resolveDueDate } = require('./statusService.cts'); const { fromCents } = require('../utils/money.mts'); const PRODID = '-//Bill Tracker//Calendar Feed//EN'; diff --git a/services/driftService.js b/services/driftService.cts similarity index 77% rename from services/driftService.js rename to services/driftService.cts index 62a3638..19b936e 100644 --- a/services/driftService.js +++ b/services/driftService.cts @@ -1,17 +1,32 @@ 'use strict'; +import type { Dollars } from '../utils/money.mts'; + const { getDb } = require('../db/database'); -const { getCycleRange } = require('./statusService'); -const { accountingActiveSql } = require('./paymentAccountingService'); +const { getCycleRange } = require('./statusService.cts'); +const { accountingActiveSql } = require('./paymentAccountingService.cts'); const { getUserSettings } = require('./userSettings'); -const { localDateString } = require('../utils/dates.mts'); -const { roundMoney, fromCents } = require('../utils/money.mts'); +const { localDateString } = require('../utils/dates.mts') as typeof import('../utils/dates.mts'); +const { roundMoney, fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts'); const MONTHS_BACK = 3; const MIN_PAID_MONTHS = 2; const MIN_ABS_DELTA = 1.00; -function median(arr) { +interface DriftBillRow { + id: any; + name: any; + category_name: any; + expected_amount: Dollars; + recent_amount: Dollars; + drift_pct: number; + direction: 'up' | 'down'; + months_sampled: number; + drift_snoozed_until: any; +} +interface DriftReport { bills: DriftBillRow[]; threshold_pct: number; error?: string } + +function median(arr: number[]): number { if (!arr.length) return 0; const sorted = [...arr].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); @@ -20,11 +35,11 @@ function median(arr) { : (sorted[mid - 1] + sorted[mid]) / 2; } -function monthEnd(year, month) { +function monthEnd(year: number, month: number): number { return new Date(Date.UTC(year, month, 0)).getUTCDate(); } -function getDriftReport(userId, now = new Date()) { +function getDriftReport(userId: number, now: Date = new Date()): DriftReport { try { const db = getDb(); const settings = getUserSettings(userId); @@ -40,7 +55,7 @@ function getDriftReport(userId, now = new Date()) { `).all(userId); const todayStr = localDateString(now); - const drifted = []; + const drifted: DriftBillRow[] = []; const mbsStmt = db.prepare( 'SELECT is_skipped FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?' @@ -57,7 +72,7 @@ function getDriftReport(userId, now = new Date()) { if (!expectedAmount || expectedAmount <= 0) continue; if (bill.drift_snoozed_until && bill.drift_snoozed_until > todayStr) continue; - const monthTotals = []; + const monthTotals: number[] = []; for (let i = 1; i <= MONTHS_BACK; i++) { const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1)); @@ -75,7 +90,7 @@ function getDriftReport(userId, now = new Date()) { if (!range) continue; const { total } = payStmt.get(bill.id, range.start, range.end); - if (total > 0) monthTotals.push(fromCents(total)); + if (total > 0) monthTotals.push(fromCents(total)!); // total>0 ⇒ fromCents non-null } if (monthTotals.length < MIN_PAID_MONTHS) continue; @@ -102,7 +117,7 @@ function getDriftReport(userId, now = new Date()) { } return { bills: drifted, threshold_pct: thresholdPct }; - } catch (err) { + } catch (err: any) { console.error('[driftService] getDriftReport error:', err.message); return { bills: [], threshold_pct: 5, error: err.message }; } diff --git a/services/encryptionService.js b/services/encryptionService.cts similarity index 92% rename from services/encryptionService.js rename to services/encryptionService.cts index fe4250b..7e5ae68 100644 --- a/services/encryptionService.js +++ b/services/encryptionService.cts @@ -1,5 +1,7 @@ 'use strict'; +import type { Db } from '../types/db'; + const crypto = require('crypto'); const ALGORITHM = 'aes-256-gcm'; @@ -16,13 +18,13 @@ const ENV_PREFIX = 'e2:'; const V2_PREFIX = 'v2:'; // Returns the env-provided IKM, or null if the var is not set. -function getEnvIkm() { +function getEnvIkm(): Buffer | null { const k = process.env.TOKEN_ENCRYPTION_KEY?.trim(); return k ? Buffer.from(k, 'utf8') : null; } // Returns the auto-generated DB IKM, creating it on first call. -function getDbIkm() { +function getDbIkm(): Buffer { const { getSetting, setSetting } = require('../db/database'); let stored = getSetting('_auto_encryption_key'); if (!stored) { @@ -32,17 +34,17 @@ function getDbIkm() { return Buffer.from(stored, 'utf8'); } -function deriveKey(ikm) { +function deriveKey(ikm: Buffer): Buffer { return Buffer.from(crypto.hkdfSync('sha256', ikm, /* salt */ '', HKDF_INFO, 32)); } // Legacy derivation — used only to decrypt pre-v0.78 ciphertext (no prefix). -function deriveLegacyKey(ikm) { +function deriveLegacyKey(ikm: Buffer): Buffer { return crypto.createHash('sha256').update(ikm).digest(); } // True when TOKEN_ENCRYPTION_KEY is present in the environment. -function isEnvKeyActive() { +function isEnvKeyActive(): boolean { return !!process.env.TOKEN_ENCRYPTION_KEY?.trim(); } @@ -52,7 +54,7 @@ function isEnvKeyActive() { // with the cipher key derivation, and it never force-creates a key (returns null // when none exists yet). A 16-hex prefix of SHA-256 over a 48-byte random key is // not reversible or guessable. -function keyFingerprint() { +function keyFingerprint(): string | null { let ikm = getEnvIkm(); if (!ikm) { const { getSetting } = require('../db/database'); @@ -68,11 +70,11 @@ function keyFingerprint() { } // No-op kept for call-site compatibility. -function assertEncryptionReady() {} +function assertEncryptionReady(): void {} // Encrypts with the env key (e2: prefix) when TOKEN_ENCRYPTION_KEY is set, // otherwise with the DB key (v2: prefix). -function encryptSecret(plaintext) { +function encryptSecret(plaintext: string): string { const envIkm = getEnvIkm(); const ikm = envIkm ?? getDbIkm(); const prefix = envIkm ? ENV_PREFIX : V2_PREFIX; @@ -85,7 +87,7 @@ function encryptSecret(plaintext) { } // Decrypts both e2: (env key), v2: (db key HKDF), and legacy (db key SHA-256) ciphertext. -function decryptSecret(stored) { +function decryptSecret(stored: string): string { const isEnv = stored.startsWith(ENV_PREFIX); const isV2 = !isEnv && stored.startsWith(V2_PREFIX); const payload = stored.slice(isEnv ? ENV_PREFIX.length : isV2 ? V2_PREFIX.length : 0); @@ -94,7 +96,7 @@ function decryptSecret(stored) { if (parts.length !== 3) throw new Error('Invalid encrypted secret format'); const [ivHex, tagHex, ctHex] = parts; - let key; + let key: Buffer; if (isEnv) { const envIkm = getEnvIkm(); if (!envIkm) throw new Error('TOKEN_ENCRYPTION_KEY is required to decrypt this secret but is not set'); @@ -116,7 +118,7 @@ function decryptSecret(stored) { // Re-encrypts all DB-key-encrypted secrets with the env key. // Called at startup when TOKEN_ENCRYPTION_KEY is present — idempotent. // Already-migrated (e2:) values are skipped. -function reEncryptWithEnvKey(db) { +function reEncryptWithEnvKey(db: Db): void { if (!isEnvKeyActive()) return; // All table+column pairs that store encrypted values. @@ -136,7 +138,7 @@ function reEncryptWithEnvKey(db) { ]; const SETTINGS_KEYS = ['notify_smtp_password', 'oidc_client_secret']; - function reEnc(val) { + function reEnc(val: string | null): string | null { if (!val || val.startsWith(ENV_PREFIX)) return val; // null or already migrated try { return encryptSecret(decryptSecret(val)); // decrypt with old key, encrypt with new @@ -150,7 +152,7 @@ function reEncryptWithEnvKey(db) { db.transaction(() => { for (const { table, col } of TABLE_COLS) { // Guard against schema differences across upgrade paths - const existing = db.prepare(`PRAGMA table_info(${table})`).all().map(r => r.name); + const existing = db.prepare(`PRAGMA table_info(${table})`).all().map((r) => r.name); if (!existing.includes(col)) continue; const rows = db.prepare(`SELECT id, ${col} FROM ${table} WHERE ${col} IS NOT NULL`).all(); diff --git a/services/matchSuggestionService.js b/services/matchSuggestionService.js index 67dfb0d..42e6193 100644 --- a/services/matchSuggestionService.js +++ b/services/matchSuggestionService.js @@ -1,7 +1,7 @@ 'use strict'; const { getDb } = require('../db/database'); -const { getCycleRange, resolveDueDate } = require('./statusService'); +const { getCycleRange, resolveDueDate } = require('./statusService.cts'); const { decorateTransaction } = require('./transactionService'); const { fromCents } = require('../utils/money.mts'); diff --git a/services/merchantStoreMatchService.js b/services/merchantStoreMatchService.js index 2f2f4d7..33dbb2e 100644 --- a/services/merchantStoreMatchService.js +++ b/services/merchantStoreMatchService.js @@ -1,6 +1,6 @@ 'use strict'; -const { categorizeTransaction } = require('./spendingService'); +const { categorizeTransaction } = require('./spendingService.cts'); // Mirrors the pack's match_order_recommendation: regional descriptor variants // (most specific — tied to a city/county) are checked first, then online diff --git a/services/notificationService.js b/services/notificationService.js index b367c32..e318063 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -1,7 +1,7 @@ const nodemailer = require('nodemailer'); const { getDb, getSetting } = require('../db/database'); -const { decryptSecret, encryptSecret } = require('./encryptionService'); -const { accountingActiveSql } = require('./paymentAccountingService'); +const { decryptSecret, encryptSecret } = require('./encryptionService.cts'); +const { accountingActiveSql } = require('./paymentAccountingService.cts'); const { markNotificationError, markNotificationSuccess, @@ -311,7 +311,7 @@ async function runNotifications() { const month = now.getMonth() + 1; const today = localDateString(now); - const { getCycleRange, resolveDueDate } = require('./statusService'); + const { getCycleRange, resolveDueDate } = require('./statusService.cts'); // Fetch all active bills. In global-notification mode, the single global recipient // legitimately receives every bill. In per-user mode, each recipient must only @@ -527,7 +527,7 @@ async function runDriftNotifications() { if (!getSetting('notify_sender_address')) return; const db = getDb(); - const { getDriftReport } = require('./driftService'); + const { getDriftReport } = require('./driftService.cts'); const now = new Date(); const year = now.getFullYear(); const month = now.getMonth() + 1; diff --git a/services/oidcService.js b/services/oidcService.js index 36ee796..6f769f6 100644 --- a/services/oidcService.js +++ b/services/oidcService.js @@ -52,7 +52,7 @@ const crypto = require('crypto'); const { Issuer } = require('openid-client'); const { getDb, getSetting, setSetting } = require('../db/database'); -const { encryptSecret, decryptSecret } = require('./encryptionService'); +const { encryptSecret, decryptSecret } = require('./encryptionService.cts'); // Decrypt the stored OIDC client secret, falling back to plaintext for // values saved before encryption was introduced (pre-v0.79). diff --git a/services/paymentAccountingService.js b/services/paymentAccountingService.cts similarity index 80% rename from services/paymentAccountingService.js rename to services/paymentAccountingService.cts index 4a1b8cc..bb522bc 100644 --- a/services/paymentAccountingService.js +++ b/services/paymentAccountingService.cts @@ -1,40 +1,46 @@ 'use strict'; +import type { Db } from '../types/db'; + const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); -const { getCycleRange } = require('./statusService'); +const { getCycleRange } = require('./statusService.cts'); + +// Dynamic better-sqlite3 rows / payloads — shape varies by query, callers read +// individual columns. Typed loosely on purpose (incremental migration). +type Row = Record; const ACCOUNTING_ACTIVE_SQL = 'COALESCE(accounting_excluded, 0) = 0'; const BANK_PAYMENT_SOURCES = new Set(['provider_sync', 'transaction_match', 'auto_match']); const OVERRIDE_REASON = 'overridden_by_bank'; -function accountingActiveSql(alias = null) { +function accountingActiveSql(alias: string | null = null): string { const prefix = alias ? `${alias}.` : ''; return `COALESCE(${prefix}accounting_excluded, 0) = 0`; } -function isBankBackedPayment(payment = {}) { +function isBankBackedPayment(payment: Row = {}): boolean { return BANK_PAYMENT_SOURCES.has(payment.payment_source) || payment.transaction_id != null; } -function appendNote(existing, line) { +function appendNote(existing: unknown, line: string): string { const current = String(existing || '').trim(); if (current.includes(line)) return current || line; return current ? `${current}\n${line}`.slice(0, 500) : line.slice(0, 500); } -function paymentMonth(paidDate) { +function paymentMonth(paidDate: unknown): { year: number; month: number } | null { const match = String(paidDate || '').match(/^(\d{4})-(\d{2})-\d{2}$/); if (!match) return null; return { year: Number(match[1]), month: Number(match[2]) }; } -function cycleRangeForPayment(bill, paidDate) { +function cycleRangeForPayment(bill: Row, paidDate: unknown): any { const ym = paymentMonth(paidDate); if (!ym) return null; return getCycleRange(ym.year, ym.month, bill); } -function reversePaymentBalance(db, payment) { +function reversePaymentBalance(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; @@ -49,7 +55,7 @@ function reversePaymentBalance(db, payment) { `).run(restored, payment.interest_delta != null ? 1 : 0, bill.id); } -function applyPaymentBalanceFromFreshBill(db, billId, amount) { +function applyPaymentBalanceFromFreshBill(db: Db, billId: number, amount: any): { balance_delta: any; interest_delta: any } { const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL').get(billId); if (!bill) return { balance_delta: null, interest_delta: null }; const balCalc = computeBalanceDelta(bill, amount); @@ -60,7 +66,7 @@ function applyPaymentBalanceFromFreshBill(db, billId, amount) { }; } -function markProvisionalManualPaymentsOverridden(db, bill, bankPayment) { +function markProvisionalManualPaymentsOverridden(db: Db, bill: Row, bankPayment: Row): { overridden: number } { if (!bill || !bankPayment || !isBankBackedPayment(bankPayment)) return { overridden: 0 }; const range = cycleRangeForPayment(bill, bankPayment.paid_date); if (!range) return { overridden: 0 }; @@ -100,7 +106,7 @@ function markProvisionalManualPaymentsOverridden(db, bill, bankPayment) { return { overridden: provisionalPayments.length }; } -function reactivatePaymentsOverriddenBy(db, bankPaymentId) { +function reactivatePaymentsOverriddenBy(db: Db, bankPaymentId: number): { reactivated: number } { const rows = db.prepare(` SELECT * FROM payments @@ -135,7 +141,7 @@ function reactivatePaymentsOverriddenBy(db, bankPaymentId) { return { reactivated: rows.length }; } -function applyBankPaymentAsSourceOfTruth(db, bill, bankPayment) { +function applyBankPaymentAsSourceOfTruth(db: Db, bill: Row, bankPayment: Row): { balance_delta: any; interest_delta: any } { markProvisionalManualPaymentsOverridden(db, bill, bankPayment); const deltas = applyPaymentBalanceFromFreshBill(db, bill.id, bankPayment.amount); db.prepare(` diff --git a/services/spendingService.js b/services/spendingService.cts similarity index 84% rename from services/spendingService.js rename to services/spendingService.cts index 4460edd..1df6ef8 100644 --- a/services/spendingService.js +++ b/services/spendingService.cts @@ -1,8 +1,10 @@ 'use strict'; +import type { Db } from '../types/db'; + const { normalizeMerchant } = require('./subscriptionService'); -const { localDateString } = require('../utils/dates.mts'); -const { toCents, fromCents } = require('../utils/money.mts'); +const { localDateString } = require('../utils/dates.mts') as typeof import('../utils/dates.mts'); +const { toCents, fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts'); // Spending = unmatched outflows (amount < 0) that haven't been ignored. // Bill-matched transactions are excluded so there's no double-counting. @@ -13,26 +15,40 @@ const SPENDING_WHERE = ` AND t.user_id = ? `; -function monthRange(year, month) { +interface SpendingCatRow { + category_id: number | string | null; + category_name: string; + group_id: number | null; + group_name: string | null; + amount: number; + tx_count: number; + budget: number | null; + avg_3mo: number | null; + pct_of_total?: number; +} +interface SpendingTxOptions { categoryId?: number | null; uncategorizedOnly?: boolean; page?: number; limit?: number } +interface IncomeOptions { page?: number; limit?: number; includeIgnored?: boolean } + +function monthRange(year: number, month: number): { start: string; end: string } { const start = `${year}-${String(month).padStart(2, '0')}-01`; const end = localDateString(new Date(year, month, 0)); // last day return { start, end }; } -function shiftMonth(year, month, delta) { +function shiftMonth(year: number, month: number, delta: number): { year: number; month: number } { let m = month + delta, y = year; while (m < 1) { m += 12; y--; } while (m > 12) { m -= 12; y++; } return { year: y, month: m }; } -function cents(raw) { +function cents(raw: unknown): number { return Math.abs(Number(raw)) / 100; } // ── Summary ────────────────────────────────────────────────────────────────── -function getSpendingSummary(db, userId, year, month) { +function getSpendingSummary(db: Db, userId: number, year: number, month: number) { const { start, end } = monthRange(year, month); const dateRangeParams = [start, end, start + 'T00:00:00', end + 'T23:59:59']; const DATE_RANGE_SQL = '(t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))'; @@ -79,7 +95,7 @@ function getSpendingSummary(db, userId, year, month) { SELECT category_id, amount FROM spending_budgets WHERE user_id = ? AND year = ? AND month = ? `).all(userId, year, month); - const budgetMap = new Map(budgets.map(b => [b.category_id, b.amount])); + const budgetMap = new Map(budgets.map((b: any) => [b.category_id, b.amount])); // 3-month spending average per category (the 3 calendar months before the requested one) const prev1 = monthRange(shiftMonth(year, month, -1).year, shiftMonth(year, month, -1).month); @@ -91,10 +107,10 @@ function getSpendingSummary(db, userId, year, month) { AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?)) GROUP BY t.spending_category_id `).all(userId, prev3.start, prev1.end, prev3.start + 'T00:00:00', prev1.end + 'T23:59:59'); - const avgMap = new Map(avgRows.map(r => [r.category_id, r.total_cents / 3])); + const avgMap = new Map(avgRows.map((r: any) => [r.category_id, r.total_cents / 3])); let totalCents = 0; - const byCategory = categoryRows.map(r => { + const byCategory: SpendingCatRow[] = categoryRows.map((r: any) => { totalCents += r.total_cents; return { category_id: r.category_id, @@ -158,17 +174,17 @@ function getSpendingSummary(db, userId, year, month) { // ── Transactions ───────────────────────────────────────────────────────────── -function getSpendingTransactions(db, userId, year, month, { +function getSpendingTransactions(db: Db, userId: number, year: number, month: number, { categoryId = undefined, uncategorizedOnly = false, page = 1, limit = 50, -} = {}) { +}: SpendingTxOptions = {}) { const { start, end } = monthRange(year, month); const offset = (Math.max(1, page) - 1) * limit; let filter = ''; - const params = [userId, start, end, start + 'T00:00:00', end + 'T23:59:59']; + const params: any[] = [userId, start, end, start + 'T00:00:00', end + 'T23:59:59']; if (uncategorizedOnly) { filter = 'AND t.spending_category_id IS NULL'; @@ -201,7 +217,7 @@ function getSpendingTransactions(db, userId, year, month, { `).get(...params).n; return { - transactions: rows.map(r => ({ + transactions: rows.map((r: any) => ({ id: r.id, amount: cents(r.amount), payee: r.payee || r.description || r.memo || '(Unknown)', @@ -217,7 +233,7 @@ function getSpendingTransactions(db, userId, year, month, { // ── Categorize ─────────────────────────────────────────────────────────────── -function categorizeTransaction(db, userId, txId, categoryId, saveMerchantRule = false) { +function categorizeTransaction(db: Db, userId: number, txId: number, categoryId: number | null, saveMerchantRule = false): void { const tx = db.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?').get(txId, userId); if (!tx) throw Object.assign(new Error('Transaction not found'), { status: 404 }); @@ -244,8 +260,8 @@ function categorizeTransaction(db, userId, txId, categoryId, saveMerchantRule = // ── Auto-categorization ────────────────────────────────────────────────────── -function applySpendingCategoryRules(db, userId, onlyMerchant = null) { - let rules; +function applySpendingCategoryRules(db: Db, userId: number, onlyMerchant: string | null = null): number { + let rules: any[]; try { const q = onlyMerchant ? db.prepare('SELECT merchant, category_id FROM spending_category_rules WHERE user_id=? AND merchant=?') @@ -276,27 +292,27 @@ function applySpendingCategoryRules(db, userId, onlyMerchant = null) { return applied; } -function merchantMatches(txMerchant, ruleMerchant) { +function merchantMatches(txMerchant: string, ruleMerchant: string): boolean { if (!txMerchant || !ruleMerchant) return false; if (txMerchant === ruleMerchant) return true; - 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(ruleMerchant).test(txMerchant) || wb(txMerchant).test(ruleMerchant); } // ── Budgets ────────────────────────────────────────────────────────────────── -function getSpendingBudgets(db, userId, year, month) { +function getSpendingBudgets(db: Db, userId: number, year: number, month: number) { const rows = db.prepare(` SELECT sb.category_id, sb.amount, c.name AS category_name FROM spending_budgets sb JOIN categories c ON c.id = sb.category_id AND c.deleted_at IS NULL WHERE sb.user_id=? AND sb.year=? AND sb.month=? `).all(userId, year, month); - return rows.map(r => ({ ...r, amount: fromCents(r.amount) })); + return rows.map((r: any) => ({ ...r, amount: fromCents(r.amount) })); } -function setSpendingBudget(db, userId, categoryId, year, month, amount) { +function setSpendingBudget(db: Db, userId: number, categoryId: number, year: number, month: number, amount: number | string | null): void { if (amount === null || amount === undefined) { db.prepare('DELETE FROM spending_budgets WHERE user_id=? AND category_id=? AND year=? AND month=?') .run(userId, categoryId, year, month); @@ -312,7 +328,7 @@ function setSpendingBudget(db, userId, categoryId, year, month, amount) { // ── Category rules ─────────────────────────────────────────────────────────── -function getSpendingCategoryRules(db, userId) { +function getSpendingCategoryRules(db: Db, userId: number) { return db.prepare(` SELECT r.id, r.merchant, r.category_id, c.name AS category_name FROM spending_category_rules r @@ -322,7 +338,7 @@ function getSpendingCategoryRules(db, userId) { `).all(userId); } -function addSpendingCategoryRule(db, userId, categoryId, merchant) { +function addSpendingCategoryRule(db: Db, userId: number, categoryId: number, merchant: string): void { const normalized = normalizeMerchant(merchant); if (!normalized || normalized.length < 2) throw Object.assign(new Error('Merchant name too short'), { status: 400 }); db.prepare(` @@ -338,13 +354,13 @@ function addSpendingCategoryRule(db, userId, categoryId, merchant) { applySpendingCategoryRules(db, userId, normalized); } -function deleteSpendingCategoryRule(db, userId, ruleId) { +function deleteSpendingCategoryRule(db: Db, userId: number, ruleId: number): void { db.prepare('DELETE FROM spending_category_rules WHERE id=? AND user_id=?').run(ruleId, userId); } // ── Income ─────────────────────────────────────────────────────────────────── -function getIncomeTransactions(db, userId, year, month, { page = 1, limit = 50, includeIgnored = false } = {}) { +function getIncomeTransactions(db: Db, userId: number, year: number, month: number, { page = 1, limit = 50, includeIgnored = false }: IncomeOptions = {}) { const { start, end } = monthRange(year, month); const offset = (Math.max(1, page) - 1) * limit; const ignoredFilter = includeIgnored ? '' : 'AND ignored = 0'; @@ -366,7 +382,7 @@ function getIncomeTransactions(db, userId, year, month, { page = 1, limit = 50, `).get(userId, start, end, start + 'T00:00:00', end + 'T23:59:59').n; return { - transactions: rows.map(r => ({ + transactions: rows.map((r: any) => ({ id: r.id, amount: Math.abs(Number(r.amount)) / 100, payee: r.payee || r.description || r.memo || '(Unknown)', @@ -377,7 +393,7 @@ function getIncomeTransactions(db, userId, year, month, { page = 1, limit = 50, page, pages: Math.ceil(total / limit), }; - } catch (err) { + } catch (err: any) { console.error('[getIncomeTransactions]', err.message); return { transactions: [], total: 0, page: 1, pages: 1 }; } diff --git a/services/statusService.js b/services/statusService.cts similarity index 79% rename from services/statusService.js rename to services/statusService.cts index 3c89fa8..5f57ed0 100644 --- a/services/statusService.js +++ b/services/statusService.cts @@ -1,14 +1,24 @@ +'use strict'; + +import type { Db } from '../types/db'; + const { getSetting } = require('../db/database'); +// Dynamic better-sqlite3 bill/payment rows — columns read individually. +type Row = Record; + +interface DateRange { start: string; end: string } +interface StatusOptions { gracePeriodDays?: string | number | null; dueSoonDays?: string | number | null } + // A bill's month is "settled" when its status is paid or autodraft (assumed paid // via autopay). Single source of truth so the ~scattered inline // `status === 'paid' || status === 'autodraft'` checks don't drift. const PAID_STATUSES = Object.freeze(['paid', 'autodraft']); -function isPaidStatus(status) { +function isPaidStatus(status: string | null | undefined): boolean { return status === 'paid' || status === 'autodraft'; } -const WEEKDAY_INDEX = { +const WEEKDAY_INDEX: Record = { sunday: 0, monday: 1, tuesday: 2, @@ -20,87 +30,90 @@ const WEEKDAY_INDEX = { const BIWEEKLY_ANCHOR = new Date(Date.UTC(2000, 0, 3)); // Monday -function resolveGracePeriodDays(value) { +function resolveGracePeriodDays(value?: string | number | null): number { const parsed = parseInt(value ?? getSetting('grace_period_days') ?? '5', 10); return Number.isInteger(parsed) && parsed >= 0 ? parsed : 5; } // How many days before the due date a bill is flagged "due soon". Configurable // like the grace period; clamped to a sane 0–31 with a fallback of 3. -function resolveDueSoonDays(value) { +function resolveDueSoonDays(value?: string | number | null): number { const parsed = parseInt(value ?? getSetting('due_soon_days') ?? '3', 10); if (!Number.isInteger(parsed) || parsed < 0) return 3; return Math.min(parsed, 31); } -function pad(value) { +function pad(value: number | string): string { return String(value).padStart(2, '0'); } -const { fromCents } = require('../utils/money.mts'); +const { fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts'); +// paymentValidation.cts is CommonJS (module.exports =); its members aren't +// namespace properties of `typeof import(...)`, so require plain (→ any). Only +// used in a .map(), so the loss of the branded signature here is immaterial. const { serializePayment } = require('./paymentValidation.cts'); -function dateString(year, month, day) { +function dateString(year: number, month: number, day: number): string { return `${year}-${pad(month)}-${pad(day)}`; } -function dateFromString(value) { +function dateFromString(value: string): Date { const [year, month, day] = String(value).split('-').map(Number); return new Date(Date.UTC(year, month - 1, day)); } -function addDays(date, days) { +function addDays(date: Date, days: number): Date { const next = new Date(date); next.setUTCDate(next.getUTCDate() + days); return next; } -function toDateString(date) { +function toDateString(date: Date): string { return dateString(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()); } -function daysInMonth(year, month) { +function daysInMonth(year: number, month: number): number { return new Date(Date.UTC(year, month, 0)).getUTCDate(); } -function clampDay(year, month, day) { +function clampDay(year: number, month: number, day: any): number { const parsed = parseInt(day, 10); const safeDay = Number.isInteger(parsed) ? parsed : 1; return Math.min(Math.max(safeDay, 1), daysInMonth(year, month)); } -function normalizeCycleType(bill = {}) { +function normalizeCycleType(bill: Row = {}): string { const value = String(bill.cycle_type || bill.billing_cycle || 'monthly').toLowerCase(); if (value === 'annually') return 'annual'; if (['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'].includes(value)) return value; return 'monthly'; } -function parseCycleMonth(value, fallback = 1) { +function parseCycleMonth(value: any, fallback = 1): number { const parsed = parseInt(value, 10); if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 12) return parsed; return fallback; } -function parseWeekday(value, fallback = 'monday') { +function parseWeekday(value: any, fallback = 'monday'): number { const normalized = String(value || fallback).trim().toLowerCase(); return WEEKDAY_INDEX[normalized] ?? WEEKDAY_INDEX[fallback]; } -function firstWeekdayInMonth(year, month, weekdayIndex) { +function firstWeekdayInMonth(year: number, month: number, weekdayIndex: number): Date { const first = new Date(Date.UTC(year, month - 1, 1)); const offset = (weekdayIndex - first.getUTCDay() + 7) % 7; return addDays(first, offset); } -function firstBiweeklyDateInMonth(year, month, weekdayIndex) { +function firstBiweeklyDateInMonth(year: number, month: number, weekdayIndex: number): Date | null { const start = new Date(Date.UTC(year, month - 1, 1)); const end = new Date(Date.UTC(year, month, 0)); const weekdayAnchor = addDays(BIWEEKLY_ANCHOR, (weekdayIndex - BIWEEKLY_ANCHOR.getUTCDay() + 7) % 7); let cursor = firstWeekdayInMonth(year, month, weekdayIndex); while (cursor <= end) { - const diffDays = Math.round((cursor - weekdayAnchor) / 86400000); + const diffDays = Math.round((cursor.getTime() - weekdayAnchor.getTime()) / 86400000); if (diffDays % 14 === 0) return cursor; cursor = addDays(cursor, 7); } @@ -108,7 +121,7 @@ function firstBiweeklyDateInMonth(year, month, weekdayIndex) { return null; } -function monthMatchesQuarterlyCycle(month, cycleStartMonth) { +function monthMatchesQuarterlyCycle(month: number, cycleStartMonth: number): boolean { return ((month - cycleStartMonth) % 3 + 3) % 3 === 0; } @@ -118,7 +131,7 @@ function monthMatchesQuarterlyCycle(month, cycleStartMonth) { * Legacy override_due_date values are intentionally ignored by the current * product behavior. */ -function resolveDueDate(bill, year, month) { +function resolveDueDate(bill: Row, year: number, month: number): string | null { const cycleType = normalizeCycleType(bill); if (cycleType === 'weekly') { @@ -147,7 +160,7 @@ function resolveDueDate(bill, year, month) { /** * Auto-assigns bucket from due_day: 1–14 → '1st', 15+ → '15th' */ -function resolveBucket(bill) { +function resolveBucket(bill: Row): string { if (bill.bucket) return bill.bucket; return bill.due_day <= 14 ? '1st' : '15th'; } @@ -156,7 +169,7 @@ function resolveBucket(bill) { * Computes the payment cycle start/end for a bill in a given month. * For monthly bills the cycle is the calendar month. */ -function getCalendarMonthRange(year, month) { +function getCalendarMonthRange(year: number, month: number): DateRange { const start = dateString(year, month, 1); const end = dateString(year, month, daysInMonth(year, month)); return { start, end }; @@ -166,7 +179,7 @@ function getCalendarMonthRange(year, month) { * Computes the payment cycle start/end for a bill in a given month. * Without a bill argument this preserves the historical calendar-month range. */ -function getCycleRange(year, month, bill = null) { +function getCycleRange(year: number, month: number, bill: Row | null = null): DateRange | null { if (!bill) return getCalendarMonthRange(year, month); const dueDate = resolveDueDate(bill, year, month); @@ -205,7 +218,7 @@ function getCycleRange(year, month, bill = null) { * late — past due, within grace period * missed — past grace period, unpaid */ -function calculateStatus(bill, payments, dueDate, today, options = {}) { +function calculateStatus(bill: Row, payments: Row[], dueDate: string | null, today: string, options: StatusOptions = {}): string { if (!dueDate) return 'inactive_cycle'; const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays); @@ -222,7 +235,7 @@ function calculateStatus(bill, payments, dueDate, today, options = {}) { const due = new Date(dueDate + 'T00:00:00'); const todayDate = new Date(today + 'T00:00:00'); - const diffDays = Math.floor((due - todayDate) / 86400000); + const diffDays = Math.floor((due.getTime() - todayDate.getTime()) / 86400000); if (diffDays > dueSoonDays) return 'upcoming'; if (diffDays >= 0) return 'due_soon'; @@ -233,7 +246,7 @@ function calculateStatus(bill, payments, dueDate, today, options = {}) { /** * Builds a full tracker row for a bill in a given month. */ -function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) { +function buildTrackerRow(bill: Row, payments: Row[], year: number, month: number, todayStr: string, options: StatusOptions = {}): Row | null { const dueDate = resolveDueDate(bill, year, month); if (!dueDate) return null; diff --git a/services/totpService.js b/services/totpService.cts similarity index 76% rename from services/totpService.js rename to services/totpService.cts index 77e9165..1a263a4 100644 --- a/services/totpService.js +++ b/services/totpService.cts @@ -1,25 +1,27 @@ 'use strict'; +import type { Db } from '../types/db'; + const crypto = require('crypto'); -const { generateSecret, generateURI, generateSync, verifySync } = require('otplib'); +const { generateSecret, generateURI, verifySync } = require('otplib'); const QRCode = require('qrcode'); -const { encryptSecret, decryptSecret } = require('./encryptionService'); +const { encryptSecret, decryptSecret } = require('./encryptionService.cts'); const APP_NAME = 'Bill Tracker'; const RECOVERY_CODE_COUNT = 8; const CHALLENGE_TTL_MS = 5 * 60 * 1000; -function newSecret() { +function newSecret(): string { return generateSecret(20); } -async function generateQrCode(secret, username) { +async function generateQrCode(secret: string, username: string): Promise<{ uri: string; qr_data_url: string }> { const uri = generateURI({ secret, account: username, issuer: APP_NAME, type: 'totp' }); const qr_data_url = await QRCode.toDataURL(uri, { width: 200, margin: 2 }); return { uri, qr_data_url }; } -function verifyToken(encryptedSecret, token) { +function verifyToken(encryptedSecret: string | null | undefined, token: string | number | null | undefined): boolean { if (!encryptedSecret || !token) return false; try { const secret = decryptSecret(encryptedSecret); @@ -30,7 +32,7 @@ function verifyToken(encryptedSecret, token) { } } -function verifyTokenRaw(secret, token) { +function verifyTokenRaw(secret: string | null | undefined, token: string | number | null | undefined): boolean { if (!secret || !token) return false; try { const result = verifySync({ secret, token: String(token).replace(/\s/g, ''), type: 'totp' }); @@ -40,7 +42,7 @@ function verifyTokenRaw(secret, token) { } } -function makeRecoveryCodes() { +function makeRecoveryCodes(): string[] { return Array.from({ length: RECOVERY_CODE_COUNT }, () => { const bytes = crypto.randomBytes(5); const hex = bytes.toString('hex').toUpperCase(); @@ -48,16 +50,16 @@ function makeRecoveryCodes() { }); } -function hashRecoveryCode(code) { +function hashRecoveryCode(code: string): string { return crypto.createHash('sha256').update(code.replace(/-/g, '').toUpperCase()).digest('hex'); } -function consumeRecoveryCode(db, userId, code) { +function consumeRecoveryCode(db: Db, userId: number, code: string): { used: boolean; remaining?: number } { const normalized = code.replace(/[-\s]/g, '').toUpperCase(); const user = db.prepare('SELECT totp_recovery_codes FROM users WHERE id = ?').get(userId); if (!user?.totp_recovery_codes) return { used: false }; - let stored; + let stored: string[]; try { stored = JSON.parse(decryptSecret(user.totp_recovery_codes)); } catch { @@ -75,7 +77,7 @@ function consumeRecoveryCode(db, userId, code) { return { used: true, remaining: stored.length }; } -function createChallenge(db, userId) { +function createChallenge(db: Db, userId: number): string { const id = crypto.randomUUID(); const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS) .toISOString().slice(0, 19).replace('T', ' '); @@ -84,7 +86,7 @@ function createChallenge(db, userId) { return id; } -function consumeChallenge(db, challengeId) { +function consumeChallenge(db: Db, challengeId: string): number | null { const row = db.prepare( "SELECT user_id FROM totp_challenges WHERE id = ? AND expires_at > datetime('now')" ).get(challengeId); @@ -93,7 +95,7 @@ function consumeChallenge(db, challengeId) { return row.user_id; } -function pruneExpiredChallenges(db) { +function pruneExpiredChallenges(db: Db): void { db.prepare("DELETE FROM totp_challenges WHERE expires_at <= datetime('now')").run(); } diff --git a/services/trackerService.js b/services/trackerService.js index e934a41..ca948fd 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -1,11 +1,11 @@ 'use strict'; const { getDb } = require('../db/database'); -const { buildTrackerRow, getCycleRange, resolveDueDate, isPaidStatus } = require('./statusService'); +const { buildTrackerRow, getCycleRange, resolveDueDate, isPaidStatus } = require('./statusService.cts'); const { getUserSettings } = require('./userSettings'); const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); -const { computeAmountSuggestionsBatch } = require('./amountSuggestionService'); -const { accountingActiveSql } = require('./paymentAccountingService'); +const { computeAmountSuggestionsBatch } = require('./amountSuggestionService.cts'); +const { accountingActiveSql } = require('./paymentAccountingService.cts'); const { normalizeMerchant } = require('./subscriptionService'); const { localDateString } = require('../utils/dates.mts'); const { sumMoney, roundMoney, fromCents } = require('../utils/money.mts'); diff --git a/services/transactionMatchService.js b/services/transactionMatchService.js index 1d02ecc..b9b5eb4 100644 --- a/services/transactionMatchService.js +++ b/services/transactionMatchService.js @@ -5,7 +5,7 @@ const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { applyBankPaymentAsSourceOfTruth, reactivatePaymentsOverriddenBy, -} = require('./paymentAccountingService'); +} = require('./paymentAccountingService.cts'); const { decorateTransaction, getTransactionForUser, @@ -259,7 +259,7 @@ function matchTransactionToBill(userId, transactionId, billId, opts = {}) { markMatched(db, userId, transaction.id, bill.id, { resetIgnored: true }); if (opts.learnMerchant) { - const { learnMerchantRuleFromMatch } = require('./billMerchantRuleService'); + const { learnMerchantRuleFromMatch } = require('./billMerchantRuleService.cts'); learnMerchantRuleFromMatch(db, userId, bill.id, transaction); } diff --git a/tests/amountSuggestionService.test.js b/tests/amountSuggestionService.test.js index 6d377d0..a9432d1 100644 --- a/tests/amountSuggestionService.test.js +++ b/tests/amountSuggestionService.test.js @@ -14,7 +14,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-amountsug-${process.pid}.sql process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database'); -const { computeAmountSuggestion, computeAmountSuggestionsBatch } = require('../services/amountSuggestionService'); +const { computeAmountSuggestion, computeAmountSuggestionsBatch } = require('../services/amountSuggestionService.cts'); let db, userId, bills; test.before(() => { diff --git a/tests/analyticsService.test.js b/tests/analyticsService.test.js index fe8ed78..98d20cb 100644 --- a/tests/analyticsService.test.js +++ b/tests/analyticsService.test.js @@ -9,7 +9,7 @@ const assert = require('node:assert/strict'); const { monthKey, monthLabel, addMonths, monthEndDate, buildMonths, validateSummaryQuery, -} = require('../services/analyticsService'); +} = require('../services/analyticsService.cts'); test('monthKey zero-pads the month', () => { assert.equal(monthKey(2026, 6), '2026-06'); diff --git a/tests/bankSyncService.test.js b/tests/bankSyncService.test.js index 50b2195..16b768f 100644 --- a/tests/bankSyncService.test.js +++ b/tests/bankSyncService.test.js @@ -8,7 +8,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-bank-sync-test-${process.pid process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database'); -const { encryptSecret } = require('../services/encryptionService'); +const { encryptSecret } = require('../services/encryptionService.cts'); const { syncDataSource } = require('../services/bankSyncService'); function createUser(db, suffix) { diff --git a/tests/billMerchantRuleService.test.js b/tests/billMerchantRuleService.test.js index 85ef9f5..aa2edaf 100644 --- a/tests/billMerchantRuleService.test.js +++ b/tests/billMerchantRuleService.test.js @@ -16,7 +16,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-merchantrule-${process.pid}. process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database'); -const { addMerchantRule, syncBillPaymentsFromSimplefin } = require('../services/billMerchantRuleService'); +const { addMerchantRule, syncBillPaymentsFromSimplefin } = require('../services/billMerchantRuleService.cts'); let db, userId, billId; const insertTx = (payee, amountCents, postedDate) => db.prepare(` diff --git a/tests/encryptionService.test.js b/tests/encryptionService.test.js index 2991bad..a0a966f 100644 --- a/tests/encryptionService.test.js +++ b/tests/encryptionService.test.js @@ -19,7 +19,7 @@ process.env.DB_PATH = dbPath; delete process.env.TOKEN_ENCRYPTION_KEY; // start in DB-key mode const { getDb, closeDb, getSetting, setSetting } = require('../db/database'); -const enc = require('../services/encryptionService'); +const enc = require('../services/encryptionService.cts'); test.before(() => { getDb(); }); // init schema + migrations (also creates the initial admin) test.after(() => { diff --git a/tests/paymentAccountingService.test.js b/tests/paymentAccountingService.test.js index 1ce9e9d..befe7c3 100644 --- a/tests/paymentAccountingService.test.js +++ b/tests/paymentAccountingService.test.js @@ -21,7 +21,7 @@ const { accountingActiveSql, markProvisionalManualPaymentsOverridden, reactivatePaymentsOverriddenBy, -} = require('../services/paymentAccountingService'); +} = require('../services/paymentAccountingService.cts'); test('isBankBackedPayment: bank sources or a transaction_id count as bank-backed', () => { assert.equal(isBankBackedPayment({ payment_source: 'provider_sync' }), true); diff --git a/tests/reconciliation.test.js b/tests/reconciliation.test.js index 190e5fa..23714bf 100644 --- a/tests/reconciliation.test.js +++ b/tests/reconciliation.test.js @@ -19,8 +19,8 @@ process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database'); const { getTracker } = require('../services/trackerService'); -const { getAnalyticsSummary } = require('../services/analyticsService'); -const { resolveDueDate } = require('../services/statusService'); +const { getAnalyticsSummary } = require('../services/analyticsService.cts'); +const { resolveDueDate } = require('../services/statusService.cts'); const summaryRouter = require('../routes/summary'); // Fixed "now" so occurrence gating is deterministic: June 20, 2026. diff --git a/tests/snowballMath.test.js b/tests/snowballMath.test.js index 52ee64c..770649e 100644 --- a/tests/snowballMath.test.js +++ b/tests/snowballMath.test.js @@ -9,7 +9,7 @@ const { calculateSnowball, calculateAvalanche } = require('../services/snowballS const { monthlyInterest, monthsToPayoff, amortizationSchedule, calculateMinimumOnly, debtAprSnapshot, -} = require('../services/aprService'); +} = require('../services/aprService.cts'); // ── primitives ─────────────────────────────────────────────────────────────── diff --git a/tests/spendingService.test.js b/tests/spendingService.test.js index 01598f5..ed21531 100644 --- a/tests/spendingService.test.js +++ b/tests/spendingService.test.js @@ -14,7 +14,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-spendsvc-${process.pid}.sqli process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database'); -const { setSpendingBudget, getSpendingBudgets } = require('../services/spendingService'); +const { setSpendingBudget, getSpendingBudgets } = require('../services/spendingService.cts'); let db, userId, catId; const rawCents = () => db.prepare('SELECT amount FROM spending_budgets WHERE user_id=? AND category_id=? AND year=2026 AND month=7').get(userId, catId)?.amount; diff --git a/tests/spendingSummary.test.js b/tests/spendingSummary.test.js index 396d14d..7ab87d5 100644 --- a/tests/spendingSummary.test.js +++ b/tests/spendingSummary.test.js @@ -8,7 +8,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-spending-summary-test-${proc process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database'); -const { getSpendingSummary, setSpendingBudget } = require('../services/spendingService'); +const { getSpendingSummary, setSpendingBudget } = require('../services/spendingService.cts'); function createUser(db, suffix) { return db.prepare(` diff --git a/tests/statusService.test.js b/tests/statusService.test.js index c22a088..eb8199e 100644 --- a/tests/statusService.test.js +++ b/tests/statusService.test.js @@ -5,7 +5,7 @@ const { buildTrackerRow, getCycleRange, resolveDueDate, -} = require('../services/statusService'); +} = require('../services/statusService.cts'); function bill(overrides = {}) { return { diff --git a/tests/totpService.test.js b/tests/totpService.test.js index d54c6eb..9b25e31 100644 --- a/tests/totpService.test.js +++ b/tests/totpService.test.js @@ -15,8 +15,8 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-totp-${process.pid}.sqlite`) process.env.DB_PATH = dbPath; const { getDb, closeDb } = require('../db/database'); -const { encryptSecret } = require('../services/encryptionService'); -const totp = require('../services/totpService'); +const { encryptSecret } = require('../services/encryptionService.cts'); +const totp = require('../services/totpService.cts'); let db, userId; test.before(() => { diff --git a/tests/transactionMatchService.test.js b/tests/transactionMatchService.test.js index 56ae10c..1b36703 100644 --- a/tests/transactionMatchService.test.js +++ b/tests/transactionMatchService.test.js @@ -532,7 +532,7 @@ test('manual match learns a merchant rule; generic descriptors and background au }); test('applyMerchantRules skips ambiguous matches (rules for >1 bill) but still applies unambiguous ones', () => { - const { applyMerchantRules, addMerchantRule } = require('../services/billMerchantRuleService'); + const { applyMerchantRules, addMerchantRule } = require('../services/billMerchantRuleService.cts'); const db = getDb(); const userId = createUser(db, 'ambiguous'); diff --git a/types/db.d.ts b/types/db.d.ts new file mode 100644 index 0000000..bd7835b --- /dev/null +++ b/types/db.d.ts @@ -0,0 +1,27 @@ +// Minimal structural types for the better-sqlite3 surface the server uses. +// better-sqlite3 ships no type declarations (and no @types is installed), so +// this covers just the prepare/get/run/all/transaction/exec/pragma calls the +// migrated server modules make. Row shapes stay `any` — the queries are dynamic +// and each caller narrows what it reads. Shared so every migrated service reuses +// one `Db` type instead of redeclaring it. + +export interface RunResult { + changes: number; + lastInsertRowid: number | bigint; +} + +export interface Statement { + get(...params: unknown[]): any; + run(...params: unknown[]): RunResult; + all(...params: unknown[]): any[]; + iterate(...params: unknown[]): IterableIterator; + pluck(toggle?: boolean): Statement; +} + +export interface Db { + prepare(sql: string): Statement; + transaction any>(fn: T): T; + exec(sql: string): void; + pragma(source: string, options?: { simple?: boolean }): any; + close(): void; +} diff --git a/workers/dailyWorker.js b/workers/dailyWorker.js index 23435f3..cff916c 100644 --- a/workers/dailyWorker.js +++ b/workers/dailyWorker.js @@ -2,7 +2,7 @@ const cron = require('node-cron'); const { getDb, getSetting } = require('../db/database'); -const { buildTrackerRow, getCycleRange } = require('../services/statusService'); +const { buildTrackerRow, getCycleRange } = require('../services/statusService.cts'); const { pruneExpiredSessions } = require('../services/authService'); const { pruneExpiredChallenges: pruneWebAuthnChallenges } = require('../services/webauthnService'); const { runNotifications, runDriftNotifications } = require('../services/notificationService');