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 <noreply@anthropic.com>
2026-07-03 13:02:10 -05:00
|
|
|
'use strict';
|
|
|
|
|
|
2026-07-06 10:47:16 -05:00
|
|
|
import type { Db } from '../types/db';
|
|
|
|
|
|
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 <noreply@anthropic.com>
2026-07-03 13:02:10 -05:00
|
|
|
// Canonical writers for a transaction's match state.
|
|
|
|
|
//
|
|
|
|
|
// A transaction's match state lives in three columns that must move together:
|
|
|
|
|
// match_status 'unmatched' | 'matched' | 'ignored'
|
|
|
|
|
// matched_bill_id the bill when matched, else NULL
|
|
|
|
|
// ignored 1 when explicitly ignored, else 0
|
|
|
|
|
//
|
|
|
|
|
// These were previously updated by copy-pasted inline UPDATEs across routes and
|
|
|
|
|
// services, which is exactly how they drift out of sync — e.g. QA-B5-04, where a
|
|
|
|
|
// bill purge left match_status='matched' with a NULL bill. Routing every
|
|
|
|
|
// single-transaction transition through these helpers keeps the columns
|
|
|
|
|
// consistent by construction. All are ownership-scoped (user_id) and return the
|
|
|
|
|
// number of rows changed. Bulk transitions (auto-match sweep, retention purge)
|
|
|
|
|
// stay in their own services where the WHERE clause is fundamentally different.
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Mark a transaction matched to a bill.
|
2026-07-06 10:47:16 -05:00
|
|
|
* @param resetIgnored also clear the ignored flag (used when matching a
|
|
|
|
|
* transaction directly, which implicitly un-ignores it).
|
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 <noreply@anthropic.com>
2026-07-03 13:02:10 -05:00
|
|
|
*/
|
2026-07-06 14:23:53 -05:00
|
|
|
function markMatched(
|
|
|
|
|
db: Db,
|
|
|
|
|
userId: number,
|
|
|
|
|
transactionId: number,
|
|
|
|
|
billId: number,
|
|
|
|
|
{ resetIgnored = false }: { resetIgnored?: boolean } = {},
|
|
|
|
|
): number {
|
|
|
|
|
return db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
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 <noreply@anthropic.com>
2026-07-03 13:02:10 -05:00
|
|
|
UPDATE transactions
|
|
|
|
|
SET matched_bill_id = ?, match_status = 'matched'${resetIgnored ? ', ignored = 0' : ''}, updated_at = datetime('now')
|
|
|
|
|
WHERE id = ? AND user_id = ?
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.run(billId, transactionId, userId).changes;
|
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 <noreply@anthropic.com>
2026-07-03 13:02:10 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clear a transaction's match — back to 'unmatched' with no bill.
|
2026-07-06 10:47:16 -05:00
|
|
|
* @param resetIgnored also clear the ignored flag (un-ignore).
|
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 <noreply@anthropic.com>
2026-07-03 13:02:10 -05:00
|
|
|
*/
|
2026-07-06 14:23:53 -05:00
|
|
|
function markUnmatched(
|
|
|
|
|
db: Db,
|
|
|
|
|
userId: number,
|
|
|
|
|
transactionId: number,
|
|
|
|
|
{ resetIgnored = false }: { resetIgnored?: boolean } = {},
|
|
|
|
|
): number {
|
|
|
|
|
return db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
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 <noreply@anthropic.com>
2026-07-03 13:02:10 -05:00
|
|
|
UPDATE transactions
|
|
|
|
|
SET matched_bill_id = NULL, match_status = 'unmatched'${resetIgnored ? ', ignored = 0' : ''}, updated_at = datetime('now')
|
|
|
|
|
WHERE id = ? AND user_id = ?
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.run(transactionId, userId).changes;
|
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 <noreply@anthropic.com>
2026-07-03 13:02:10 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Mark a transaction ignored — dropped from matching, no bill. */
|
2026-07-06 10:47:16 -05:00
|
|
|
function markIgnored(db: Db, userId: number, transactionId: number): number {
|
2026-07-06 14:23:53 -05:00
|
|
|
return db
|
|
|
|
|
.prepare(
|
|
|
|
|
`
|
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 <noreply@anthropic.com>
2026-07-03 13:02:10 -05:00
|
|
|
UPDATE transactions
|
|
|
|
|
SET ignored = 1, match_status = 'ignored', matched_bill_id = NULL, updated_at = datetime('now')
|
|
|
|
|
WHERE id = ? AND user_id = ?
|
2026-07-06 14:23:53 -05:00
|
|
|
`,
|
|
|
|
|
)
|
|
|
|
|
.run(transactionId, userId).changes;
|
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 <noreply@anthropic.com>
2026-07-03 13:02:10 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = { markMatched, markUnmatched, markIgnored };
|