633 lines
23 KiB
JavaScript
633 lines
23 KiB
JavaScript
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');
|
|
const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService');
|
|
|
|
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 = ? AND monitored = 1').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 SORT_COLUMNS = {
|
|
date: "COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)",
|
|
amount: 't.amount',
|
|
};
|
|
const sortBy = SORT_COLUMNS[req.query.sort_by] ? req.query.sort_by : 'date';
|
|
const sortDir = req.query.sort_dir === 'asc' ? 'ASC' : 'DESC';
|
|
const orderBy = `${SORT_COLUMNS[sortBy]} ${sortDir}, t.id ${sortDir}`;
|
|
|
|
const where = [
|
|
't.user_id = ?',
|
|
'(t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)',
|
|
];
|
|
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 whereClause = where.join(' AND ');
|
|
const joins = `
|
|
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
|
|
`;
|
|
|
|
const total = db.prepare(`SELECT COUNT(*) AS n ${joins} WHERE ${whereClause}`).get(...params).n;
|
|
|
|
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.pending, 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
|
|
${joins}
|
|
WHERE ${whereClause}
|
|
ORDER BY ${orderBy}
|
|
LIMIT ? OFFSET ?
|
|
`).all(...params, page.limit, page.offset);
|
|
|
|
res.json({
|
|
transactions: rows.map(row => {
|
|
const decorated = decorateTransaction(row);
|
|
const title = row.payee || row.description || row.memo || '';
|
|
decorated.advisory_filter = advisoryCheck(title);
|
|
return decorated;
|
|
}),
|
|
total,
|
|
limit: page.limit,
|
|
offset: page.offset,
|
|
});
|
|
});
|
|
|
|
// 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,
|
|
{ learnMerchant: true },
|
|
);
|
|
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/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.accounting_excluded && 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);
|
|
}
|
|
}
|
|
reactivatePaymentsOverriddenBy(db, payment.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');
|
|
}
|
|
});
|
|
|
|
// 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;
|