BillTracker/routes/matches.cts

174 lines
5.6 KiB
TypeScript
Raw Normal View History

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