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