// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). import type { Req, Res, Next } from '../types/http'; const router = require('express').Router(); const { getDb } = require('../db/database.cts'); const { ApiError, ValidationError, NotFoundError, ConflictError, } = require('../utils/apiError.cts'); const { applyBankPaymentAsSourceOfTruth, reactivatePaymentsOverriddenBy, } = require('../services/paymentAccountingService.cts'); const { listMatchSuggestions, rejectMatchSuggestion, } = require('../services/matchSuggestionService.cts'); const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService.cts'); const { markMatched, markUnmatched } = require('../services/transactionMatchState.cts'); const { serializePayment } = require('../services/paymentValidation.cts'); const { todayLocal } = require('../utils/dates.mts'); // 4xx service errors keep their message/code on the wire; anything else is // rethrown raw so the terminal handler masks + logs it as a 5xx. function toMatchError(err) { if (err.status && err.status < 500) { return new ApiError(err.code || 'MATCH_ERROR', err.message, err.status, { field: err.field }); } return err; } // GET /api/matches/suggestions router.get('/suggestions', (req: Req, res: Res) => { res.json(listMatchSuggestions(req.user.id, req.query)); }); // POST /api/matches/:id/reject router.post('/:id/reject', (req: Req, res: Res) => { res.json(rejectMatchSuggestion(req.user.id, req.params.id)); }); // POST /api/matches/confirm — link a transaction to a bill and record a payment router.post('/confirm', (req: Req, res: Res) => { const txId = parseInt(req.body?.transaction_id, 10); const billId = parseInt(req.body?.bill_id, 10); if (!Number.isInteger(txId) || !Number.isInteger(billId)) { throw ValidationError('transaction_id and bill_id are required integers'); } const db = getDb(); const tx = db .prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?') .get(txId, req.user.id); if (!tx) throw NotFoundError('Transaction not found', 'transaction_id'); if (tx.match_status === 'matched') { throw ConflictError( 'Transaction is already matched to a bill', 'transaction_id', 'ALREADY_MATCHED', ); } const bill = db .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .get(billId, req.user.id); if (!bill) throw NotFoundError('Bill not found', 'bill_id'); const existing = db .prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL') .get(txId); if (existing) throw ConflictError( 'A payment is already linked to this transaction', undefined, 'DUPLICATE_MATCH', ); const paidDate = tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : todayLocal()); const amount = Math.round(Math.abs(tx.amount)); // tx.amount and payments.amount are both cents try { db.exec('BEGIN'); const payResult = db .prepare( "INSERT INTO payments (bill_id, amount, paid_date, payment_source, transaction_id) VALUES (?, ?, ?, 'transaction_match', ?)", ) .run(billId, amount, paidDate, txId); const paymentForAccounting = db .prepare('SELECT * FROM payments WHERE id = ?') .get(payResult.lastInsertRowid); applyBankPaymentAsSourceOfTruth(db, bill, paymentForAccounting); markMatched(db, req.user.id, txId, billId); // Learn a merchant→bill rule from this explicit confirmation so future // synced transactions from the same merchant auto-match. Best-effort. learnMerchantRuleFromMatch(db, req.user.id, billId, tx); db.exec('COMMIT'); const payment = db .prepare('SELECT * FROM payments WHERE id = ?') .get(payResult.lastInsertRowid); const updated = db .prepare( ` SELECT t.*, b.name AS matched_bill_name FROM transactions t LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.deleted_at IS NULL WHERE t.id = ? `, ) .get(txId); res.json({ transaction: updated, payment: serializePayment(payment) }); } catch (err) { try { db.exec('ROLLBACK'); } catch {} throw toMatchError(err); } }); // POST /api/matches/:transactionId/unmatch — remove a manual match router.post('/:transactionId/unmatch', (req: Req, res: Res) => { const txId = parseInt(req.params.transactionId, 10); if (!Number.isInteger(txId)) { throw ValidationError('transactionId must be an integer'); } const db = getDb(); const tx = db .prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?') .get(txId, req.user.id); if (!tx) throw NotFoundError('Transaction not found'); if (tx.match_status !== 'matched') { throw ConflictError('Transaction is not matched', undefined, 'NOT_MATCHED'); } try { db.exec('BEGIN'); const matchedPayments = db .prepare( ` SELECT * FROM payments WHERE transaction_id = ? AND payment_source = 'transaction_match' AND deleted_at IS NULL `, ) .all(txId); for (const payment of matchedPayments) { reactivatePaymentsOverriddenBy(db, payment.id); } db.prepare( ` UPDATE payments SET deleted_at = datetime('now'), updated_at = datetime('now') WHERE transaction_id = ? AND payment_source = 'transaction_match' AND deleted_at IS NULL `, ).run(txId); markUnmatched(db, req.user.id, txId); db.exec('COMMIT'); res.json({ ok: true }); } catch (err) { try { db.exec('ROLLBACK'); } catch {} throw toMatchError(err); } }); module.exports = router;