BillTracker/routes/transactions.js

611 lines
22 KiB
JavaScript
Raw Normal View History

2026-05-16 20:26:09 -05:00
const router = require('express').Router();
const { getDb } = require('../db/database');
const { standardizeError } = require('../middleware/errorFormatter');
const {
decorateTransaction,
ensureManualDataSource,
getTransactionForUser,
} = require('../services/transactionService');
const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService');
const {
ignoreTransaction,
matchTransactionToBill,
unignoreTransaction,
unmatchTransaction,
} = require('../services/transactionMatchService');
2026-05-16 20:26:09 -05:00
const MATCH_STATUSES = new Set(['unmatched', 'matched', 'ignored']);
const SOURCE_TYPES = new Set(['manual', 'file_import', 'provider_sync']);
const MATCH_CONTROL_FIELDS = ['matched_bill_id', 'match_status', 'ignored'];
2026-05-16 20:26:09 -05:00
const TEXT_FIELDS = {
transaction_type: 64,
currency: 16,
description: 500,
payee: 255,
memo: 500,
category: 255,
};
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function cleanText(value, maxLength) {
if (value === undefined) return undefined;
if (value === null) return null;
const text = String(value).trim();
if (!text) return null;
return text.slice(0, maxLength);
}
function parseInteger(value, field, { allowNull = false, min = null, max = null } = {}) {
if (value === null && allowNull) return { value: null };
if (value === undefined) return { value: undefined };
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isSafeInteger(n)) {
return { error: standardizeError(`${field} must be an integer`, 'VALIDATION_ERROR', field) };
}
if (min !== null && n < min) {
return { error: standardizeError(`${field} must be at least ${min}`, 'VALIDATION_ERROR', field) };
}
if (max !== null && n > max) {
return { error: standardizeError(`${field} must be at most ${max}`, 'VALIDATION_ERROR', field) };
}
return { value: n };
}
function parseBooleanInt(value, field) {
if (value === undefined) return { value: undefined };
if (value === true || value === 'true' || value === '1' || value === 1) return { value: 1 };
if (value === false || value === 'false' || value === '0' || value === 0) return { value: 0 };
return { error: standardizeError(`${field} must be true or false`, 'VALIDATION_ERROR', field) };
}
function parseDate(value, field, { allowNull = false } = {}) {
if (value === null && allowNull) return { value: null };
if (value === undefined) return { value: undefined };
const text = String(value).trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) {
return { error: standardizeError(`${field} must be a valid YYYY-MM-DD date`, 'VALIDATION_ERROR', field) };
}
const date = new Date(`${text}T00:00:00Z`);
if (
Number.isNaN(date.getTime()) ||
date.toISOString().slice(0, 10) !== text
) {
return { error: standardizeError(`${field} must be a valid calendar date`, 'VALIDATION_ERROR', field) };
}
return { value: text };
}
function parseOptionalDateTime(value, field) {
if (value === undefined) return { value: undefined };
if (value === null) return { value: null };
const text = String(value).trim();
if (!text) return { value: null };
const match = /^(\d{4}-\d{2}-\d{2})(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/.exec(text);
if (!match) {
return { error: standardizeError(`${field} must be a valid ISO date or date-time`, 'VALIDATION_ERROR', field) };
}
const parsedDate = parseDate(match[1], field);
if (parsedDate.error) {
return parsedDate;
}
return { value: text };
}
function hasOwn(obj, field) {
return Object.prototype.hasOwnProperty.call(obj, field);
}
function getOwnedAccount(db, userId, accountId) {
if (accountId == null) return null;
return db.prepare('SELECT * FROM financial_accounts WHERE id = ? AND user_id = ? AND monitored = 1').get(accountId, userId);
2026-05-16 20:26:09 -05:00
}
function getOwnedBill(db, userId, billId) {
if (billId == null) return null;
return db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, userId);
}
function normalizeTransactionFields(db, userId, body = {}, { partial = false } = {}) {
const normalized = {};
if (!partial || body.amount !== undefined) {
const parsed = parseInteger(body.amount, 'amount');
if (parsed.error) return { error: parsed.error };
if (parsed.value === 0) {
return { error: standardizeError('amount must be non-zero integer cents', 'VALIDATION_ERROR', 'amount') };
}
normalized.amount = parsed.value;
}
if (!partial || body.posted_date !== undefined) {
const postedDateValue = body.posted_date === undefined && !partial
? todayStr()
: body.posted_date;
const parsed = parseDate(postedDateValue, 'posted_date', { allowNull: partial });
if (parsed.error) return { error: parsed.error };
normalized.posted_date = parsed.value;
}
if (body.transacted_at !== undefined) {
const parsed = parseOptionalDateTime(body.transacted_at, 'transacted_at');
if (parsed.error) return { error: parsed.error };
normalized.transacted_at = parsed.value;
} else if (!partial) {
normalized.transacted_at = null;
}
for (const [field, maxLength] of Object.entries(TEXT_FIELDS)) {
if (!partial || body[field] !== undefined) {
const value = cleanText(body[field], maxLength);
normalized[field] = value === undefined ? null : value;
}
}
if (!partial && !normalized.currency) normalized.currency = 'USD';
if (body.account_id !== undefined) {
const parsed = parseInteger(body.account_id, 'account_id', { allowNull: true });
if (parsed.error) return { error: parsed.error };
if (parsed.value !== null && !getOwnedAccount(db, userId, parsed.value)) {
return { error: standardizeError('Financial account not found', 'NOT_FOUND', 'account_id'), status: 404 };
}
normalized.account_id = parsed.value;
} else if (!partial) {
normalized.account_id = null;
}
if (body.matched_bill_id !== undefined) {
const parsed = parseInteger(body.matched_bill_id, 'matched_bill_id', { allowNull: true });
if (parsed.error) return { error: parsed.error };
if (parsed.value !== null && !getOwnedBill(db, userId, parsed.value)) {
return { error: standardizeError('Matched bill not found', 'NOT_FOUND', 'matched_bill_id'), status: 404 };
}
normalized.matched_bill_id = parsed.value;
} else if (!partial) {
normalized.matched_bill_id = null;
}
if (body.match_status !== undefined) {
const matchStatus = cleanText(body.match_status, 32);
if (!MATCH_STATUSES.has(matchStatus)) {
return { error: standardizeError('match_status must be unmatched, matched, or ignored', 'VALIDATION_ERROR', 'match_status') };
}
normalized.match_status = matchStatus;
}
if (body.ignored !== undefined) {
const parsed = parseBooleanInt(body.ignored, 'ignored');
if (parsed.error) return { error: parsed.error };
normalized.ignored = parsed.value;
}
return { normalized };
}
function resolveTransactionState(next, existing = {}) {
const hasStatus = hasOwn(next, 'match_status');
const hasIgnored = hasOwn(next, 'ignored');
const hasMatchedBill = hasOwn(next, 'matched_bill_id');
if (hasStatus && next.match_status === 'ignored' && hasIgnored && next.ignored === 0) {
return { error: standardizeError('ignored must be true when match_status is ignored', 'VALIDATION_ERROR', 'ignored') };
}
if (hasIgnored && next.ignored === 1 && hasStatus && next.match_status !== 'ignored') {
return { error: standardizeError('match_status must be ignored when ignored is true', 'VALIDATION_ERROR', 'match_status') };
}
if (hasStatus && next.match_status === 'unmatched' && hasMatchedBill && next.matched_bill_id !== null) {
return { error: standardizeError('matched_bill_id must be null when match_status is unmatched', 'VALIDATION_ERROR', 'matched_bill_id') };
}
if (hasStatus && next.match_status === 'matched' && hasMatchedBill && next.matched_bill_id === null) {
return { error: standardizeError('matched_bill_id is required when match_status is matched', 'VALIDATION_ERROR', 'matched_bill_id') };
}
let matchedBillId = hasMatchedBill ? next.matched_bill_id : (existing.matched_bill_id ?? null);
let matchStatus = hasStatus ? next.match_status : (existing.match_status ?? (matchedBillId ? 'matched' : 'unmatched'));
let ignored = hasIgnored ? next.ignored : (existing.ignored ?? 0);
if (hasIgnored && ignored === 0 && matchStatus === 'ignored') {
matchStatus = 'unmatched';
matchedBillId = null;
}
if (ignored === 1 || matchStatus === 'ignored') {
return {
matched_bill_id: null,
match_status: 'ignored',
ignored: 1,
};
}
if (matchStatus === 'matched' || matchedBillId !== null) {
if (matchedBillId === null) {
return { error: standardizeError('matched_bill_id is required when match_status is matched', 'VALIDATION_ERROR', 'matched_bill_id') };
}
return {
matched_bill_id: matchedBillId,
match_status: 'matched',
ignored: 0,
};
}
return {
matched_bill_id: null,
match_status: 'unmatched',
ignored: 0,
};
}
function parseLimitOffset(query) {
const limit = parseInteger(query.limit ?? 50, 'limit', { min: 1, max: 200 });
if (limit.error) return { error: limit.error };
const offset = parseInteger(query.offset ?? 0, 'offset', { min: 0 });
if (offset.error) return { error: offset.error };
return { limit: limit.value, offset: offset.value };
}
function selectedTransaction(db, userId, id) {
return decorateTransaction(getTransactionForUser(db, userId, id));
}
function rejectDirectMatchState(body = {}) {
const field = MATCH_CONTROL_FIELDS.find(name => hasOwn(body, name));
if (!field) return null;
return standardizeError(
'Use the transaction match, unmatch, ignore, or unignore endpoint to change match state',
'VALIDATION_ERROR',
field,
);
}
function sendTransactionServiceError(res, err, fallbackMessage = 'Transaction operation failed') {
if (err.status) {
return res.status(err.status).json(standardizeError(err.message, err.code || 'TRANSACTION_ERROR', err.field));
}
console.error('[transactions] service error:', err.stack || err.message);
return res.status(500).json(standardizeError(fallbackMessage, 'TRANSACTION_ERROR'));
}
2026-05-16 20:26:09 -05:00
// GET /api/transactions
router.get('/', (req, res) => {
const db = getDb();
ensureManualDataSource(db, req.user.id);
const page = parseLimitOffset(req.query);
if (page.error) return res.status(400).json(page.error);
const where = [
't.user_id = ?',
'(t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)',
];
2026-05-16 20:26:09 -05:00
const params = [req.user.id];
const matchStatusFilter = req.query.match_status ? String(req.query.match_status).trim() : '';
if (matchStatusFilter && !MATCH_STATUSES.has(matchStatusFilter)) {
return res.status(400).json(standardizeError('match_status must be unmatched, matched, or ignored', 'VALIDATION_ERROR', 'match_status'));
}
const ignored = req.query.ignored;
if (ignored === 'true' || ignored === '1') {
where.push('t.ignored = 1');
} else if (ignored === 'false' || ignored === '0') {
where.push('t.ignored = 0');
} else if (ignored !== 'all') {
if (ignored !== undefined) {
return res.status(400).json(standardizeError('ignored must be true, false, or all', 'VALIDATION_ERROR', 'ignored'));
}
where.push(matchStatusFilter === 'ignored' ? 't.ignored = 1' : 't.ignored = 0');
}
if (req.query.source_type) {
const sourceType = String(req.query.source_type).trim();
if (!SOURCE_TYPES.has(sourceType)) {
return res.status(400).json(standardizeError('source_type must be manual, file_import, or provider_sync', 'VALIDATION_ERROR', 'source_type'));
}
where.push('t.source_type = ?');
params.push(sourceType);
}
if (matchStatusFilter) {
where.push('t.match_status = ?');
params.push(matchStatusFilter);
}
for (const field of ['transaction_type']) {
if (req.query[field]) {
where.push(`t.${field} = ?`);
params.push(String(req.query[field]).trim());
}
}
for (const field of ['data_source_id', 'account_id', 'matched_bill_id']) {
if (req.query[field] !== undefined) {
const parsed = parseInteger(req.query[field], field);
if (parsed.error) return res.status(400).json(parsed.error);
where.push(`t.${field} = ?`);
params.push(parsed.value);
}
}
if (req.query.start_date) {
const parsed = parseDate(req.query.start_date, 'start_date');
if (parsed.error) return res.status(400).json(parsed.error);
where.push("COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?");
params.push(parsed.value);
}
if (req.query.end_date) {
const parsed = parseDate(req.query.end_date, 'end_date');
if (parsed.error) return res.status(400).json(parsed.error);
where.push("COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?");
params.push(parsed.value);
}
if (req.query.q) {
const q = `%${String(req.query.q).trim()}%`;
where.push('(t.description LIKE ? OR t.payee LIKE ? OR t.memo LIKE ? OR t.category LIKE ?)');
params.push(q, q, q, q);
}
const rows = db.prepare(`
SELECT
t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id,
t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount,
t.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id,
t.match_status, t.ignored, t.created_at, t.updated_at,
ds.type AS data_source_type, ds.provider AS data_source_provider,
ds.name AS data_source_name, ds.status AS data_source_status,
fa.name AS account_name, fa.org_name AS account_org_name,
fa.account_type AS account_type,
b.name AS matched_bill_name
FROM transactions t
LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
WHERE ${where.join(' AND ')}
ORDER BY COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at) DESC, t.id DESC
LIMIT ? OFFSET ?
`).all(...params, page.limit, page.offset);
res.json(rows.map(row => {
const decorated = decorateTransaction(row);
const title = row.payee || row.description || row.memo || '';
decorated.advisory_filter = advisoryCheck(title);
return decorated;
}));
2026-05-16 20:26:09 -05:00
});
// POST /api/transactions/manual
router.post('/manual', (req, res) => {
const db = getDb();
const directMatchState = rejectDirectMatchState(req.body);
if (directMatchState) return res.status(400).json(directMatchState);
2026-05-16 20:26:09 -05:00
const validation = normalizeTransactionFields(db, req.user.id, req.body);
if (validation.error) return res.status(validation.status || 400).json(validation.error);
const tx = validation.normalized;
const source = ensureManualDataSource(db, req.user.id);
const state = resolveTransactionState(tx);
if (state.error) return res.status(400).json(state.error);
Object.assign(tx, state);
const result = db.prepare(`
INSERT INTO transactions
(user_id, data_source_id, account_id, source_type, transaction_type,
posted_date, transacted_at, amount, currency, description, payee, memo,
category, matched_bill_id, match_status, ignored)
VALUES (?, ?, ?, 'manual', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
req.user.id,
source.id,
tx.account_id,
tx.transaction_type,
tx.posted_date,
tx.transacted_at,
tx.amount,
tx.currency,
tx.description,
tx.payee,
tx.memo,
tx.category,
tx.matched_bill_id,
tx.match_status,
tx.ignored,
);
res.status(201).json(selectedTransaction(db, req.user.id, result.lastInsertRowid));
});
// PUT /api/transactions/:id
router.put('/:id', (req, res) => {
const db = getDb();
const directMatchState = rejectDirectMatchState(req.body);
if (directMatchState) return res.status(400).json(directMatchState);
2026-05-16 20:26:09 -05:00
const id = parseInteger(req.params.id, 'id');
if (id.error) return res.status(400).json(id.error);
const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
const validation = normalizeTransactionFields(db, req.user.id, req.body, { partial: true });
if (validation.error) return res.status(validation.status || 400).json(validation.error);
const tx = validation.normalized;
const state = resolveTransactionState(tx, existing);
if (state.error) return res.status(400).json(state.error);
Object.assign(tx, state);
const nextPostedDate = hasOwn(tx, 'posted_date') ? tx.posted_date : existing.posted_date;
const nextTransactedAt = hasOwn(tx, 'transacted_at') ? tx.transacted_at : existing.transacted_at;
if (!nextPostedDate && !nextTransactedAt) {
return res.status(400).json(standardizeError('posted_date or transacted_at is required', 'VALIDATION_ERROR', 'posted_date'));
}
const fields = [
'account_id', 'transaction_type', 'posted_date', 'transacted_at', 'amount',
'currency', 'description', 'payee', 'memo', 'category', 'matched_bill_id',
'match_status', 'ignored',
];
const sets = [];
const params = [];
for (const field of fields) {
if (hasOwn(tx, field)) {
sets.push(`${field} = ?`);
params.push(tx[field]);
}
}
if (!sets.length) {
return res.status(400).json(standardizeError('No transaction fields provided', 'VALIDATION_ERROR'));
}
sets.push("updated_at = datetime('now')");
db.prepare(`
UPDATE transactions
SET ${sets.join(', ')}
WHERE id = ? AND user_id = ?
`).run(...params, id.value, req.user.id);
res.json(selectedTransaction(db, req.user.id, id.value));
});
// DELETE /api/transactions/:id
router.delete('/:id', (req, res) => {
const db = getDb();
const id = parseInteger(req.params.id, 'id');
if (id.error) return res.status(400).json(id.error);
const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
db.transaction(() => {
unmatchTransaction(req.user.id, id.value);
2026-05-16 20:26:09 -05:00
db.prepare('DELETE FROM transactions WHERE id = ? AND user_id = ?').run(id.value, req.user.id);
})();
res.json({ success: true, deleted: true, id: id.value });
});
// POST /api/transactions/:id/match
router.post('/:id/match', (req, res) => {
try {
const result = matchTransactionToBill(
req.user.id,
req.params.id,
req.body?.billId ?? req.body?.bill_id,
{ learnMerchant: true },
);
res.json(result);
} catch (err) {
return sendTransactionServiceError(res, err, 'Transaction match failed');
2026-05-16 20:26:09 -05:00
}
});
2026-05-16 20:26:09 -05:00
// POST /api/transactions/:id/unmatch
router.post('/:id/unmatch', (req, res) => {
try {
const result = unmatchTransaction(req.user.id, req.params.id);
res.json(result);
} catch (err) {
return sendTransactionServiceError(res, err, 'Transaction unmatch failed');
}
});
2026-05-16 20:26:09 -05:00
// POST /api/transactions/unmatch-bulk
// Body: { matches: [{ transaction_id, payment_id, payment_source }] }
// Handles both provider_sync (full undo: balance restore + delete payment) and
// transaction_match (standard service unmatch) in one call.
router.post('/unmatch-bulk', (req, res) => {
const matches = req.body?.matches;
if (!Array.isArray(matches) || matches.length === 0) {
return res.status(400).json(standardizeError('matches array required', 'VALIDATION_ERROR'));
}
if (matches.length > 50) {
return res.status(400).json(standardizeError('Cannot unmatch more than 50 at once', 'VALIDATION_ERROR'));
}
const db = getDb();
const userId = req.user.id;
const results = [];
db.transaction(() => {
for (const m of matches) {
const txId = parseInt(m.transaction_id, 10);
if (!Number.isInteger(txId) || txId < 1) {
results.push({ transaction_id: m.transaction_id, ok: false, error: 'Invalid transaction_id' });
continue;
}
try {
if (m.payment_source === 'provider_sync' && m.payment_id) {
// Full reversal: restore balance, soft-delete payment, unlink tx
const payment = db.prepare(`
SELECT p.* FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE p.id = ? AND p.deleted_at IS NULL AND b.user_id = ?
`).get(m.payment_id, userId);
if (payment) {
if (payment.balance_delta != null) {
const bill = db.prepare('SELECT current_balance FROM bills WHERE id = ?').get(payment.bill_id);
if (bill?.current_balance != null) {
const restored = Math.max(0, Math.round((Number(bill.current_balance) - Number(payment.balance_delta)) * 100) / 100);
db.prepare(`
UPDATE bills
SET current_balance = ?,
interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
updated_at = datetime('now')
WHERE id = ?
`).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
}
}
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id);
}
db.prepare(`
UPDATE transactions
SET match_status = 'unmatched', matched_bill_id = NULL, updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(txId, userId);
results.push({ transaction_id: txId, ok: true });
} else {
// Standard service unmatch (restores balance for transaction_match payments)
unmatchTransaction(userId, String(txId));
results.push({ transaction_id: txId, ok: true });
}
} catch (err) {
results.push({ transaction_id: txId, ok: false, error: err.message });
}
}
})();
const failed = results.filter(r => !r.ok);
if (failed.length > 0 && failed.length === results.length) {
return res.status(500).json({ error: 'All unmatches failed', results });
}
res.json({ results, unmatched: results.filter(r => r.ok).length });
});
// POST /api/transactions/:id/ignore
router.post('/:id/ignore', (req, res) => {
try {
const result = ignoreTransaction(req.user.id, req.params.id);
res.json(result.transaction);
} catch (err) {
return sendTransactionServiceError(res, err, 'Transaction ignore failed');
}
2026-05-16 20:26:09 -05:00
});
// POST /api/transactions/:id/unignore
router.post('/:id/unignore', (req, res) => {
try {
const result = unignoreTransaction(req.user.id, req.params.id);
res.json(result.transaction);
} catch (err) {
return sendTransactionServiceError(res, err, 'Transaction unignore failed');
2026-05-16 20:26:09 -05:00
}
});
module.exports = router;