docs: update engineering reference manual to v0.28.01

- Add sections 5.15-5.21 (Data Sources, Transactions, CSV Import, Match Suggestions)
- Add v0.47-v0.64 migrations to database reference
- Add data_sources, financial_accounts, transactions table schemas
- Add payment_source and transaction_id to payments table
- Update version header to 0.28.01, date to 2026-05-16
- Fix section numbering
This commit is contained in:
null 2026-05-16 21:41:13 -05:00
parent 060c8dc2f4
commit 55837b8b25
6 changed files with 386 additions and 16 deletions

View File

@ -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:

View File

@ -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'

View File

@ -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

View File

@ -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') {

View File

@ -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);

View File

@ -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);
});