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:
parent
343cc3a36f
commit
005b0b7b64
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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: [] };
|
||||||
}
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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(`
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
@ -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 0–31 with a fallback of 3.
|
// like the grace period; clamped to a sane 0–31 with a fallback of 3.
|
||||||
function resolveDueSoonDays(value) {
|
function resolveDueSoonDays(value?: string | number | null): number {
|
||||||
const parsed = parseInt(value ?? getSetting('due_soon_days') ?? '3', 10);
|
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: 1–14 → '1st', 15+ → '15th'
|
* Auto-assigns bucket from due_day: 1–14 → '1st', 15+ → '15th'
|
||||||
*/
|
*/
|
||||||
function resolveBucket(bill) {
|
function resolveBucket(bill: Row): string {
|
||||||
if (bill.bucket) return bill.bucket;
|
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;
|
||||||
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(`
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(`
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue