const router = require('express').Router(); const { getDb } = require('../db/database'); const { standardizeError } = require('../middleware/errorFormatter'); const { decorateTransaction, ensureManualDataSource, getTransactionForUser, } = require('../services/transactionService'); const { ignoreTransaction, matchTransactionToBill, unignoreTransaction, unmatchTransaction, } = require('../services/transactionMatchService'); 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']; 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 = ?').get(accountId, userId); } 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')); } // 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 = ?']; 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(decorateTransaction)); }); // 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); 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); 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); 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, ); res.json(result); } catch (err) { return sendTransactionServiceError(res, err, 'Transaction match failed'); } }); // 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'); } }); // 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'); } }); // 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'); } }); module.exports = router;