diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index 85b62ef..808b738 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -2,7 +2,7 @@ **Status:** Current code reference **Last Updated:** 2026-05-16 -**Version:** 0.28.1 +**Version:** 0.28.01 **Primary stack:** Node.js + Express, React + Vite, Tailwind CSS + shadcn/ui, Sonner, SQLite via `better-sqlite3` This manual reflects the current application code in `server.js`, `routes/`, `services/`, `middleware/`, `db/`, `client/`, `package.json`, `Dockerfile`, and `docker-compose.yml`. It is written as a current-state reference, not a changelog. @@ -496,23 +496,23 @@ Mounted under `/api/payments`; auth: user/admin tracker access. All queries are - Response: live payment or 404. - `POST /payments` - - Body: `{bill_id, amount, paid_date, method?, notes?}`. - - Validation: bill exists and owned; amount > 0; required fields present. + - Body: `{bill_id, amount, paid_date, method?, notes?, payment_source?}`. + - Validation: bill exists and owned; amount > 0; required fields present; payment_source one of `manual`, `file_import`, `provider_sync`, `transaction_match`. - Response 201: created payment. - `POST /payments/quick` - - Body: `{bill_id, amount?, paid_date?, method?, notes?}`. - - Defaults amount to bill expected amount and date to today; confirms autodraft status for autopay bills. + - Body: `{bill_id, amount?, paid_date?, method?, notes?, payment_source?}`. + - Defaults amount to bill expected amount and date to today; confirms autodraft status for autopay bills; defaults payment_source to `manual`. - Response 201: created payment. - `POST /payments/bulk` - - Body: `{payments:[{bill_id, amount, paid_date, method?, notes?}]}`. - - Validation: array required; max 50; bill_id integer; `paid_date` `YYYY-MM-DD`; amount finite >= 0. + - Body: `{payments:[{bill_id, amount, paid_date, method?, notes?, payment_source?}]}`. + - Validation: array required; max 50; bill_id integer; `paid_date` `YYYY-MM-DD`; amount finite >= 0; payment_source must be valid. - Duplicate live payments by user/bill/date/amount are skipped. - Response 201: `{created:[...], skipped:[...], errors:[...]}`. - `PUT /payments/:id` - - Body: partial `{amount, paid_date, method, notes}`. + - Body: partial `{amount, paid_date, method, notes, payment_source}`. - Response: updated payment. Current code preserves existing fields when omitted. - `DELETE /payments/:id` @@ -581,6 +581,27 @@ Mounted under `/api/categories`; auth: user/admin tracker access. - Validation: valid year/month; numeric amounts. - Response: recomputed starting-amount response after upsert. +### 5.8.1 Snowball + +Mounted under `/api/snowball`; auth: user/admin tracker access. + +- `GET /snowball` + - Response: current user's debt bills (snowball_include or debt-like categories), pre-sorted by snowball_order. + +- `GET /snowball/settings` + - Response: `{extra_payment, ramsey_mode, ready_current_on_bills, ready_emergency_fund}`. + +- `PATCH /snowball/settings` + - Body: `{extra_payment?, ramsey_mode?, ready_current_on_bills?, ready_emergency_fund?}`. + - Response: saved settings and computed response. + +- `GET /snowball/projection` + - Response: `{snowball, avalanche, minimum_only, comparison}` with enriched debt arrays including APR snapshots. + +- `PATCH /snowball/order` + - Body: `[{id, snowball_order}]`. + - Response: `{success:true}` after batch update. + ### 5.9 Analytics - `GET /analytics/summary?year=&month=&months=&category_id=&bill_id=&include_inactive=true&include_skipped=false` @@ -703,7 +724,72 @@ Mounted under `/api/import`; auth: user/admin tracker access; import limiter app - `GET /import/history` - Response: current user's import history. -### 5.15 Export +### 5.15 Data Sources + +Mounted under `/api/data-sources`; auth: user/admin tracker access. + +- `GET /data-sources?type=&status=` + - Query params: `type` (`manual`, `file_import`, `provider_sync`), `status` (`active`, `inactive`, `error`). + - Response: array of data sources with `source_label`, `source_type_label`, `account_count`, `transaction_count`, safe fields (encrypted_secret excluded), timestamps, sync info. + +### 5.16 Transactions + +Mounted under `/api/transactions`; auth: user/admin tracker access. + +- `GET /transactions?limit=&offset=&match_status=&ignored=&source_type=&start_date=&end_date=&q=&data_source_id=&account_id=&matched_bill_id=` + - Response: paginated list of transactions with embedded data_source and account details, `source_label`, `source_type_label`. + - Filter: `match_status` (`unmatched`, `matched`, `ignored`), `ignored` (boolean), `source_type`, date range, free-text search (`q`) across description/payee/memo/category. + +- `POST /transactions/manual` + - Body: `{account_id?, transaction_type?, posted_date, transacted_at?, amount, currency?, description?, payee?, memo?, category?, matched_bill_id?, match_status?, ignored?}`. + - Response 201: created transaction with manual `source_type`. + +- `PUT /transactions/:id` + - Body: partial transaction fields. + - Validation: match_state changes use dedicated endpoints. + - Response: updated transaction. + +- `DELETE /transactions/:id` + - Behavior: soft-deletes via unmatch then hard delete. + - Response: `{success:true, deleted:true, id}`. + +- `POST /transactions/:id/match` + - Body: `{billId}`. + - Response: `{success:true, matched:true, transaction}`. + +- `POST /transactions/:id/unmatch` + - Response: `{success:true, unmatched:true, transaction}`. + +- `POST /transactions/:id/ignore` + - Response: `{transaction}` with `match_status='ignored'`, `ignored=1`. + +- `POST /transactions/:id/unignore` + - Response: `{transaction}` restored to `unmatched` state. + +### 5.17 CSV Import + +Mounted under `/api/import`; auth: user/admin tracker access; import limiter applies. Gated by `DATA_IMPORT_ENABLED` env var (defaults to true). + +- `POST /import/csv/preview` + - Content-Type: `text/csv`. + - Body: raw CSV. + - Response: `{import_session_id, headers, sampleRows, rowCount, suggestedMapping, errors, fields}`. + +- `POST /import/csv/commit` + - Body: `{import_session_id, mapping, options?}`. + - Response: `{imported, skipped, failed, details}`. + +### 5.18 Match Suggestions + +Mounted under `/api/matches`; auth: user/admin tracker access. + +- `GET /matches/suggestions?transaction_id=&bill_id=&limit=&offset=` + - Response: `{suggestions:[{id, transaction, bill, score, match_status, created_at}]}`. + +- `POST /matches/:id/reject` + - Response: `{success:true}` after recording rejection. + +### 5.19 Export Mounted under `/api/export`; auth: user/admin tracker access; export limiter applies. @@ -716,13 +802,13 @@ Mounted under `/api/export`; auth: user/admin tracker access; export limiter app - `GET /export/user-db` - Response: portable SQLite file with export metadata and user-owned categories, bills, payments, monthly state, monthly starting amounts, and notes. -### 5.16 Status +### 5.20 Status - `GET /status` - Auth: admin. - Response: app version, uptime, runtime worker state, DB health/counts/path/size, SMTP configuration status, backup status/schedule, current-month tracker health, recent errors. -### 5.17 About and Version +### 5.21 About and Version - `GET /about` - Public. @@ -890,6 +976,130 @@ SQLite uses WAL mode and foreign keys. Base schema is in `db/schema.sql`; `db/da - `created_at TEXT DEFAULT datetime('now')` - `updated_at TEXT DEFAULT datetime('now')` +#### `data_sources` + +- `id INTEGER PRIMARY KEY AUTOINCREMENT` +- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE` +- `type TEXT NOT NULL` (`manual`, `file_import`, `provider_sync`) +- `provider TEXT` +- `name TEXT NOT NULL` +- `status TEXT NOT NULL DEFAULT 'active'` (`active`, `inactive`, `error`) +- `config_json TEXT` +- `encrypted_secret TEXT` +- `last_sync_at TEXT` +- `last_error TEXT` +- `created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP` +- `updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP` +- Unique partial index: `(user_id, type, provider)` WHERE `type='manual' AND provider='manual'` + +#### `financial_accounts` + +- `id INTEGER PRIMARY KEY` +- `user_id INTEGER REFERENCES users(id) ON DELETE CASCADE` +- `data_source_id INTEGER REFERENCES data_sources(id) ON DELETE SET NULL` +- `provider_account_id TEXT NOT NULL` +- `name TEXT` +- `org_name TEXT` +- `account_type TEXT` +- `currency TEXT` +- `balance REAL` +- `available_balance REAL` +- `raw_data TEXT` +- `created_at TEXT DEFAULT datetime('now')` +- `updated_at TEXT DEFAULT datetime('now')` +- Unique on `(data_source_id, provider_account_id)` + +#### `transactions` + +- `id INTEGER PRIMARY KEY` +- `user_id INTEGER REFERENCES users(id) ON DELETE CASCADE` +- `data_source_id INTEGER REFERENCES data_sources(id) ON DELETE SET NULL` +- `account_id INTEGER REFERENCES financial_accounts(id) ON DELETE SET NULL` +- `provider_transaction_id TEXT` +- `source_type TEXT NOT NULL` (`manual`, `file_import`, `provider_sync`) +- `transaction_type TEXT` +- `posted_date TEXT` +- `transacted_at TEXT` +- `amount INTEGER NOT NULL` (cents) +- `currency TEXT` +- `description TEXT` +- `payee TEXT` +- `memo TEXT` +- `category TEXT` +- `raw_data TEXT` +- `matched_bill_id INTEGER REFERENCES bills(id) ON DELETE SET NULL` +- `match_status TEXT` (`unmatched`, `matched`, `ignored`) +- `ignored INTEGER NOT NULL DEFAULT 0` +- `created_at TEXT DEFAULT datetime('now')` +- `updated_at TEXT DEFAULT datetime('now')` +- Unique partial index on `(data_source_id, provider_transaction_id)` WHERE `provider_transaction_id IS NOT NULL` +- Indexes on `(user_id, COALESCE(posted_date, transacted_at, created_at))`, `(user_id, match_status, ignored)`, `account_id`, `matched_bill_id` + +#### `import_sessions` + +- `id TEXT PRIMARY KEY` +- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE` +- `created_at TEXT NOT NULL` +- `expires_at TEXT NOT NULL` +- `preview_json TEXT NOT NULL` + +#### `import_history` + +- `id INTEGER PRIMARY KEY` +- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE` +- `imported_at TEXT NOT NULL` +- `source_filename TEXT` +- `file_type TEXT DEFAULT 'csv_transactions'` +- `rows_parsed INTEGER DEFAULT 0` +- `rows_created INTEGER DEFAULT 0` +- `rows_updated INTEGER DEFAULT 0` +- `rows_skipped INTEGER DEFAULT 0` +- `rows_errored INTEGER DEFAULT 0` +- `options_json TEXT` +- `summary_json TEXT` +- `created_at TEXT DEFAULT datetime('now')` + +#### `autopay_suggestion_dismissals` + +- `id INTEGER PRIMARY KEY AUTOINCREMENT` +- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE` +- `bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE` +- `year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100)` +- `month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12)` +- `dismissed_at TEXT NOT NULL DEFAULT (datetime('now'))` +- Unique: `(user_id, bill_id, year, month)` + +#### `bill_templates` + +- `id INTEGER PRIMARY KEY AUTOINCREMENT` +- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE` +- `name TEXT NOT NULL` +- `data TEXT NOT NULL` +- `created_at TEXT DEFAULT (datetime('now'))` +- `updated_at TEXT DEFAULT (datetime('now'))` +- Unique: `(user_id, name)` + +#### `match_suggestion_rejections` + +- `id INTEGER PRIMARY KEY AUTOINCREMENT` +- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE` +- `transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE` +- `bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE` +- `rejected_at TEXT NOT NULL DEFAULT (datetime('now'))` +- Unique: `(user_id, transaction_id, bill_id)` + +#### `user_login_history` + +- `id INTEGER PRIMARY KEY AUTOINCREMENT` +- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE` +- `logged_in_at TEXT NOT NULL DEFAULT (datetime('now'))` +- `ip_address TEXT` +- `user_agent TEXT` +- `browser TEXT` +- `os TEXT` +- `device_type TEXT` +- `device_fingerprint TEXT` + #### `settings` - `key TEXT PRIMARY KEY` @@ -962,6 +1172,65 @@ Used for app settings, auth mode, OIDC settings, SMTP settings, backup schedule, - `description TEXT NOT NULL` - `applied_at TEXT NOT NULL DEFAULT datetime('now')` +#### `data_sources` + +- `id INTEGER PRIMARY KEY AUTOINCREMENT` +- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE` +- `type TEXT NOT NULL` (`manual`, `file_import`, `provider_sync`) +- `provider TEXT` +- `name TEXT NOT NULL` +- `status TEXT NOT NULL DEFAULT 'active'` (`active`, `inactive`, `error`) +- `config_json TEXT` +- `encrypted_secret TEXT` +- `last_sync_at TEXT` +- `last_error TEXT` +- `created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP` +- `updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP` +- Unique partial index: `(user_id, type, provider)` WHERE `type='manual' AND provider='manual'` + +#### `financial_accounts` + +- `id INTEGER PRIMARY KEY AUTOINCREMENT` +- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE` +- `data_source_id INTEGER REFERENCES data_sources(id) ON DELETE SET NULL` +- `provider_account_id TEXT` +- `name TEXT NOT NULL` +- `org_name TEXT` +- `account_type TEXT` +- `currency TEXT` +- `balance INTEGER` +- `available_balance INTEGER` +- `raw_data TEXT` +- `created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP` +- `updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP` +- Unique: `(data_source_id, provider_account_id)` + +#### `transactions` + +- `id INTEGER PRIMARY KEY AUTOINCREMENT` +- `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE` +- `data_source_id INTEGER REFERENCES data_sources(id) ON DELETE SET NULL` +- `account_id INTEGER REFERENCES financial_accounts(id) ON DELETE SET NULL` +- `provider_transaction_id TEXT` +- `source_type TEXT NOT NULL` (`manual`, `file_import`, `provider_sync`) +- `transaction_type TEXT` +- `posted_date TEXT` +- `transacted_at TEXT` +- `amount INTEGER NOT NULL` +- `currency TEXT` +- `description TEXT` +- `payee TEXT` +- `memo TEXT` +- `category TEXT` +- `raw_data TEXT` +- `matched_bill_id INTEGER REFERENCES bills(id) ON DELETE SET NULL` +- `match_status TEXT NOT NULL DEFAULT 'unmatched'` (`unmatched`, `matched`, `ignored`) +- `ignored INTEGER NOT NULL DEFAULT 0` +- `created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP` +- `updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP` +- Unique partial index: `(data_source_id, provider_transaction_id)` WHERE `provider_transaction_id IS NOT NULL` +- Indexes: `(user_id, posted_date, transacted_at)`, `(user_id, match_status, ignored)`, `(account_id)`, `(matched_bill_id)` + ### Indexes Important indexes include: @@ -988,6 +1257,14 @@ Important indexes include: - `idx_oidc_states_expires(expires_at)` - `idx_bill_history_ranges_bill(bill_id)` - `idx_audit_log_user(user_id, created_at)`, `idx_audit_log_action(action, created_at)` +- `idx_data_sources_user_type(user_id, type, status)` +- `idx_data_sources_user_manual(user_id, type, provider)` WHERE `type='manual' AND provider='manual'` +- `idx_financial_accounts_user_source(user_id, data_source_id)` +- `idx_transactions_user_date(user_id, posted_date, transacted_at)` +- `idx_transactions_user_match(user_id, match_status, ignored)` +- `idx_transactions_account(account_id)` +- `idx_transactions_matched_bill(matched_bill_id)` +- `idx_transactions_provider_dedupe(data_source_id, provider_transaction_id)` WHERE `provider_transaction_id IS NOT NULL` ### Migration system @@ -1023,6 +1300,22 @@ Current migration set: - `v0.44` performance indexes. - `v0.45` audit log. - `v0.46` bill `cycle_type` and `cycle_day`. +- `v0.47` bills: `current_balance`, `minimum_payment`, `snowball_order`, `snowball_include`, `snowball_exempt`, `auto_mark_paid`, `deleted_at` columns. +- `v0.48` users: `snowball_extra_payment` column. +- `v0.49` payments: `balance_delta` column for debt payoff tracking. +- `v0.50` users: `last_seen_version` for release-notes notifications. +- `v0.51` user_login_history table. +- `v0.54` bills/categories: soft-delete columns (`deleted_at`). +- `v0.55` autopay: auto_mark_paid and suggestion dismissals table. +- `v0.56` bills: saved bill templates table. +- `v0.57` match_suggestion_rejections table. +- `v0.58` import_sessions and import_history tables. +- `v0.59` payments: `payment_source` and `transaction_id` columns. +- `v0.60` data_sources, financial_accounts, transactions tables. +- `v0.61` payments: one active payment per linked transaction (unique index on `transaction_id`). +- `v0.62` match_suggestion_rejections table. +- `v0.63` data_sources: partial unique index on `(user_id, type, provider)` WHERE type='manual' AND provider='manual'. +- `v0.64` transactions: partial unique index on `(data_source_id, provider_transaction_id)` WHERE provider_transaction_id IS NOT NULL; indexes on `(user_id, posted_date)`, `(user_id, match_status, ignored)`, `account_id`, `matched_bill_id`. - Unversioned user notification columns are also reconciled. Migration logging is both console-based and audit-backed: @@ -1037,6 +1330,22 @@ Rollback support is defined by `ROLLBACK_SQL_MAP`: - `v0.44` — drops selected performance indexes: `idx_bills_user_name`, `idx_payments_method`, `idx_monthly_starting_amounts_user`, and `idx_import_history_imported_at`. - `v0.45` — drops `idx_audit_log_user`, `idx_audit_log_action`, and the `audit_log` table. - `v0.46` — drops `bills.cycle_day` and `bills.cycle_type`. +- `v0.47` — drops `current_balance`, `minimum_payment`, `snowball_order`, `snowball_include`, `snowball_exempt`, `auto_mark_paid`, `deleted_at` columns from bills. +- `v0.48` — drops `snowball_extra_payment` column from users. +- `v0.49` — drops `balance_delta` column from payments. +- `v0.50` — drops `last_seen_version` column from users. +- `v0.51` — drops `user_login_history` table. +- `v0.54` — removes soft-delete columns (`deleted_at`) from bills and categories. +- `v0.55` — drops autopay suggestion dismissals table. +- `v0.56` — drops `bill_templates` table. +- `v0.57` — drops `match_suggestion_rejections` table. +- `v0.58` — drops `import_sessions` and `import_history` tables. +- `v0.59` — drops `payment_source` and `transaction_id` columns from payments. +- `v0.60` — drops `data_sources`, `financial_accounts`, and `transactions` tables. +- `v0.61` — drops unique index on `transaction_id` from payments. +- `v0.62` — drops `match_suggestion_rejections` table. +- `v0.63` — drops partial unique index on `data_sources`. +- `v0.64` — drops partial unique index and indexes on `transactions`. `rollbackMigration(version)` requires an initialized database, verifies the version exists in `schema_migrations`, looks up rollback SQL in `ROLLBACK_SQL_MAP`, executes all rollback statements inside a transaction, deletes the migration record, logs elapsed time, audits success, and returns `{success:true, version, description, elapsed_ms}`. If the migration is not recorded, it throws `NOT_APPLIED`. If no rollback definition exists, it throws `ROLLBACK_NOT_SUPPORTED`. Execution failures roll back the transaction and are audited as `migration.rollback.failure`. @@ -1178,7 +1487,7 @@ These use TanStack Query keys and cache server data for common pages. ### `package.json` -Version: `0.23.2`. +Version: `0.28.1`. Scripts: diff --git a/routes/bills.js b/routes/bills.js index 378324c..b41d515 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -427,7 +427,7 @@ router.get('/:id/transactions', (req, res) => { LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL - LEFT JOIN payments p ON p.transaction_id = t.id AND p.bill_id = ? AND p.deleted_at IS NULL + JOIN payments p ON p.transaction_id = t.id AND p.bill_id = ? AND p.deleted_at IS NULL WHERE t.user_id = ? AND t.matched_bill_id = ? AND t.match_status = 'matched' diff --git a/routes/export.js b/routes/export.js index 80ab4bc..35f2974 100644 --- a/routes/export.js +++ b/routes/export.js @@ -99,7 +99,8 @@ function getUserExportData(userId) { `).all(userId); const payments = db.prepare(` SELECT p.id, p.bill_id, p.amount, p.paid_date, p.method, p.notes, - p.payment_source, NULL AS transaction_id, p.created_at, p.updated_at + CASE WHEN p.payment_source = 'transaction_match' THEN 'manual' ELSE p.payment_source END AS payment_source, + NULL AS transaction_id, p.created_at, p.updated_at FROM payments p JOIN bills b ON b.id = p.bill_id WHERE b.user_id = ? AND b.deleted_at IS NULL AND p.deleted_at IS NULL diff --git a/services/paymentValidation.js b/services/paymentValidation.js index 839cd9f..a9519dd 100644 --- a/services/paymentValidation.js +++ b/services/paymentValidation.js @@ -39,7 +39,7 @@ function validatePositiveAmount(value, field = 'amount') { return { value: amount }; } -const PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync', 'transaction_match']; +const PAYMENT_SOURCES = ['manual', 'file_import', 'provider_sync']; function validatePaymentSource(value, field = 'payment_source') { if (typeof value !== 'string') { diff --git a/services/userDbImportService.js b/services/userDbImportService.js index a6c3ced..77ae547 100644 --- a/services/userDbImportService.js +++ b/services/userDbImportService.js @@ -12,7 +12,7 @@ const SESSION_TTL_HOURS = 24; const REQUIRED_TABLES = ['export_metadata', 'categories', 'bills', 'payments', 'monthly_bill_state']; const VALID_BILLING_CYCLES = new Set(['monthly', 'quarterly', 'annually', 'irregular']); const VALID_AUTODRAFT = new Set(['none', 'pending', 'assumed_paid', 'confirmed']); -const VALID_PAYMENT_SOURCES = new Set(['manual', 'file_import', 'provider_sync', 'transaction_match']); +const VALID_PAYMENT_SOURCES = new Set(['manual', 'file_import', 'provider_sync']); function importError(status, message, code, details = []) { const err = new Error(message); diff --git a/tests/transactionMatchService.test.js b/tests/transactionMatchService.test.js index 7595457..564fdd1 100644 --- a/tests/transactionMatchService.test.js +++ b/tests/transactionMatchService.test.js @@ -265,6 +265,46 @@ test('transaction match payments cannot be edited, deleted, or restored through assert.equal(db.prepare('SELECT match_status, matched_bill_id FROM transactions WHERE id = ?').get(transactionId).match_status, 'unmatched'); }); +test('generic payment routes cannot create transaction_match payments', async () => { + const db = getDb(); + const userId = createUser(db, 'payment-source-lock'); + const billId = createBill(db, userId, 'Water'); + + const createRes = await callPaymentsRoute('/', 'post', { + userId, + body: { + bill_id: billId, + amount: 85, + paid_date: '2026-05-16', + payment_source: 'transaction_match', + }, + }); + assert.equal(createRes.status, 400); + + const quickRes = await callPaymentsRoute('/quick', 'post', { + userId, + body: { + bill_id: billId, + payment_source: 'transaction_match', + }, + }); + assert.equal(quickRes.status, 400); + + const bulkRes = await callPaymentsRoute('/bulk', 'post', { + userId, + body: { + payments: [{ + bill_id: billId, + amount: 85, + paid_date: '2026-05-16', + payment_source: 'transaction_match', + }], + }, + }); + assert.equal(bulkRes.status, 400); + assert.equal(db.prepare('SELECT COUNT(*) AS n FROM payments WHERE bill_id = ?').get(billId).n, 0); +}); + test('matching marks the tracker row paid and unmatching recalculates it as unpaid', () => { const db = getDb(); const userId = createUser(db, 'tracker'); @@ -381,3 +421,23 @@ test('manual payment history remains visible and suppresses duplicate suggestion assert.equal(transactionsRes.data.transactions[0].id, transactionId); assert.equal(transactionsRes.data.transactions[0].linked_payment.id, matched.payment.id); }); + +test('bill linked transactions require an active linked payment', async () => { + const db = getDb(); + const userId = createUser(db, 'orphan-link'); + const billId = createBill(db, userId, 'Orphaned'); + const transactionId = createTransaction(db, userId); + + db.prepare(` + UPDATE transactions + SET matched_bill_id = ?, match_status = 'matched', ignored = 0 + WHERE id = ? AND user_id = ? + `).run(billId, transactionId, userId); + + const transactionsRes = await callBillsRoute('/:id/transactions', { + userId, + params: { id: String(billId) }, + }); + assert.equal(transactionsRes.status, 200); + assert.equal(transactionsRes.data.transactions.length, 0); +});