2026-06-14 15:15:31 -05:00
|
|
|
|
'use strict';
|
|
|
|
|
|
|
2026-07-06 10:54:10 -05:00
|
|
|
|
import type { Db } from '../types/db';
|
|
|
|
|
|
|
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>
2026-07-06 10:35:29 -05:00
|
|
|
|
const { categorizeTransaction } = require('./spendingService.cts');
|
2026-06-14 15:15:31 -05:00
|
|
|
|
|
2026-07-06 10:54:10 -05:00
|
|
|
|
type Row = Record<string, any>;
|
|
|
|
|
|
|
2026-06-14 15:15:31 -05:00
|
|
|
|
// Mirrors the pack's match_order_recommendation: regional descriptor variants
|
|
|
|
|
|
// (most specific — tied to a city/county) are checked first, then online
|
|
|
|
|
|
// billing descriptors (PAYPAL/STRIPE/APPLE.COM/BILL/etc.), then canonical
|
|
|
|
|
|
// national merchants as the fallback.
|
2026-07-06 10:54:10 -05:00
|
|
|
|
const ENTRY_KIND_ORDER: Record<string, number> = {
|
2026-06-14 15:15:31 -05:00
|
|
|
|
regional_descriptor_variant: 0,
|
|
|
|
|
|
online_billing_descriptor_variant: 1,
|
|
|
|
|
|
canonical_merchant: 2,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-06 10:54:10 -05:00
|
|
|
|
let _entries: any[] | null = null;
|
2026-06-14 15:15:31 -05:00
|
|
|
|
|
2026-07-06 10:54:10 -05:00
|
|
|
|
function maxPatternLength(patterns: string[]): number {
|
2026-06-14 15:15:31 -05:00
|
|
|
|
return patterns.reduce((max, p) => Math.max(max, p.length), 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Lazily load and pre-sort all merchant_store_matches rows. Cached for the
|
|
|
|
|
|
// lifetime of the process — this is static reference data seeded once by the
|
|
|
|
|
|
// v1.05 migration.
|
2026-07-06 10:54:10 -05:00
|
|
|
|
function loadMerchantMatchEntries(db: Db): any[] {
|
2026-06-14 15:15:31 -05:00
|
|
|
|
if (_entries) return _entries;
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const rows = db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`
|
2026-06-14 15:15:31 -05:00
|
|
|
|
SELECT id, entry_kind, canonical_merchant_id, canonical_name, display_name,
|
|
|
|
|
|
category, merchant_type, scope, priority, match_patterns, negative_patterns,
|
|
|
|
|
|
locality_city, locality_state
|
|
|
|
|
|
FROM merchant_store_matches
|
2026-07-06 14:23:53 -05:00
|
|
|
|
`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.all();
|
2026-06-14 15:15:31 -05:00
|
|
|
|
|
2026-07-06 10:54:10 -05:00
|
|
|
|
_entries = rows.map((row: Row) => ({
|
2026-06-14 15:15:31 -05:00
|
|
|
|
...row,
|
|
|
|
|
|
match_patterns: JSON.parse(row.match_patterns || '[]'),
|
|
|
|
|
|
negative_patterns: JSON.parse(row.negative_patterns || '[]'),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
_entries.sort((a, b) => {
|
|
|
|
|
|
const orderA = ENTRY_KIND_ORDER[a.entry_kind] ?? 99;
|
|
|
|
|
|
const orderB = ENTRY_KIND_ORDER[b.entry_kind] ?? 99;
|
|
|
|
|
|
if (orderA !== orderB) return orderA - orderB;
|
|
|
|
|
|
if (b.priority !== a.priority) return b.priority - a.priority;
|
|
|
|
|
|
return maxPatternLength(b.match_patterns) - maxPatternLength(a.match_patterns);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return _entries;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Apply the pack's normalization rules: uppercase, "&" -> "AND", strip
|
|
|
|
|
|
// apostrophes/punctuation, collapse whitespace. match_patterns in the pack
|
|
|
|
|
|
// are pre-normalized to this same shape (e.g. "THE CHILDREN S PLACE").
|
2026-07-06 10:54:10 -05:00
|
|
|
|
function normalizeForMatch(value: unknown): string {
|
2026-06-14 15:15:31 -05:00
|
|
|
|
return String(value || '')
|
|
|
|
|
|
.toUpperCase()
|
|
|
|
|
|
.replace(/&/g, ' AND ')
|
|
|
|
|
|
.replace(/['’]/g, '')
|
|
|
|
|
|
.replace(/[^A-Z0-9\s]/g, ' ')
|
|
|
|
|
|
.replace(/\s+/g, ' ')
|
|
|
|
|
|
.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Find the best merchant/store match for a raw transaction description.
|
|
|
|
|
|
// Returns { entry_id, canonical_name, display_name, category, scope, priority } or null.
|
2026-07-06 10:54:10 -05:00
|
|
|
|
function findMerchantMatch(db: Db, description: unknown): Row | null {
|
2026-06-14 15:15:31 -05:00
|
|
|
|
const normalized = normalizeForMatch(description);
|
|
|
|
|
|
if (!normalized) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const entries = loadMerchantMatchEntries(db);
|
|
|
|
|
|
for (const entry of entries) {
|
2026-07-06 10:54:10 -05:00
|
|
|
|
if (entry.negative_patterns.some((p: string) => normalized.includes(p))) continue;
|
|
|
|
|
|
if (entry.match_patterns.some((p: string) => p && normalized.includes(p))) {
|
2026-06-14 15:15:31 -05:00
|
|
|
|
return {
|
|
|
|
|
|
entry_id: entry.id,
|
|
|
|
|
|
canonical_name: entry.canonical_name,
|
|
|
|
|
|
display_name: entry.display_name,
|
|
|
|
|
|
category: entry.category,
|
|
|
|
|
|
scope: entry.scope,
|
|
|
|
|
|
priority: entry.priority,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Find-or-create a spending category by name for this user, matching the
|
|
|
|
|
|
// COLLATE NOCASE convention used by ensureUserDefaultCategories (db/database.js).
|
2026-07-06 10:54:10 -05:00
|
|
|
|
function findOrCreateCategory(db: Db, userId: number, name: string): number | bigint {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const existing = db
|
|
|
|
|
|
.prepare('SELECT id FROM categories WHERE user_id = ? AND name = ? COLLATE NOCASE')
|
2026-06-14 15:15:31 -05:00
|
|
|
|
.get(userId, name);
|
|
|
|
|
|
if (existing) return existing.id;
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const result = db
|
|
|
|
|
|
.prepare('INSERT INTO categories (user_id, name) VALUES (?, ?)')
|
|
|
|
|
|
.run(userId, name);
|
2026-06-14 15:15:31 -05:00
|
|
|
|
return result.lastInsertRowid;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Scan the user's uncategorized outflows, apply merchant/store pack matches,
|
|
|
|
|
|
// and categorize them (writing spending_category_rules so future syncs hit
|
|
|
|
|
|
// the cheaper rule path instead of rescanning the full pack).
|
|
|
|
|
|
//
|
2026-07-06 10:54:10 -05:00
|
|
|
|
// With { dryRun: true }, computes the same matches without writing anything.
|
|
|
|
|
|
// Returns { updated, categories: [{name,count}], changes: [{transaction_id,display_name,category}] }.
|
2026-07-06 14:23:53 -05:00
|
|
|
|
function applyMerchantStoreMatches(
|
|
|
|
|
|
db: Db,
|
|
|
|
|
|
userId: number,
|
|
|
|
|
|
{ dryRun = false }: { dryRun?: boolean } = {},
|
|
|
|
|
|
) {
|
|
|
|
|
|
const txRows = db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`
|
2026-06-14 15:15:31 -05:00
|
|
|
|
SELECT id, payee, description, memo
|
|
|
|
|
|
FROM transactions
|
|
|
|
|
|
WHERE user_id = ?
|
|
|
|
|
|
AND amount < 0
|
|
|
|
|
|
AND ignored = 0
|
|
|
|
|
|
AND match_status != 'matched'
|
|
|
|
|
|
AND spending_category_id IS NULL
|
2026-07-06 14:23:53 -05:00
|
|
|
|
`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.all(userId);
|
2026-06-14 15:15:31 -05:00
|
|
|
|
|
2026-07-06 10:54:10 -05:00
|
|
|
|
if (txRows.length === 0) return { updated: 0, categories: [] as any[], changes: [] as any[] };
|
2026-06-14 15:15:31 -05:00
|
|
|
|
|
2026-07-06 10:54:10 -05:00
|
|
|
|
const categoryCounts = new Map<string, number>(); // category name -> count
|
|
|
|
|
|
const changes: any[] = [];
|
2026-06-14 15:15:31 -05:00
|
|
|
|
let updated = 0;
|
|
|
|
|
|
|
|
|
|
|
|
const apply = () => {
|
|
|
|
|
|
for (const tx of txRows) {
|
|
|
|
|
|
const match = findMerchantMatch(db, tx.payee || tx.description || tx.memo || '');
|
|
|
|
|
|
if (!match) continue;
|
|
|
|
|
|
|
|
|
|
|
|
if (!dryRun) {
|
|
|
|
|
|
const categoryId = findOrCreateCategory(db, userId, match.category);
|
|
|
|
|
|
categorizeTransaction(db, userId, tx.id, categoryId, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
updated++;
|
2026-07-06 14:23:53 -05:00
|
|
|
|
changes.push({
|
|
|
|
|
|
transaction_id: tx.id,
|
|
|
|
|
|
display_name: match.display_name,
|
|
|
|
|
|
category: match.category,
|
|
|
|
|
|
});
|
2026-06-14 15:15:31 -05:00
|
|
|
|
categoryCounts.set(match.category, (categoryCounts.get(match.category) || 0) + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (dryRun) {
|
|
|
|
|
|
apply();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
db.transaction(apply)();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
updated,
|
|
|
|
|
|
categories: [...categoryCounts.entries()].map(([name, count]) => ({ name, count })),
|
|
|
|
|
|
changes,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
module.exports = {
|
|
|
|
|
|
loadMerchantMatchEntries,
|
|
|
|
|
|
normalizeForMatch,
|
|
|
|
|
|
findMerchantMatch,
|
|
|
|
|
|
findOrCreateCategory,
|
|
|
|
|
|
applyMerchantStoreMatches,
|
|
|
|
|
|
};
|