refactor(match): one canonical writer for transaction match state (IMP-CODE-03)
match_status, matched_bill_id and ignored must move together, but they were
updated by copy-pasted inline UPDATEs across six routes/services — exactly how
they drift apart (QA-B5-04 left match_status='matched' with a NULL bill).
Add services/transactionMatchState.js (markMatched / markUnmatched / markIgnored,
each ownership-scoped, returning rows changed) and route the six single-
transaction transitions through it: matchTransactionToBill, unmatchTransaction,
ignoreTransaction, unignoreTransaction (transactionMatchService), the match/
unmatch handlers (routes/matches), and unmatch-on-payment-delete (routes/
transactions, routes/payments).
Guarded bulk auto-match sweeps (subscription tracking, merchant-rule matching,
historical import) and the retention purge intentionally keep their own queries
— their WHERE clauses carry idempotency guards (AND match_status='unmatched')
the simple helper must not silently drop.
Test: tests/transactionMatchState.test.js (transitions + ownership scoping).
transactionMatchService/subscriptionService regression suites still pass;
server 122 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 13:02:10 -05:00
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
// IMP-CODE-03: the canonical match-state writers keep match_status,
|
|
|
|
|
// matched_bill_id and ignored moving together, so no path can leave the columns
|
|
|
|
|
// inconsistent (the QA-B5-04 class of bug).
|
|
|
|
|
const test = require('node:test');
|
|
|
|
|
const assert = require('node:assert/strict');
|
|
|
|
|
const os = require('node:os');
|
|
|
|
|
const path = require('node:path');
|
|
|
|
|
const fs = require('node:fs');
|
|
|
|
|
|
|
|
|
|
const dbPath = path.join(os.tmpdir(), `bill-tracker-match-state-${process.pid}.sqlite`);
|
|
|
|
|
process.env.DB_PATH = dbPath;
|
|
|
|
|
|
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 11:34:08 -05:00
|
|
|
const { getDb, closeDb } = require('../db/database.cts');
|
2026-07-06 14:23:53 -05:00
|
|
|
const {
|
|
|
|
|
markMatched,
|
|
|
|
|
markUnmatched,
|
|
|
|
|
markIgnored,
|
|
|
|
|
} = require('../services/transactionMatchState.cts');
|
refactor(match): one canonical writer for transaction match state (IMP-CODE-03)
match_status, matched_bill_id and ignored must move together, but they were
updated by copy-pasted inline UPDATEs across six routes/services — exactly how
they drift apart (QA-B5-04 left match_status='matched' with a NULL bill).
Add services/transactionMatchState.js (markMatched / markUnmatched / markIgnored,
each ownership-scoped, returning rows changed) and route the six single-
transaction transitions through it: matchTransactionToBill, unmatchTransaction,
ignoreTransaction, unignoreTransaction (transactionMatchService), the match/
unmatch handlers (routes/matches), and unmatch-on-payment-delete (routes/
transactions, routes/payments).
Guarded bulk auto-match sweeps (subscription tracking, merchant-rule matching,
historical import) and the retention purge intentionally keep their own queries
— their WHERE clauses carry idempotency guards (AND match_status='unmatched')
the simple helper must not silently drop.
Test: tests/transactionMatchState.test.js (transitions + ownership scoping).
transactionMatchService/subscriptionService regression suites still pass;
server 122 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 13:02:10 -05:00
|
|
|
|
|
|
|
|
let db, userId, otherId, billId;
|
|
|
|
|
|
|
|
|
|
function newTxn(uid, { status = 'unmatched', bill = null, ignored = 0 } = {}) {
|
2026-07-06 14:23:53 -05:00
|
|
|
return db
|
|
|
|
|
.prepare(
|
|
|
|
|
'INSERT INTO transactions (user_id, source_type, provider_transaction_id, amount, match_status, matched_bill_id, ignored) VALUES (?, ?, ?, -1000, ?, ?, ?)',
|
|
|
|
|
)
|
|
|
|
|
.run(uid, 'manual', `ms-${Math.random().toString(36).slice(2)}`, status, bill, ignored)
|
|
|
|
|
.lastInsertRowid;
|
refactor(match): one canonical writer for transaction match state (IMP-CODE-03)
match_status, matched_bill_id and ignored must move together, but they were
updated by copy-pasted inline UPDATEs across six routes/services — exactly how
they drift apart (QA-B5-04 left match_status='matched' with a NULL bill).
Add services/transactionMatchState.js (markMatched / markUnmatched / markIgnored,
each ownership-scoped, returning rows changed) and route the six single-
transaction transitions through it: matchTransactionToBill, unmatchTransaction,
ignoreTransaction, unignoreTransaction (transactionMatchService), the match/
unmatch handlers (routes/matches), and unmatch-on-payment-delete (routes/
transactions, routes/payments).
Guarded bulk auto-match sweeps (subscription tracking, merchant-rule matching,
historical import) and the retention purge intentionally keep their own queries
— their WHERE clauses carry idempotency guards (AND match_status='unmatched')
the simple helper must not silently drop.
Test: tests/transactionMatchState.test.js (transitions + ownership scoping).
transactionMatchService/subscriptionService regression suites still pass;
server 122 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 13:02:10 -05:00
|
|
|
}
|
2026-07-06 14:23:53 -05:00
|
|
|
const row = (id) =>
|
|
|
|
|
db
|
|
|
|
|
.prepare('SELECT match_status, matched_bill_id, ignored FROM transactions WHERE id = ?')
|
|
|
|
|
.get(id);
|
refactor(match): one canonical writer for transaction match state (IMP-CODE-03)
match_status, matched_bill_id and ignored must move together, but they were
updated by copy-pasted inline UPDATEs across six routes/services — exactly how
they drift apart (QA-B5-04 left match_status='matched' with a NULL bill).
Add services/transactionMatchState.js (markMatched / markUnmatched / markIgnored,
each ownership-scoped, returning rows changed) and route the six single-
transaction transitions through it: matchTransactionToBill, unmatchTransaction,
ignoreTransaction, unignoreTransaction (transactionMatchService), the match/
unmatch handlers (routes/matches), and unmatch-on-payment-delete (routes/
transactions, routes/payments).
Guarded bulk auto-match sweeps (subscription tracking, merchant-rule matching,
historical import) and the retention purge intentionally keep their own queries
— their WHERE clauses carry idempotency guards (AND match_status='unmatched')
the simple helper must not silently drop.
Test: tests/transactionMatchState.test.js (transitions + ownership scoping).
transactionMatchService/subscriptionService regression suites still pass;
server 122 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 13:02:10 -05:00
|
|
|
|
|
|
|
|
test.before(() => {
|
|
|
|
|
db = getDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
userId = db
|
|
|
|
|
.prepare(
|
|
|
|
|
"INSERT INTO users (username, password_hash, role, active) VALUES ('ms-user','x','user',1)",
|
|
|
|
|
)
|
|
|
|
|
.run().lastInsertRowid;
|
|
|
|
|
otherId = db
|
|
|
|
|
.prepare(
|
|
|
|
|
"INSERT INTO users (username, password_hash, role, active) VALUES ('ms-other','x','user',1)",
|
|
|
|
|
)
|
|
|
|
|
.run().lastInsertRowid;
|
|
|
|
|
billId = db
|
|
|
|
|
.prepare(
|
|
|
|
|
'INSERT INTO bills (user_id, name, due_day, expected_amount, active) VALUES (?, ?, 1, 1000, 1)',
|
|
|
|
|
)
|
|
|
|
|
.run(userId, 'Bill').lastInsertRowid;
|
refactor(match): one canonical writer for transaction match state (IMP-CODE-03)
match_status, matched_bill_id and ignored must move together, but they were
updated by copy-pasted inline UPDATEs across six routes/services — exactly how
they drift apart (QA-B5-04 left match_status='matched' with a NULL bill).
Add services/transactionMatchState.js (markMatched / markUnmatched / markIgnored,
each ownership-scoped, returning rows changed) and route the six single-
transaction transitions through it: matchTransactionToBill, unmatchTransaction,
ignoreTransaction, unignoreTransaction (transactionMatchService), the match/
unmatch handlers (routes/matches), and unmatch-on-payment-delete (routes/
transactions, routes/payments).
Guarded bulk auto-match sweeps (subscription tracking, merchant-rule matching,
historical import) and the retention purge intentionally keep their own queries
— their WHERE clauses carry idempotency guards (AND match_status='unmatched')
the simple helper must not silently drop.
Test: tests/transactionMatchState.test.js (transitions + ownership scoping).
transactionMatchService/subscriptionService regression suites still pass;
server 122 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 13:02:10 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test.after(() => {
|
|
|
|
|
closeDb();
|
2026-07-06 14:23:53 -05:00
|
|
|
for (const suffix of ['', '-wal', '-shm']) {
|
|
|
|
|
try {
|
|
|
|
|
fs.unlinkSync(dbPath + suffix);
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
refactor(match): one canonical writer for transaction match state (IMP-CODE-03)
match_status, matched_bill_id and ignored must move together, but they were
updated by copy-pasted inline UPDATEs across six routes/services — exactly how
they drift apart (QA-B5-04 left match_status='matched' with a NULL bill).
Add services/transactionMatchState.js (markMatched / markUnmatched / markIgnored,
each ownership-scoped, returning rows changed) and route the six single-
transaction transitions through it: matchTransactionToBill, unmatchTransaction,
ignoreTransaction, unignoreTransaction (transactionMatchService), the match/
unmatch handlers (routes/matches), and unmatch-on-payment-delete (routes/
transactions, routes/payments).
Guarded bulk auto-match sweeps (subscription tracking, merchant-rule matching,
historical import) and the retention purge intentionally keep their own queries
— their WHERE clauses carry idempotency guards (AND match_status='unmatched')
the simple helper must not silently drop.
Test: tests/transactionMatchState.test.js (transitions + ownership scoping).
transactionMatchService/subscriptionService regression suites still pass;
server 122 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 13:02:10 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('markMatched sets status + bill together', () => {
|
|
|
|
|
const id = newTxn(userId);
|
|
|
|
|
const changes = markMatched(db, userId, id, billId);
|
|
|
|
|
assert.equal(changes, 1);
|
|
|
|
|
assert.deepEqual(row(id), { match_status: 'matched', matched_bill_id: billId, ignored: 0 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('markMatched leaves the ignored flag unless resetIgnored is set', () => {
|
|
|
|
|
const kept = newTxn(userId, { ignored: 1, status: 'ignored' });
|
|
|
|
|
markMatched(db, userId, kept, billId);
|
|
|
|
|
assert.equal(row(kept).ignored, 1, 'ignored preserved by default');
|
|
|
|
|
|
|
|
|
|
const cleared = newTxn(userId, { ignored: 1, status: 'ignored' });
|
|
|
|
|
markMatched(db, userId, cleared, billId, { resetIgnored: true });
|
|
|
|
|
assert.equal(row(cleared).ignored, 0, 'resetIgnored clears it');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('markUnmatched clears the bill and status (never leaves matched+NULL)', () => {
|
|
|
|
|
const id = newTxn(userId, { status: 'matched', bill: billId });
|
|
|
|
|
markUnmatched(db, userId, id);
|
|
|
|
|
assert.deepEqual(row(id), { match_status: 'unmatched', matched_bill_id: null, ignored: 0 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('markUnmatched with resetIgnored un-ignores', () => {
|
|
|
|
|
const id = newTxn(userId, { status: 'ignored', ignored: 1 });
|
|
|
|
|
markUnmatched(db, userId, id, { resetIgnored: true });
|
|
|
|
|
assert.deepEqual(row(id), { match_status: 'unmatched', matched_bill_id: null, ignored: 0 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('markIgnored sets ignored + status, clears bill', () => {
|
|
|
|
|
const id = newTxn(userId, { status: 'matched', bill: billId });
|
|
|
|
|
markIgnored(db, userId, id);
|
|
|
|
|
assert.deepEqual(row(id), { match_status: 'ignored', matched_bill_id: null, ignored: 1 });
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
test("all writers are ownership-scoped — never touch another user's transaction", () => {
|
refactor(match): one canonical writer for transaction match state (IMP-CODE-03)
match_status, matched_bill_id and ignored must move together, but they were
updated by copy-pasted inline UPDATEs across six routes/services — exactly how
they drift apart (QA-B5-04 left match_status='matched' with a NULL bill).
Add services/transactionMatchState.js (markMatched / markUnmatched / markIgnored,
each ownership-scoped, returning rows changed) and route the six single-
transaction transitions through it: matchTransactionToBill, unmatchTransaction,
ignoreTransaction, unignoreTransaction (transactionMatchService), the match/
unmatch handlers (routes/matches), and unmatch-on-payment-delete (routes/
transactions, routes/payments).
Guarded bulk auto-match sweeps (subscription tracking, merchant-rule matching,
historical import) and the retention purge intentionally keep their own queries
— their WHERE clauses carry idempotency guards (AND match_status='unmatched')
the simple helper must not silently drop.
Test: tests/transactionMatchState.test.js (transitions + ownership scoping).
transactionMatchService/subscriptionService regression suites still pass;
server 122 pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-03 13:02:10 -05:00
|
|
|
const foreign = newTxn(otherId, { status: 'matched', bill: null });
|
|
|
|
|
assert.equal(markMatched(db, userId, foreign, billId), 0);
|
|
|
|
|
assert.equal(markUnmatched(db, userId, foreign), 0);
|
|
|
|
|
assert.equal(markIgnored(db, userId, foreign), 0);
|
|
|
|
|
assert.equal(row(foreign).match_status, 'matched', 'foreign row untouched');
|
|
|
|
|
});
|