120 lines
4.9 KiB
JavaScript
120 lines
4.9 KiB
JavaScript
const router = require('express').Router();
|
|
const { standardizeError } = require('../middleware/errorFormatter');
|
|
const { getDb } = require('../db/database');
|
|
const {
|
|
listMatchSuggestions,
|
|
rejectMatchSuggestion,
|
|
} = require('../services/matchSuggestionService');
|
|
|
|
function sendMatchError(res, err, fallbackMessage = 'Match operation failed') {
|
|
if (err.status) {
|
|
return res.status(err.status).json(standardizeError(err.message, err.code || 'MATCH_ERROR', err.field));
|
|
}
|
|
console.error('[matches] service error:', err.stack || err.message);
|
|
return res.status(500).json(standardizeError(fallbackMessage, 'MATCH_ERROR'));
|
|
}
|
|
|
|
// GET /api/matches/suggestions
|
|
router.get('/suggestions', (req, res) => {
|
|
try {
|
|
res.json(listMatchSuggestions(req.user.id, req.query));
|
|
} catch (err) {
|
|
return sendMatchError(res, err, 'Match suggestions failed');
|
|
}
|
|
});
|
|
|
|
// POST /api/matches/:id/reject
|
|
router.post('/:id/reject', (req, res) => {
|
|
try {
|
|
res.json(rejectMatchSuggestion(req.user.id, req.params.id));
|
|
} catch (err) {
|
|
return sendMatchError(res, err, 'Rejecting match suggestion failed');
|
|
}
|
|
});
|
|
|
|
// POST /api/matches/confirm — link a transaction to a bill and record a payment
|
|
router.post('/confirm', (req, res) => {
|
|
const txId = parseInt(req.body?.transaction_id, 10);
|
|
const billId = parseInt(req.body?.bill_id, 10);
|
|
if (!Number.isInteger(txId) || !Number.isInteger(billId)) {
|
|
return res.status(400).json(standardizeError('transaction_id and bill_id are required integers', 'VALIDATION_ERROR'));
|
|
}
|
|
|
|
const db = getDb();
|
|
const tx = db.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?').get(txId, req.user.id);
|
|
if (!tx) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'transaction_id'));
|
|
if (tx.match_status === 'matched') {
|
|
return res.status(409).json(standardizeError('Transaction is already matched to a bill', 'ALREADY_MATCHED', 'transaction_id'));
|
|
}
|
|
|
|
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id);
|
|
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
|
|
|
const existing = db.prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL').get(txId);
|
|
if (existing) return res.status(409).json(standardizeError('A payment is already linked to this transaction', 'DUPLICATE_MATCH'));
|
|
|
|
const paidDate = tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : new Date().toISOString().slice(0, 10));
|
|
const amount = Math.round(Math.abs(tx.amount)) / 100; // cents → dollars
|
|
|
|
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);
|
|
|
|
db.prepare(`
|
|
UPDATE transactions
|
|
SET matched_bill_id = ?, match_status = 'matched', updated_at = datetime('now')
|
|
WHERE id = ? AND user_id = ?
|
|
`).run(billId, txId, req.user.id);
|
|
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 });
|
|
} catch (err) {
|
|
try { db.exec('ROLLBACK'); } catch {}
|
|
return sendMatchError(res, err, 'Failed to confirm match');
|
|
}
|
|
});
|
|
|
|
// POST /api/matches/:transactionId/unmatch — remove a manual match
|
|
router.post('/:transactionId/unmatch', (req, res) => {
|
|
const txId = parseInt(req.params.transactionId, 10);
|
|
if (!Number.isInteger(txId)) {
|
|
return res.status(400).json(standardizeError('transactionId must be an integer', 'VALIDATION_ERROR'));
|
|
}
|
|
|
|
const db = getDb();
|
|
const tx = db.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?').get(txId, req.user.id);
|
|
if (!tx) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND'));
|
|
if (tx.match_status !== 'matched') {
|
|
return res.status(409).json(standardizeError('Transaction is not matched', 'NOT_MATCHED'));
|
|
}
|
|
|
|
try {
|
|
db.exec('BEGIN');
|
|
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);
|
|
db.prepare(`
|
|
UPDATE transactions
|
|
SET matched_bill_id = NULL, match_status = 'unmatched', updated_at = datetime('now')
|
|
WHERE id = ? AND user_id = ?
|
|
`).run(txId, req.user.id);
|
|
db.exec('COMMIT');
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
try { db.exec('ROLLBACK'); } catch {}
|
|
return sendMatchError(res, err, 'Failed to unmatch transaction');
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|