refactor(server): migrate 10 services to TypeScript (.cts)

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-06 10:35:29 -05:00
parent 343cc3a36f
commit 005b0b7b64
54 changed files with 408 additions and 265 deletions

View File

@ -915,7 +915,7 @@ module.exports = function buildVersionedMigrations(deps) {
dependsOn: ['v0.76'], dependsOn: ['v0.76'],
run: function() { run: function() {
try { 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(); const row = db.prepare("SELECT value FROM settings WHERE key = 'notify_smtp_password'").get();
if (row?.value) { if (row?.value) {
try { try {
@ -937,7 +937,7 @@ module.exports = function buildVersionedMigrations(deps) {
dependsOn: ['v0.77'], dependsOn: ['v0.77'],
run: function() { run: function() {
try { try {
const { decryptSecret, encryptSecret } = require('../../services/encryptionService'); const { decryptSecret, encryptSecret } = require('../../services/encryptionService.cts');
// Re-encrypt SimpleFIN tokens in data_sources // Re-encrypt SimpleFIN tokens in data_sources
const sources = db.prepare( const sources = db.prepare(
@ -973,7 +973,7 @@ module.exports = function buildVersionedMigrations(deps) {
dependsOn: ['v0.78'], dependsOn: ['v0.78'],
run: function() { run: function() {
try { 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(); const row = db.prepare("SELECT value FROM settings WHERE key = 'oidc_client_secret'").get();
if (row?.value) { if (row?.value) {
try { try {
@ -1046,7 +1046,7 @@ module.exports = function buildVersionedMigrations(deps) {
description: 'user_login_history: encrypt ip/useragent at rest + add location + keep 10 records', description: 'user_login_history: encrypt ip/useragent at rest + add location + keep 10 records',
dependsOn: ['v0.83'], dependsOn: ['v0.83'],
run: function() { 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); const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name);
// Add location columns // Add location columns

View File

@ -11,7 +11,7 @@
"dev:ui": "vite", "dev:ui": "vite",
"dev": "concurrently \"npm run dev:api\" \"npm run dev:ui\"", "dev": "concurrently \"npm run dev:api\" \"npm run dev:ui\"",
"build": "vite build", "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", "typecheck:server": "tsc --noEmit -p tsconfig.server.json",
"lint": "eslint client", "lint": "eslint client",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",

View File

@ -3,7 +3,7 @@ const router = express.Router();
const { getDb, rollbackMigration } = require('../db/database'); const { getDb, rollbackMigration } = require('../db/database');
const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging } = require('../services/bankSyncConfigService'); const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging } = require('../services/bankSyncConfigService');
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker'); 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 { hashPassword } = require('../services/authService');
const { logAudit } = require('../services/auditService'); const { logAudit } = require('../services/auditService');
const { const {

View File

@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const { getAnalyticsSummary } = require('../services/analyticsService'); const { getAnalyticsSummary } = require('../services/analyticsService.cts');
router.get('/summary', (req, res) => { router.get('/summary', (req, res) => {
const result = getAnalyticsSummary(req.user.id, req.query); const result = getAnalyticsSummary(req.user.id, req.query);

View File

@ -11,7 +11,7 @@ function getAppVersion() {
const { getDb, getSetting, setSetting } = require('../db/database'); 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 { 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 { getCsrfToken } = require('../middleware/csrf');
const { requireAuth, requireAdmin } = require('../middleware/requireAuth'); const { requireAuth, requireAdmin } = require('../middleware/requireAuth');
const { getPublicOidcInfo } = require('../services/oidcService'); const { getPublicOidcInfo } = require('../services/oidcService');
@ -171,8 +171,8 @@ const {
generateSecret, generateQrCode, verifyToken, verifyTokenRaw, generateSecret, generateQrCode, verifyToken, verifyTokenRaw,
generateRecoveryCodes, hashRecoveryCode, consumeRecoveryCode, generateRecoveryCodes, hashRecoveryCode, consumeRecoveryCode,
createChallenge, consumeChallenge, createChallenge, consumeChallenge,
} = require('../services/totpService'); } = require('../services/totpService.cts');
const { encryptSecret: encTotpSecret } = require('../services/encryptionService'); const { encryptSecret: encTotpSecret } = require('../services/encryptionService.cts');
// POST /api/auth/totp/challenge — second step of login when TOTP is enabled. // 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. // Takes challenge_token (from first login step) + totp_code, creates a session.

View File

@ -12,16 +12,16 @@ const {
computeBalanceDelta, computeBalanceDelta,
applyBalanceDelta, applyBalanceDelta,
} = require('../services/billsService'); } = require('../services/billsService');
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService'); const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService.cts');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts'); 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 { normalizeMerchant } = require('../services/subscriptionService');
const { decorateTransaction } = require('../services/transactionService'); const { decorateTransaction } = require('../services/transactionService');
const { const {
accountingActiveSql, accountingActiveSql,
applyBankPaymentAsSourceOfTruth, applyBankPaymentAsSourceOfTruth,
} = require('../services/paymentAccountingService'); } = require('../services/paymentAccountingService.cts');
const { localDateString, todayLocal } = require('../utils/dates.mts'); const { localDateString, todayLocal } = require('../utils/dates.mts');
const { roundMoney, sumMoney, toCents, fromCents } = require('../utils/money.mts'); const { roundMoney, sumMoney, toCents, fromCents } = require('../utils/money.mts');
@ -131,7 +131,7 @@ router.get('/audit', (req, res) => {
// ── GET /api/bills/drift-report ────────────────────────────────────────────── // ── GET /api/bills/drift-report ──────────────────────────────────────────────
router.get('/drift-report', (req, res) => { router.get('/drift-report', (req, res) => {
const { getDriftReport } = require('../services/driftService'); const { getDriftReport } = require('../services/driftService.cts');
try { try {
res.json(getDriftReport(req.user.id)); res.json(getDriftReport(req.user.id));
} catch (err) { } catch (err) {
@ -1273,7 +1273,7 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => {
const { normalizeMerchant: nm, ..._ } = { normalizeMerchant }; const { normalizeMerchant: nm, ..._ } = { normalizeMerchant };
const rules2 = db.prepare('SELECT due_day FROM bills WHERE id = ?').get(billId); const rules2 = db.prepare('SELECT due_day FROM bills WHERE id = ?').get(billId);
if (rules2?.due_day) { if (rules2?.due_day) {
const { lateAttributionCandidate } = require('../services/billMerchantRuleService'); const { lateAttributionCandidate } = require('../services/billMerchantRuleService.cts');
// inline check // inline check
const paid = new Date(paidDate + 'T00:00:00'); const paid = new Date(paidDate + 'T00:00:00');
const dom = paid.getDate(); const dom = paid.getDate();

View File

@ -2,9 +2,9 @@ const express = require('express');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange } = require('../services/statusService'); const { buildTrackerRow, getCycleRange } = require('../services/statusService.cts');
const { getUserSettings } = require('../services/userSettings'); const { getUserSettings } = require('../services/userSettings');
const { accountingActiveSql } = require('../services/paymentAccountingService'); const { accountingActiveSql } = require('../services/paymentAccountingService.cts');
const { const {
feedUrlForToken, feedUrlForToken,
getActiveToken, getActiveToken,

View File

@ -2,7 +2,7 @@ const express = require('express');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const router = express.Router(); const router = express.Router();
const { getDb, ensureUserDefaultCategories } = require('../db/database'); const { getDb, ensureUserDefaultCategories } = require('../db/database');
const { accountingActiveSql } = require('../services/paymentAccountingService'); const { accountingActiveSql } = require('../services/paymentAccountingService.cts');
// GET /api/categories // GET /api/categories
router.get('/', (req, res) => { router.get('/', (req, res) => {

View File

@ -1,12 +1,12 @@
const router = require('express').Router(); const router = require('express').Router();
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { applyBankPaymentAsSourceOfTruth, reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService'); const { applyBankPaymentAsSourceOfTruth, reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService.cts');
const { const {
listMatchSuggestions, listMatchSuggestions,
rejectMatchSuggestion, rejectMatchSuggestion,
} = require('../services/matchSuggestionService'); } = require('../services/matchSuggestionService');
const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService'); const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService.cts');
const { markMatched, markUnmatched } = require('../services/transactionMatchState'); const { markMatched, markUnmatched } = require('../services/transactionMatchState');
const { serializePayment } = require('../services/paymentValidation.cts'); const { serializePayment } = require('../services/paymentValidation.cts');
const { todayLocal } = require('../utils/dates.mts'); const { todayLocal } = require('../utils/dates.mts');

View File

@ -1,8 +1,8 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { getCycleRange } = require('../services/statusService'); const { getCycleRange } = require('../services/statusService.cts');
const { accountingActiveSql } = require('../services/paymentAccountingService'); const { accountingActiveSql } = require('../services/paymentAccountingService.cts');
const { toCents, fromCents } = require('../utils/money.mts'); const { toCents, fromCents } = require('../utils/money.mts');
function parseYearMonth(source) { function parseYearMonth(source) {

View File

@ -4,8 +4,8 @@ const { getDb, getSetting, setSetting } = require('../db/database');
const { requireAuth, requireUser, requireAdmin } = require('../middleware/requireAuth'); const { requireAuth, requireUser, requireAdmin } = require('../middleware/requireAuth');
const { sendTestEmail } = require('../services/notificationService'); const { sendTestEmail } = require('../services/notificationService');
const { sendTestPush } = require('../services/notificationService')._push || {}; const { sendTestPush } = require('../services/notificationService')._push || {};
const { decryptSecret } = require('../services/encryptionService'); const { decryptSecret } = require('../services/encryptionService.cts');
const { encryptSecret } = require('../services/encryptionService'); const { encryptSecret } = require('../services/encryptionService.cts');
// ── Admin: SMTP configuration ───────────────────────────────────────────────── // ── Admin: SMTP configuration ─────────────────────────────────────────────────

View File

@ -4,12 +4,12 @@ const router = require('express').Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService'); const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts'); 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 { markUnmatched } = require('../services/transactionMatchState');
const { const {
markProvisionalManualPaymentsOverridden, markProvisionalManualPaymentsOverridden,
reactivatePaymentsOverriddenBy, reactivatePaymentsOverriddenBy,
} = require('../services/paymentAccountingService'); } = require('../services/paymentAccountingService.cts');
const { todayLocal } = require('../utils/dates.mts'); const { todayLocal } = require('../utils/dates.mts');
const { fromCents } = require('../utils/money.mts'); const { fromCents } = require('../utils/money.mts');

View File

@ -10,7 +10,7 @@ const { hashPassword, invalidateOtherSessions, rotateSessionId, COOKIE_NAME, coo
const { getImportHistory } = require('../services/spreadsheetImportService'); const { getImportHistory } = require('../services/spreadsheetImportService');
const { logAudit } = require('../services/auditService'); const { logAudit } = require('../services/auditService');
const { standardizeError } = require('../middleware/errorFormatter'); 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. // 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. // req.user is always the signed-in user; user_id is never accepted from the body.

View File

@ -3,7 +3,7 @@ const router = express.Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService'); 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 { serializeBill } = require('../services/billsService');
const { toCents, fromCents } = require('../utils/money.mts'); const { toCents, fromCents } = require('../utils/money.mts');

View File

@ -8,7 +8,7 @@ const {
getSpendingBudgets, setSpendingBudget, getSpendingBudgets, setSpendingBudget,
getSpendingCategoryRules, addSpendingCategoryRule, deleteSpendingCategoryRule, getSpendingCategoryRules, addSpendingCategoryRule, deleteSpendingCategoryRule,
getIncomeTransactions, getIncomeTransactions,
} = require('../services/spendingService'); } = require('../services/spendingService.cts');
function parseYM(source) { function parseYM(source) {
const now = new Date(); const now = new Date();

View File

@ -9,7 +9,7 @@ const { getScheduleStatus } = require('../services/backupScheduler');
const { checkForUpdates } = require('../services/updateCheckService'); const { checkForUpdates } = require('../services/updateCheckService');
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker');
const { getBankSyncConfig } = require('../services/bankSyncConfigService'); const { getBankSyncConfig } = require('../services/bankSyncConfigService');
const { accountingActiveSql } = require('../services/paymentAccountingService'); const { accountingActiveSql } = require('../services/paymentAccountingService.cts');
const { localDateString } = require('../utils/dates.mts'); const { localDateString } = require('../utils/dates.mts');
const startTime = Date.now(); const startTime = Date.now();

View File

@ -14,7 +14,7 @@ const {
recordSubscriptionFeedback, recordSubscriptionFeedback,
searchSubscriptionTransactions, searchSubscriptionTransactions,
} = require('../services/subscriptionService'); } = require('../services/subscriptionService');
const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService'); const { addMerchantRule, applyMerchantRules } = require('../services/billMerchantRuleService.cts');
router.get('/', (req, res) => { router.get('/', (req, res) => {
const db = getDb(); const db = getDb();

View File

@ -1,9 +1,9 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { getCycleRange, resolveDueDate } = require('../services/statusService'); const { getCycleRange, resolveDueDate } = require('../services/statusService.cts');
const { getUserSettings } = require('../services/userSettings'); const { getUserSettings } = require('../services/userSettings');
const { accountingActiveSql } = require('../services/paymentAccountingService'); const { accountingActiveSql } = require('../services/paymentAccountingService.cts');
const { toCents, fromCents } = require('../utils/money.mts'); const { toCents, fromCents } = require('../utils/money.mts');
const DEFAULT_INCOME_LABEL = 'Salary'; const DEFAULT_INCOME_LABEL = 'Salary';

View File

@ -15,13 +15,13 @@ const {
unignoreTransaction, unignoreTransaction,
unmatchTransaction, unmatchTransaction,
} = require('../services/transactionMatchService'); } = require('../services/transactionMatchService');
const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService'); const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService.cts');
const { const {
findMerchantMatch, findMerchantMatch,
applyMerchantStoreMatches, applyMerchantStoreMatches,
findOrCreateCategory, findOrCreateCategory,
} = require('../services/merchantStoreMatchService'); } = require('../services/merchantStoreMatchService');
const { categorizeTransaction } = require('../services/spendingService'); const { categorizeTransaction } = require('../services/spendingService.cts');
const { todayLocal } = require('../utils/dates.mts'); const { todayLocal } = require('../utils/dates.mts');
const { roundMoney } = require('../utils/money.mts'); const { roundMoney } = require('../utils/money.mts');

View File

@ -213,7 +213,7 @@ async function main() {
const db = getDb(); const db = getDb();
// Migrate DB-key-encrypted secrets to env key when TOKEN_ENCRYPTION_KEY is set // 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 { try {
reEncryptWithEnvKey(db); reEncryptWithEnvKey(db);
} catch (err) { } catch (err) {

View File

@ -1,12 +1,24 @@
'use strict'; 'use strict';
const { accountingActiveSql } = require('./paymentAccountingService'); import type { Dollars } from '../utils/money.mts';
const { fromCents } = require('../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 // The 6 calendar months immediately preceding (year, month), newest first, each
// as { y, m, key: 'YYYY-MM' }. // as { y, m, key: 'YYYY-MM' }.
function priorMonths(year, month, count = 6) { function priorMonths(year: number, month: number, count = 6): PriorMonth[] {
const list = []; const list: PriorMonth[] = [];
let y = year; let y = year;
let m = month; let m = month;
for (let i = 0; i < count; i++) { 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). // 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; if (amounts.length === 0) return null;
const sorted = [...amounts].sort((a, b) => a - b); const sorted = [...amounts].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2); 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 * of the last 6 months of actual data. Prefers monthly_bill_state.actual_amount
* (user-corrected values) over raw payment sums. * (user-corrected values) over raw payment sums.
*/ */
function computeAmountSuggestion(db, billId, year, month) { function computeAmountSuggestion(db: Db, billId: number, year: number, month: number): AmountSuggestion | null {
const amounts = []; const amounts: number[] = [];
for (const { y, m } of priorMonths(year, month, 6)) { for (const { y, m } of priorMonths(year, month, 6)) {
const mbs = db.prepare( const mbs = db.prepare(
@ -72,8 +84,8 @@ function computeAmountSuggestion(db, billId, year, month) {
* bill. Returns a Map<billId, suggestion|null>. Behavior-identical to calling * bill. Returns a Map<billId, suggestion|null>. Behavior-identical to calling
* computeAmountSuggestion per bill (guarded by amountSuggestionService.test.js). * computeAmountSuggestion per bill (guarded by amountSuggestionService.test.js).
*/ */
function computeAmountSuggestionsBatch(db, billIds, year, month) { function computeAmountSuggestionsBatch(db: Db, billIds: number[], year: number, month: number): Map<number, AmountSuggestion | null> {
const result = new Map(); const result = new Map<number, AmountSuggestion | null>();
if (!billIds || billIds.length === 0) return result; if (!billIds || billIds.length === 0) return result;
const months = priorMonths(year, month, 6); const months = priorMonths(year, month, 6);
@ -88,7 +100,7 @@ function computeAmountSuggestionsBatch(db, billIds, year, month) {
const placeholders = billIds.map(() => '?').join(','); const placeholders = billIds.map(() => '?').join(',');
// User-corrected monthly amounts. // User-corrected monthly amounts.
const mbsMap = new Map(); // billId → { 'YYYY-MM': actual_amount } const mbsMap = new Map<number, Record<string, number>>(); // billId → { 'YYYY-MM': actual_amount }
const mbsRows = db.prepare(` const mbsRows = db.prepare(`
SELECT bill_id, year, month, actual_amount SELECT bill_id, year, month, actual_amount
FROM monthly_bill_state FROM monthly_bill_state
@ -98,12 +110,13 @@ function computeAmountSuggestionsBatch(db, billIds, year, month) {
if (r.actual_amount == null) continue; if (r.actual_amount == null) continue;
const key = `${r.year}-${String(r.month).padStart(2, '0')}`; const key = `${r.year}-${String(r.month).padStart(2, '0')}`;
if (!monthKeys.has(key)) continue; if (!monthKeys.has(key)) continue;
if (!mbsMap.has(r.bill_id)) mbsMap.set(r.bill_id, {}); let bucket = mbsMap.get(r.bill_id);
mbsMap.get(r.bill_id)[key] = r.actual_amount; if (!bucket) { bucket = {}; mbsMap.set(r.bill_id, bucket); }
bucket[key] = r.actual_amount;
} }
// Payment sums grouped by bill + month. // Payment sums grouped by bill + month.
const payMap = new Map(); // billId → { 'YYYY-MM': total } const payMap = new Map<number, Record<string, number>>(); // billId → { 'YYYY-MM': total }
const payRows = db.prepare(` const payRows = db.prepare(`
SELECT bill_id, strftime('%Y-%m', paid_date) AS ym, COALESCE(SUM(amount), 0) AS total SELECT bill_id, strftime('%Y-%m', paid_date) AS ym, COALESCE(SUM(amount), 0) AS total
FROM payments FROM payments
@ -115,12 +128,13 @@ function computeAmountSuggestionsBatch(db, billIds, year, month) {
`).all(...billIds, windowStart, windowEnd); `).all(...billIds, windowStart, windowEnd);
for (const r of payRows) { for (const r of payRows) {
if (!monthKeys.has(r.ym)) continue; if (!monthKeys.has(r.ym)) continue;
if (!payMap.has(r.bill_id)) payMap.set(r.bill_id, {}); let bucket = payMap.get(r.bill_id);
payMap.get(r.bill_id)[r.ym] = r.total; if (!bucket) { bucket = {}; payMap.set(r.bill_id, bucket); }
bucket[r.ym] = r.total;
} }
for (const billId of billIds) { for (const billId of billIds) {
const amounts = []; const amounts: number[] = [];
const mbsFor = mbsMap.get(billId) || {}; const mbsFor = mbsMap.get(billId) || {};
const payFor = payMap.get(billId) || {}; const payFor = payMap.get(billId) || {};
for (const { key } of months) { for (const { key } of months) {

View File

@ -1,21 +1,41 @@
'use strict'; 'use strict';
const { getDb } = require('../db/database'); import type { Db } from '../types/db';
const { accountingActiveSql } = require('./paymentAccountingService');
const { sumMoney, fromCents } = require('../utils/money.mts');
const { resolveDueDate } = require('./statusService');
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<string, any>;
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; if (value === undefined || value === null || value === '') return fallback;
const parsed = Number(value); const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : NaN; return Number.isInteger(parsed) ? parsed : NaN;
} }
function monthKey(year, month) { function monthKey(year: number, month: number): string {
return `${year}-${String(month).padStart(2, '0')}`; 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', { return new Date(Date.UTC(year, month - 1, 1)).toLocaleString('en-US', {
month: 'short', month: 'short',
year: '2-digit', 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)); const date = new Date(Date.UTC(year, month - 1 + delta, 1));
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 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(); const day = new Date(Date.UTC(year, month, 0)).getUTCDate();
return `${monthKey(year, month)}-${String(day).padStart(2, '0')}`; 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) => { return Array.from({ length: count }, (_, index) => {
const value = addMonths(endYear, endMonth, index - count + 1); const value = addMonths(endYear, endMonth, index - count + 1);
return { 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 year = parseInteger(query.year, now.getFullYear());
const month = parseInteger(query.month, now.getMonth() + 1); const month = parseInteger(query.month, now.getMonth() + 1);
const months = parseInteger(query.months, 12); const months = parseInteger(query.months, 12);
@ -55,13 +75,13 @@ function validateSummaryQuery(query, now = new Date()) {
const includeInactive = query.include_inactive === 'true'; const includeInactive = query.include_inactive === 'true';
const includeSkipped = query.include_skipped !== 'false'; 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' }; 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' }; 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' }; return { error: 'months must be an integer between 1 and 36' };
} }
if (categoryId !== null && (!Number.isInteger(categoryId) || categoryId < 1)) { 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 { 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 now = new Date();
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const targetMonthStart = new Date(year, month - 1, 1); const targetMonthStart = new Date(year, month - 1, 1);
return targetMonthStart < currentMonthStart; 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 clauses = ['b.user_id = ?', 'b.deleted_at IS NULL'];
const params = [userId]; const params: any[] = [userId];
if (!includeInactive) clauses.push('b.active = 1'); if (!includeInactive) clauses.push('b.active = 1');
if (categoryId) { if (categoryId) {
clauses.push('b.category_id = ?'); clauses.push('b.category_id = ?');
@ -96,7 +116,7 @@ function buildBillWhere({ userId, categoryId, billId, includeInactive }) {
return { where: clauses.join(' AND '), params }; 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 { return {
range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate }, range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate },
filters: { 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); 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 rangeMonths = buildMonths(parsed.year, parsed.month, parsed.months);
const startDate = rangeMonths[0].start; const startDate = rangeMonths[0].start;
const endDate = rangeMonths[rangeMonths.length - 1].end; const endDate = rangeMonths[rangeMonths.length - 1].end;
@ -147,7 +167,7 @@ function getAnalyticsSummary(userId, query = {}) {
return emptySummary(parsed, rangeMonths, startDate, endDate, categories); 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 placeholders = billIds.map(() => '?').join(',');
const paymentRows = db.prepare(` const paymentRows = db.prepare(`
@ -180,13 +200,13 @@ function getAnalyticsSummary(userId, query = {}) {
rangeMonths[rangeMonths.length - 1].year * 100 + rangeMonths[rangeMonths.length - 1].month, 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 paymentByBillMonth = new Map<string, number>(paymentRows.map((row: 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 stateByBillMonth = new Map<string, any>(stateRows.map((row: Row) => [`${row.bill_id}:${monthKey(row.year, row.month)}`, row]));
const monthly_spending = rangeMonths.map(m => { 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) }; 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 expected_vs_actual = rangeMonths.map(m => {
const [mYear, mMonth] = m.key.split('-').map(Number); const [mYear, mMonth] = m.key.split('-').map(Number);
@ -214,9 +234,9 @@ function getAnalyticsSummary(userId, query = {}) {
actual: fromCents(actual), actual: fromCents(actual),
skipped_count, 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<string, any>();
for (const bill of bills) { for (const bill of bills) {
const categoryId = bill.category_id || null; const categoryId = bill.category_id || null;
const key = categoryId == null ? 'uncategorized' : String(categoryId); const key = categoryId == null ? 'uncategorized' : String(categoryId);
@ -235,7 +255,7 @@ function getAnalyticsSummary(userId, query = {}) {
.filter(row => row.total > 0) .filter(row => row.total > 0)
.sort((a, b) => b.total - a.total); .sort((a, b) => b.total - a.total);
const heatmapRows = bills.map(bill => { const heatmapRows = bills.map((bill: Row) => {
const cells = rangeMonths.map(m => { const cells = rangeMonths.map(m => {
const paid = (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0) > 0; const paid = (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0) > 0;
const state = stateByBillMonth.get(`${bill.id}:${m.key}`); const state = stateByBillMonth.get(`${bill.id}:${m.key}`);
@ -269,7 +289,7 @@ function getAnalyticsSummary(userId, query = {}) {
include_skipped: parsed.includeSkipped, include_skipped: parsed.includeSkipped,
}, },
categories, categories,
bills: bills.map(b => ({ bills: bills.map((b: Row) => ({
id: b.id, id: b.id,
name: b.name, name: b.name,
category_id: b.category_id, category_id: b.category_id,

View File

@ -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. * APR / amortization mathematics.
* All functions are pure no DB access, no side effects. * All functions are pure no DB access, no side effects.
*/ */
// Dynamic debt/bill rows — columns read individually.
type Row = Record<string, any>;
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 const MAX_MONTHS = 600; // 50-year simulation cap
// ── Primitives ──────────────────────────────────────────────────────────────── // ── 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. * 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); 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) * Returns null if the payment never covers the interest (debt grows forever)
* or if the balance/payment are invalid. * 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; const rate = Math.max(0, Number(annualRatePct) || 0) / 100 / 12;
let bal = Number(balance); let bal = Number(balance);
const pmt = Number(monthlyPayment); const pmt = Number(monthlyPayment);
@ -42,13 +70,8 @@ function monthsToPayoff(balance, annualRatePct, monthlyPayment) {
/** /**
* Full month-by-month amortization schedule for a single debt. * Full month-by-month amortization schedule for a single debt.
* Each row: { month, payment, principal, interest, balance } * 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; const rate = Math.max(0, Number(annualRatePct) || 0) / 100 / 12;
let bal = Number(balance); let bal = Number(balance);
const pmt = Number(monthlyPayment); 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 (!Number.isFinite(bal) || bal <= 0 || !Number.isFinite(pmt) || pmt <= 0) return [];
if (rate > 0 && pmt <= bal * rate) return []; if (rate > 0 && pmt <= bal * rate) return [];
const schedule = []; const schedule: ScheduleRow[] = [];
let month = 0; let month = 0;
while (bal > 0.005 && month < cap) { 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. * Returns the same shape as calculateSnowball so callers can compare directly.
*/ */
function calculateMinimumOnly(debts, startDate = new Date()) { function calculateMinimumOnly(debts: Row[], startDate: Date = new Date()): DebtProjection {
const active = []; const active: ActiveDebt[] = [];
const skipped = []; const skipped: SkippedDebt[] = [];
for (const d of debts) { for (const d of debts) {
const bal = Number(d.current_balance); const bal = Number(d.current_balance);
@ -150,11 +173,11 @@ function calculateMinimumOnly(debts, startDate = new Date()) {
const baseYear = startDate.getFullYear(); const baseYear = startDate.getFullYear();
const baseMo = startDate.getMonth(); const baseMo = startDate.getMonth();
function monthLabel(m) { function monthLabel(m: number): string {
const d = new Date(baseYear, baseMo + m, 1); const d = new Date(baseYear, baseMo + m, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; 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) return new Date(baseYear, baseMo + m, 1)
.toLocaleDateString('en-US', { year: 'numeric', month: 'long' }); .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. * 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. * 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 balance = Number(bill.current_balance);
const apr = Number(bill.interest_rate) || 0; const apr = Number(bill.interest_rate) || 0;
const min = Number(bill.minimum_payment) || 0; const min = Number(bill.minimum_payment) || 0;
@ -217,7 +243,7 @@ function debtAprSnapshot(bill) {
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
function round2(n) { function round2(n: number): Dollars {
return roundMoney(n); return roundMoney(n);
} }

View File

@ -2,7 +2,7 @@ const crypto = require('crypto');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { buildDeviceFingerprint } = require('./loginFingerprint'); 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. // Store SHA-256(token) in the DB; the raw token stays only in the cookie.
function hashSession(token) { 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 // TOTP is enabled — don't create a session yet; issue a short-lived challenge instead
if (user.totp_enabled) { if (user.totp_enabled) {
const { createChallenge } = require('./totpService'); const { createChallenge } = require('./totpService.cts');
const challengeToken = createChallenge(getDb(), user.id); const challengeToken = createChallenge(getDb(), user.id);
return { requires_totp: true, challenge_token: challengeToken }; return { requires_totp: true, challenge_token: challengeToken };
} }

View File

@ -1,6 +1,6 @@
'use strict'; 'use strict';
const { assertEncryptionReady, encryptSecret, decryptSecret } = require('./encryptionService'); const { assertEncryptionReady, encryptSecret, decryptSecret } = require('./encryptionService.cts');
const { const {
claimSetupToken, claimSetupToken,
fetchAccountsAndTransactions, fetchAccountsAndTransactions,
@ -10,8 +10,8 @@ const {
} = require('./simplefinService'); } = require('./simplefinService');
const { getBankSyncConfig, SYNC_DAYS_EFFECTIVE, SYNC_DAYS_DEFAULT } = require('./bankSyncConfigService'); const { getBankSyncConfig, SYNC_DAYS_EFFECTIVE, SYNC_DAYS_DEFAULT } = require('./bankSyncConfigService');
const { decorateDataSource } = require('./transactionService'); const { decorateDataSource } = require('./transactionService');
const { applyMerchantRules } = require('./billMerchantRuleService'); const { applyMerchantRules } = require('./billMerchantRuleService.cts');
const { applySpendingCategoryRules } = require('./spendingService'); const { applySpendingCategoryRules } = require('./spendingService.cts');
const { applyMerchantStoreMatches } = require('./merchantStoreMatchService'); const { applyMerchantStoreMatches } = require('./merchantStoreMatchService');
const { autoMatchForUser } = require('./matchSuggestionService'); const { autoMatchForUser } = require('./matchSuggestionService');
const { getUserSettings } = require('./userSettings'); const { getUserSettings } = require('./userSettings');

View File

@ -1,19 +1,21 @@
'use strict'; 'use strict';
import type { Db } from '../types/db';
const { normalizeMerchant } = require('./subscriptionService'); const { normalizeMerchant } = require('./subscriptionService');
const { getUserSettings } = require('./userSettings'); const { getUserSettings } = require('./userSettings');
const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService'); const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService.cts');
const { localDateString } = require('../utils/dates.mts'); const { localDateString } = require('../utils/dates.mts') as typeof import('../utils/dates.mts');
const { fromCents } = require('../utils/money.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) // 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. // within the transaction string (or vice versa), not just as a substring.
// Prevents "suno" matching "sunoco", "prime" matching "prime video", etc. // 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 false;
if (txMerchant === ruleMerchant) return true; if (txMerchant === ruleMerchant) return true;
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordBoundary = s => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`); const wordBoundary = (s: string) => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`);
return wordBoundary(ruleMerchant).test(txMerchant) || return wordBoundary(ruleMerchant).test(txMerchant) ||
wordBoundary(txMerchant).test(ruleMerchant); 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 // 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. // prior month. Grace window: up to `graceDays` days into the new month.
// Module-scoped so both applyMerchantRules and syncBillPaymentsFromSimplefin can use it. // 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 paid = new Date(paidDateStr + 'T00:00:00');
const dayOfMonth = paid.getDate(); const dayOfMonth = paid.getDate();
if (dayOfMonth > graceDays) return null; 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. // 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); const normalized = normalizeMerchant(merchant);
if (!normalized || normalized.length < 3) return; if (!normalized || normalized.length < 3) return;
try { try {
@ -57,14 +59,14 @@ const GENERIC_MERCHANT_TOKENS = new Set([
// Derive a specific, reusable merchant string from a confirmed transaction match. // 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. // 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; if (!transaction) return null;
const raw = transaction.payee || transaction.description || ''; const raw = transaction.payee || transaction.description || '';
const norm = normalizeMerchant(raw); const norm = normalizeMerchant(raw);
if (!norm || norm.length < 4) return null; if (!norm || norm.length < 4) return null;
const tokens = norm.split(' ').filter(Boolean); const tokens = norm.split(' ').filter(Boolean);
// Require at least one meaningful (non-generic, length >= 3) token. // 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; if (meaningful.length === 0) return null;
return norm; return norm;
} }
@ -72,7 +74,7 @@ function learnableMerchantFromTransaction(transaction) {
// Learn a merchant→bill rule from an explicit user confirmation so future synced // Learn a merchant→bill rule from an explicit user confirmation so future synced
// transactions from the same merchant auto-match. Best-effort and idempotent — // transactions from the same merchant auto-match. Best-effort and idempotent —
// never throws, returns the merchant it stored (or null when nothing was learned). // 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 { try {
const merchant = learnableMerchantFromTransaction(transaction); const merchant = learnableMerchantFromTransaction(transaction);
if (!merchant) return null; 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 // Scan all unmatched negative transactions for this user, apply any stored
// merchant rules, create payments, and mark the transactions matched. // merchant rules, create payments, and mark the transactions matched.
// Returns { matched: number }. // Returns { matched: number }.
function applyMerchantRules(db, userId) { function applyMerchantRules(db: Db, userId: number): { matched: number; matched_bills?: string[]; late_attributions?: any[] } {
let rules; let rules: any[];
try { try {
rules = db.prepare(` rules = db.prepare(`
SELECT bmr.bill_id, bmr.merchant, bmr.auto_attribute_late, b.name AS bill_name, b.due_day 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 // Longer (more specific) rules win when multiple could match the same transaction
rules.sort((a, b) => b.merchant.length - a.merchant.length); rules.sort((a, b) => b.merchant.length - a.merchant.length);
let txRows; let txRows: any[];
try { try {
txRows = db.prepare(` txRows = db.prepare(`
SELECT t.id, t.amount, t.payee, t.description, t.memo, t.posted_date, t.transacted_at 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.pending = 0
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1) AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
`).all(userId); `).all(userId);
} catch (err) { } catch (err: any) {
console.error('[applyMerchantRules] Failed to fetch transactions:', err.message); console.error('[applyMerchantRules] Failed to fetch transactions:', err.message);
return { matched: 0, matched_bills: [] }; return { matched: 0, matched_bills: [] };
} }
@ -125,7 +127,7 @@ function applyMerchantRules(db, userId) {
if (txRows.length === 0) return { matched: 0, matched_bills: [] }; if (txRows.length === 0) return { matched: 0, matched_bills: [] };
// Global grace window — auto-apply late attribution for ALL bills within this many days // 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 globalGraceDays = parseInt(userSettings.bank_late_attribution_days, 10) || 0;
const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL'); 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; let matched = 0;
const matchedBills = new Map(); // bill_id → bill_name for the summary const matchedBills = new Map<number, string>(); // bill_id → bill_name for the summary
const lateAttributions = []; // payments that crossed a month boundary const lateAttributions: any[] = []; // payments that crossed a month boundary
try { try {
db.transaction(() => { 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); console.error('[applyMerchantRules] Transaction failed, no payments recorded:', err.message);
return { matched: 0, matched_bills: [], late_attributions: [] }; 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 // merchant rules. If no rule exists yet but the bill has a detected merchant in
// its notes, the rule is created on the fly. // its notes, the rule is created on the fly.
// Returns { added: number }. // 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 // Load rules for this specific bill
let rules; let rules: any[];
try { try {
rules = db.prepare(` rules = db.prepare(`
SELECT merchant FROM bill_merchant_rules SELECT merchant FROM bill_merchant_rules
WHERE user_id = ? AND bill_id = ? WHERE user_id = ? AND bill_id = ?
ORDER BY LENGTH(merchant) DESC ORDER BY LENGTH(merchant) DESC
`).all(userId, billId).map(r => r.merchant); `).all(userId, billId).map((r: any) => r.merchant);
} catch { } catch {
return { added: 0 }; return { added: 0 };
} }
@ -248,13 +250,13 @@ function syncBillPaymentsFromSimplefin(db, userId, billId) {
rules = [extracted]; rules = [extracted];
} }
} }
} catch (err) { } catch (err: any) {
console.error('[syncBillPaymentsFromSimplefin] Failed to read bill notes:', err.message); console.error('[syncBillPaymentsFromSimplefin] Failed to read bill notes:', err.message);
} }
if (rules.length === 0) return { added: 0 }; if (rules.length === 0) return { added: 0 };
} }
let txRows; let txRows: any[];
try { try {
txRows = db.prepare(` txRows = db.prepare(`
SELECT t.id, t.amount, t.payee, t.description, t.memo, t.posted_date, t.transacted_at 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.pending = 0
AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1) AND (t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)
`).all(userId); `).all(userId);
} catch (err) { } catch (err: any) {
console.error('[syncBillPaymentsFromSimplefin] Failed to fetch transactions:', err.message); console.error('[syncBillPaymentsFromSimplefin] Failed to fetch transactions:', err.message);
return { added: 0 }; 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'); const getPaymentId = db.prepare('SELECT * FROM payments WHERE transaction_id = ? AND bill_id = ? AND deleted_at IS NULL');
let added = 0; let added = 0;
const lateAttributions = []; const lateAttributions: any[] = [];
try { try {
db.transaction(() => { db.transaction(() => {
for (const tx of txRows) { 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); console.error('[syncBillPaymentsFromSimplefin] Transaction failed, no payments recorded:', err.message);
return { added: 0, late_attributions: [] }; return { added: 0, late_attributions: [] };
} }

View File

@ -2,7 +2,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { normalizeCycleType, resolveDueDate } = require('./statusService'); const { normalizeCycleType, resolveDueDate } = require('./statusService.cts');
const { fromCents } = require('../utils/money.mts'); const { fromCents } = require('../utils/money.mts');
const PRODID = '-//Bill Tracker//Calendar Feed//EN'; const PRODID = '-//Bill Tracker//Calendar Feed//EN';

View File

@ -1,17 +1,32 @@
'use strict'; 'use strict';
import type { Dollars } from '../utils/money.mts';
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { getCycleRange } = require('./statusService'); const { getCycleRange } = require('./statusService.cts');
const { accountingActiveSql } = require('./paymentAccountingService'); const { accountingActiveSql } = require('./paymentAccountingService.cts');
const { getUserSettings } = require('./userSettings'); const { getUserSettings } = require('./userSettings');
const { localDateString } = require('../utils/dates.mts'); const { localDateString } = require('../utils/dates.mts') as typeof import('../utils/dates.mts');
const { roundMoney, fromCents } = require('../utils/money.mts'); const { roundMoney, fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts');
const MONTHS_BACK = 3; const MONTHS_BACK = 3;
const MIN_PAID_MONTHS = 2; const MIN_PAID_MONTHS = 2;
const MIN_ABS_DELTA = 1.00; 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; if (!arr.length) return 0;
const sorted = [...arr].sort((a, b) => a - b); const sorted = [...arr].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2); const mid = Math.floor(sorted.length / 2);
@ -20,11 +35,11 @@ function median(arr) {
: (sorted[mid - 1] + sorted[mid]) / 2; : (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(); 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 { try {
const db = getDb(); const db = getDb();
const settings = getUserSettings(userId); const settings = getUserSettings(userId);
@ -40,7 +55,7 @@ function getDriftReport(userId, now = new Date()) {
`).all(userId); `).all(userId);
const todayStr = localDateString(now); const todayStr = localDateString(now);
const drifted = []; const drifted: DriftBillRow[] = [];
const mbsStmt = db.prepare( const mbsStmt = db.prepare(
'SELECT is_skipped FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?' '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 (!expectedAmount || expectedAmount <= 0) continue;
if (bill.drift_snoozed_until && bill.drift_snoozed_until > todayStr) 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++) { for (let i = 1; i <= MONTHS_BACK; i++) {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1)); 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; if (!range) continue;
const { total } = payStmt.get(bill.id, range.start, range.end); 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; if (monthTotals.length < MIN_PAID_MONTHS) continue;
@ -102,7 +117,7 @@ function getDriftReport(userId, now = new Date()) {
} }
return { bills: drifted, threshold_pct: thresholdPct }; return { bills: drifted, threshold_pct: thresholdPct };
} catch (err) { } catch (err: any) {
console.error('[driftService] getDriftReport error:', err.message); console.error('[driftService] getDriftReport error:', err.message);
return { bills: [], threshold_pct: 5, error: err.message }; return { bills: [], threshold_pct: 5, error: err.message };
} }

View File

@ -1,5 +1,7 @@
'use strict'; 'use strict';
import type { Db } from '../types/db';
const crypto = require('crypto'); const crypto = require('crypto');
const ALGORITHM = 'aes-256-gcm'; const ALGORITHM = 'aes-256-gcm';
@ -16,13 +18,13 @@ const ENV_PREFIX = 'e2:';
const V2_PREFIX = 'v2:'; const V2_PREFIX = 'v2:';
// Returns the env-provided IKM, or null if the var is not set. // 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(); const k = process.env.TOKEN_ENCRYPTION_KEY?.trim();
return k ? Buffer.from(k, 'utf8') : null; return k ? Buffer.from(k, 'utf8') : null;
} }
// Returns the auto-generated DB IKM, creating it on first call. // Returns the auto-generated DB IKM, creating it on first call.
function getDbIkm() { function getDbIkm(): Buffer {
const { getSetting, setSetting } = require('../db/database'); const { getSetting, setSetting } = require('../db/database');
let stored = getSetting('_auto_encryption_key'); let stored = getSetting('_auto_encryption_key');
if (!stored) { if (!stored) {
@ -32,17 +34,17 @@ function getDbIkm() {
return Buffer.from(stored, 'utf8'); return Buffer.from(stored, 'utf8');
} }
function deriveKey(ikm) { function deriveKey(ikm: Buffer): Buffer {
return Buffer.from(crypto.hkdfSync('sha256', ikm, /* salt */ '', HKDF_INFO, 32)); return Buffer.from(crypto.hkdfSync('sha256', ikm, /* salt */ '', HKDF_INFO, 32));
} }
// Legacy derivation — used only to decrypt pre-v0.78 ciphertext (no prefix). // 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(); return crypto.createHash('sha256').update(ikm).digest();
} }
// True when TOKEN_ENCRYPTION_KEY is present in the environment. // True when TOKEN_ENCRYPTION_KEY is present in the environment.
function isEnvKeyActive() { function isEnvKeyActive(): boolean {
return !!process.env.TOKEN_ENCRYPTION_KEY?.trim(); 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 // 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 // when none exists yet). A 16-hex prefix of SHA-256 over a 48-byte random key is
// not reversible or guessable. // not reversible or guessable.
function keyFingerprint() { function keyFingerprint(): string | null {
let ikm = getEnvIkm(); let ikm = getEnvIkm();
if (!ikm) { if (!ikm) {
const { getSetting } = require('../db/database'); const { getSetting } = require('../db/database');
@ -68,11 +70,11 @@ function keyFingerprint() {
} }
// No-op kept for call-site compatibility. // 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, // Encrypts with the env key (e2: prefix) when TOKEN_ENCRYPTION_KEY is set,
// otherwise with the DB key (v2: prefix). // otherwise with the DB key (v2: prefix).
function encryptSecret(plaintext) { function encryptSecret(plaintext: string): string {
const envIkm = getEnvIkm(); const envIkm = getEnvIkm();
const ikm = envIkm ?? getDbIkm(); const ikm = envIkm ?? getDbIkm();
const prefix = envIkm ? ENV_PREFIX : V2_PREFIX; 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. // 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 isEnv = stored.startsWith(ENV_PREFIX);
const isV2 = !isEnv && stored.startsWith(V2_PREFIX); const isV2 = !isEnv && stored.startsWith(V2_PREFIX);
const payload = stored.slice(isEnv ? ENV_PREFIX.length : isV2 ? V2_PREFIX.length : 0); 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'); if (parts.length !== 3) throw new Error('Invalid encrypted secret format');
const [ivHex, tagHex, ctHex] = parts; const [ivHex, tagHex, ctHex] = parts;
let key; let key: Buffer;
if (isEnv) { if (isEnv) {
const envIkm = getEnvIkm(); const envIkm = getEnvIkm();
if (!envIkm) throw new Error('TOKEN_ENCRYPTION_KEY is required to decrypt this secret but is not set'); 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. // Re-encrypts all DB-key-encrypted secrets with the env key.
// Called at startup when TOKEN_ENCRYPTION_KEY is present — idempotent. // Called at startup when TOKEN_ENCRYPTION_KEY is present — idempotent.
// Already-migrated (e2:) values are skipped. // Already-migrated (e2:) values are skipped.
function reEncryptWithEnvKey(db) { function reEncryptWithEnvKey(db: Db): void {
if (!isEnvKeyActive()) return; if (!isEnvKeyActive()) return;
// All table+column pairs that store encrypted values. // All table+column pairs that store encrypted values.
@ -136,7 +138,7 @@ function reEncryptWithEnvKey(db) {
]; ];
const SETTINGS_KEYS = ['notify_smtp_password', 'oidc_client_secret']; 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 if (!val || val.startsWith(ENV_PREFIX)) return val; // null or already migrated
try { try {
return encryptSecret(decryptSecret(val)); // decrypt with old key, encrypt with new return encryptSecret(decryptSecret(val)); // decrypt with old key, encrypt with new
@ -150,7 +152,7 @@ function reEncryptWithEnvKey(db) {
db.transaction(() => { db.transaction(() => {
for (const { table, col } of TABLE_COLS) { for (const { table, col } of TABLE_COLS) {
// Guard against schema differences across upgrade paths // 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; if (!existing.includes(col)) continue;
const rows = db.prepare(`SELECT id, ${col} FROM ${table} WHERE ${col} IS NOT NULL`).all(); const rows = db.prepare(`SELECT id, ${col} FROM ${table} WHERE ${col} IS NOT NULL`).all();

View File

@ -1,7 +1,7 @@
'use strict'; 'use strict';
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { getCycleRange, resolveDueDate } = require('./statusService'); const { getCycleRange, resolveDueDate } = require('./statusService.cts');
const { decorateTransaction } = require('./transactionService'); const { decorateTransaction } = require('./transactionService');
const { fromCents } = require('../utils/money.mts'); const { fromCents } = require('../utils/money.mts');

View File

@ -1,6 +1,6 @@
'use strict'; 'use strict';
const { categorizeTransaction } = require('./spendingService'); const { categorizeTransaction } = require('./spendingService.cts');
// Mirrors the pack's match_order_recommendation: regional descriptor variants // Mirrors the pack's match_order_recommendation: regional descriptor variants
// (most specific — tied to a city/county) are checked first, then online // (most specific — tied to a city/county) are checked first, then online

View File

@ -1,7 +1,7 @@
const nodemailer = require('nodemailer'); const nodemailer = require('nodemailer');
const { getDb, getSetting } = require('../db/database'); const { getDb, getSetting } = require('../db/database');
const { decryptSecret, encryptSecret } = require('./encryptionService'); const { decryptSecret, encryptSecret } = require('./encryptionService.cts');
const { accountingActiveSql } = require('./paymentAccountingService'); const { accountingActiveSql } = require('./paymentAccountingService.cts');
const { const {
markNotificationError, markNotificationError,
markNotificationSuccess, markNotificationSuccess,
@ -311,7 +311,7 @@ async function runNotifications() {
const month = now.getMonth() + 1; const month = now.getMonth() + 1;
const today = localDateString(now); 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 // Fetch all active bills. In global-notification mode, the single global recipient
// legitimately receives every bill. In per-user mode, each recipient must only // 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; if (!getSetting('notify_sender_address')) return;
const db = getDb(); const db = getDb();
const { getDriftReport } = require('./driftService'); const { getDriftReport } = require('./driftService.cts');
const now = new Date(); const now = new Date();
const year = now.getFullYear(); const year = now.getFullYear();
const month = now.getMonth() + 1; const month = now.getMonth() + 1;

View File

@ -52,7 +52,7 @@ const crypto = require('crypto');
const { Issuer } = require('openid-client'); const { Issuer } = require('openid-client');
const { getDb, getSetting, setSetting } = require('../db/database'); 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 // Decrypt the stored OIDC client secret, falling back to plaintext for
// values saved before encryption was introduced (pre-v0.79). // values saved before encryption was introduced (pre-v0.79).

View File

@ -1,40 +1,46 @@
'use strict'; 'use strict';
import type { Db } from '../types/db';
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); 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<string, any>;
const ACCOUNTING_ACTIVE_SQL = 'COALESCE(accounting_excluded, 0) = 0'; const ACCOUNTING_ACTIVE_SQL = 'COALESCE(accounting_excluded, 0) = 0';
const BANK_PAYMENT_SOURCES = new Set(['provider_sync', 'transaction_match', 'auto_match']); const BANK_PAYMENT_SOURCES = new Set(['provider_sync', 'transaction_match', 'auto_match']);
const OVERRIDE_REASON = 'overridden_by_bank'; const OVERRIDE_REASON = 'overridden_by_bank';
function accountingActiveSql(alias = null) { function accountingActiveSql(alias: string | null = null): string {
const prefix = alias ? `${alias}.` : ''; const prefix = alias ? `${alias}.` : '';
return `COALESCE(${prefix}accounting_excluded, 0) = 0`; 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; 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(); const current = String(existing || '').trim();
if (current.includes(line)) return current || line; if (current.includes(line)) return current || line;
return current ? `${current}\n${line}`.slice(0, 500) : line.slice(0, 500); 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}$/); const match = String(paidDate || '').match(/^(\d{4})-(\d{2})-\d{2}$/);
if (!match) return null; if (!match) return null;
return { year: Number(match[1]), month: Number(match[2]) }; return { year: Number(match[1]), month: Number(match[2]) };
} }
function cycleRangeForPayment(bill, paidDate) { function cycleRangeForPayment(bill: Row, paidDate: unknown): any {
const ym = paymentMonth(paidDate); const ym = paymentMonth(paidDate);
if (!ym) return null; if (!ym) return null;
return getCycleRange(ym.year, ym.month, bill); 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; if (!payment || payment.balance_delta == null) return;
const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id); const bill = db.prepare('SELECT id, current_balance FROM bills WHERE id = ?').get(payment.bill_id);
if (bill?.current_balance == null) return; if (bill?.current_balance == null) return;
@ -49,7 +55,7 @@ function reversePaymentBalance(db, payment) {
`).run(restored, payment.interest_delta != null ? 1 : 0, bill.id); `).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); 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 }; if (!bill) return { balance_delta: null, interest_delta: null };
const balCalc = computeBalanceDelta(bill, amount); 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 }; if (!bill || !bankPayment || !isBankBackedPayment(bankPayment)) return { overridden: 0 };
const range = cycleRangeForPayment(bill, bankPayment.paid_date); const range = cycleRangeForPayment(bill, bankPayment.paid_date);
if (!range) return { overridden: 0 }; if (!range) return { overridden: 0 };
@ -100,7 +106,7 @@ function markProvisionalManualPaymentsOverridden(db, bill, bankPayment) {
return { overridden: provisionalPayments.length }; return { overridden: provisionalPayments.length };
} }
function reactivatePaymentsOverriddenBy(db, bankPaymentId) { function reactivatePaymentsOverriddenBy(db: Db, bankPaymentId: number): { reactivated: number } {
const rows = db.prepare(` const rows = db.prepare(`
SELECT * SELECT *
FROM payments FROM payments
@ -135,7 +141,7 @@ function reactivatePaymentsOverriddenBy(db, bankPaymentId) {
return { reactivated: rows.length }; 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); markProvisionalManualPaymentsOverridden(db, bill, bankPayment);
const deltas = applyPaymentBalanceFromFreshBill(db, bill.id, bankPayment.amount); const deltas = applyPaymentBalanceFromFreshBill(db, bill.id, bankPayment.amount);
db.prepare(` db.prepare(`

View File

@ -1,8 +1,10 @@
'use strict'; 'use strict';
import type { Db } from '../types/db';
const { normalizeMerchant } = require('./subscriptionService'); const { normalizeMerchant } = require('./subscriptionService');
const { localDateString } = require('../utils/dates.mts'); const { localDateString } = require('../utils/dates.mts') as typeof import('../utils/dates.mts');
const { toCents, fromCents } = require('../utils/money.mts'); const { toCents, fromCents } = require('../utils/money.mts') as typeof import('../utils/money.mts');
// Spending = unmatched outflows (amount < 0) that haven't been ignored. // Spending = unmatched outflows (amount < 0) that haven't been ignored.
// Bill-matched transactions are excluded so there's no double-counting. // Bill-matched transactions are excluded so there's no double-counting.
@ -13,26 +15,40 @@ const SPENDING_WHERE = `
AND t.user_id = ? 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 start = `${year}-${String(month).padStart(2, '0')}-01`;
const end = localDateString(new Date(year, month, 0)); // last day const end = localDateString(new Date(year, month, 0)); // last day
return { start, end }; 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; let m = month + delta, y = year;
while (m < 1) { m += 12; y--; } while (m < 1) { m += 12; y--; }
while (m > 12) { m -= 12; y++; } while (m > 12) { m -= 12; y++; }
return { year: y, month: m }; return { year: y, month: m };
} }
function cents(raw) { function cents(raw: unknown): number {
return Math.abs(Number(raw)) / 100; return Math.abs(Number(raw)) / 100;
} }
// ── Summary ────────────────────────────────────────────────────────────────── // ── Summary ──────────────────────────────────────────────────────────────────
function getSpendingSummary(db, userId, year, month) { function getSpendingSummary(db: Db, userId: number, year: number, month: number) {
const { start, end } = monthRange(year, month); const { start, end } = monthRange(year, month);
const dateRangeParams = [start, end, start + 'T00:00:00', end + 'T23:59:59']; 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 ?))'; 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 SELECT category_id, amount FROM spending_budgets
WHERE user_id = ? AND year = ? AND month = ? WHERE user_id = ? AND year = ? AND month = ?
`).all(userId, year, 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) // 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); 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 ?)) AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))
GROUP BY t.spending_category_id GROUP BY t.spending_category_id
`).all(userId, prev3.start, prev1.end, prev3.start + 'T00:00:00', prev1.end + 'T23:59:59'); `).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; let totalCents = 0;
const byCategory = categoryRows.map(r => { const byCategory: SpendingCatRow[] = categoryRows.map((r: any) => {
totalCents += r.total_cents; totalCents += r.total_cents;
return { return {
category_id: r.category_id, category_id: r.category_id,
@ -158,17 +174,17 @@ function getSpendingSummary(db, userId, year, month) {
// ── Transactions ───────────────────────────────────────────────────────────── // ── Transactions ─────────────────────────────────────────────────────────────
function getSpendingTransactions(db, userId, year, month, { function getSpendingTransactions(db: Db, userId: number, year: number, month: number, {
categoryId = undefined, categoryId = undefined,
uncategorizedOnly = false, uncategorizedOnly = false,
page = 1, page = 1,
limit = 50, limit = 50,
} = {}) { }: SpendingTxOptions = {}) {
const { start, end } = monthRange(year, month); const { start, end } = monthRange(year, month);
const offset = (Math.max(1, page) - 1) * limit; const offset = (Math.max(1, page) - 1) * limit;
let filter = ''; 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) { if (uncategorizedOnly) {
filter = 'AND t.spending_category_id IS NULL'; filter = 'AND t.spending_category_id IS NULL';
@ -201,7 +217,7 @@ function getSpendingTransactions(db, userId, year, month, {
`).get(...params).n; `).get(...params).n;
return { return {
transactions: rows.map(r => ({ transactions: rows.map((r: any) => ({
id: r.id, id: r.id,
amount: cents(r.amount), amount: cents(r.amount),
payee: r.payee || r.description || r.memo || '(Unknown)', payee: r.payee || r.description || r.memo || '(Unknown)',
@ -217,7 +233,7 @@ function getSpendingTransactions(db, userId, year, month, {
// ── Categorize ─────────────────────────────────────────────────────────────── // ── 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); 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 }); 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 ────────────────────────────────────────────────────── // ── Auto-categorization ──────────────────────────────────────────────────────
function applySpendingCategoryRules(db, userId, onlyMerchant = null) { function applySpendingCategoryRules(db: Db, userId: number, onlyMerchant: string | null = null): number {
let rules; let rules: any[];
try { try {
const q = onlyMerchant const q = onlyMerchant
? db.prepare('SELECT merchant, category_id FROM spending_category_rules WHERE user_id=? AND merchant=?') ? 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; return applied;
} }
function merchantMatches(txMerchant, ruleMerchant) { function merchantMatches(txMerchant: string, ruleMerchant: string): boolean {
if (!txMerchant || !ruleMerchant) return false; if (!txMerchant || !ruleMerchant) return false;
if (txMerchant === ruleMerchant) return true; if (txMerchant === ruleMerchant) return true;
const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wb = s => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`); const wb = (s: string) => new RegExp(`(^|\\s)${esc(s)}(\\s|$)`);
return wb(ruleMerchant).test(txMerchant) || wb(txMerchant).test(ruleMerchant); return wb(ruleMerchant).test(txMerchant) || wb(txMerchant).test(ruleMerchant);
} }
// ── Budgets ────────────────────────────────────────────────────────────────── // ── Budgets ──────────────────────────────────────────────────────────────────
function getSpendingBudgets(db, userId, year, month) { function getSpendingBudgets(db: Db, userId: number, year: number, month: number) {
const rows = db.prepare(` const rows = db.prepare(`
SELECT sb.category_id, sb.amount, c.name AS category_name SELECT sb.category_id, sb.amount, c.name AS category_name
FROM spending_budgets sb FROM spending_budgets sb
JOIN categories c ON c.id = sb.category_id AND c.deleted_at IS NULL 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=? WHERE sb.user_id=? AND sb.year=? AND sb.month=?
`).all(userId, year, 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) { if (amount === null || amount === undefined) {
db.prepare('DELETE FROM spending_budgets WHERE user_id=? AND category_id=? AND year=? AND month=?') db.prepare('DELETE FROM spending_budgets WHERE user_id=? AND category_id=? AND year=? AND month=?')
.run(userId, categoryId, year, month); .run(userId, categoryId, year, month);
@ -312,7 +328,7 @@ function setSpendingBudget(db, userId, categoryId, year, month, amount) {
// ── Category rules ─────────────────────────────────────────────────────────── // ── Category rules ───────────────────────────────────────────────────────────
function getSpendingCategoryRules(db, userId) { function getSpendingCategoryRules(db: Db, userId: number) {
return db.prepare(` return db.prepare(`
SELECT r.id, r.merchant, r.category_id, c.name AS category_name SELECT r.id, r.merchant, r.category_id, c.name AS category_name
FROM spending_category_rules r FROM spending_category_rules r
@ -322,7 +338,7 @@ function getSpendingCategoryRules(db, userId) {
`).all(userId); `).all(userId);
} }
function addSpendingCategoryRule(db, userId, categoryId, merchant) { function addSpendingCategoryRule(db: Db, userId: number, categoryId: number, merchant: string): void {
const normalized = normalizeMerchant(merchant); const normalized = normalizeMerchant(merchant);
if (!normalized || normalized.length < 2) throw Object.assign(new Error('Merchant name too short'), { status: 400 }); if (!normalized || normalized.length < 2) throw Object.assign(new Error('Merchant name too short'), { status: 400 });
db.prepare(` db.prepare(`
@ -338,13 +354,13 @@ function addSpendingCategoryRule(db, userId, categoryId, merchant) {
applySpendingCategoryRules(db, userId, normalized); 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); db.prepare('DELETE FROM spending_category_rules WHERE id=? AND user_id=?').run(ruleId, userId);
} }
// ── Income ─────────────────────────────────────────────────────────────────── // ── 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 { start, end } = monthRange(year, month);
const offset = (Math.max(1, page) - 1) * limit; const offset = (Math.max(1, page) - 1) * limit;
const ignoredFilter = includeIgnored ? '' : 'AND ignored = 0'; 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; `).get(userId, start, end, start + 'T00:00:00', end + 'T23:59:59').n;
return { return {
transactions: rows.map(r => ({ transactions: rows.map((r: any) => ({
id: r.id, id: r.id,
amount: Math.abs(Number(r.amount)) / 100, amount: Math.abs(Number(r.amount)) / 100,
payee: r.payee || r.description || r.memo || '(Unknown)', payee: r.payee || r.description || r.memo || '(Unknown)',
@ -377,7 +393,7 @@ function getIncomeTransactions(db, userId, year, month, { page = 1, limit = 50,
page, page,
pages: Math.ceil(total / limit), pages: Math.ceil(total / limit),
}; };
} catch (err) { } catch (err: any) {
console.error('[getIncomeTransactions]', err.message); console.error('[getIncomeTransactions]', err.message);
return { transactions: [], total: 0, page: 1, pages: 1 }; return { transactions: [], total: 0, page: 1, pages: 1 };
} }

View File

@ -1,14 +1,24 @@
'use strict';
import type { Db } from '../types/db';
const { getSetting } = require('../db/database'); const { getSetting } = require('../db/database');
// Dynamic better-sqlite3 bill/payment rows — columns read individually.
type Row = Record<string, any>;
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 // 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 // via autopay). Single source of truth so the ~scattered inline
// `status === 'paid' || status === 'autodraft'` checks don't drift. // `status === 'paid' || status === 'autodraft'` checks don't drift.
const PAID_STATUSES = Object.freeze(['paid', 'autodraft']); const PAID_STATUSES = Object.freeze(['paid', 'autodraft']);
function isPaidStatus(status) { function isPaidStatus(status: string | null | undefined): boolean {
return status === 'paid' || status === 'autodraft'; return status === 'paid' || status === 'autodraft';
} }
const WEEKDAY_INDEX = { const WEEKDAY_INDEX: Record<string, number> = {
sunday: 0, sunday: 0,
monday: 1, monday: 1,
tuesday: 2, tuesday: 2,
@ -20,87 +30,90 @@ const WEEKDAY_INDEX = {
const BIWEEKLY_ANCHOR = new Date(Date.UTC(2000, 0, 3)); // Monday 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); const parsed = parseInt(value ?? getSetting('grace_period_days') ?? '5', 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 5; return Number.isInteger(parsed) && parsed >= 0 ? parsed : 5;
} }
// How many days before the due date a bill is flagged "due soon". Configurable // How many days before the due date a bill is flagged "due soon". Configurable
// like the grace period; clamped to a sane 031 with a fallback of 3. // like the grace period; clamped to a sane 031 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); const parsed = parseInt(value ?? getSetting('due_soon_days') ?? '3', 10);
if (!Number.isInteger(parsed) || parsed < 0) return 3; if (!Number.isInteger(parsed) || parsed < 0) return 3;
return Math.min(parsed, 31); return Math.min(parsed, 31);
} }
function pad(value) { function pad(value: number | string): string {
return String(value).padStart(2, '0'); 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'); 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)}`; return `${year}-${pad(month)}-${pad(day)}`;
} }
function dateFromString(value) { function dateFromString(value: string): Date {
const [year, month, day] = String(value).split('-').map(Number); const [year, month, day] = String(value).split('-').map(Number);
return new Date(Date.UTC(year, month - 1, day)); 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); const next = new Date(date);
next.setUTCDate(next.getUTCDate() + days); next.setUTCDate(next.getUTCDate() + days);
return next; return next;
} }
function toDateString(date) { function toDateString(date: Date): string {
return dateString(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()); 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(); 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 parsed = parseInt(day, 10);
const safeDay = Number.isInteger(parsed) ? parsed : 1; const safeDay = Number.isInteger(parsed) ? parsed : 1;
return Math.min(Math.max(safeDay, 1), daysInMonth(year, month)); 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(); const value = String(bill.cycle_type || bill.billing_cycle || 'monthly').toLowerCase();
if (value === 'annually') return 'annual'; if (value === 'annually') return 'annual';
if (['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'].includes(value)) return value; if (['monthly', 'weekly', 'biweekly', 'quarterly', 'annual'].includes(value)) return value;
return 'monthly'; return 'monthly';
} }
function parseCycleMonth(value, fallback = 1) { function parseCycleMonth(value: any, fallback = 1): number {
const parsed = parseInt(value, 10); const parsed = parseInt(value, 10);
if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 12) return parsed; if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 12) return parsed;
return fallback; return fallback;
} }
function parseWeekday(value, fallback = 'monday') { function parseWeekday(value: any, fallback = 'monday'): number {
const normalized = String(value || fallback).trim().toLowerCase(); const normalized = String(value || fallback).trim().toLowerCase();
return WEEKDAY_INDEX[normalized] ?? WEEKDAY_INDEX[fallback]; 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 first = new Date(Date.UTC(year, month - 1, 1));
const offset = (weekdayIndex - first.getUTCDay() + 7) % 7; const offset = (weekdayIndex - first.getUTCDay() + 7) % 7;
return addDays(first, offset); 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 start = new Date(Date.UTC(year, month - 1, 1));
const end = new Date(Date.UTC(year, month, 0)); const end = new Date(Date.UTC(year, month, 0));
const weekdayAnchor = addDays(BIWEEKLY_ANCHOR, (weekdayIndex - BIWEEKLY_ANCHOR.getUTCDay() + 7) % 7); const weekdayAnchor = addDays(BIWEEKLY_ANCHOR, (weekdayIndex - BIWEEKLY_ANCHOR.getUTCDay() + 7) % 7);
let cursor = firstWeekdayInMonth(year, month, weekdayIndex); let cursor = firstWeekdayInMonth(year, month, weekdayIndex);
while (cursor <= end) { 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; if (diffDays % 14 === 0) return cursor;
cursor = addDays(cursor, 7); cursor = addDays(cursor, 7);
} }
@ -108,7 +121,7 @@ function firstBiweeklyDateInMonth(year, month, weekdayIndex) {
return null; return null;
} }
function monthMatchesQuarterlyCycle(month, cycleStartMonth) { function monthMatchesQuarterlyCycle(month: number, cycleStartMonth: number): boolean {
return ((month - cycleStartMonth) % 3 + 3) % 3 === 0; 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 * Legacy override_due_date values are intentionally ignored by the current
* product behavior. * product behavior.
*/ */
function resolveDueDate(bill, year, month) { function resolveDueDate(bill: Row, year: number, month: number): string | null {
const cycleType = normalizeCycleType(bill); const cycleType = normalizeCycleType(bill);
if (cycleType === 'weekly') { if (cycleType === 'weekly') {
@ -147,7 +160,7 @@ function resolveDueDate(bill, year, month) {
/** /**
* Auto-assigns bucket from due_day: 114 '1st', 15+ '15th' * Auto-assigns bucket from due_day: 114 '1st', 15+ '15th'
*/ */
function resolveBucket(bill) { function resolveBucket(bill: Row): string {
if (bill.bucket) return bill.bucket; if (bill.bucket) return bill.bucket;
return bill.due_day <= 14 ? '1st' : '15th'; 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. * Computes the payment cycle start/end for a bill in a given month.
* For monthly bills the cycle is the calendar 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 start = dateString(year, month, 1);
const end = dateString(year, month, daysInMonth(year, month)); const end = dateString(year, month, daysInMonth(year, month));
return { start, end }; return { start, end };
@ -166,7 +179,7 @@ function getCalendarMonthRange(year, month) {
* Computes the payment cycle start/end for a bill in a given 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. * 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); if (!bill) return getCalendarMonthRange(year, month);
const dueDate = resolveDueDate(bill, year, month); const dueDate = resolveDueDate(bill, year, month);
@ -205,7 +218,7 @@ function getCycleRange(year, month, bill = null) {
* late past due, within grace period * late past due, within grace period
* missed past grace period, unpaid * 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'; if (!dueDate) return 'inactive_cycle';
const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays); 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 due = new Date(dueDate + 'T00:00:00');
const todayDate = new Date(today + '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 > dueSoonDays) return 'upcoming';
if (diffDays >= 0) return 'due_soon'; 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. * 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); const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) return null; if (!dueDate) return null;

View File

@ -1,25 +1,27 @@
'use strict'; 'use strict';
import type { Db } from '../types/db';
const crypto = require('crypto'); const crypto = require('crypto');
const { generateSecret, generateURI, generateSync, verifySync } = require('otplib'); const { generateSecret, generateURI, verifySync } = require('otplib');
const QRCode = require('qrcode'); const QRCode = require('qrcode');
const { encryptSecret, decryptSecret } = require('./encryptionService'); const { encryptSecret, decryptSecret } = require('./encryptionService.cts');
const APP_NAME = 'Bill Tracker'; const APP_NAME = 'Bill Tracker';
const RECOVERY_CODE_COUNT = 8; const RECOVERY_CODE_COUNT = 8;
const CHALLENGE_TTL_MS = 5 * 60 * 1000; const CHALLENGE_TTL_MS = 5 * 60 * 1000;
function newSecret() { function newSecret(): string {
return generateSecret(20); 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 uri = generateURI({ secret, account: username, issuer: APP_NAME, type: 'totp' });
const qr_data_url = await QRCode.toDataURL(uri, { width: 200, margin: 2 }); const qr_data_url = await QRCode.toDataURL(uri, { width: 200, margin: 2 });
return { uri, qr_data_url }; 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; if (!encryptedSecret || !token) return false;
try { try {
const secret = decryptSecret(encryptedSecret); 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; if (!secret || !token) return false;
try { try {
const result = verifySync({ secret, token: String(token).replace(/\s/g, ''), type: 'totp' }); 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 }, () => { return Array.from({ length: RECOVERY_CODE_COUNT }, () => {
const bytes = crypto.randomBytes(5); const bytes = crypto.randomBytes(5);
const hex = bytes.toString('hex').toUpperCase(); 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'); 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 normalized = code.replace(/[-\s]/g, '').toUpperCase();
const user = db.prepare('SELECT totp_recovery_codes FROM users WHERE id = ?').get(userId); const user = db.prepare('SELECT totp_recovery_codes FROM users WHERE id = ?').get(userId);
if (!user?.totp_recovery_codes) return { used: false }; if (!user?.totp_recovery_codes) return { used: false };
let stored; let stored: string[];
try { try {
stored = JSON.parse(decryptSecret(user.totp_recovery_codes)); stored = JSON.parse(decryptSecret(user.totp_recovery_codes));
} catch { } catch {
@ -75,7 +77,7 @@ function consumeRecoveryCode(db, userId, code) {
return { used: true, remaining: stored.length }; return { used: true, remaining: stored.length };
} }
function createChallenge(db, userId) { function createChallenge(db: Db, userId: number): string {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS) const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS)
.toISOString().slice(0, 19).replace('T', ' '); .toISOString().slice(0, 19).replace('T', ' ');
@ -84,7 +86,7 @@ function createChallenge(db, userId) {
return id; return id;
} }
function consumeChallenge(db, challengeId) { function consumeChallenge(db: Db, challengeId: string): number | null {
const row = db.prepare( const row = db.prepare(
"SELECT user_id FROM totp_challenges WHERE id = ? AND expires_at > datetime('now')" "SELECT user_id FROM totp_challenges WHERE id = ? AND expires_at > datetime('now')"
).get(challengeId); ).get(challengeId);
@ -93,7 +95,7 @@ function consumeChallenge(db, challengeId) {
return row.user_id; return row.user_id;
} }
function pruneExpiredChallenges(db) { function pruneExpiredChallenges(db: Db): void {
db.prepare("DELETE FROM totp_challenges WHERE expires_at <= datetime('now')").run(); db.prepare("DELETE FROM totp_challenges WHERE expires_at <= datetime('now')").run();
} }

View File

@ -1,11 +1,11 @@
'use strict'; 'use strict';
const { getDb } = require('../db/database'); 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 { getUserSettings } = require('./userSettings');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { computeAmountSuggestionsBatch } = require('./amountSuggestionService'); const { computeAmountSuggestionsBatch } = require('./amountSuggestionService.cts');
const { accountingActiveSql } = require('./paymentAccountingService'); const { accountingActiveSql } = require('./paymentAccountingService.cts');
const { normalizeMerchant } = require('./subscriptionService'); const { normalizeMerchant } = require('./subscriptionService');
const { localDateString } = require('../utils/dates.mts'); const { localDateString } = require('../utils/dates.mts');
const { sumMoney, roundMoney, fromCents } = require('../utils/money.mts'); const { sumMoney, roundMoney, fromCents } = require('../utils/money.mts');

View File

@ -5,7 +5,7 @@ const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { const {
applyBankPaymentAsSourceOfTruth, applyBankPaymentAsSourceOfTruth,
reactivatePaymentsOverriddenBy, reactivatePaymentsOverriddenBy,
} = require('./paymentAccountingService'); } = require('./paymentAccountingService.cts');
const { const {
decorateTransaction, decorateTransaction,
getTransactionForUser, getTransactionForUser,
@ -259,7 +259,7 @@ function matchTransactionToBill(userId, transactionId, billId, opts = {}) {
markMatched(db, userId, transaction.id, bill.id, { resetIgnored: true }); markMatched(db, userId, transaction.id, bill.id, { resetIgnored: true });
if (opts.learnMerchant) { if (opts.learnMerchant) {
const { learnMerchantRuleFromMatch } = require('./billMerchantRuleService'); const { learnMerchantRuleFromMatch } = require('./billMerchantRuleService.cts');
learnMerchantRuleFromMatch(db, userId, bill.id, transaction); learnMerchantRuleFromMatch(db, userId, bill.id, transaction);
} }

View File

@ -14,7 +14,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-amountsug-${process.pid}.sql
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database'); const { getDb, closeDb } = require('../db/database');
const { computeAmountSuggestion, computeAmountSuggestionsBatch } = require('../services/amountSuggestionService'); const { computeAmountSuggestion, computeAmountSuggestionsBatch } = require('../services/amountSuggestionService.cts');
let db, userId, bills; let db, userId, bills;
test.before(() => { test.before(() => {

View File

@ -9,7 +9,7 @@ const assert = require('node:assert/strict');
const { const {
monthKey, monthLabel, addMonths, monthEndDate, buildMonths, validateSummaryQuery, monthKey, monthLabel, addMonths, monthEndDate, buildMonths, validateSummaryQuery,
} = require('../services/analyticsService'); } = require('../services/analyticsService.cts');
test('monthKey zero-pads the month', () => { test('monthKey zero-pads the month', () => {
assert.equal(monthKey(2026, 6), '2026-06'); assert.equal(monthKey(2026, 6), '2026-06');

View File

@ -8,7 +8,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-bank-sync-test-${process.pid
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database'); const { getDb, closeDb } = require('../db/database');
const { encryptSecret } = require('../services/encryptionService'); const { encryptSecret } = require('../services/encryptionService.cts');
const { syncDataSource } = require('../services/bankSyncService'); const { syncDataSource } = require('../services/bankSyncService');
function createUser(db, suffix) { function createUser(db, suffix) {

View File

@ -16,7 +16,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-merchantrule-${process.pid}.
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database'); const { getDb, closeDb } = require('../db/database');
const { addMerchantRule, syncBillPaymentsFromSimplefin } = require('../services/billMerchantRuleService'); const { addMerchantRule, syncBillPaymentsFromSimplefin } = require('../services/billMerchantRuleService.cts');
let db, userId, billId; let db, userId, billId;
const insertTx = (payee, amountCents, postedDate) => db.prepare(` const insertTx = (payee, amountCents, postedDate) => db.prepare(`

View File

@ -19,7 +19,7 @@ process.env.DB_PATH = dbPath;
delete process.env.TOKEN_ENCRYPTION_KEY; // start in DB-key mode delete process.env.TOKEN_ENCRYPTION_KEY; // start in DB-key mode
const { getDb, closeDb, getSetting, setSetting } = require('../db/database'); 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.before(() => { getDb(); }); // init schema + migrations (also creates the initial admin)
test.after(() => { test.after(() => {

View File

@ -21,7 +21,7 @@ const {
accountingActiveSql, accountingActiveSql,
markProvisionalManualPaymentsOverridden, markProvisionalManualPaymentsOverridden,
reactivatePaymentsOverriddenBy, reactivatePaymentsOverriddenBy,
} = require('../services/paymentAccountingService'); } = require('../services/paymentAccountingService.cts');
test('isBankBackedPayment: bank sources or a transaction_id count as bank-backed', () => { test('isBankBackedPayment: bank sources or a transaction_id count as bank-backed', () => {
assert.equal(isBankBackedPayment({ payment_source: 'provider_sync' }), true); assert.equal(isBankBackedPayment({ payment_source: 'provider_sync' }), true);

View File

@ -19,8 +19,8 @@ process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database'); const { getDb, closeDb } = require('../db/database');
const { getTracker } = require('../services/trackerService'); const { getTracker } = require('../services/trackerService');
const { getAnalyticsSummary } = require('../services/analyticsService'); const { getAnalyticsSummary } = require('../services/analyticsService.cts');
const { resolveDueDate } = require('../services/statusService'); const { resolveDueDate } = require('../services/statusService.cts');
const summaryRouter = require('../routes/summary'); const summaryRouter = require('../routes/summary');
// Fixed "now" so occurrence gating is deterministic: June 20, 2026. // Fixed "now" so occurrence gating is deterministic: June 20, 2026.

View File

@ -9,7 +9,7 @@ const { calculateSnowball, calculateAvalanche } = require('../services/snowballS
const { const {
monthlyInterest, monthsToPayoff, amortizationSchedule, monthlyInterest, monthsToPayoff, amortizationSchedule,
calculateMinimumOnly, debtAprSnapshot, calculateMinimumOnly, debtAprSnapshot,
} = require('../services/aprService'); } = require('../services/aprService.cts');
// ── primitives ─────────────────────────────────────────────────────────────── // ── primitives ───────────────────────────────────────────────────────────────

View File

@ -14,7 +14,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-spendsvc-${process.pid}.sqli
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database'); const { getDb, closeDb } = require('../db/database');
const { setSpendingBudget, getSpendingBudgets } = require('../services/spendingService'); const { setSpendingBudget, getSpendingBudgets } = require('../services/spendingService.cts');
let db, userId, catId; 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; 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;

View File

@ -8,7 +8,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-spending-summary-test-${proc
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database'); const { getDb, closeDb } = require('../db/database');
const { getSpendingSummary, setSpendingBudget } = require('../services/spendingService'); const { getSpendingSummary, setSpendingBudget } = require('../services/spendingService.cts');
function createUser(db, suffix) { function createUser(db, suffix) {
return db.prepare(` return db.prepare(`

View File

@ -5,7 +5,7 @@ const {
buildTrackerRow, buildTrackerRow,
getCycleRange, getCycleRange,
resolveDueDate, resolveDueDate,
} = require('../services/statusService'); } = require('../services/statusService.cts');
function bill(overrides = {}) { function bill(overrides = {}) {
return { return {

View File

@ -15,8 +15,8 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-totp-${process.pid}.sqlite`)
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database'); const { getDb, closeDb } = require('../db/database');
const { encryptSecret } = require('../services/encryptionService'); const { encryptSecret } = require('../services/encryptionService.cts');
const totp = require('../services/totpService'); const totp = require('../services/totpService.cts');
let db, userId; let db, userId;
test.before(() => { test.before(() => {

View File

@ -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', () => { 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 db = getDb();
const userId = createUser(db, 'ambiguous'); const userId = createUser(db, 'ambiguous');

27
types/db.d.ts vendored Normal file
View File

@ -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<any>;
pluck(toggle?: boolean): Statement;
}
export interface Db {
prepare(sql: string): Statement;
transaction<T extends (...args: any[]) => any>(fn: T): T;
exec(sql: string): void;
pragma(source: string, options?: { simple?: boolean }): any;
close(): void;
}

View File

@ -2,7 +2,7 @@
const cron = require('node-cron'); const cron = require('node-cron');
const { getDb, getSetting } = require('../db/database'); 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 { pruneExpiredSessions } = require('../services/authService');
const { pruneExpiredChallenges: pruneWebAuthnChallenges } = require('../services/webauthnService'); const { pruneExpiredChallenges: pruneWebAuthnChallenges } = require('../services/webauthnService');
const { runNotifications, runDriftNotifications } = require('../services/notificationService'); const { runNotifications, runDriftNotifications } = require('../services/notificationService');