From 71674ef432e20e2e6e2f10f0ba076e5d3f593f0b Mon Sep 17 00:00:00 2001 From: ydeng11 Date: Sat, 18 Jul 2026 23:32:09 -0400 Subject: [PATCH] refactor: remove legacy migration paths --- README.md | 11 +- docs/json-to-sqlite-migration-runbook.md | 6 +- docs/self-host-feature-matrix.md | 5 +- justfile | 8 - package.json | 1 - packages/domain/src/constants.ts | 4 +- scripts/load-legacy-api.ts | 107 --- scripts/sqlite-cutover-lib.ts | 56 +- services/api/data/development-minance.sqlite | Bin 634880 -> 634880 bytes services/api/data/test-minance.sqlite | Bin 634880 -> 634880 bytes services/api/sql/schema.sql | 11 - services/api/src/analytics.ts | 14 +- services/api/src/auth.ts | 1 - services/api/src/category-strategy.ts | 92 --- services/api/src/imports.ts | 2 +- services/api/src/legacy-api-loader.ts | 679 ------------------ services/api/src/migration.ts | 347 --------- .../src/migrations/account-identity-repair.ts | 148 ---- services/api/src/recurrings.ts | 3 - services/api/src/sqlite-store-repository.ts | 3 - services/api/src/store.ts | 3 +- services/api/src/transactionFilters.ts | 8 +- services/api/src/transactions.ts | 42 +- services/api/test/analytics.test.ts | 83 ++- services/api/test/api-contract.test.ts | 11 - services/api/test/auth.test.ts | 1 - services/api/test/categories.test.ts | 1 - services/api/test/categorization.test.ts | 22 +- services/api/test/category-strategy.test.ts | 16 - .../deterministic-financial-fixture.js | 33 +- .../deterministic-financial-store.json | 429 ++++++----- .../test/greenfield-legacy-cleanup.test.ts | 61 ++ services/api/test/imports.test.ts | 1 - .../integration/agent-integration.test.ts | 1 - services/api/test/legacy-api-loader.test.ts | 168 ----- services/api/test/legacy-loader-cli.test.ts | 40 -- services/api/test/llm/tool-executor.test.ts | 1 - .../api/test/migrate-json-to-sqlite.test.ts | 26 +- .../account-identity-repair.test.ts | 254 ------- services/api/test/performance-50k.test.ts | 1 - .../api/test/sqlite-store-repository.test.ts | 6 +- services/api/test/store.test.ts | 1 - .../test/transactions-normalization.test.ts | 59 +- 43 files changed, 442 insertions(+), 2324 deletions(-) delete mode 100644 scripts/load-legacy-api.ts delete mode 100644 services/api/src/legacy-api-loader.ts delete mode 100644 services/api/src/migration.ts delete mode 100644 services/api/src/migrations/account-identity-repair.ts create mode 100644 services/api/test/greenfield-legacy-cleanup.test.ts delete mode 100644 services/api/test/legacy-api-loader.test.ts delete mode 100644 services/api/test/legacy-loader-cli.test.ts delete mode 100644 services/api/test/migrations/account-identity-repair.test.ts diff --git a/README.md b/README.md index b37a770..0fed31f 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,6 @@ minance/ - Conversational assistant endpoint with explainable output and drill-down filters - Database backup and reload feature - Help center for self-host UX -- Legacy Minance API loader script for dev database seeding - Responsive web UI covering dashboard, explorer, imports, transactions, analytics, assistant, help, and settings - Saved views/bookmarks @@ -81,14 +80,6 @@ Dry-run summary: pnpm seed:fixture -- --dry-run ``` -## Legacy API seed (dev) - -Load accounts + transactions from legacy Minance API into the current dev database (mapped category as tier-2, inferred tier-1 group): - -```bash -pnpm seed:legacy-api -- --base-url http://10.0.0.20:18080 --start 2024-01-01 --end 2026-12-31 -``` - Fixture source of truth: - `services/api/test/fixtures/deterministic-financial-fixture.js` - `services/api/test/fixtures/deterministic-financial-store.json` @@ -159,4 +150,4 @@ docker compose -f docker-compose.selfhost.yml --env-file .env.selfhost up -d - AI key encryption uses `AI_CREDENTIAL_SECRET` (set in environment for non-local use). - Account provider abstraction is exposed via `GET /v1/accounts/providers` and `GET /v1/accounts/providers/:providerId` (self-host default provider is `manual_csv`; direct-link actions return explicit unsupported-action errors). - CrewAI analysis agent script lives at `services/agents/crewai_analysis_agent.py` (enable/disable with `AI_CREW_ANALYSIS_ENABLED`; install Python deps from `services/agents/requirements.txt`). -- SQLite migration requires `sqlite3` CLI installed on the host machine. +- SQLite storage requires the `sqlite3` CLI on the host machine. diff --git a/docs/json-to-sqlite-migration-runbook.md b/docs/json-to-sqlite-migration-runbook.md index 6b41710..f597023 100644 --- a/docs/json-to-sqlite-migration-runbook.md +++ b/docs/json-to-sqlite-migration-runbook.md @@ -13,8 +13,7 @@ This runbook defines a deterministic, idempotent path for loading a JSON fixture - Runtime store: `services/api/data/{env}-minance.sqlite` (or the environment-specific override). - Default JSON fixture input: `services/api/test/fixtures/deterministic-financial-store.json` (or `MINANCE_DATA_FILE` override). - SQLite foundation bootstrap exists in API startup (`MINANCE_SQLITE_FILE`, `MINANCE_SQLITE_SCHEMA_FILE`, `MINANCE_SQLITE_AUTO_INIT`) and status is observable via `GET /v1/system/storage`. -- Active write paths: auth, imports, transactions, categories/rules, AI settings, assistant queries, saved views, migration runs, audit events. -- Legacy Minance SQLite import endpoint already exists (`/v1/migrations/minance/sqlite`) and is separate from this JSON-to-SQLite cutover. +- Active write paths: auth, imports, transactions, categories/rules, AI settings, assistant queries, saved views, and audit events. ## Target SQLite Schema (Canonical Tables) @@ -35,7 +34,6 @@ Use one table per top-level JSON collection (from `services/api/src/store.js`): - `ai_provider_preferences` - `assistant_queries` - `saved_views` -- `migration_runs` - `audit_events` Recommended constraints/indexes: @@ -85,7 +83,7 @@ Insert in dependency order: 5. `transactions` 6. `ai_provider_credentials`, `ai_provider_preferences` 7. `assistant_queries`, `saved_views` -8. `migration_runs`, `audit_events` +8. `audit_events` Use idempotent writes: diff --git a/docs/self-host-feature-matrix.md b/docs/self-host-feature-matrix.md index 8c2b30f..5244234 100644 --- a/docs/self-host-feature-matrix.md +++ b/docs/self-host-feature-matrix.md @@ -15,7 +15,6 @@ This document defines how Minance Next maps Copilot-style product expectations t | Authentication and sessions | Email/password auth, session refresh, user profile | Supported | `POST /v1/auth/signup`, `POST /v1/auth/login`, `POST /v1/auth/refresh`, `GET/DELETE /v1/users/me` | Local session/token storage in SQLite runtime data. | | Canonical data store | Durable relational storage | Supported | Production SQLite runtime store (`services/api/data/production-minance.sqlite`) is active, with foundation/bootstrap status exposed at `GET /v1/system/storage` | JSON fixtures remain only for explicit test or fixture-import flows; see [JSON-to-SQLite runbook](./json-to-sqlite-migration-runbook.md). | | CSV import and mapping | Bank CSV ingestion with review, mapping, diagnostics | Supported | Implemented import workflow (`/v1/imports*`) with processed-row editor and dedupe | Deterministic parser + manual mapping/editing when heuristics/AI confidence is low. | -| Legacy Minance migration | Import from legacy Minance SQLite DB | Supported | `POST /v1/migrations/minance/sqlite` and migration report endpoint are implemented | Requires host `sqlite3` CLI. If unavailable, operator uses CSV import path. | | Transactions lifecycle | Create/edit/delete and filter transactions | Partially supported | Manual CRUD and query filters are implemented (`/v1/transactions*`) with canonical day-boundary semantics documented in [`transaction-date-day-boundary-semantics.md`](./transaction-date-day-boundary-semantics.md) | Bulk operations, review workflows, and parity details tracked by open parity tasks. | | Categories and rules | Category CRUD, strategy tuning, mapping rules | Partially supported | Category list/create/update/delete, rules create, strategy get/update implemented | Group/type/budget parity and full Categories-tab parity are tracked separately. | | Accounts workflows | Dedicated accounts onboarding/settings flows | Partially supported | Accounts API create/update/list, supported-type, balance-history, and manual-adjustment endpoints are implemented (`/v1/accounts*`), while the Accounts tab UI is still placeholder | Manual/CSV provider fallback remains default; deeper account UX/settings/archive flows are tracked by open tasks. | @@ -34,7 +33,7 @@ This document defines how Minance Next maps Copilot-style product expectations t - Required runtime dependencies: - Node.js runtime for web/API. - Optional dependencies: - - `sqlite3` CLI for legacy migration flow and SQLite foundation bootstrap/validation. + - `sqlite3` CLI for SQLite foundation bootstrap/validation. - AI provider APIs (OpenAI/OpenRouter/Anthropic/Google) only when the operator/user configures BYOK keys. - Explicitly non-required for baseline operation: - Proprietary bank-link providers. @@ -55,7 +54,7 @@ This document defines how Minance Next maps Copilot-style product expectations t - [`self-host-operations-runbook.md`](./self-host-operations-runbook.md) - [`self-host-breaking-migration-guide.md`](./self-host-breaking-migration-guide.md) - App remains usable without any AI keys: - - import, manual transaction CRUD, analytics, categories, and migration UI continue to function. + - import, manual transaction CRUD, analytics, and categories continue to function. - Development defaults: - optional dev/test account seeding (can be disabled via env). diff --git a/justfile b/justfile index 191ae43..f50896d 100644 --- a/justfile +++ b/justfile @@ -156,11 +156,3 @@ seed-fixture: pnpm seed:fixture -- --target services/api/test/fixtures/deterministic-financial-store.json just _prepare-dev-data just _prepare-test-data - -# Load data from the legacy API into the dev database -seed-legacy-api: - just --dotenv-filename .env.development _seed-legacy-api - -[private] -_seed-legacy-api: - pnpm seed:legacy-api -- --base-url http://10.0.0.20:18080 --start 2024-01-01 --end 2026-12-31 diff --git a/package.json b/package.json index 70ee8cc..8287180 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "migrate:sqlite": "tsx scripts/migrate-json-to-sqlite.ts", "validate:sqlite": "tsx scripts/validate-json-vs-sqlite.ts", "seed:fixture": "tsx scripts/seed-deterministic-fixture.ts", - "seed:legacy-api": "tsx scripts/load-legacy-api.ts", "docs:api": "tsx scripts/generate-api-docs.ts", "guardrails": "tsx scripts/run-guardrails.ts", "test:test-first": "node --test scripts/check-root-script-binaries.test.mjs && env NODE_ENV=test tsx --test scripts/check-frontend-test-first.test.ts && env NODE_ENV=test tsx --test scripts/run-with-open-ports.test.ts && env NODE_ENV=test tsx scripts/check-frontend-test-first.ts", diff --git a/packages/domain/src/constants.ts b/packages/domain/src/constants.ts index 9ef2af2..881e28b 100644 --- a/packages/domain/src/constants.ts +++ b/packages/domain/src/constants.ts @@ -8,9 +8,7 @@ export const IMPORT_STATUSES = [ "failed" ]; -export const SOURCE_TYPES = ["imported", "manual", "migrated"]; - -export const DIRECTIONS = ["debit", "credit"]; +export const SOURCE_TYPES = ["imported", "manual"]; export const AI_PROVIDERS = { openai: { diff --git a/scripts/load-legacy-api.ts b/scripts/load-legacy-api.ts deleted file mode 100644 index e296831..0000000 --- a/scripts/load-legacy-api.ts +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env node - -import process from "node:process"; -import { seedFromLegacyApiToStore } from "../services/api/src/legacy-api-loader.ts"; - -function printHelp() { - console.log(`Usage: tsx scripts/load-legacy-api.ts [options] - -Options: - --base-url Legacy Minance API base URL (default: LEGACY_MINANCE_BASE_URL or http://10.0.0.20:18080) - --start Transaction start date (default: LEGACY_MINANCE_START or 2024-01-01) - --end Transaction end date (default: LEGACY_MINANCE_END or 2026-12-31) - --user-email Target Minance2 user email (default: dev seeded user) - --user-password Password for the target Minance2 user (requires --user-email) - --no-reset Keep existing user data and append with dedupe (default resets user financial data first) - --help Show this help message -`); -} - -function parseArgs(argv) { - const options = { - baseUrl: String(process.env.LEGACY_MINANCE_BASE_URL || "http://10.0.0.20:18080").trim(), - startDate: String(process.env.LEGACY_MINANCE_START || "2024-01-01").trim(), - endDate: String(process.env.LEGACY_MINANCE_END || "2026-12-31").trim(), - userEmail: process.env.LEGACY_MINANCE_USER_EMAIL || null, - userPassword: null, - resetUserData: true, - help: false - }; - - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index]; - if (token === "--base-url") { - options.baseUrl = String(argv[index + 1] || "").trim(); - index += 1; - continue; - } - if (token === "--start") { - options.startDate = String(argv[index + 1] || "").trim(); - index += 1; - continue; - } - if (token === "--end") { - options.endDate = String(argv[index + 1] || "").trim(); - index += 1; - continue; - } - if (token === "--user-email") { - options.userEmail = String(argv[index + 1] || "").trim() || null; - index += 1; - continue; - } - if (token === "--user-password") { - options.userPassword = String(argv[index + 1] || ""); - index += 1; - continue; - } - if (token === "--no-reset") { - options.resetUserData = false; - continue; - } - if (token === "-h" || token === "--help") { - options.help = true; - continue; - } - } - - return options; -} - -async function main() { - const args = parseArgs(process.argv.slice(2)); - if (args.help) { - printHelp(); - return; - } - if (args.userPassword != null && !args.userEmail) { - throw new Error("--user-password requires --user-email"); - } - - const result = await seedFromLegacyApiToStore({ - baseUrl: args.baseUrl, - startDate: args.startDate, - endDate: args.endDate, - userEmail: args.userEmail, - userPassword: args.userPassword, - resetUserData: args.resetUserData - }); - - console.log( - JSON.stringify( - { - ok: true, - loader: "legacy-api", - ...result - }, - null, - 2 - ) - ); - process.exit(0); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); -}); diff --git a/scripts/sqlite-cutover-lib.ts b/scripts/sqlite-cutover-lib.ts index c753fc3..b9203e4 100644 --- a/scripts/sqlite-cutover-lib.ts +++ b/scripts/sqlite-cutover-lib.ts @@ -64,32 +64,30 @@ export const STORE_TABLE_SPECS = [ sampleKey: { source: "id", table: "id" }, mapRow: (row) => ({ id: row.id ?? null, - user_id: row.user_id ?? row.userId ?? null, - account_id: row.account_id ?? row.accountId ?? null, - account_key: row.account_key ?? row.accountKey ?? null, - source_type: row.source_type ?? row.sourceType ?? null, - source_file_id: row.source_file_id ?? row.sourceFileId ?? null, - transaction_date: row.transaction_date ?? row.transactionDate ?? null, - post_date: row.post_date ?? row.postDate ?? null, - merchant_raw: row.merchant_raw ?? row.merchantRaw ?? null, - merchant_normalized: row.merchant_normalized ?? row.merchantNormalized ?? null, + user_id: row.user_id ?? null, + account_id: row.account_id ?? null, + account_key: row.account_key ?? null, + source_type: row.source_type ?? null, + source_file_id: row.source_file_id ?? null, + transaction_date: row.transaction_date ?? null, + post_date: row.post_date ?? null, + merchant_raw: row.merchant_raw ?? null, + merchant_normalized: row.merchant_normalized ?? null, description: row.description ?? null, amount: toFiniteNumber(row.amount), currency: row.currency ?? null, direction: row.direction ?? null, - category_raw: row.category_raw ?? row.categoryRaw ?? null, - category_final: row.category_final ?? row.categoryFinal ?? null, - category_coarse: row.category_coarse ?? row.categoryCoarse ?? null, - category_emoji: row.category_emoji ?? row.categoryEmoji ?? null, - category_confidence: toFiniteNumber(row.category_confidence ?? row.categoryConfidence), - category_strategy: row.category_strategy ?? row.categoryStrategy ?? null, - needs_category_review: toNullableBooleanInt( - row.needs_category_review ?? row.needsCategoryReview - ), + category_raw: row.category_raw ?? null, + category_final: row.category_final ?? null, + category_coarse: row.category_coarse ?? null, + category_emoji: row.category_emoji ?? null, + category_confidence: toFiniteNumber(row.category_confidence), + category_strategy: row.category_strategy ?? null, + needs_category_review: toNullableBooleanInt(row.needs_category_review), memo: row.memo ?? null, - dedupe_fingerprint: row.dedupe_fingerprint ?? row.dedupeFingerprint ?? null, - created_at: row.created_at ?? row.createdAt ?? null, - updated_at: row.updated_at ?? row.updatedAt ?? null, + dedupe_fingerprint: row.dedupe_fingerprint ?? null, + created_at: row.created_at ?? null, + updated_at: row.updated_at ?? null, payload_json: JSON.stringify(row ?? {}) }) }, @@ -346,22 +344,6 @@ export const STORE_TABLE_SPECS = [ payload_json: JSON.stringify(row ?? {}) }) }, - { - storeKey: "migrationRuns", - tableName: "migration_runs", - keyColumns: ["id"], - sampleKey: { source: "id", table: "id" }, - mapRow: (row) => ({ - id: row.id ?? null, - user_id: row.userId ?? row.user_id ?? null, - status: row.status ?? null, - sqlite_path: row.sqlitePath ?? row.sqlite_path ?? null, - created_at: row.createdAt ?? row.created_at ?? null, - updated_at: row.updatedAt ?? row.updated_at ?? null, - report_json: toJsonOrNull(row.report), - payload_json: JSON.stringify(row ?? {}) - }) - }, { storeKey: "auditEvents", tableName: "audit_events", diff --git a/services/api/data/development-minance.sqlite b/services/api/data/development-minance.sqlite index 9cb7d77f00d7f39e87fe7700ef22413142c4f802..6f6add6c73df0728d387a46cd12631a2d68bca5c 100644 GIT binary patch delta 15809 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 YhxFK*?IGB<&;HOU|2ihNZHNE*KNb#wA^-pY delta 16019 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 0) { - direction = "inflow"; - } + const direction = rawDirection === "inflow" ? "inflow" : "outflow"; return { ...txn, diff --git a/services/api/src/auth.ts b/services/api/src/auth.ts index e48b6a6..93f801a 100644 --- a/services/api/src/auth.ts +++ b/services/api/src/auth.ts @@ -275,7 +275,6 @@ export function deleteUser(userId) { store.aiProviderPreferences = store.aiProviderPreferences.filter((entry) => entry.userId !== userId); store.assistantQueries = store.assistantQueries.filter((entry) => entry.userId !== userId); store.savedViews = store.savedViews.filter((entry) => entry.userId !== userId); - store.migrationRuns = store.migrationRuns.filter((entry) => entry.userId !== userId); store.auditEvents = store.auditEvents.filter((entry) => entry.userId !== userId); saveStore(store); diff --git a/services/api/src/category-strategy.ts b/services/api/src/category-strategy.ts index daefb07..31ceaed 100644 --- a/services/api/src/category-strategy.ts +++ b/services/api/src/category-strategy.ts @@ -1,7 +1,4 @@ -import { spawnSync } from "node:child_process"; -import path from "node:path"; import { loadStore, saveStore } from "./store.ts"; -import { ROOT_DIR } from "./config.ts"; import { createId, normalizeText, nowIso } from "./utils.ts"; export const COPILOT_CATEGORY_STRATEGY_URL = "https://imgur.com/a/copilot-categorization-strategy-Ut9IGEv"; @@ -551,92 +548,3 @@ export function resolveCategoryForUser(userId, input = {}) { const resolve = createCategoryResolver(strategy); return resolve(input); } - -function sqliteJsonQuery(dbPath, sql) { - const result = spawnSync("sqlite3", ["-json", dbPath, sql], { - encoding: "utf8" - }); - if (result.status !== 0 || result.error) { - return []; - } - - const raw = String(result.stdout || "").trim(); - if (!raw) { - return []; - } - - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -export function checkStrategyCoverageAgainstBackupDb(dbPath = path.join(ROOT_DIR, "backup_2026-02-26_00-00-03.db")) { - const strategy = { - coarseCategories: cloneCoarseCategories(DEFAULT_COARSE_CATEGORIES), - granularCategories: buildDefaultGranularSet() - }; - const lookup = buildLookup(strategy); - - const canonicalRows = sqliteJsonQuery( - dbPath, - "SELECT category FROM minance_category WHERE category IS NOT NULL AND category <> '';" - ); - const transactionRows = sqliteJsonQuery( - dbPath, - ` - SELECT DISTINCT - t.category AS raw_category, - mc.category AS mapped_category - FROM transactions t - LEFT JOIN raw_category_to_minance_category rc - ON rc.raw_category = t.category - LEFT JOIN minance_category mc - ON mc.m_category_id = rc.minance_category_id - WHERE t.category IS NOT NULL - AND t.category <> ''; - ` - ); - - const isCovered = (value) => { - const normalized = normalizeText(value); - if (!normalized) { - return false; - } - if (lookup.granularByName.has(normalized) || lookup.granularByAlias.has(normalized)) { - return true; - } - if (CATEGORY_ALIASES[normalized]) { - return true; - } - return Boolean(inferFallbackCoarseKey(value, { allowDefault: false })); - }; - - const missingCanonicalCategories = canonicalRows - .map((entry) => String(entry.category || "").trim()) - .filter((value) => value && !isCovered(value)); - - const missingTransactionCategories = transactionRows - .map((entry) => ({ - rawCategory: String(entry.raw_category || "").trim(), - mappedCategory: String(entry.mapped_category || "").trim() - })) - .filter((entry) => entry.rawCategory) - .filter((entry) => { - if (entry.mappedCategory) { - return !isCovered(entry.mappedCategory); - } - return !isCovered(entry.rawCategory); - }) - .map((entry) => entry.rawCategory); - - return { - dbPath, - canonicalCategoryCount: canonicalRows.length, - transactionDistinctCategoryCount: transactionRows.length, - missingCanonicalCategories, - missingTransactionCategories - }; -} diff --git a/services/api/src/imports.ts b/services/api/src/imports.ts index b27ad57..5849150 100644 --- a/services/api/src/imports.ts +++ b/services/api/src/imports.ts @@ -280,7 +280,7 @@ function normalizeDirection(value, fallbackAmount = null, signConvention = "nega if (normalized === "outflow" || normalized === "inflow") { return normalized; } - // Map legacy values + // Map common bank-statement values. if (normalized === "debit") return "outflow"; if (normalized === "credit") return "inflow"; if (typeof fallbackAmount === "number") { diff --git a/services/api/src/legacy-api-loader.ts b/services/api/src/legacy-api-loader.ts deleted file mode 100644 index 9bacf95..0000000 --- a/services/api/src/legacy-api-loader.ts +++ /dev/null @@ -1,679 +0,0 @@ -import { ensureDevTestAccount } from "./auth.ts"; -import { normalizeMerchant } from "./categorization.ts"; -import { loadStore, saveStore } from "./store.ts"; -import { createId, hashPassword, normalizeText, nowIso, parseDate, stableHash, toDecimal } from "./utils.ts"; - -export const LEGACY_COARSE_CATEGORIES = [ - { key: "essential", name: "Essential", emoji: "🟢", isExcluded: false, order: 1 }, - { key: "extra", name: "Extra", emoji: "🔴", isExcluded: false, order: 2 }, - { key: "neutral", name: "Neutral", emoji: "🟡", isExcluded: false, order: 3 }, - { key: "other", name: "Other", emoji: "⚫", isExcluded: true, order: 4 } -]; - -const COARSE_NAME_BY_KEY = new Map(LEGACY_COARSE_CATEGORIES.map((entry) => [entry.key, entry.name])); - -function readField(row, candidates, fallback = null) { - if (!row || typeof row !== "object") { - return fallback; - } - for (const candidate of candidates) { - if (!Object.hasOwn(row, candidate)) { - continue; - } - const value = row[candidate]; - if (value == null) { - continue; - } - if (typeof value === "string" && value.trim() === "") { - continue; - } - return value; - } - return fallback; -} - -function coerceArray(payload, candidateKeys = []) { - if (Array.isArray(payload)) { - return payload; - } - if (payload && typeof payload === "object") { - for (const key of candidateKeys) { - if (Array.isArray(payload[key])) { - return payload[key]; - } - } - } - return []; -} - -function uniqByNormalized(values = []) { - const out = []; - const seen = new Set(); - for (const value of values) { - const name = String(value || "").trim(); - const normalized = normalizeText(name); - if (!normalized || seen.has(normalized)) { - continue; - } - seen.add(normalized); - out.push(name); - } - return out; -} - -export function inferLegacyTier1CoarseKey(mappedCategory) { - const normalized = normalizeText(mappedCategory || ""); - if (!normalized) { - return "other"; - } - - if (/\b(misc|miscellaneous|other|uncategorized|unknown|fees?|adjustments?)\b/.test(normalized)) { - return "other"; - } - - if (/(income|salary|paycheck|reimburse|refund|credit card payment|transfer|payment|deposit|interest|dividend|investment|withdraw)/.test(normalized)) { - return "neutral"; - } - - if (/(grocer|utility|bill|housing|home|rent|mortgage|loan|health|medical|pharmacy|insurance|transport|auto|automotive|gas|fuel|car|pet|education|childcare)/.test(normalized)) { - return "essential"; - } - - if (/(dining|restaurant|food|entertain|shopping|travel|subscription|fashion|hobby|merchandise|gift|coffee|bar|service)/.test(normalized)) { - return "extra"; - } - - return "other"; -} - -function inferLegacyTier2Emoji(mappedCategory, coarseKey) { - const normalized = normalizeText(mappedCategory || ""); - - if (/\b(grocery|market)\b/.test(normalized)) { - return "🛒"; - } - if (/\b(dining|restaurant|food|coffee|bar)\b/.test(normalized)) { - return "🍽️"; - } - if (/\b(auto|automotive|gas|fuel|car)\b/.test(normalized)) { - return "🚗"; - } - if (/\b(health|medical|pharmacy|care)\b/.test(normalized)) { - return "🩺"; - } - if (/\b(travel|flight|hotel)\b/.test(normalized)) { - return "✈️"; - } - if (/\b(home|housing|rent|mortgage)\b/.test(normalized)) { - return "🏠"; - } - if (/\b(income|salary|refund|reimburse|interest|investment)\b/.test(normalized)) { - return "💰"; - } - if (/\b(transfer|payment|withdraw|deposit)\b/.test(normalized)) { - return "🔁"; - } - if (coarseKey === "essential") { - return "🟢"; - } - if (coarseKey === "extra") { - return "🔴"; - } - if (coarseKey === "neutral") { - return "🟡"; - } - return "⚫"; -} - -function inferLegacyCategoryType(mappedCategory, coarseKey, flowDirection = null) { - const normalized = normalizeText(mappedCategory || ""); - - if (/\b(transfer|payment|withdraw|deposit)\b/.test(normalized)) { - return "transfer"; - } - - if (/\b(income|salary|paycheck|refund|reimburse|interest|dividend|investment)\b/.test(normalized)) { - return "income"; - } - - if (coarseKey === "essential" || coarseKey === "extra") { - return "expense"; - } - - if (coarseKey === "neutral") { - return flowDirection === "inflow" ? "income" : "transfer"; - } - - return flowDirection === "inflow" ? "income" : "expense"; -} - -export function resolveLegacyMappedCategory(rawCategory, rawToMappedCategory = new Map()) { - const rawValue = String(rawCategory || "").trim(); - const normalizedRaw = normalizeText(rawValue); - if (normalizedRaw && rawToMappedCategory.has(normalizedRaw)) { - const mapped = String(rawToMappedCategory.get(normalizedRaw) || "").trim(); - if (mapped) { - return mapped; - } - } - - if (rawValue) { - return rawValue; - } - - return "Uncategorized"; -} - -export function buildLegacyCategoryStrategy(mappedCategories = []) { - const distinctCategories = uniqByNormalized(mappedCategories); - const categories = distinctCategories.length > 0 ? distinctCategories : ["Uncategorized"]; - - return { - coarseCategories: LEGACY_COARSE_CATEGORIES.map((entry) => ({ ...entry })), - granularCategories: categories.map((name) => { - const coarseKey = inferLegacyTier1CoarseKey(name); - return { - name, - coarseKey, - emoji: inferLegacyTier2Emoji(name, coarseKey), - aliases: [], - isSystem: false - }; - }) - }; -} - -function normalizeLegacyAccountType(rawType) { - const normalized = normalizeText(rawType || ""); - if (!normalized) { - return "checking"; - } - if (normalized.includes("credit")) { - return "credit"; - } - if (normalized.includes("saving")) { - return "savings"; - } - if (normalized.includes("loan")) { - return "loan"; - } - if (normalized.includes("invest") || normalized.includes("broker")) { - return "investment"; - } - if (normalized.includes("cash")) { - return "cash"; - } - return "checking"; -} - -function inferLegacyFlowDirection(rawType, amount) { - const normalizedType = normalizeText(rawType || ""); - if (normalizedType.includes("debit") || normalizedType.includes("withdraw")) { - return "outflow"; - } - if (normalizedType.includes("credit") || normalizedType.includes("deposit")) { - return "inflow"; - } - // Legacy convention: positive = outflow (debit), negative = inflow (credit) - return amount < 0 ? "inflow" : "outflow"; -} - -function parseLegacyUploadAt(rawValue) { - const value = String(rawValue || "").trim(); - if (!value) { - return nowIso(); - } - - const legacyTimestamp = value.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/); - if (legacyTimestamp) { - return `${legacyTimestamp[1]}-${legacyTimestamp[2]}-${legacyTimestamp[3]}T${legacyTimestamp[4]}:${legacyTimestamp[5]}:${legacyTimestamp[6]}.000Z`; - } - - const parsedDate = parseDate(value); - if (parsedDate) { - return `${parsedDate}T00:00:00.000Z`; - } - - return nowIso(); -} - -function buildFingerprint({ userId, accountKey, merchantNormalized, amount, flowDirection, transactionDate, memo }) { - return stableHash( - [ - userId, - accountKey, - merchantNormalized, - Math.abs(Number(amount || 0)).toFixed(2), - flowDirection, - transactionDate, - memo ? stableHash(String(memo)) : "" - ].join("|") - ); -} - -function extractMappedCategoryNames(rawPayload) { - const rows = coerceArray(rawPayload, ["categories", "items", "results"]); - return uniqByNormalized( - rows.map((row) => - readField(row, ["category", "name", "minanceCategory", "mapped_category", "mappedCategory"], "") - ) - ); -} - -function extractRawCategoryNames(rawPayload) { - const rows = coerceArray(rawPayload, ["items", "categories", "results"]); - return uniqByNormalized( - rows.map((row) => readField(row, ["rawCategory", "raw_category", "category", "name"], "")) - ); -} - -async function fetchJson(url) { - const response = await fetch(url, { - headers: { - Accept: "application/json" - } - }); - - if (!response.ok) { - const body = await response.text(); - throw new Error(`Request failed (${response.status}) for ${url}: ${body || response.statusText}`); - } - - return response.json(); -} - -function normalizeBaseUrl(baseUrl) { - return String(baseUrl || "").trim().replace(/\/+$/, ""); -} - -export async function fetchLegacyApiData({ baseUrl, startDate, endDate }) { - const normalizedBaseUrl = normalizeBaseUrl(baseUrl); - if (!normalizedBaseUrl) { - throw new Error("baseUrl is required"); - } - if (!parseDate(startDate) || !parseDate(endDate)) { - throw new Error("startDate and endDate must be valid dates in YYYY-MM-DD format"); - } - - const warnings = []; - const accountsPayload = await fetchJson(`${normalizedBaseUrl}/1.0/minance/account/listAll`); - const transactionsPayload = await fetchJson( - `${normalizedBaseUrl}/1.0/minance/transactions/retrieve/${startDate}/${endDate}` - ); - - const accounts = coerceArray(accountsPayload, ["accounts", "items", "results"]); - const transactions = coerceArray(transactionsPayload, ["transactions", "items", "results"]); - - let mappedCategories = []; - const rawToMappedCategory = new Map(); - - try { - const mappedCategoriesPayload = await fetchJson( - `${normalizedBaseUrl}/1.0/minance/mapping_category/minanceCategory/retrieveAll` - ); - mappedCategories = extractMappedCategoryNames(mappedCategoriesPayload); - - await Promise.all( - mappedCategories.map(async (mappedCategory) => { - try { - const linkedRawPayload = await fetchJson( - `${normalizedBaseUrl}/1.0/minance/mapping_category/retrieve/${encodeURIComponent(mappedCategory)}` - ); - const linkedRawCategories = extractRawCategoryNames(linkedRawPayload); - for (const rawCategory of linkedRawCategories) { - rawToMappedCategory.set(normalizeText(rawCategory), mappedCategory); - } - } catch (error) { - warnings.push( - `Failed to fetch raw category links for ${mappedCategory}: ${error instanceof Error ? error.message : String(error)}` - ); - } - }) - ); - } catch (error) { - warnings.push(`Failed to load mapped category catalog: ${error instanceof Error ? error.message : String(error)}`); - } - - return { - accounts, - transactions, - mappedCategories, - rawToMappedCategory, - warnings - }; -} - -function resolveLegacyLoaderPassword(userPassword = null) { - const passwordSource = - userPassword == null ? process.env.DEV_TEST_ACCOUNT_PASSWORD || "devpassword123" : userPassword; - const password = String(passwordSource); - if (password.length < 8) { - throw new Error("Legacy loader user password must be at least 8 characters"); - } - return password; -} - -export function resolveLegacyLoaderUserId(userEmail = null, userPassword = null) { - const normalizedEmail = String(userEmail || "").trim().toLowerCase(); - - if (!normalizedEmail) { - if (userPassword != null) { - throw new Error("--user-password requires --user-email"); - } - - const seeded = ensureDevTestAccount(); - if (seeded?.user?.id) { - return seeded.user.id; - } - - const store = loadStore(); - if (store.users.length > 0) { - return String(store.users[0].id); - } - - throw new Error("No user available. Provide --user-email or enable dev account seeding."); - } - - const store = loadStore(); - const existing = store.users.find((entry) => String(entry.email || "").toLowerCase() === normalizedEmail); - if (existing) { - if (userPassword != null) { - const nextPassword = resolveLegacyLoaderPassword(userPassword); - const { passwordHash, salt } = hashPassword(nextPassword); - existing.passwordHash = passwordHash; - existing.passwordSalt = salt; - existing.updatedAt = nowIso(); - saveStore(store); - } - return existing.id; - } - - const now = nowIso(); - const password = resolveLegacyLoaderPassword(userPassword); - const { passwordHash, salt } = hashPassword(password); - const user = { - id: createId("user"), - email: normalizedEmail, - passwordHash, - passwordSalt: salt, - createdAt: now, - updatedAt: now - }; - - store.users.push(user); - saveStore(store); - return user.id; -} - -export function applyLegacyApiDataToStore({ - userId, - accounts = [], - transactions = [], - mappedCategories = [], - rawToMappedCategory = new Map(), - resetUserData = true -}) { - if (!userId) { - throw new Error("userId is required"); - } - - const store = loadStore(); - - if (resetUserData) { - store.accounts = store.accounts.filter((entry) => entry.userId !== userId); - store.transactions = store.transactions.filter((entry) => entry.user_id !== userId); - store.categories = store.categories.filter((entry) => entry.userId !== userId); - store.categoryRules = store.categoryRules.filter((entry) => entry.userId !== userId); - store.categoryStrategies = store.categoryStrategies.filter((entry) => entry.userId !== userId); - } - - const summary = { - userId, - accountsScanned: 0, - accountsImported: 0, - transactionsScanned: 0, - transactionsImported: 0, - invalidTransactions: 0, - duplicateTransactionsSkipped: 0, - categoriesImported: 0, - mappedCategoryCount: 0, - resetUserData: Boolean(resetUserData) - }; - - const legacyAccountIdToNewId = new Map(); - const accountNameToNewId = new Map(); - - for (const row of accounts) { - summary.accountsScanned += 1; - - const accountName = String(readField(row, ["account_name", "accountName", "name"], "Legacy Account")).trim(); - const bankName = String(readField(row, ["bank_name", "bankName", "institution", "bank"], "")).trim(); - const accountType = normalizeLegacyAccountType(readField(row, ["account_type", "accountType", "type"], "checking")); - const keySource = `${bankName} ${accountName}`.trim() || accountName || `legacy-account-${summary.accountsScanned}`; - const normalizedKey = normalizeText(keySource) || `legacy_account_${summary.accountsScanned}`; - - let existing = store.accounts.find((entry) => entry.userId === userId && entry.normalizedKey === normalizedKey); - if (!existing) { - existing = { - id: createId("acct"), - userId, - normalizedKey, - displayName: accountName, - sourceInstitution: bankName || null, - accountType, - currency: "USD", - initialBalance: 0, - status: "active", - includeInCharts: true, - version: 1, - createdAt: nowIso(), - updatedAt: nowIso() - }; - store.accounts.push(existing); - summary.accountsImported += 1; - } - - const legacyAccountId = String(readField(row, ["account_id", "accountId", "id"], "")).trim(); - if (legacyAccountId) { - legacyAccountIdToNewId.set(legacyAccountId, existing.id); - } - - const normalizedAccountName = normalizeText(accountName); - if (normalizedAccountName) { - accountNameToNewId.set(normalizedAccountName, existing.id); - } - } - - const mappedCategoryNames = uniqByNormalized(mappedCategories); - - const existingFingerprints = new Set( - store.transactions - .filter((entry) => entry.user_id === userId) - .map((entry) => String(entry.dedupe_fingerprint || "")) - .filter(Boolean) - ); - - for (const row of transactions) { - summary.transactionsScanned += 1; - - const transactionDate = parseDate( - readField(row, ["transaction_date", "transactionDate", "date", "post_date", "postDate"], null) - ); - const rawAmount = toDecimal(readField(row, ["amount", "transaction_amount", "value"], null)); - const description = String(readField(row, ["description", "merchant", "payee", "memo"], "")).trim(); - - if (!transactionDate || rawAmount == null || !description) { - summary.invalidTransactions += 1; - continue; - } - - const legacyAccountId = String(readField(row, ["account_id", "accountId", "account"], "")).trim(); - const accountName = String(readField(row, ["account_name", "accountName"], "")).trim(); - const accountId = legacyAccountIdToNewId.get(legacyAccountId) - || accountNameToNewId.get(normalizeText(accountName)) - || null; - - const account = accountId ? store.accounts.find((entry) => entry.id === accountId) : null; - const accountKey = account?.normalizedKey || normalizeText(accountName) || "legacy_account"; - - const flowDirection = inferLegacyFlowDirection(readField(row, ["transaction_type", "transactionType", "type"], ""), rawAmount); - const amount = Math.abs(rawAmount); - - const rawCategory = String(readField(row, ["category", "raw_category", "category_raw"], "")).trim(); - const categoryFinal = resolveLegacyMappedCategory(rawCategory, rawToMappedCategory); - if (categoryFinal) { - mappedCategoryNames.push(categoryFinal); - } - - const coarseKey = inferLegacyTier1CoarseKey(categoryFinal); - const coarseName = COARSE_NAME_BY_KEY.get(coarseKey) || "Other"; - const categoryEmoji = inferLegacyTier2Emoji(categoryFinal, coarseKey); - const transactionType = inferLegacyCategoryType(categoryFinal, coarseKey, flowDirection); - - const memo = readField(row, ["memo", "notes"], null); - const merchantRaw = description; - const merchantNormalized = normalizeMerchant(merchantRaw); - const fingerprint = buildFingerprint({ - userId, - accountKey, - merchantNormalized, - amount, - flowDirection, - transactionDate, - memo - }); - - if (existingFingerprints.has(fingerprint)) { - summary.duplicateTransactionsSkipped += 1; - continue; - } - - existingFingerprints.add(fingerprint); - - store.transactions.push({ - id: createId("txn"), - user_id: userId, - account_id: accountId, - account_key: accountKey, - source_type: "legacy_api", - source_file_id: null, - transaction_date: transactionDate, - post_date: parseDate(readField(row, ["post_date", "postDate"], null)), - merchant_raw: merchantRaw, - merchant_normalized: merchantNormalized, - description, - amount, - currency: String(readField(row, ["currency"], "USD")).toUpperCase(), - direction: flowDirection, - transaction_type: transactionType, - category_raw: null, - category_final: categoryFinal, - category_coarse: coarseName, - category_emoji: categoryEmoji, - category_confidence: 1, - category_strategy: "legacy_api_mapping", - needs_category_review: false, - memo: memo ? String(memo) : null, - dedupe_fingerprint: fingerprint, - created_at: parseLegacyUploadAt(readField(row, ["upload_time", "uploadTime", "created_at", "createdAt"], null)), - updated_at: nowIso() - }); - - summary.transactionsImported += 1; - } - - const distinctCategories = uniqByNormalized(mappedCategoryNames); - const strategy = buildLegacyCategoryStrategy(distinctCategories); - - for (const categoryName of distinctCategories) { - const normalized = normalizeText(categoryName); - const exists = store.categories.find( - (entry) => entry.userId === userId && normalizeText(entry.name) === normalized - ); - if (exists) { - continue; - } - - const coarseKey = inferLegacyTier1CoarseKey(categoryName); - store.categories.push({ - id: createId("cat"), - userId, - name: categoryName, - emoji: inferLegacyTier2Emoji(categoryName, coarseKey), - coarseKey, - type: inferLegacyCategoryType(categoryName, coarseKey), - isSystem: false, - createdAt: nowIso(), - updatedAt: nowIso() - }); - summary.categoriesImported += 1; - } - - const now = nowIso(); - const existingStrategy = store.categoryStrategies.find((entry) => entry.userId === userId); - if (existingStrategy) { - existingStrategy.sourceUrl = "legacy-api-loader"; - existingStrategy.version = "legacy-api-v1"; - existingStrategy.coarseCategories = strategy.coarseCategories; - existingStrategy.granularCategories = strategy.granularCategories; - existingStrategy.updatedAt = now; - } else { - store.categoryStrategies.push({ - id: createId("cstrat"), - userId, - sourceUrl: "legacy-api-loader", - version: "legacy-api-v1", - coarseCategories: strategy.coarseCategories, - granularCategories: strategy.granularCategories, - createdAt: now, - updatedAt: now - }); - } - - summary.mappedCategoryCount = distinctCategories.length; - - store.auditEvents.push({ - id: createId("audit"), - userId, - action: "legacy_api_loader.completed", - details: { - accountsImported: summary.accountsImported, - transactionsImported: summary.transactionsImported, - categoriesImported: summary.categoriesImported, - mappedCategoryCount: summary.mappedCategoryCount, - resetUserData: summary.resetUserData - }, - createdAt: nowIso() - }); - - saveStore(store); - return summary; -} - -export async function seedFromLegacyApiToStore({ - baseUrl, - startDate, - endDate, - userEmail = null, - userPassword = null, - resetUserData = true -}) { - const userId = resolveLegacyLoaderUserId(userEmail, userPassword); - const dataset = await fetchLegacyApiData({ baseUrl, startDate, endDate }); - const summary = applyLegacyApiDataToStore({ - userId, - accounts: dataset.accounts, - transactions: dataset.transactions, - mappedCategories: dataset.mappedCategories, - rawToMappedCategory: dataset.rawToMappedCategory, - resetUserData - }); - - return { - ...summary, - baseUrl: normalizeBaseUrl(baseUrl), - startDate, - endDate, - warnings: dataset.warnings - }; -} diff --git a/services/api/src/migration.ts b/services/api/src/migration.ts deleted file mode 100644 index 1ddb2a4..0000000 --- a/services/api/src/migration.ts +++ /dev/null @@ -1,347 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { spawn } from "node:child_process"; -import { TMP_DIR } from "./config.ts"; -import { loadStore, saveStore, addAuditEvent } from "./store.ts"; -import { createId, nowIso, parseDate, stableHash, normalizeText, toDecimal } from "./utils.ts"; -import { normalizeMerchant } from "./categorization.ts"; - -function runCommand(command, args) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - stdio: ["ignore", "pipe", "pipe"] - }); - - let stdout = ""; - let stderr = ""; - - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - - child.on("error", (error) => { - reject(error); - }); - - child.on("close", (code) => { - if (code !== 0) { - reject(new Error(stderr || `${command} exited with code ${code}`)); - return; - } - resolve(stdout); - }); - }); -} - -async function ensureSqliteInstalled() { - try { - await runCommand("sqlite3", ["--version"]); - } catch { - throw new Error("sqlite3 CLI is required for migration but was not found on this machine"); - } -} - -async function querySqlite(dbPath, sql) { - const raw = await runCommand("sqlite3", ["-json", dbPath, sql]); - const trimmed = raw.trim(); - if (!trimmed) { - return []; - } - - try { - return JSON.parse(trimmed); - } catch { - return []; - } -} - -function field(row, candidates, fallback = null) { - for (const candidate of candidates) { - if (Object.hasOwn(row, candidate) && row[candidate] != null) { - return row[candidate]; - } - } - return fallback; -} - -function normalizeLegacyAmount(value) { - const amount = toDecimal(value); - return amount == null ? null : Math.round(amount * 100) / 100; -} - -function inferDirection(transactionType, amount) { - const normalizedType = String(transactionType || "").toLowerCase(); - if (normalizedType.includes("debit") || normalizedType.includes("withdraw")) { - return "outflow"; - } - if (normalizedType.includes("credit") || normalizedType.includes("deposit")) { - return "inflow"; - } - - return amount < 0 ? "outflow" : "inflow"; -} - -function dedupeFingerprint(userId, accountKey, merchantNormalized, amount, date, memo) { - return stableHash( - [ - userId, - accountKey, - merchantNormalized, - Math.abs(amount).toFixed(2), - parseDate(date) || "", - memo ? stableHash(String(memo)) : "" - ].join("|") - ); -} - -function ensureTmpDir() { - fs.mkdirSync(TMP_DIR, { recursive: true }); -} - -export function writeUploadedSqliteFile(fileName, sqliteBase64) { - if (!sqliteBase64) { - throw new Error("sqliteBase64 is required"); - } - - ensureTmpDir(); - const safeName = `${Date.now()}-${String(fileName || "legacy.db").replace(/[^a-zA-Z0-9_.-]/g, "_")}`; - const filePath = path.join(TMP_DIR, safeName); - - fs.writeFileSync(filePath, Buffer.from(sqliteBase64, "base64")); - return filePath; -} - -export async function runLegacyMigration({ userId, sqlitePath }) { - if (!sqlitePath) { - throw new Error("sqlitePath is required"); - } - if (!fs.existsSync(sqlitePath)) { - throw new Error("SQLite file not found"); - } - - await ensureSqliteInstalled(); - - const store = loadStore(); - const runId = createId("mig"); - const run = { - id: runId, - userId, - status: "processing", - sqlitePath, - createdAt: nowIso(), - updatedAt: nowIso(), - report: { - scanned: 0, - imported: 0, - duplicatesSkipped: 0, - invalidRows: 0, - accountsImported: 0, - categoriesImported: 0, - rulesImported: 0, - warnings: [] - } - }; - - store.migrationRuns.push(run); - saveStore(store); - - const banks = await querySqlite(sqlitePath, "SELECT * FROM banks;"); - const accounts = await querySqlite(sqlitePath, "SELECT * FROM accounts;"); - const transactions = await querySqlite(sqlitePath, "SELECT * FROM transactions;"); - const categories = await querySqlite(sqlitePath, "SELECT * FROM minance_category;"); - const rawMappings = await querySqlite(sqlitePath, "SELECT * FROM raw_category_to_minance_category;"); - - const banksById = new Map(); - for (const bank of banks) { - const bankId = field(bank, ["id", "bank_id"]); - const bankName = field(bank, ["bank_name", "name"], "Unknown"); - banksById.set(String(bankId), bankName); - } - - const accountIdMap = new Map(); - const categoryIdToName = new Map(); - for (const accountRow of accounts) { - const legacyId = String(field(accountRow, ["id", "account_id"])); - const displayName = String(field(accountRow, ["account_name", "name"], "Legacy Account")); - const accountType = String(field(accountRow, ["account_type", "type"], "checking")); - const bankId = String(field(accountRow, ["bank_id", "bank"], "")); - const institution = banksById.get(bankId) || null; - const normalizedKey = normalizeText(displayName); - - let existing = store.accounts.find((entry) => entry.userId === userId && entry.normalizedKey === normalizedKey); - if (!existing) { - existing = { - id: createId("acct"), - userId, - normalizedKey, - displayName, - sourceInstitution: institution, - accountType, - createdAt: nowIso(), - updatedAt: nowIso() - }; - store.accounts.push(existing); - run.report.accountsImported += 1; - } - - accountIdMap.set(legacyId, existing.id); - } - - for (const categoryRow of categories) { - const categoryName = String( - field(categoryRow, ["category", "name", "category_name", "minance_category"], "") - ).trim(); - const categoryId = String(field(categoryRow, ["m_category_id", "id", "category_id"], "")).trim(); - if (!categoryName) { - continue; - } - if (categoryId) { - categoryIdToName.set(categoryId, categoryName); - } - - let existing = store.categories.find((entry) => entry.userId === userId && entry.name === categoryName); - if (!existing) { - existing = { - id: createId("cat"), - userId, - name: categoryName, - isSystem: false, - createdAt: nowIso(), - updatedAt: nowIso() - }; - store.categories.push(existing); - run.report.categoriesImported += 1; - } - } - - for (const mapping of rawMappings) { - const pattern = String(field(mapping, ["raw_category", "raw", "source_category"], "")).trim(); - const mappedCategoryId = String( - field(mapping, ["minance_category_id", "m_category_id", "category_id"], "") - ).trim(); - const category = String( - field( - mapping, - ["minance_category", "mapped_category", "category"], - mappedCategoryId ? categoryIdToName.get(mappedCategoryId) || "" : "" - ) - ).trim(); - if (!pattern || !category) { - continue; - } - - const exists = store.categoryRules.find( - (entry) => entry.userId === userId && entry.pattern === pattern && entry.category === category - ); - if (exists) { - continue; - } - - store.categoryRules.push({ - id: createId("rule"), - userId, - type: "contains", - pattern, - category, - priority: 80, - createdAt: nowIso(), - updatedAt: nowIso() - }); - run.report.rulesImported += 1; - } - - const existingFingerprints = new Set( - store.transactions.filter((entry) => entry.user_id === userId).map((entry) => entry.dedupe_fingerprint) - ); - - for (const tx of transactions) { - run.report.scanned += 1; - - const transactionDate = parseDate(field(tx, ["transaction_date", "date", "post_date"])); - const rawAmount = normalizeLegacyAmount(field(tx, ["amount", "value", "transaction_amount"])); - const description = String(field(tx, ["description", "merchant", "payee"], "")).trim(); - - if (!transactionDate || rawAmount == null || !description) { - run.report.invalidRows += 1; - continue; - } - - const legacyAccountId = String(field(tx, ["account_id", "account"], "")); - const accountId = accountIdMap.get(legacyAccountId) || null; - const account = store.accounts.find((entry) => entry.id === accountId); - const accountKey = account?.normalizedKey || "legacy-account"; - - const merchantRaw = description; - const merchantNormalized = normalizeMerchant(merchantRaw); - const memo = field(tx, ["memo", "notes"], null); - const direction = inferDirection(field(tx, ["transaction_type", "type"], ""), rawAmount); - const amount = Math.abs(rawAmount); - - const fingerprint = dedupeFingerprint( - userId, - accountKey, - merchantNormalized, - rawAmount, - transactionDate, - memo - ); - - if (existingFingerprints.has(fingerprint)) { - run.report.duplicatesSkipped += 1; - continue; - } - - existingFingerprints.add(fingerprint); - - store.transactions.push({ - id: createId("txn"), - user_id: userId, - account_id: accountId, - account_key: accountKey, - source_type: "migrated", - source_file_id: runId, - transaction_date: transactionDate, - post_date: parseDate(field(tx, ["post_date"], null)), - merchant_raw: merchantRaw, - merchant_normalized: merchantNormalized, - description, - amount, - currency: String(field(tx, ["currency"], "USD")).toUpperCase(), - direction, - category_raw: String(field(tx, ["category"], "")).trim() || null, - category_final: String(field(tx, ["category"], "Uncategorized")).trim() || "Uncategorized", - category_confidence: 0.8, - category_strategy: "migration", - needs_category_review: false, - memo: memo ? String(memo) : null, - dedupe_fingerprint: fingerprint, - created_at: parseDate(field(tx, ["upload_time"], null)) - ? `${parseDate(field(tx, ["upload_time"]))}T00:00:00.000Z` - : nowIso(), - updated_at: nowIso() - }); - - run.report.imported += 1; - } - - run.status = "completed"; - run.updatedAt = nowIso(); - - saveStore(store); - addAuditEvent(userId, "migration.completed", { migrationId: runId, report: run.report }); - - return run; -} - -export function getMigrationReport(userId, migrationId) { - const store = loadStore(); - const run = store.migrationRuns.find((entry) => entry.id === migrationId && entry.userId === userId); - if (!run) { - throw new Error("Migration run not found"); - } - return run; -} diff --git a/services/api/src/migrations/account-identity-repair.ts b/services/api/src/migrations/account-identity-repair.ts deleted file mode 100644 index 8f670a9..0000000 --- a/services/api/src/migrations/account-identity-repair.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { loadStore, saveStore } from "../store.ts"; -import { createId, nowIso, normalizeText } from "../utils.ts"; -import { buildTransactionFingerprint } from "../transaction-fingerprint.ts"; -import { findAccountsByDisplayName, pickPreferredAccountIdentity } from "../account-identity.ts"; - -function appendAuditEvent(store, userId, details) { - store.auditEvents.push({ - id: createId("audit"), - userId, - action: "account.identity.repaired", - details, - createdAt: nowIso() - }); -} - -function rewriteTransactionAccountIdentity(transaction, userId, accountId, accountKey, updatedAt) { - transaction.account_id = accountId; - transaction.account_key = accountKey; - transaction.updated_at = updatedAt; - - if (transaction.dedupe_fingerprint) { - transaction.dedupe_fingerprint = buildTransactionFingerprint({ - userId, - accountKey, - merchantNormalized: transaction.merchant_normalized, - amount: transaction.amount, - transactionDate: transaction.transaction_date, - memo: transaction.memo - }); - } -} - -function mergeManualAdjustments(survivor, duplicates = []) { - const survivorAdjustments = Array.isArray(survivor.manualAdjustments) ? survivor.manualAdjustments : []; - const duplicateAdjustments = duplicates.flatMap((duplicate) => - Array.isArray(duplicate.manualAdjustments) ? duplicate.manualAdjustments : [] - ); - if (duplicateAdjustments.length === 0) { - return survivorAdjustments; - } - return survivorAdjustments.concat(duplicateAdjustments); -} - -function shouldRepairDuplicateGroup(duplicates, displayNameKey) { - const normalizedKeys = duplicates - .map((entry) => normalizeText(entry.normalizedKey || "")) - .filter(Boolean); - const institutions = new Set( - duplicates - .map((entry) => normalizeText(entry.sourceInstitution || "")) - .filter(Boolean) - ); - - return ( - normalizedKeys.includes(displayNameKey) - && normalizedKeys.some((key) => key !== displayNameKey) - && institutions.size <= 1 - ); -} - -export function repairLegacyAccountIdentityDrift() { - const store = loadStore(); - const userIds = Array.from(new Set((store.accounts || []).map((account) => account.userId).filter(Boolean))); - let duplicateGroupsRepaired = 0; - const updatedAt = nowIso(); - - for (const userId of userIds) { - const accounts = (store.accounts || []).filter((entry) => entry.userId === userId); - const processedKeys = new Set(); - - for (const account of accounts) { - const displayNameKey = normalizeText(account.displayName || ""); - if (!displayNameKey || processedKeys.has(displayNameKey)) { - continue; - } - processedKeys.add(displayNameKey); - - const duplicates = findAccountsByDisplayName(store, userId, displayNameKey); - if (duplicates.length < 2) { - continue; - } - if (!shouldRepairDuplicateGroup(duplicates, displayNameKey)) { - continue; - } - - const survivor = pickPreferredAccountIdentity(store, userId, duplicates); - if (!survivor) { - continue; - } - - const survivorKey = String(survivor.normalizedKey || "").trim() || displayNameKey; - const duplicateIds = new Set(duplicates.filter((entry) => entry.id !== survivor.id).map((entry) => entry.id)); - const historicalKeys = new Set(duplicates.map((entry) => String(entry.normalizedKey || "").trim()).filter(Boolean)); - - for (const transaction of store.transactions || []) { - if (transaction.user_id !== userId) { - continue; - } - if ( - duplicateIds.has(transaction.account_id) - || transaction.account_id === survivor.id - || historicalKeys.has(String(transaction.account_key || "").trim()) - ) { - rewriteTransactionAccountIdentity(transaction, userId, survivor.id, survivorKey, updatedAt); - } - } - - for (const recurringRule of store.recurringRules || []) { - if (recurringRule.user_id === userId && duplicateIds.has(recurringRule.account_id)) { - recurringRule.account_id = survivor.id; - recurringRule.updated_at = updatedAt; - } - } - - survivor.updatedAt = updatedAt; - if (survivor.updated_at != null) { - survivor.updated_at = updatedAt; - } - if (Number.isFinite(Number(survivor.version))) { - survivor.version = Number(survivor.version) + 1; - } - survivor.manualAdjustments = mergeManualAdjustments( - survivor, - duplicates.filter((entry) => entry.id !== survivor.id) - ); - - store.accounts = (store.accounts || []).filter( - (entry) => entry.userId !== userId || normalizeText(entry.displayName || "") !== displayNameKey || entry.id === survivor.id - ); - - appendAuditEvent(store, userId, { - displayName: survivor.displayName, - survivorAccountId: survivor.id, - removedAccountIds: Array.from(duplicateIds), - canonicalKey: survivorKey - }); - duplicateGroupsRepaired += 1; - } - } - - if (duplicateGroupsRepaired > 0) { - saveStore(store); - } - - return { - duplicateGroupsRepaired - }; -} diff --git a/services/api/src/recurrings.ts b/services/api/src/recurrings.ts index eff4145..d9b12d0 100644 --- a/services/api/src/recurrings.ts +++ b/services/api/src/recurrings.ts @@ -105,9 +105,6 @@ function normalizeDirection(rawValue, fallback = null) { return fallback; } const direction = String(rawValue).trim().toLowerCase(); - // Map legacy values - if (direction === "debit") return "outflow"; - if (direction === "credit") return "inflow"; if (!DIRECTION_VALUES.has(direction)) { throw new Error("Invalid recurring direction"); } diff --git a/services/api/src/sqlite-store-repository.ts b/services/api/src/sqlite-store-repository.ts index cd062ff..3b3778d 100644 --- a/services/api/src/sqlite-store-repository.ts +++ b/services/api/src/sqlite-store-repository.ts @@ -232,9 +232,6 @@ function withRequiredColumns(spec, mapped, row, rowIndex) { } else if (spec.tableName === "investment_snapshots") { ensured.id = ensureText(ensured.id, `investment_snapshot_${rowIndex}`); ensured.user_id = ensureText(ensured.user_id, "unknown_user"); - } else if (spec.tableName === "migration_runs") { - ensured.id = ensureText(ensured.id, `migration_${rowIndex}`); - ensured.user_id = ensureText(ensured.user_id, "unknown_user"); } else if (spec.tableName === "audit_events") { ensured.id = ensureText(ensured.id, `audit_${rowIndex}`); ensured.user_id = ensureText(ensured.user_id, "unknown_user"); diff --git a/services/api/src/store.ts b/services/api/src/store.ts index 3f4a1ca..dce5e74 100644 --- a/services/api/src/store.ts +++ b/services/api/src/store.ts @@ -40,7 +40,6 @@ const defaultStore = { aiProviderPreferences: [], assistantQueries: [], savedViews: [], - migrationRuns: [], auditEvents: [], userRecurringScanState: [], scanRunState: { @@ -96,7 +95,7 @@ function buildTransactionIndex() { if (!cache) return; const index = new Map(); for (const txn of cache.transactions) { - const uid = txn.user_id || txn.userId; + const uid = txn.user_id; if (!uid) continue; if (txn.deleted_at) continue; let list = index.get(uid); diff --git a/services/api/src/transactionFilters.ts b/services/api/src/transactionFilters.ts index d86524a..6f9bd36 100644 --- a/services/api/src/transactionFilters.ts +++ b/services/api/src/transactionFilters.ts @@ -160,17 +160,11 @@ function normalizeDirection(entry) { if (rawDirection === "inflow" || rawDirection === "outflow") { return rawDirection; } - if (rawDirection === "credit") { - return "inflow"; - } - if (rawDirection === "debit") { - return "outflow"; - } return rawAmount > 0 ? "inflow" : "outflow"; } function resolveCategoryType(entry, categoryTypeLookup) { - const userId = String(entry?.user_id || entry?.userId || "").trim(); + const userId = String(entry?.user_id || "").trim(); const categoryName = normalizeText(entry?.category_final || ""); if (!userId || !categoryName) { return null; diff --git a/services/api/src/transactions.ts b/services/api/src/transactions.ts index 68cab9c..c13316e 100644 --- a/services/api/src/transactions.ts +++ b/services/api/src/transactions.ts @@ -12,17 +12,6 @@ import { createCategoryResolver, ensureCategoryStrategyForUser } from "./categor import { ensureAccountIdentity } from "./account-identity.ts"; const TRANSACTION_TYPE_VALUES = new Set(["expense", "income", "transfer"]); -const TRANSACTION_TYPE_ALIASES = new Map( - Object.entries({ - expense: "expense", - spending: "expense", - debit: "expense", - income: "income", - credit: "income", - transfer: "transfer", - internal_transfer: "transfer" - }) -); const REVIEW_STATUS_VALUES = new Set(["reviewed", "needs_review"]); const CATEGORY_VALUE_MAX_LENGTH = 120; const TAG_MAX_LENGTH = 40; @@ -56,13 +45,13 @@ function ensureAccount(store, userId, accountId, accountName) { } function deriveDirection(rawDirection, rawAmount) { - const direction = String(rawDirection || "").toLowerCase(); + const direction = String(rawDirection || "").trim().toLowerCase(); if (direction === "outflow" || direction === "inflow") { return direction; } - // Map legacy values - if (direction === "debit") return "outflow"; - if (direction === "credit") return "inflow"; + if (direction) { + throw new Error("Invalid transaction direction"); + } return rawAmount < 0 ? "outflow" : "inflow"; } @@ -121,10 +110,10 @@ function normalizeTransactionType(rawValue, direction, categoryFinal, categoryTy } const normalized = normalizeText(rawValue).replace(/\s+/g, "_"); - const transactionType = TRANSACTION_TYPE_ALIASES.get(normalized); - if (!transactionType) { + if (!TRANSACTION_TYPE_VALUES.has(normalized)) { throw new Error("Invalid transaction type"); } + const transactionType = normalized; if (transactionType === "expense" && direction === "inflow") { throw new Error("Invalid transaction type for inflow direction"); @@ -314,22 +303,13 @@ function normalizeRecurringRuleId(rawValue, fallback = null) { function normalizeTransactionRecord(transaction, store = null) { const tx = transaction || {}; const rawAmount = Number(tx.amount ?? 0); - const amount = Number.isFinite(rawAmount) ? Math.abs(rawAmount) : 0; + const amount = Number.isFinite(rawAmount) ? rawAmount : 0; const rawDirection = String(tx.direction || "").trim().toLowerCase(); - let direction = "outflow"; - if (rawDirection === "inflow" || rawDirection === "outflow") { - direction = rawDirection; - } else if (rawDirection === "credit") { - direction = "inflow"; - } else if (rawDirection === "debit") { - direction = "outflow"; - } else if (rawAmount > 0) { - direction = "inflow"; - } + const direction = rawDirection === "inflow" ? "inflow" : "outflow"; const fallbackCategoryFinal = direction === "inflow" ? "Income" : "Uncategorized"; const categoryFinal = String(tx.category_final || fallbackCategoryFinal).trim() || fallbackCategoryFinal; - const category = findUserCategoryByName(store || loadStore(), tx.user_id || tx.userId || null, categoryFinal); + const category = findUserCategoryByName(store || loadStore(), tx.user_id || null, categoryFinal); let transactionType; try { @@ -466,7 +446,7 @@ function resolveManualContractFields( throw new Error("Invalid category"); } - const rawTransactionType = pickFirstDefined(payload, ["transaction_type", "type"]); + const rawTransactionType = pickFirstDefined(payload, ["transaction_type"]); const transactionType = normalizeTransactionType( rawTransactionType === undefined ? fallback?.transaction_type : rawTransactionType, normalizedInput.direction, @@ -477,7 +457,7 @@ function resolveManualContractFields( const tags = normalizeTags(pickFirstDefined(payload, ["tags"]), fallback?.tags || []); const reviewState = normalizeReviewState(payload || {}, fallback?.needs_category_review || false); const recurringRuleId = normalizeRecurringRuleId( - pickFirstDefined(payload, ["recurring_rule_id", "recurringRuleId"]), + pickFirstDefined(payload, ["recurring_rule_id"]), fallback?.recurring_rule_id ?? null ); diff --git a/services/api/test/analytics.test.ts b/services/api/test/analytics.test.ts index 1221f16..bbd2e81 100644 --- a/services/api/test/analytics.test.ts +++ b/services/api/test/analytics.test.ts @@ -23,7 +23,7 @@ const baseStore = { merchant_raw: "Coffee", description: "Coffee", amount: 10, - direction: "debit", + direction: "outflow", category_final: "Dining", dedupe_fingerprint: "a" }, @@ -35,7 +35,7 @@ const baseStore = { merchant_raw: "Payroll", description: "Payroll", amount: 1000, - direction: "credit", + direction: "inflow", category_final: "Income", dedupe_fingerprint: "b" }, @@ -47,7 +47,7 @@ const baseStore = { merchant_raw: "Coffee", description: "Coffee", amount: 12, - direction: "debit", + direction: "outflow", category_final: "Dining", dedupe_fingerprint: "prev" }, @@ -59,7 +59,7 @@ const baseStore = { merchant_raw: "Flight", description: "Flight", amount: 400, - direction: "debit", + direction: "outflow", category_final: "Transport", dedupe_fingerprint: "c" } @@ -73,7 +73,6 @@ const baseStore = { aiProviderPreferences: [], assistantQueries: [], savedViews: [], - migrationRuns: [], auditEvents: [] }; @@ -86,7 +85,7 @@ test("overview calculates spend, income, and net", () => { assert.equal(overview.summary.netFlow, 590); }); -test("category rollup groups debit amounts", () => { +test("category rollup groups outflow amounts", () => { resetStoreForTests(structuredClone(baseStore)); const categories = getCategoryRollup("user_1", { start: "2026-01-01", end: "2026-01-31" }); @@ -120,7 +119,7 @@ test("overview and explorer omit excluded-group transactions by default", () => merchant_raw: "Internal Transfer", description: "Move to savings", amount: 250, - direction: "debit", + direction: "outflow", category_final: "Uncategorized", dedupe_fingerprint: "excluded" }); @@ -169,7 +168,7 @@ test("analytics transaction filtering honors account, query, tag, review, type, description: "Fuel stop", memo: "car commute", amount: 40, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Transport", tags: ["car"], @@ -188,7 +187,7 @@ test("analytics transaction filtering honors account, query, tag, review, type, description: "Morning coffee", memo: "latte run", amount: 10, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Dining", tags: ["coffee"], @@ -227,7 +226,7 @@ test("analytics transaction filtering honors recurring-only sentinel", () => { merchant_raw: "Netflix", description: "Netflix subscription", amount: 15, - direction: "debit", + direction: "outflow", category_final: "Entertainment", recurring_rule_id: "rule_netflix", dedupe_fingerprint: "recurring" @@ -240,7 +239,7 @@ test("analytics transaction filtering honors recurring-only sentinel", () => { merchant_raw: "Movie Theater", description: "Weekend movie", amount: 24, - direction: "debit", + direction: "outflow", category_final: "Entertainment", dedupe_fingerprint: "one-off" } @@ -314,7 +313,7 @@ test("getExplorerAnalytics returns comparison data and account rollups", () => { merchant_raw: "Gas Station", description: "Fuel stop", amount: 40, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Transport", dedupe_fingerprint: "fuel_jan" @@ -329,7 +328,7 @@ test("getExplorerAnalytics returns comparison data and account rollups", () => { merchant_raw: "Super Market", description: "Groceries", amount: 60, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Groceries", dedupe_fingerprint: "grocery_jan" @@ -344,7 +343,7 @@ test("getExplorerAnalytics returns comparison data and account rollups", () => { merchant_raw: "Payroll", description: "Salary", amount: 1000, - direction: "credit", + direction: "inflow", transaction_type: "income", category_final: "Income", dedupe_fingerprint: "payroll_jan" @@ -359,7 +358,7 @@ test("getExplorerAnalytics returns comparison data and account rollups", () => { merchant_raw: "Gas Station", description: "Fuel stop", amount: 20, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Transport", dedupe_fingerprint: "fuel_dec" @@ -374,7 +373,7 @@ test("getExplorerAnalytics returns comparison data and account rollups", () => { merchant_raw: "Coffee", description: "Coffee", amount: 10, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Dining", dedupe_fingerprint: "coffee_dec" @@ -412,7 +411,7 @@ test("getExplorerAnalytics returns a seven-point summary sparkline", () => { merchant_raw: "Grocer", description: "Groceries", amount: 45, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Groceries", dedupe_fingerprint: "spark_1" @@ -425,7 +424,7 @@ test("getExplorerAnalytics returns a seven-point summary sparkline", () => { merchant_raw: "Gas", description: "Gas", amount: 20, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Transport", dedupe_fingerprint: "spark_2" @@ -438,7 +437,7 @@ test("getExplorerAnalytics returns a seven-point summary sparkline", () => { merchant_raw: "Payroll", description: "Payroll", amount: 500, - direction: "credit", + direction: "inflow", transaction_type: "income", category_final: "Income", dedupe_fingerprint: "spark_3" @@ -472,7 +471,7 @@ test("getExplorerAnalytics includes category balance metrics and monthly composi merchant_raw: "Super Market", description: "Groceries", amount: 60, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Groceries", dedupe_fingerprint: "groceries_feb" @@ -485,7 +484,7 @@ test("getExplorerAnalytics includes category balance metrics and monthly composi merchant_raw: "Noodles", description: "Dinner", amount: 40, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Dining", dedupe_fingerprint: "dining_feb" @@ -498,7 +497,7 @@ test("getExplorerAnalytics includes category balance metrics and monthly composi merchant_raw: "Payroll", description: "Salary", amount: 1000, - direction: "credit", + direction: "inflow", transaction_type: "income", category_final: "Salary", dedupe_fingerprint: "salary_feb" @@ -511,7 +510,7 @@ test("getExplorerAnalytics includes category balance metrics and monthly composi merchant_raw: "Insurance", description: "Refund", amount: 200, - direction: "credit", + direction: "inflow", transaction_type: "income", category_final: "Refunds", dedupe_fingerprint: "refund_feb" @@ -572,7 +571,7 @@ test("getExplorerAnalytics returns weekday summary buckets and top category week merchant_raw: "Super Market", description: "Groceries", amount: 80, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Groceries", dedupe_fingerprint: "weekday_groceries_sun" @@ -587,7 +586,7 @@ test("getExplorerAnalytics returns weekday summary buckets and top category week merchant_raw: "Noodles", description: "Dinner", amount: 70, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Dining", dedupe_fingerprint: "weekday_dining_tue" @@ -602,7 +601,7 @@ test("getExplorerAnalytics returns weekday summary buckets and top category week merchant_raw: "Gas Station", description: "Fuel", amount: 60, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Transport", dedupe_fingerprint: "weekday_transport_wed" @@ -617,7 +616,7 @@ test("getExplorerAnalytics returns weekday summary buckets and top category week merchant_raw: "Landlord", description: "Rent", amount: 50, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Rent", dedupe_fingerprint: "weekday_rent_thu" @@ -632,7 +631,7 @@ test("getExplorerAnalytics returns weekday summary buckets and top category week merchant_raw: "Cinema", description: "Movie", amount: 40, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Entertainment", dedupe_fingerprint: "weekday_entertainment_fri" @@ -647,7 +646,7 @@ test("getExplorerAnalytics returns weekday summary buckets and top category week merchant_raw: "Pharmacy", description: "Pharmacy", amount: 30, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Health", dedupe_fingerprint: "weekday_health_sat" @@ -662,7 +661,7 @@ test("getExplorerAnalytics returns weekday summary buckets and top category week merchant_raw: "Utility Co", description: "Utilities", amount: 20, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Utilities", dedupe_fingerprint: "weekday_utilities_mon" @@ -677,7 +676,7 @@ test("getExplorerAnalytics returns weekday summary buckets and top category week merchant_raw: "Corner Shop", description: "Misc", amount: 10, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Misc", dedupe_fingerprint: "weekday_misc_tue" @@ -692,7 +691,7 @@ test("getExplorerAnalytics returns weekday summary buckets and top category week merchant_raw: "Payroll", description: "Salary", amount: 1000, - direction: "credit", + direction: "inflow", transaction_type: "income", category_final: "Salary", dedupe_fingerprint: "weekday_income_ignored" @@ -753,7 +752,7 @@ test("analytics transfer filters honor custom category transfer types", () => { merchant_raw: "Brokerage", description: "Sweep to brokerage", amount: 250, - direction: "debit", + direction: "outflow", category_final: "Brokerage Sweep", dedupe_fingerprint: "transfer_custom" } @@ -821,7 +820,7 @@ test("explorer selector rollups stay populated while category or account is focu merchant_raw: "Gas Station", description: "Fuel stop", amount: 40, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Transport", dedupe_fingerprint: "selector_card_transport" @@ -836,7 +835,7 @@ test("explorer selector rollups stay populated while category or account is focu merchant_raw: "Super Market", description: "Groceries", amount: 60, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Groceries", dedupe_fingerprint: "selector_checking_groceries" @@ -926,7 +925,7 @@ test("category weekday matrix honors account and merchant filters", () => { merchant_raw: "Super Market", description: "Groceries", amount: 60, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Groceries", dedupe_fingerprint: "matrix_checking_groceries" @@ -941,7 +940,7 @@ test("category weekday matrix honors account and merchant filters", () => { merchant_raw: "Coffee", description: "Coffee", amount: 20, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Dining", dedupe_fingerprint: "matrix_checking_dining" @@ -956,7 +955,7 @@ test("category weekday matrix honors account and merchant filters", () => { merchant_raw: "Gas Station", description: "Fuel stop", amount: 40, - direction: "debit", + direction: "outflow", transaction_type: "expense", category_final: "Transport", dedupe_fingerprint: "matrix_card_transport" @@ -999,7 +998,7 @@ test("recurringSpend only counts transactions with recurring_rule_id", () => { merchant_raw: "Netflix", description: "Netflix subscription", amount: 15, - direction: "debit", + direction: "outflow", category_final: "Entertainment", recurring_rule_id: "rule_netflix", dedupe_fingerprint: "recurring_1" @@ -1012,7 +1011,7 @@ test("recurringSpend only counts transactions with recurring_rule_id", () => { merchant_raw: "Gym", description: "Gym membership", amount: 50, - direction: "debit", + direction: "outflow", category_final: "Health", recurring_rule_id: "rule_gym", dedupe_fingerprint: "recurring_2" @@ -1025,7 +1024,7 @@ test("recurringSpend only counts transactions with recurring_rule_id", () => { merchant_raw: "Groceries", description: "Groceries", amount: 100, - direction: "debit", + direction: "outflow", category_final: "Groceries", dedupe_fingerprint: "non_recurring" }, @@ -1037,7 +1036,7 @@ test("recurringSpend only counts transactions with recurring_rule_id", () => { merchant_raw: "Salary", description: "Salary", amount: 2000, - direction: "credit", + direction: "inflow", category_final: "Income", recurring_rule_id: "rule_salary", dedupe_fingerprint: "recurring_income" diff --git a/services/api/test/api-contract.test.ts b/services/api/test/api-contract.test.ts index b50b441..d82d3b3 100644 --- a/services/api/test/api-contract.test.ts +++ b/services/api/test/api-contract.test.ts @@ -1670,17 +1670,6 @@ test("api parity contract suite for categories/transactions/settings and missing assert.equal(response.payload?.error?.message, "Endpoint not found: GET /v1/settings"); }); - await t.test("legacy migration endpoint is not exposed", async () => { - const response = await apiRequest(context, "POST", "/v1/migrations/minance/sqlite", { - token: accessToken, - expectedStatus: 404, - body: { - fileName: "legacy.db", - sqliteBase64: "abcd" - } - }); - assert.equal(response.payload?.error?.message, "Endpoint not found: POST /v1/migrations/minance/sqlite"); - }); }); test("POST /v1/system/backups creates a backup", { skip: !hasSqlite3Cli() }, async (t) => { diff --git a/services/api/test/auth.test.ts b/services/api/test/auth.test.ts index fc17b84..2093286 100644 --- a/services/api/test/auth.test.ts +++ b/services/api/test/auth.test.ts @@ -23,7 +23,6 @@ const EMPTY_STORE = { aiProviderPreferences: [], assistantQueries: [], savedViews: [], - migrationRuns: [], auditEvents: [] }; diff --git a/services/api/test/categories.test.ts b/services/api/test/categories.test.ts index 638749f..65fc8d3 100644 --- a/services/api/test/categories.test.ts +++ b/services/api/test/categories.test.ts @@ -28,7 +28,6 @@ const EMPTY_STORE = { aiProviderPreferences: [], assistantQueries: [], savedViews: [], - migrationRuns: [], auditEvents: [] }; diff --git a/services/api/test/categorization.test.ts b/services/api/test/categorization.test.ts index 0d35852..f2d7f96 100644 --- a/services/api/test/categorization.test.ts +++ b/services/api/test/categorization.test.ts @@ -17,7 +17,7 @@ test("categorizeTransaction prioritizes rule over other strategies", () => { merchant_normalized: "coffee123", description: "Coffee House", memo: "", - direction: "debit" + direction: "outflow" }; const result = categorizeTransaction({ @@ -35,7 +35,7 @@ test("merchant memory is used when rules do not match", () => { merchant_normalized: "safe_market", description: "safe market", memo: "", - direction: "debit" + direction: "outflow" }; const result = categorizeTransaction({ @@ -63,7 +63,7 @@ test("categorizeTransaction maps Jeep/Honda merchants to Auto", () => { merchant_normalized: "american honda finance", description: "Honda Financial Services", memo: "", - direction: "debit", + direction: "outflow", category_raw: "Automotive" }; @@ -82,7 +82,7 @@ test("categorizeTransactionWithAgent prioritizes rules over agent", async () => merchant_normalized: "coffee123", description: "Coffee House", memo: "", - direction: "debit" + direction: "outflow" }; const result = await categorizeTransactionWithAgent({ @@ -101,7 +101,7 @@ test("categorizeTransactionWithAgent prioritizes merchant memory over agent", as merchant_normalized: "safe_market", description: "safe market", memo: "", - direction: "debit" + direction: "outflow" }; const result = await categorizeTransactionWithAgent({ @@ -121,7 +121,7 @@ test("categorizeTransactionWithAgent calls agent when rules and memory do not ma merchant_normalized: "unknown_merchant", description: "Unknown Store", memo: "", - direction: "debit", + direction: "outflow", category_raw: "Food & Drink", amount: 50 }; @@ -153,7 +153,7 @@ test("categorizeTransactionWithAgent uses agent_history strategy when source is merchant_normalized: "starbucks", description: "Starbucks Coffee", memo: "", - direction: "debit", + direction: "outflow", amount: 15 }; @@ -180,7 +180,7 @@ test("categorizeTransactionWithAgent falls back to keyword model when agent fail merchant_normalized: "xyzzy_plugh", description: "Xyzzy Plugh", memo: "", - direction: "debit", + direction: "outflow", amount: 45 }; @@ -204,7 +204,7 @@ test("categorizeTransactionWithAgent falls back to keyword model when agent retu merchant_normalized: "xyzzy_plugh", description: "Xyzzy Plugh", memo: "", - direction: "debit", + direction: "outflow", amount: 45 }; @@ -229,7 +229,7 @@ test("categorizeTransactionWithAgent skips agent when flag is disabled", async ( merchant_normalized: "xyzzy_plugh", description: "Xyzzy Plugh", memo: "", - direction: "debit", + direction: "outflow", amount: 50 }; @@ -256,7 +256,7 @@ test("categorizeTransactionWithAgent handles agent exceptions gracefully", async merchant_normalized: "xyzzy_plugh", description: "Xyzzy Plugh", memo: "", - direction: "debit", + direction: "outflow", amount: 50 }; diff --git a/services/api/test/category-strategy.test.ts b/services/api/test/category-strategy.test.ts index 1055c6c..b0096d8 100644 --- a/services/api/test/category-strategy.test.ts +++ b/services/api/test/category-strategy.test.ts @@ -1,10 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; - import { loadStore, resetStoreForTests } from "../src/store.ts"; import { - checkStrategyCoverageAgainstBackupDb, createCategoryResolver, ensureCategoryStrategyForUser, updateCategoryStrategyForUser @@ -26,7 +23,6 @@ const EMPTY_STORE = { aiProviderPreferences: [], assistantQueries: [], savedViews: [], - migrationRuns: [], auditEvents: [] }; @@ -82,15 +78,3 @@ test("strategy update persists emoji and coarse mapping changes", () => { assert.equal(dining?.emoji, "🍜"); assert.equal(dining?.coarseKey, "essential"); }); - -test("default strategy covers categories found in the legacy backup db", (t) => { - const sqliteAvailable = spawnSync("sqlite3", ["-version"], { encoding: "utf8" }); - if (sqliteAvailable.status !== 0) { - t.skip("sqlite3 is unavailable in test environment"); - return; - } - - const coverage = checkStrategyCoverageAgainstBackupDb(); - assert.deepEqual(coverage.missingCanonicalCategories, []); - assert.deepEqual(coverage.missingTransactionCategories, []); -}); diff --git a/services/api/test/fixtures/deterministic-financial-fixture.js b/services/api/test/fixtures/deterministic-financial-fixture.js index 0d44a02..0ba2382 100644 --- a/services/api/test/fixtures/deterministic-financial-fixture.js +++ b/services/api/test/fixtures/deterministic-financial-fixture.js @@ -87,26 +87,26 @@ const TRANSACTION_INPUTS = MONTHS.flatMap((month, monthIndex) => { const groceryBase = 112 + monthIndex * 2.35; const investmentAmount = monthIndex % 3 === 2 ? 750 : 500; return [ - [`${month}-01`, "Fixture Employer", "Payroll", 5200, "credit", "Income", "income", "acct_fixture_checking", "income", ["recurring", "payroll"], "rr_fixture_payroll"], - [`${month}-03`, "Green Market", "Weekly groceries", groceryBase, "debit", "Groceries", "essential", "acct_fixture_checking", "expense", ["recurring", "groceries"], "rr_fixture_groceries"], - [`${month}-10`, "Neighborhood Foods", "Weekly groceries", groceryBase + 18.75, "debit", "Groceries", "essential", "acct_fixture_credit", "expense", ["recurring", "groceries"], "rr_fixture_groceries"], - [`${month}-05`, "Sunset Apartments", "Monthly rent", 1850, "debit", "Housing", "essential", "acct_fixture_checking", "expense", ["recurring", "fixed"], "rr_fixture_rent"], - [`${month}-08`, "Fixture Energy", "Electric bill", energyAmount, "debit", "Utilities", "essential", "acct_fixture_checking", "expense", ["recurring", "utilities"], "rr_fixture_energy"], - [`${month}-12`, "Stream Box", "Video subscription", 15.99, "debit", "Entertainment", "extra", "acct_fixture_credit", "expense", ["recurring", "subscription"], "rr_fixture_streaming"], - [`${month}-14`, "Cafe Brisk", "Dinner", 32 + monthIndex * 1.8, "debit", "Dining", "extra", "acct_fixture_credit", "expense", ["dining"], null], - [`${month}-16`, "Savings Transfer", "Monthly savings", 400, "debit", "Transfer", "neutral", "acct_fixture_checking", "transfer", ["transfer"], null], - [`${month}-16`, "Savings Transfer", "Monthly savings", 400, "credit", "Transfer", "neutral", "acct_fixture_savings", "transfer", ["transfer"], null], - [`${month}-18`, "Broker Transfer", "Investment contribution", investmentAmount, "debit", "Investments", "investments", "acct_fixture_checking", "transfer", ["transfer", "investment"], null], - [`${month}-18`, "Broker Transfer", "Investment contribution", investmentAmount, "credit", "Transfer", "neutral", "acct_fixture_brokerage", "transfer", ["transfer", "investment"], null] + [`${month}-01`, "Fixture Employer", "Payroll", 5200, "inflow", "Income", "income", "acct_fixture_checking", "income", ["recurring", "payroll"], "rr_fixture_payroll"], + [`${month}-03`, "Green Market", "Weekly groceries", groceryBase, "outflow", "Groceries", "essential", "acct_fixture_checking", "expense", ["recurring", "groceries"], "rr_fixture_groceries"], + [`${month}-10`, "Neighborhood Foods", "Weekly groceries", groceryBase + 18.75, "outflow", "Groceries", "essential", "acct_fixture_credit", "expense", ["recurring", "groceries"], "rr_fixture_groceries"], + [`${month}-05`, "Sunset Apartments", "Monthly rent", 1850, "outflow", "Housing", "essential", "acct_fixture_checking", "expense", ["recurring", "fixed"], "rr_fixture_rent"], + [`${month}-08`, "Fixture Energy", "Electric bill", energyAmount, "outflow", "Utilities", "essential", "acct_fixture_checking", "expense", ["recurring", "utilities"], "rr_fixture_energy"], + [`${month}-12`, "Stream Box", "Video subscription", 15.99, "outflow", "Entertainment", "extra", "acct_fixture_credit", "expense", ["recurring", "subscription"], "rr_fixture_streaming"], + [`${month}-14`, "Cafe Brisk", "Dinner", 32 + monthIndex * 1.8, "outflow", "Dining", "extra", "acct_fixture_credit", "expense", ["dining"], null], + [`${month}-16`, "Savings Transfer", "Monthly savings", 400, "outflow", "Transfer", "neutral", "acct_fixture_checking", "transfer", ["transfer"], null], + [`${month}-16`, "Savings Transfer", "Monthly savings", 400, "inflow", "Transfer", "neutral", "acct_fixture_savings", "transfer", ["transfer"], null], + [`${month}-18`, "Broker Transfer", "Investment contribution", investmentAmount, "outflow", "Investments", "investments", "acct_fixture_checking", "transfer", ["transfer", "investment"], null], + [`${month}-18`, "Broker Transfer", "Investment contribution", investmentAmount, "inflow", "Transfer", "neutral", "acct_fixture_brokerage", "transfer", ["transfer", "investment"], null] ]; }); TRANSACTION_INPUTS.push( - ["2025-10-18", "Fixture Health Clinic", "Annual checkup", 180, "debit", "Healthcare", "essential", "acct_fixture_credit", "expense", ["health"], null], - ["2025-12-22", "Northwind Airlines", "Holiday trip", 845.5, "debit", "Travel", "extra", "acct_fixture_credit", "expense", ["travel"], null], - ["2026-01-02", "Northwind Airlines", "Fare adjustment refund", 125, "credit", "Refunds", "income", "acct_fixture_credit", "income", ["refund", "travel"], null], - ["2026-04-21", "Fixture Insurance", "Annual premium", 720, "debit", "Utilities", "essential", "acct_fixture_checking", "expense", ["recurring", "annual"], "rr_fixture_insurance"], - ["2026-06-27", "Fixture Bank", "Service fee needs review", 35, "debit", "Fees", "extra", "acct_fixture_checking", "expense", ["fee"], null, true] + ["2025-10-18", "Fixture Health Clinic", "Annual checkup", 180, "outflow", "Healthcare", "essential", "acct_fixture_credit", "expense", ["health"], null], + ["2025-12-22", "Northwind Airlines", "Holiday trip", 845.5, "outflow", "Travel", "extra", "acct_fixture_credit", "expense", ["travel"], null], + ["2026-01-02", "Northwind Airlines", "Fare adjustment refund", 125, "inflow", "Refunds", "income", "acct_fixture_credit", "income", ["refund", "travel"], null], + ["2026-04-21", "Fixture Insurance", "Annual premium", 720, "outflow", "Utilities", "essential", "acct_fixture_checking", "expense", ["recurring", "annual"], "rr_fixture_insurance"], + ["2026-06-27", "Fixture Bank", "Service fee needs review", 35, "outflow", "Fees", "extra", "acct_fixture_checking", "expense", ["fee"], null, true] ); const TRANSACTIONS = TRANSACTION_INPUTS.map(([ @@ -397,7 +397,6 @@ const FIXTURE_STORE = { updatedAt: UPDATED_AT } ], - migrationRuns: [], auditEvents: [ { id: "audit_fixture_seed_001", diff --git a/services/api/test/fixtures/deterministic-financial-store.json b/services/api/test/fixtures/deterministic-financial-store.json index c3a6158..e97601b 100644 --- a/services/api/test/fixtures/deterministic-financial-store.json +++ b/services/api/test/fixtures/deterministic-financial-store.json @@ -72,7 +72,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -106,7 +106,7 @@ "description": "Weekly groceries", "amount": 112, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -140,7 +140,7 @@ "description": "Weekly groceries", "amount": 130.75, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -174,7 +174,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -208,7 +208,7 @@ "description": "Electric bill", "amount": 142, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -242,7 +242,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -276,7 +276,7 @@ "description": "Dinner", "amount": 32, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -309,7 +309,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -342,7 +342,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -375,7 +375,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -409,7 +409,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -443,7 +443,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -477,7 +477,7 @@ "description": "Weekly groceries", "amount": 114.35, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -511,7 +511,7 @@ "description": "Weekly groceries", "amount": 133.1, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -545,7 +545,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -579,7 +579,7 @@ "description": "Electric bill", "amount": 133, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -613,7 +613,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -647,7 +647,7 @@ "description": "Dinner", "amount": 33.8, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -680,7 +680,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -713,7 +713,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -746,7 +746,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -780,7 +780,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -814,7 +814,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -848,7 +848,7 @@ "description": "Weekly groceries", "amount": 116.7, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -882,7 +882,7 @@ "description": "Weekly groceries", "amount": 135.45, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -916,7 +916,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -950,7 +950,7 @@ "description": "Electric bill", "amount": 96, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -984,7 +984,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -1018,7 +1018,7 @@ "description": "Dinner", "amount": 35.6, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -1051,7 +1051,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -1084,7 +1084,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -1117,7 +1117,7 @@ "description": "Investment contribution", "amount": 750, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -1151,7 +1151,7 @@ "description": "Investment contribution", "amount": 750, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -1185,7 +1185,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -1219,7 +1219,7 @@ "description": "Weekly groceries", "amount": 119.05, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -1253,7 +1253,7 @@ "description": "Weekly groceries", "amount": 137.8, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -1287,7 +1287,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -1321,7 +1321,7 @@ "description": "Electric bill", "amount": 78, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -1355,7 +1355,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -1389,7 +1389,7 @@ "description": "Dinner", "amount": 37.4, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -1422,7 +1422,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -1455,7 +1455,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -1488,7 +1488,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -1522,7 +1522,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -1556,7 +1556,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -1590,7 +1590,7 @@ "description": "Weekly groceries", "amount": 121.4, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -1624,7 +1624,7 @@ "description": "Weekly groceries", "amount": 140.15, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -1658,7 +1658,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -1692,7 +1692,7 @@ "description": "Electric bill", "amount": 74, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -1726,7 +1726,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -1760,7 +1760,7 @@ "description": "Dinner", "amount": 39.2, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -1793,7 +1793,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -1826,7 +1826,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -1859,7 +1859,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -1893,7 +1893,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -1927,7 +1927,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -1961,7 +1961,7 @@ "description": "Weekly groceries", "amount": 123.75, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -1995,7 +1995,7 @@ "description": "Weekly groceries", "amount": 142.5, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -2029,7 +2029,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -2063,7 +2063,7 @@ "description": "Electric bill", "amount": 92, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -2097,7 +2097,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -2131,7 +2131,7 @@ "description": "Dinner", "amount": 41, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -2164,7 +2164,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -2197,7 +2197,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -2230,7 +2230,7 @@ "description": "Investment contribution", "amount": 750, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -2264,7 +2264,7 @@ "description": "Investment contribution", "amount": 750, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -2298,7 +2298,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -2332,7 +2332,7 @@ "description": "Weekly groceries", "amount": 126.1, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -2366,7 +2366,7 @@ "description": "Weekly groceries", "amount": 144.85, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -2400,7 +2400,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -2434,7 +2434,7 @@ "description": "Electric bill", "amount": 128, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -2468,7 +2468,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -2502,7 +2502,7 @@ "description": "Dinner", "amount": 42.8, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -2535,7 +2535,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -2568,7 +2568,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -2601,7 +2601,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -2635,7 +2635,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -2669,7 +2669,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -2703,7 +2703,7 @@ "description": "Weekly groceries", "amount": 128.45, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -2737,7 +2737,7 @@ "description": "Weekly groceries", "amount": 147.2, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -2771,7 +2771,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -2805,7 +2805,7 @@ "description": "Electric bill", "amount": 136, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -2839,7 +2839,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -2873,7 +2873,7 @@ "description": "Dinner", "amount": 44.6, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -2906,7 +2906,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -2939,7 +2939,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -2972,7 +2972,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -3006,7 +3006,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -3040,7 +3040,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -3074,7 +3074,7 @@ "description": "Weekly groceries", "amount": 130.8, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -3108,7 +3108,7 @@ "description": "Weekly groceries", "amount": 149.55, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -3142,7 +3142,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -3176,7 +3176,7 @@ "description": "Electric bill", "amount": 101, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -3210,7 +3210,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -3244,7 +3244,7 @@ "description": "Dinner", "amount": 46.4, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -3277,7 +3277,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -3310,7 +3310,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -3343,7 +3343,7 @@ "description": "Investment contribution", "amount": 750, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -3377,7 +3377,7 @@ "description": "Investment contribution", "amount": 750, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -3411,7 +3411,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -3445,7 +3445,7 @@ "description": "Weekly groceries", "amount": 133.15, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -3479,7 +3479,7 @@ "description": "Weekly groceries", "amount": 151.9, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -3513,7 +3513,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -3547,7 +3547,7 @@ "description": "Electric bill", "amount": 84, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -3581,7 +3581,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -3615,7 +3615,7 @@ "description": "Dinner", "amount": 48.2, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -3648,7 +3648,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -3681,7 +3681,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -3714,7 +3714,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -3748,7 +3748,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -3782,7 +3782,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -3816,7 +3816,7 @@ "description": "Weekly groceries", "amount": 135.5, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -3850,7 +3850,7 @@ "description": "Weekly groceries", "amount": 154.25, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -3884,7 +3884,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -3918,7 +3918,7 @@ "description": "Electric bill", "amount": 81, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -3952,7 +3952,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -3986,7 +3986,7 @@ "description": "Dinner", "amount": 50, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -4019,7 +4019,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -4052,7 +4052,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -4085,7 +4085,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -4119,7 +4119,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -4153,7 +4153,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -4187,7 +4187,7 @@ "description": "Weekly groceries", "amount": 137.85, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -4221,7 +4221,7 @@ "description": "Weekly groceries", "amount": 156.6, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -4255,7 +4255,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -4289,7 +4289,7 @@ "description": "Electric bill", "amount": 109, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -4323,7 +4323,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -4357,7 +4357,7 @@ "description": "Dinner", "amount": 51.8, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -4390,7 +4390,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -4423,7 +4423,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -4456,7 +4456,7 @@ "description": "Investment contribution", "amount": 750, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -4490,7 +4490,7 @@ "description": "Investment contribution", "amount": 750, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -4524,7 +4524,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -4558,7 +4558,7 @@ "description": "Weekly groceries", "amount": 140.2, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -4592,7 +4592,7 @@ "description": "Weekly groceries", "amount": 158.95, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -4626,7 +4626,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -4660,7 +4660,7 @@ "description": "Electric bill", "amount": 142, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -4694,7 +4694,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -4728,7 +4728,7 @@ "description": "Dinner", "amount": 53.6, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -4761,7 +4761,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -4794,7 +4794,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -4827,7 +4827,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -4861,7 +4861,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -4895,7 +4895,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -4929,7 +4929,7 @@ "description": "Weekly groceries", "amount": 142.55, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -4963,7 +4963,7 @@ "description": "Weekly groceries", "amount": 161.3, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -4997,7 +4997,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -5031,7 +5031,7 @@ "description": "Electric bill", "amount": 133, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -5065,7 +5065,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -5099,7 +5099,7 @@ "description": "Dinner", "amount": 55.400000000000006, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -5132,7 +5132,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -5165,7 +5165,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -5198,7 +5198,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -5232,7 +5232,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -5266,7 +5266,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -5300,7 +5300,7 @@ "description": "Weekly groceries", "amount": 144.9, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -5334,7 +5334,7 @@ "description": "Weekly groceries", "amount": 163.65, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -5368,7 +5368,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -5402,7 +5402,7 @@ "description": "Electric bill", "amount": 96, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -5436,7 +5436,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -5470,7 +5470,7 @@ "description": "Dinner", "amount": 57.2, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -5503,7 +5503,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -5536,7 +5536,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -5569,7 +5569,7 @@ "description": "Investment contribution", "amount": 750, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -5603,7 +5603,7 @@ "description": "Investment contribution", "amount": 750, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -5637,7 +5637,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -5671,7 +5671,7 @@ "description": "Weekly groceries", "amount": 147.25, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -5705,7 +5705,7 @@ "description": "Weekly groceries", "amount": 166, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -5739,7 +5739,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -5773,7 +5773,7 @@ "description": "Electric bill", "amount": 78, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -5807,7 +5807,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -5841,7 +5841,7 @@ "description": "Dinner", "amount": 59, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -5874,7 +5874,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -5907,7 +5907,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -5940,7 +5940,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -5974,7 +5974,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -6008,7 +6008,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -6042,7 +6042,7 @@ "description": "Weekly groceries", "amount": 149.6, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -6076,7 +6076,7 @@ "description": "Weekly groceries", "amount": 168.35, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -6110,7 +6110,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -6144,7 +6144,7 @@ "description": "Electric bill", "amount": 74, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -6178,7 +6178,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -6212,7 +6212,7 @@ "description": "Dinner", "amount": 60.8, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -6245,7 +6245,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -6278,7 +6278,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -6311,7 +6311,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -6345,7 +6345,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -6379,7 +6379,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -6413,7 +6413,7 @@ "description": "Weekly groceries", "amount": 151.95, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -6447,7 +6447,7 @@ "description": "Weekly groceries", "amount": 170.7, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -6481,7 +6481,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -6515,7 +6515,7 @@ "description": "Electric bill", "amount": 92, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -6549,7 +6549,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -6583,7 +6583,7 @@ "description": "Dinner", "amount": 62.6, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -6616,7 +6616,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -6649,7 +6649,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -6682,7 +6682,7 @@ "description": "Investment contribution", "amount": 750, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -6716,7 +6716,7 @@ "description": "Investment contribution", "amount": 750, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -6750,7 +6750,7 @@ "description": "Payroll", "amount": 5200, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Income", "category_final": "Income", @@ -6784,7 +6784,7 @@ "description": "Weekly groceries", "amount": 154.3, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -6818,7 +6818,7 @@ "description": "Weekly groceries", "amount": 173.05, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Groceries", "category_final": "Groceries", @@ -6852,7 +6852,7 @@ "description": "Monthly rent", "amount": 1850, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Housing", "category_final": "Housing", @@ -6886,7 +6886,7 @@ "description": "Electric bill", "amount": 128, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -6920,7 +6920,7 @@ "description": "Video subscription", "amount": 15.99, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Entertainment", "category_final": "Entertainment", @@ -6954,7 +6954,7 @@ "description": "Dinner", "amount": 64.4, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Dining", "category_final": "Dining", @@ -6987,7 +6987,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -7020,7 +7020,7 @@ "description": "Monthly savings", "amount": 400, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -7053,7 +7053,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "transfer", "category_raw": "Investments", "category_final": "Investments", @@ -7087,7 +7087,7 @@ "description": "Investment contribution", "amount": 500, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "transfer", "category_raw": "Transfer", "category_final": "Transfer", @@ -7121,7 +7121,7 @@ "description": "Annual checkup", "amount": 180, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Healthcare", "category_final": "Healthcare", @@ -7154,7 +7154,7 @@ "description": "Holiday trip", "amount": 845.5, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Travel", "category_final": "Travel", @@ -7187,7 +7187,7 @@ "description": "Fare adjustment refund", "amount": 125, "currency": "USD", - "direction": "credit", + "direction": "inflow", "transaction_type": "income", "category_raw": "Refunds", "category_final": "Refunds", @@ -7221,7 +7221,7 @@ "description": "Annual premium", "amount": 720, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Utilities", "category_final": "Utilities", @@ -7255,7 +7255,7 @@ "description": "Service fee needs review", "amount": 35, "currency": "USD", - "direction": "debit", + "direction": "outflow", "transaction_type": "expense", "category_raw": "Fees", "category_final": "Fees", @@ -7598,7 +7598,6 @@ "updatedAt": "2026-03-01T00:00:00.000Z" } ], - "migrationRuns": [], "auditEvents": [ { "id": "audit_fixture_seed_001", diff --git a/services/api/test/greenfield-legacy-cleanup.test.ts b/services/api/test/greenfield-legacy-cleanup.test.ts new file mode 100644 index 0000000..48252f0 --- /dev/null +++ b/services/api/test/greenfield-legacy-cleanup.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import test from "node:test"; +import { resolve } from "node:path"; + +const ROOT_DIR = resolve(import.meta.dirname, "../../.."); + +function repoFile(path: string) { + return readFileSync(resolve(ROOT_DIR, path), "utf8"); +} + +test("greenfield runtime does not ship Minance V1 migration entry points", () => { + for (const path of [ + "services/api/src/migration.ts", + "services/api/src/legacy-api-loader.ts", + "services/api/src/migrations/account-identity-repair.ts", + "scripts/load-legacy-api.ts" + ]) { + assert.equal(existsSync(resolve(ROOT_DIR, path)), false, `${path} should be removed`); + } + + const packageJson = JSON.parse(repoFile("package.json")); + assert.equal(packageJson.scripts["seed:legacy-api"], undefined); + assert.doesNotMatch(repoFile("justfile"), /seed-legacy-api/); +}); + +test("greenfield store does not retain V1 migration report state", () => { + assert.doesNotMatch(repoFile("services/api/src/store.ts"), /migrationRuns/); + assert.doesNotMatch(repoFile("services/api/sql/schema.sql"), /migration_runs/); + assert.doesNotMatch(repoFile("scripts/sqlite-cutover-lib.ts"), /migrationRuns|migration_runs/); + assert.doesNotMatch(repoFile("services/api/src/category-strategy.ts"), /checkStrategyCoverageAgainstBackupDb|backup_.*\.db/); +}); + +test("transaction runtime does not accept the pre-refactor contract", () => { + const transactions = repoFile("services/api/src/transactions.ts"); + const analytics = repoFile("services/api/src/analytics.ts"); + const filters = repoFile("services/api/src/transactionFilters.ts"); + const recurrings = repoFile("services/api/src/recurrings.ts"); + const sqliteStore = repoFile("scripts/sqlite-cutover-lib.ts"); + const transactionStoreSpec = sqliteStore.slice( + sqliteStore.indexOf('storeKey: "transactions"'), + sqliteStore.indexOf('storeKey: "categories"') + ); + const recordNormalizer = transactions.slice( + transactions.indexOf("function normalizeTransactionRecord"), + transactions.indexOf("function normalizeManualInput") + ); + + for (const source of [transactions, analytics, filters, recurrings]) { + assert.doesNotMatch(source, /rawDirection === "(?:debit|credit)"/); + assert.doesNotMatch(source, /direction === "(?:debit|credit)"/); + } + + assert.doesNotMatch(transactions, /spending: "expense"/); + assert.doesNotMatch(transactions, /internal_transfer: "transfer"/); + assert.doesNotMatch(recordNormalizer, /Math\.abs\(rawAmount\)/); + assert.doesNotMatch(analytics, /Math\.abs\(rawAmount\)/); + assert.doesNotMatch(transactions, /\["transaction_type", "type"\]/); + assert.doesNotMatch(transactions, /\["recurring_rule_id", "recurringRuleId"\]/); + assert.doesNotMatch(transactionStoreSpec, /row\.(?:userId|accountId|accountKey|sourceType|transactionDate|merchantRaw|categoryFinal|dedupeFingerprint)/); +}); diff --git a/services/api/test/imports.test.ts b/services/api/test/imports.test.ts index 7b42f46..1d7bd0a 100644 --- a/services/api/test/imports.test.ts +++ b/services/api/test/imports.test.ts @@ -30,7 +30,6 @@ const EMPTY_STORE = { aiProviderPreferences: [], assistantQueries: [], savedViews: [], - migrationRuns: [], auditEvents: [] }; diff --git a/services/api/test/integration/agent-integration.test.ts b/services/api/test/integration/agent-integration.test.ts index f7974a9..b90142f 100644 --- a/services/api/test/integration/agent-integration.test.ts +++ b/services/api/test/integration/agent-integration.test.ts @@ -236,7 +236,6 @@ function createBaseStore() { aiProviderPreferences: [], assistantQueries: [], savedViews: [], - migrationRuns: [], auditEvents: [] }; } diff --git a/services/api/test/legacy-api-loader.test.ts b/services/api/test/legacy-api-loader.test.ts deleted file mode 100644 index b5f26a7..0000000 --- a/services/api/test/legacy-api-loader.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { - inferLegacyTier1CoarseKey, - resolveLegacyMappedCategory, - buildLegacyCategoryStrategy, - applyLegacyApiDataToStore -} from "../src/legacy-api-loader.ts"; -import * as legacyApiLoader from "../src/legacy-api-loader.ts"; -import { login } from "../src/auth.ts"; -import { loadStore, resetStoreForTests } from "../src/store.ts"; - -const EMPTY_STORE = { - users: [], - sessions: [], - accounts: [], - transactions: [], - recurringRules: [], - investmentHoldings: [], - investmentSnapshots: [], - categories: [], - categoryStrategies: [], - categoryRules: [], - imports: [], - importRowsRaw: [], - importRowsProcessed: [], - importRowDiagnostics: [], - aiProviderCredentials: [], - aiProviderPreferences: [], - assistantQueries: [], - savedViews: [], - migrationRuns: [], - auditEvents: [] -}; - -test("inferLegacyTier1CoarseKey groups mapped categories into first-tier buckets", () => { - assert.equal(inferLegacyTier1CoarseKey("Groceries"), "essential"); - assert.equal(inferLegacyTier1CoarseKey("Dining"), "extra"); - assert.equal(inferLegacyTier1CoarseKey("Salary"), "neutral"); - assert.equal(inferLegacyTier1CoarseKey("Credit Card Payments"), "neutral"); - assert.equal(inferLegacyTier1CoarseKey("Miscellaneous"), "other"); -}); - -test("resolveLegacyMappedCategory uses mapped category when available", () => { - const rawToMapped = new Map([ - ["restaurants", "Dining"], - ["uber technologies inc", "Travel"] - ]); - - assert.equal(resolveLegacyMappedCategory("Restaurants", rawToMapped), "Dining"); - assert.equal(resolveLegacyMappedCategory("Uber Technologies, Inc", rawToMapped), "Travel"); - assert.equal(resolveLegacyMappedCategory("Unknown Raw", rawToMapped), "Unknown Raw"); - assert.equal(resolveLegacyMappedCategory("", rawToMapped), "Uncategorized"); -}); - -test("buildLegacyCategoryStrategy uses only provided mapped categories as tier-2", () => { - const strategy = buildLegacyCategoryStrategy(["Dining", "Groceries", "Salary", "Miscellaneous", "Dining"]); - - assert.deepEqual( - strategy.coarseCategories.map((entry) => entry.key), - ["essential", "extra", "neutral", "other"] - ); - - const byName = new Map(strategy.granularCategories.map((entry) => [entry.name, entry.coarseKey])); - assert.equal(byName.get("Groceries"), "essential"); - assert.equal(byName.get("Dining"), "extra"); - assert.equal(byName.get("Salary"), "neutral"); - assert.equal(byName.get("Miscellaneous"), "other"); - assert.equal(strategy.granularCategories.length, 4); -}); - -test("legacy api loader stores debit expenses as positive amounts", () => { - resetStoreForTests({ - users: [{ id: "user_1", email: "user@example.com", createdAt: "2026-01-01", updatedAt: "2026-01-01" }], - sessions: [], - accounts: [], - transactions: [], - recurringRules: [], - investmentHoldings: [], - investmentSnapshots: [], - categories: [], - categoryStrategies: [], - categoryRules: [], - imports: [], - importRowsRaw: [], - importRowsProcessed: [], - importRowDiagnostics: [], - aiProviderCredentials: [], - aiProviderPreferences: [], - assistantQueries: [], - savedViews: [], - migrationRuns: [], - auditEvents: [] - }); - - applyLegacyApiDataToStore({ - userId: "user_1", - accounts: [ - { account_id: "legacy_account_1", bank_name: "PayPal", account_name: "PayPal Balance" } - ], - transactions: [ - { - transaction_date: "2026-02-01", - account_id: "legacy_account_1", - description: "COSTCO WHSE #00 - General PayPal Debit Card Transaction", - amount: -80.3, - transaction_type: "debit" - }, - { - transaction_date: "2026-02-02", - account_id: "legacy_account_1", - description: "ENSON MARKET - General PayPal Debit Card Transaction", - amount: -25.7, - transaction_type: "debit" - } - ], - mappedCategories: ["Groceries"], - rawToMappedCategory: new Map(), - resetUserData: true - }); - - const store = loadStore(); - const imported = store.transactions.filter((entry) => entry.user_id === "user_1"); - - assert.equal(imported.length, 2); - imported.forEach((entry) => { - assert.equal(entry.direction, "outflow"); - assert.equal(entry.amount > 0, true); - }); -}); - -test("legacy loader creates a loginable user for explicit migration credentials", () => { - resetStoreForTests(structuredClone(EMPTY_STORE)); - - assert.equal(typeof legacyApiLoader.resolveLegacyLoaderUserId, "function"); - - const userId = legacyApiLoader.resolveLegacyLoaderUserId("dev@minance.local", "12345678"); - const result = login("dev@minance.local", "12345678"); - - assert.equal(result.user.id, userId); - assert.equal(result.user.email, "dev@minance.local"); -}); - -test("legacy loader updates an existing migration user's password when rerun with explicit credentials", () => { - resetStoreForTests(structuredClone(EMPTY_STORE)); - - assert.equal(typeof legacyApiLoader.resolveLegacyLoaderUserId, "function"); - - const firstUserId = legacyApiLoader.resolveLegacyLoaderUserId("dev@minance.local", "12345678"); - const secondUserId = legacyApiLoader.resolveLegacyLoaderUserId("dev@minance.local", "abcdefgh"); - - assert.equal(secondUserId, firstUserId); - assert.throws(() => login("dev@minance.local", "12345678"), /Invalid credentials/); - - const result = login("dev@minance.local", "abcdefgh"); - assert.equal(result.user.id, firstUserId); -}); - -test("legacy loader rejects a blank explicit migration password", () => { - resetStoreForTests(structuredClone(EMPTY_STORE)); - - assert.equal(typeof legacyApiLoader.resolveLegacyLoaderUserId, "function"); - assert.throws( - () => legacyApiLoader.resolveLegacyLoaderUserId("dev@minance.local", ""), - /at least 8 characters/ - ); -}); diff --git a/services/api/test/legacy-loader-cli.test.ts b/services/api/test/legacy-loader-cli.test.ts deleted file mode 100644 index d5853aa..0000000 --- a/services/api/test/legacy-loader-cli.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; -import { createRequire } from "node:module"; -import { fileURLToPath } from "node:url"; -import test from "node:test"; -import assert from "node:assert/strict"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const ROOT_DIR = path.resolve(__dirname, "../../.."); -const require = createRequire(import.meta.url); -const TSX_CLI = require.resolve("tsx/cli", { paths: [path.join(ROOT_DIR, "apps/web")] }); - -test("legacy loader cli rejects --user-password without --user-email", () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "minance-legacy-loader-cli-")); - - try { - const result = spawnSync( - process.execPath, - [TSX_CLI, "scripts/load-legacy-api.ts", "--user-password", "12345678"], - { - cwd: ROOT_DIR, - encoding: "utf8", - env: { - ...process.env, - NODE_ENV: "test", - MINANCE_SEED_TEST_ACCOUNT: "false", - MINANCE_SQLITE_FILE_TEST: path.join(tempDir, "test.sqlite") - } - } - ); - - assert.notEqual(result.status, 0); - assert.match(result.stderr, /--user-password requires --user-email/); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -}); diff --git a/services/api/test/llm/tool-executor.test.ts b/services/api/test/llm/tool-executor.test.ts index deae7ea..6ac3aef 100644 --- a/services/api/test/llm/tool-executor.test.ts +++ b/services/api/test/llm/tool-executor.test.ts @@ -107,7 +107,6 @@ const baseStore = { aiProviderPreferences: [], assistantQueries: [], savedViews: [], - migrationRuns: [], auditEvents: [] }; diff --git a/services/api/test/migrate-json-to-sqlite.test.ts b/services/api/test/migrate-json-to-sqlite.test.ts index 7369e45..6f80fbb 100644 --- a/services/api/test/migrate-json-to-sqlite.test.ts +++ b/services/api/test/migrate-json-to-sqlite.test.ts @@ -78,22 +78,22 @@ test( transactions: [ { id: "txn_stale", - userId: "user_stale", - accountId: "acct_stale", - sourceType: "manual", - transactionDate: "2026-02-01", - merchantRaw: "Stale Merchant", - merchantNormalized: "stale merchant", + user_id: "user_stale", + account_id: "acct_stale", + source_type: "manual", + transaction_date: "2026-02-01", + merchant_raw: "Stale Merchant", + merchant_normalized: "stale merchant", description: "stale row", amount: 1.25, currency: "USD", - direction: "debit", - categoryRaw: "Stale", - categoryFinal: "Stale", - dedupeFingerprint: "fp_stale", + direction: "outflow", + category_raw: "Stale", + category_final: "Stale", + dedupe_fingerprint: "fp_stale", needs_category_review: false, - createdAt: now, - updatedAt: now + created_at: now, + updated_at: now } ] }; @@ -132,7 +132,7 @@ test( description: "groceries", amount: 42.5, currency: "USD", - direction: "debit", + direction: "outflow", category_raw: "Groceries", category_final: "Groceries", category_strategy: "rule", diff --git a/services/api/test/migrations/account-identity-repair.test.ts b/services/api/test/migrations/account-identity-repair.test.ts deleted file mode 100644 index 783e753..0000000 --- a/services/api/test/migrations/account-identity-repair.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { loadStore, resetStoreForTests } from "../../src/store.ts"; -import { repairLegacyAccountIdentityDrift } from "../../src/migrations/account-identity-repair.ts"; -import { stableHash } from "../../src/utils.ts"; - -const BASE_STORE = { - users: [{ id: "user_1", email: "user@example.com", createdAt: "2026-01-01", updatedAt: "2026-01-01" }], - sessions: [], - accounts: [], - transactions: [], - recurringRules: [], - recurringSuggestions: [], - dismissedRecurringSuggestions: [], - investmentHoldings: [], - investmentSnapshots: [], - categories: [], - categoryStrategies: [], - categoryRules: [], - imports: [], - importRowsRaw: [], - importRowsProcessed: [], - importRowDiagnostics: [], - aiProviderCredentials: [], - aiProviderPreferences: [], - assistantQueries: [], - savedViews: [], - migrationRuns: [], - auditEvents: [], - userRecurringScanState: [], - scanRunState: { - is_running: false, - last_run_at: null, - last_run_status: null, - last_run_duration_ms: null - } -}; - -test("repairLegacyAccountIdentityDrift merges drift duplicates without rewriting the survivor key", () => { - resetStoreForTests({ - ...structuredClone(BASE_STORE), - accounts: [ - { - id: "acct_survivor", - userId: "user_1", - normalizedKey: "chase hyatt", - displayName: "Hyatt", - sourceInstitution: "CHASE", - accountType: "credit", - currency: "USD", - initialBalance: 0, - status: "active", - includeInCharts: true, - version: 1, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z" - }, - { - id: "acct_duplicate", - userId: "user_1", - normalizedKey: "hyatt", - displayName: "Hyatt", - sourceInstitution: null, - accountType: "checking", - currency: "USD", - initialBalance: 0, - status: "active", - includeInCharts: true, - version: 1, - createdAt: "2026-02-01T00:00:00.000Z", - updatedAt: "2026-02-01T00:00:00.000Z" - } - ], - transactions: [ - { - id: "txn_duplicate", - user_id: "user_1", - account_id: "acct_duplicate", - account_key: "hyatt", - source_type: "imported", - source_file_id: "imp_1", - transaction_date: "2026-01-15", - post_date: null, - merchant_raw: "Payment", - merchant_normalized: "payment", - description: "Payment", - amount: 149.4, - currency: "USD", - direction: "outflow", - category_raw: null, - category_final: "Uncategorized", - category_confidence: 1, - category_strategy: "manual", - needs_category_review: false, - review_status: "reviewed", - tags: [], - recurring_rule_id: null, - memo: null, - dedupe_fingerprint: stableHash(["user_1", "hyatt", "payment", "149.40", "2026-01-15", ""].join("|")), - deleted_at: null, - deleted_reason: null, - deleted_by: null, - created_at: "2026-01-15T00:00:00.000Z", - updated_at: "2026-01-15T00:00:00.000Z" - } - ] - }); - - const firstRun = repairLegacyAccountIdentityDrift(); - assert.equal(firstRun.duplicateGroupsRepaired, 1); - - const storeAfterFirstRun = loadStore(); - assert.equal(storeAfterFirstRun.accounts.length, 1); - assert.equal(storeAfterFirstRun.accounts[0]?.id, "acct_survivor"); - assert.equal(storeAfterFirstRun.accounts[0]?.normalizedKey, "chase hyatt"); - assert.equal(storeAfterFirstRun.accounts[0]?.sourceInstitution, "CHASE"); - assert.equal(storeAfterFirstRun.transactions[0]?.account_id, "acct_survivor"); - assert.equal(storeAfterFirstRun.transactions[0]?.account_key, "chase hyatt"); - assert.equal( - storeAfterFirstRun.transactions[0]?.dedupe_fingerprint, - stableHash(["user_1", "chase hyatt", "payment", "149.40", "2026-01-15", ""].join("|")) - ); - assert.equal( - storeAfterFirstRun.auditEvents.filter((entry) => entry.action === "account.identity.repaired").length, - 1 - ); - - const secondRun = repairLegacyAccountIdentityDrift(); - assert.equal(secondRun.duplicateGroupsRepaired, 0); - - const storeAfterSecondRun = loadStore(); - assert.equal(storeAfterSecondRun.accounts.length, 1); - assert.equal( - storeAfterSecondRun.auditEvents.filter((entry) => entry.action === "account.identity.repaired").length, - 1 - ); -}); - -test("repairLegacyAccountIdentityDrift skips same-name accounts that differ only by institution", () => { - resetStoreForTests({ - ...structuredClone(BASE_STORE), - accounts: [ - { - id: "acct_chase", - userId: "user_1", - normalizedKey: "chase checking", - displayName: "Checking", - sourceInstitution: "CHASE", - accountType: "checking", - currency: "USD", - initialBalance: 0, - status: "active", - includeInCharts: true, - version: 1, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z" - }, - { - id: "acct_ally", - userId: "user_1", - normalizedKey: "ally checking", - displayName: "Checking", - sourceInstitution: "ALLY", - accountType: "checking", - currency: "USD", - initialBalance: 0, - status: "active", - includeInCharts: true, - version: 1, - createdAt: "2026-02-01T00:00:00.000Z", - updatedAt: "2026-02-01T00:00:00.000Z" - } - ], - transactions: [ - { - id: "txn_chase", - user_id: "user_1", - account_id: "acct_chase", - account_key: "chase checking", - source_type: "imported", - source_file_id: "imp_1", - transaction_date: "2026-01-15", - post_date: null, - merchant_raw: "Store A", - merchant_normalized: "store a", - description: "Store A", - amount: 25, - currency: "USD", - direction: "outflow", - category_raw: null, - category_final: "Uncategorized", - category_confidence: 1, - category_strategy: "manual", - needs_category_review: false, - review_status: "reviewed", - tags: [], - recurring_rule_id: null, - memo: null, - dedupe_fingerprint: stableHash(["user_1", "chase checking", "store a", "25.00", "2026-01-15", ""].join("|")), - deleted_at: null, - deleted_reason: null, - deleted_by: null, - created_at: "2026-01-15T00:00:00.000Z", - updated_at: "2026-01-15T00:00:00.000Z" - }, - { - id: "txn_ally", - user_id: "user_1", - account_id: "acct_ally", - account_key: "ally checking", - source_type: "imported", - source_file_id: "imp_2", - transaction_date: "2026-01-16", - post_date: null, - merchant_raw: "Store B", - merchant_normalized: "store b", - description: "Store B", - amount: 40, - currency: "USD", - direction: "outflow", - category_raw: null, - category_final: "Uncategorized", - category_confidence: 1, - category_strategy: "manual", - needs_category_review: false, - review_status: "reviewed", - tags: [], - recurring_rule_id: null, - memo: null, - dedupe_fingerprint: stableHash(["user_1", "ally checking", "store b", "40.00", "2026-01-16", ""].join("|")), - deleted_at: null, - deleted_reason: null, - deleted_by: null, - created_at: "2026-01-16T00:00:00.000Z", - updated_at: "2026-01-16T00:00:00.000Z" - } - ] - }); - - const result = repairLegacyAccountIdentityDrift(); - assert.equal(result.duplicateGroupsRepaired, 0); - - const storeAfterRun = loadStore(); - assert.equal(storeAfterRun.accounts.length, 2); - assert.deepEqual( - new Set(storeAfterRun.transactions.map((entry) => entry.account_id)), - new Set(["acct_chase", "acct_ally"]) - ); - assert.equal( - storeAfterRun.auditEvents.filter((entry) => entry.action === "account.identity.repaired").length, - 0 - ); -}); diff --git a/services/api/test/performance-50k.test.ts b/services/api/test/performance-50k.test.ts index c392583..5b66563 100644 --- a/services/api/test/performance-50k.test.ts +++ b/services/api/test/performance-50k.test.ts @@ -92,7 +92,6 @@ function createLargeStore() { aiProviderPreferences: [], assistantQueries: [], savedViews: [], - migrationRuns: [], auditEvents: [] }; } diff --git a/services/api/test/sqlite-store-repository.test.ts b/services/api/test/sqlite-store-repository.test.ts index e9e470a..4dee0e1 100644 --- a/services/api/test/sqlite-store-repository.test.ts +++ b/services/api/test/sqlite-store-repository.test.ts @@ -87,7 +87,7 @@ test("sqlite store repository round-trips payload collections", { skip: !isSqlit description: "Coffee", amount: 7.45, currency: "USD", - direction: "debit", + direction: "outflow", categoryRaw: "Dining", categoryFinal: "Dining", categoryCoarse: "extra", @@ -167,7 +167,6 @@ test("sqlite store repository round-trips payload collections", { skip: !isSqlit updatedAt: now } ], - migrationRuns: [], auditEvents: [ { id: "audit_1", @@ -375,7 +374,7 @@ test( description: "Oversized regression transaction", amount: index + 0.01, currency: "USD", - direction: "debit", + direction: "outflow", categoryRaw: "Dining", categoryFinal: "Dining", categoryCoarse: "extra", @@ -395,7 +394,6 @@ test( aiProviderPreferences: [], assistantQueries: [], savedViews: [], - migrationRuns: [], auditEvents: [] }; diff --git a/services/api/test/store.test.ts b/services/api/test/store.test.ts index 16cd6ae..2905156 100644 --- a/services/api/test/store.test.ts +++ b/services/api/test/store.test.ts @@ -37,7 +37,6 @@ const EMPTY_STORE = { aiProviderPreferences: [], assistantQueries: [], savedViews: [], - migrationRuns: [], auditEvents: [], userRecurringScanState: [], scanRunState: { diff --git a/services/api/test/transactions-normalization.test.ts b/services/api/test/transactions-normalization.test.ts index d1f5552..a013b26 100644 --- a/services/api/test/transactions-normalization.test.ts +++ b/services/api/test/transactions-normalization.test.ts @@ -52,7 +52,7 @@ const BASE_STORE = { accounts: [], transactions: [ { - id: "txn_imported_neg", + id: "txn_imported_outflow", user_id: "user_1", account_id: "acct_1", account_key: "checking", @@ -63,7 +63,7 @@ const BASE_STORE = { merchant_raw: "COSTCO WHSE #00", merchant_normalized: "costco whse 00", description: "General PayPal Debit Card Transaction", - amount: -120.45, + amount: 120.45, currency: "USD", direction: "outflow", transaction_type: "expense", @@ -76,36 +76,36 @@ const BASE_STORE = { tags: [], recurring_rule_id: null, memo: null, - dedupe_fingerprint: "fp_imported_neg", + dedupe_fingerprint: "fp_imported_outflow", created_at: "2026-01-03T00:00:00.000Z", updated_at: "2026-01-03T00:00:00.000Z" }, { - id: "txn_legacy_neg", + id: "txn_imported_second_outflow", user_id: "user_1", account_id: "acct_1", account_key: "checking", - source_type: "legacy_api", + source_type: "imported", source_file_id: null, transaction_date: "2026-01-04", post_date: null, merchant_raw: "ENSON MARKET", merchant_normalized: "enson market", description: "General PayPal Debit Card Transaction", - amount: -56.1, + amount: 56.1, currency: "USD", direction: "outflow", transaction_type: "expense", category_raw: "Groceries", category_final: "Groceries", category_confidence: 1, - category_strategy: "legacy_api_mapping", + category_strategy: "import_override", needs_category_review: false, review_status: "reviewed", tags: [], recurring_rule_id: null, memo: null, - dedupe_fingerprint: "fp_legacy_neg", + dedupe_fingerprint: "fp_imported_second_outflow", created_at: "2026-01-04T00:00:00.000Z", updated_at: "2026-01-04T00:00:00.000Z" }, @@ -153,23 +153,22 @@ const BASE_STORE = { aiProviderPreferences: [], assistantQueries: [], savedViews: [], - migrationRuns: [], auditEvents: [] }; -test("listTransactions normalizes negative outflow amounts to positive expense amounts", () => { +test("listTransactions preserves canonical positive outflow amounts", () => { resetStoreForTests(structuredClone(BASE_STORE)); const listed = listTransactions("user_1", { range: "all", limit: 50, offset: 0 }); const byId = new Map(listed.items.map((entry) => [entry.id, entry])); - assert.equal(byId.get("txn_imported_neg")?.direction, "outflow"); - assert.equal(byId.get("txn_imported_neg")?.amount, 120.45); - assert.equal(byId.get("txn_legacy_neg")?.direction, "outflow"); - assert.equal(byId.get("txn_legacy_neg")?.amount, 56.1); + assert.equal(byId.get("txn_imported_outflow")?.direction, "outflow"); + assert.equal(byId.get("txn_imported_outflow")?.amount, 120.45); + assert.equal(byId.get("txn_imported_second_outflow")?.direction, "outflow"); + assert.equal(byId.get("txn_imported_second_outflow")?.amount, 56.1); }); -test("overview spend remains positive even if persisted outflow rows are signed negative", () => { +test("overview spend uses canonical positive outflow amounts", () => { resetStoreForTests(structuredClone(BASE_STORE)); const overview = getOverview("user_1", { start: "2026-01-01", end: "2026-01-31" }); @@ -254,6 +253,34 @@ test("createManualTransaction returns the normalized transaction contract", () = assert.deepEqual(sortedKeys(created), NORMALIZED_TRANSACTION_KEYS); }); +test("createManualTransaction rejects pre-refactor direction values", () => { + const store = structuredClone(BASE_STORE); + store.categories = [ + { + id: "cat_groceries", + userId: "user_1", + name: "Groceries", + type: "expense", + isSystem: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z" + } + ]; + resetStoreForTests(store); + + assert.throws( + () => createManualTransaction("user_1", { + transaction_date: "2026-01-11", + description: "Legacy debit", + amount: 45, + direction: "debit", + category_final: "Groceries", + account_name: "checking" + }), + /Invalid transaction direction/ + ); +}); + test("createManualTransaction reuses account selected by display identifier label", () => { const store = structuredClone(BASE_STORE); store.accounts = [ @@ -365,7 +392,7 @@ test("listTransactions filters by minimum and maximum absolute amount", () => { offset: 0 }); - assert.deepEqual(listed.items.map((entry) => entry.id), ["txn_imported_neg"]); + assert.deepEqual(listed.items.map((entry) => entry.id), ["txn_imported_outflow"]); }); test("listTransactions keeps custom transfer categories when filtering by transfer type", () => {