1109 lines
35 KiB
TypeScript
1109 lines
35 KiB
TypeScript
// @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 { log } = require('../utils/logger.cts');
|
|
const { getDb } = require('../db/database.cts');
|
|
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
|
const { ApiError, ValidationError, NotFoundError } = require('../utils/apiError.cts');
|
|
|
|
// Boundary adapter: the internal parse/normalize helpers return
|
|
// { error: <standardized body> } result objects; at the HTTP boundary we
|
|
// rethrow them as ApiError so the terminal handler emits the same wire shape.
|
|
function throwStandardized(errBody, status = 400) {
|
|
throw new ApiError(errBody.code || 'VALIDATION_ERROR', errBody.message, status, {
|
|
field: errBody.field,
|
|
});
|
|
}
|
|
const {
|
|
decorateTransaction,
|
|
ensureManualDataSource,
|
|
getTransactionForUser,
|
|
} = require('../services/transactionService.cts');
|
|
const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService.cts');
|
|
const { getBankSyncConfig } = require('../services/bankSyncConfigService.cts');
|
|
const { markUnmatched } = require('../services/transactionMatchState.cts');
|
|
const {
|
|
ignoreTransaction,
|
|
matchTransactionToBill,
|
|
unignoreTransaction,
|
|
unmatchTransaction,
|
|
} = require('../services/transactionMatchService.cts');
|
|
const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService.cts');
|
|
const {
|
|
findMerchantMatch,
|
|
applyMerchantStoreMatches,
|
|
findOrCreateCategory,
|
|
} = require('../services/merchantStoreMatchService.cts');
|
|
const { categorizeTransaction } = require('../services/spendingService.cts');
|
|
const { todayLocal } = require('../utils/dates.mts');
|
|
const { roundMoney } = require('../utils/money.mts');
|
|
|
|
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 todayLocal();
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
// 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 toTransactionServiceError(err) {
|
|
if (err.status && err.status < 500) {
|
|
return new ApiError(err.code || 'TRANSACTION_ERROR', err.message, err.status, {
|
|
field: err.field,
|
|
});
|
|
}
|
|
return err;
|
|
}
|
|
|
|
function emptyBankLedger(enabled, hasConnections = false) {
|
|
return {
|
|
enabled: Boolean(enabled),
|
|
has_connections: Boolean(hasConnections),
|
|
sources: [],
|
|
accounts: [],
|
|
summary: {
|
|
total: 0,
|
|
inflow: 0,
|
|
outflow: 0,
|
|
net: 0,
|
|
pending: 0,
|
|
matched: 0,
|
|
unmatched: 0,
|
|
latest_date: null,
|
|
category_breakdown: [],
|
|
},
|
|
transactions: [],
|
|
total: 0,
|
|
limit: 50,
|
|
offset: 0,
|
|
};
|
|
}
|
|
|
|
function bankLedgerWhere(query, userId) {
|
|
const where = [
|
|
't.user_id = ?',
|
|
"ds.type = 'provider_sync'",
|
|
"ds.provider = 'simplefin'",
|
|
'(t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)',
|
|
];
|
|
const params = [userId];
|
|
|
|
if (query.account_id && query.account_id !== 'all') {
|
|
const parsed = parseInteger(query.account_id, 'account_id');
|
|
if (parsed.error) return { error: parsed.error };
|
|
where.push('t.account_id = ?');
|
|
params.push(parsed.value);
|
|
}
|
|
|
|
const flow = query.flow ? String(query.flow).trim() : 'all';
|
|
if (
|
|
!['all', 'in', 'out', 'pending', 'matched', 'unmatched', 'ignored', 'uncategorized'].includes(
|
|
flow,
|
|
)
|
|
) {
|
|
return {
|
|
error: standardizeError(
|
|
'flow must be all, in, out, pending, matched, unmatched, ignored, or uncategorized',
|
|
'VALIDATION_ERROR',
|
|
'flow',
|
|
),
|
|
};
|
|
}
|
|
if (flow === 'in') where.push('t.amount > 0');
|
|
if (flow === 'out') where.push('t.amount < 0');
|
|
if (flow === 'pending') where.push('t.pending = 1');
|
|
if (flow === 'matched') where.push("t.match_status = 'matched'");
|
|
if (flow === 'unmatched') where.push("t.match_status = 'unmatched' AND t.ignored = 0");
|
|
if (flow === 'ignored') where.push('t.ignored = 1');
|
|
if (flow === 'uncategorized')
|
|
where.push('t.spending_category_id IS NULL AND t.amount < 0 AND t.ignored = 0');
|
|
|
|
if (query.start_date) {
|
|
const parsed = parseDate(query.start_date, 'start_date');
|
|
if (parsed.error) return { error: parsed.error };
|
|
where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?');
|
|
params.push(parsed.value);
|
|
}
|
|
if (query.end_date) {
|
|
const parsed = parseDate(query.end_date, 'end_date');
|
|
if (parsed.error) return { error: parsed.error };
|
|
where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?');
|
|
params.push(parsed.value);
|
|
}
|
|
|
|
if (query.q) {
|
|
const q = `%${String(query.q).trim()}%`;
|
|
where.push(`(
|
|
t.description LIKE ? OR t.payee LIKE ? OR t.memo LIKE ? OR t.category LIKE ?
|
|
OR fa.name LIKE ? OR fa.org_name LIKE ? OR b.name LIKE ? OR c.name LIKE ?
|
|
)`);
|
|
params.push(q, q, q, q, q, q, q, q);
|
|
}
|
|
|
|
return { whereClause: where.join(' AND '), params };
|
|
}
|
|
|
|
// GET /api/transactions
|
|
router.get('/', (req: Req, res: Res) => {
|
|
const db = getDb();
|
|
ensureManualDataSource(db, req.user.id);
|
|
|
|
const page = parseLimitOffset(req.query);
|
|
if (page.error) throwStandardized(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) throwStandardized(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) throwStandardized(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) throwStandardized(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,
|
|
});
|
|
});
|
|
|
|
// GET /api/transactions/bank-ledger
|
|
router.get('/bank-ledger', (req: Req, res: Res) => {
|
|
const config = getBankSyncConfig();
|
|
const page = parseLimitOffset(req.query);
|
|
if (page.error) throwStandardized(page.error);
|
|
|
|
const db = getDb();
|
|
const sources = db
|
|
.prepare(
|
|
`
|
|
SELECT id, user_id, type, provider, name, status, last_sync_at, created_at, updated_at
|
|
FROM data_sources
|
|
WHERE user_id = ? AND type = 'provider_sync' AND provider = 'simplefin'
|
|
ORDER BY updated_at DESC, id DESC
|
|
`,
|
|
)
|
|
.all(req.user.id);
|
|
|
|
if (!config.enabled || sources.length === 0) {
|
|
return res.json({
|
|
...emptyBankLedger(config.enabled, sources.length > 0),
|
|
sources,
|
|
limit: page.limit,
|
|
offset: page.offset,
|
|
});
|
|
}
|
|
|
|
const accounts = db
|
|
.prepare(
|
|
`
|
|
SELECT
|
|
fa.id, fa.data_source_id, fa.name, fa.org_name, fa.account_type, fa.currency,
|
|
fa.balance, fa.available_balance, fa.monitored, fa.updated_at,
|
|
ds.name AS source_name, ds.status AS source_status, ds.last_sync_at,
|
|
COUNT(t.id) AS transaction_count,
|
|
MAX(COALESCE(t.posted_date, substr(t.transacted_at, 1, 10))) AS last_transaction_date
|
|
FROM financial_accounts fa
|
|
JOIN data_sources ds ON ds.id = fa.data_source_id AND ds.user_id = fa.user_id
|
|
LEFT JOIN transactions t ON t.account_id = fa.id AND t.user_id = fa.user_id
|
|
WHERE fa.user_id = ?
|
|
AND fa.monitored = 1
|
|
AND ds.type = 'provider_sync'
|
|
AND ds.provider = 'simplefin'
|
|
GROUP BY fa.id
|
|
ORDER BY COALESCE(fa.org_name, ''), fa.name
|
|
`,
|
|
)
|
|
.all(req.user.id);
|
|
|
|
const filtered = bankLedgerWhere(req.query, req.user.id);
|
|
if (filtered.error) throwStandardized(filtered.error);
|
|
|
|
const SORT_COLUMNS = {
|
|
date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)',
|
|
amount: 't.amount',
|
|
merchant: "LOWER(COALESCE(t.payee, t.description, t.memo, ''))",
|
|
account: "LOWER(COALESCE(fa.org_name, fa.name, ''))",
|
|
status: 't.match_status',
|
|
};
|
|
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 joins = `
|
|
FROM transactions t
|
|
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
|
|
LEFT JOIN categories c ON c.id = t.spending_category_id
|
|
`;
|
|
|
|
const summary = db
|
|
.prepare(
|
|
`
|
|
SELECT
|
|
COUNT(*) AS total,
|
|
COALESCE(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END), 0) AS inflow,
|
|
COALESCE(SUM(CASE WHEN t.amount < 0 THEN ABS(t.amount) ELSE 0 END), 0) AS outflow,
|
|
COALESCE(SUM(t.amount), 0) AS net,
|
|
COALESCE(SUM(CASE WHEN t.pending = 1 THEN 1 ELSE 0 END), 0) AS pending,
|
|
COALESCE(SUM(CASE WHEN t.match_status = 'matched' THEN 1 ELSE 0 END), 0) AS matched,
|
|
COALESCE(SUM(CASE WHEN t.match_status = 'unmatched' AND t.ignored = 0 THEN 1 ELSE 0 END), 0) AS unmatched,
|
|
MAX(COALESCE(t.posted_date, substr(t.transacted_at, 1, 10))) AS latest_date
|
|
${joins}
|
|
WHERE ${filtered.whereClause}
|
|
`,
|
|
)
|
|
.get(...filtered.params);
|
|
|
|
summary.category_breakdown = db
|
|
.prepare(
|
|
`
|
|
SELECT
|
|
COALESCE(c.name, 'Uncategorized') AS name,
|
|
COUNT(*) AS count,
|
|
COALESCE(SUM(CASE WHEN t.amount < 0 THEN ABS(t.amount) ELSE 0 END), 0) AS total
|
|
${joins}
|
|
WHERE ${filtered.whereClause}
|
|
GROUP BY COALESCE(c.name, 'Uncategorized')
|
|
ORDER BY total DESC
|
|
LIMIT 6
|
|
`,
|
|
)
|
|
.all(...filtered.params);
|
|
|
|
const total = summary.total;
|
|
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,
|
|
t.spending_category_id, c.name AS spending_category_name,
|
|
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, fa.balance AS account_balance,
|
|
fa.available_balance AS account_available_balance,
|
|
b.name AS matched_bill_name
|
|
${joins}
|
|
WHERE ${filtered.whereClause}
|
|
ORDER BY ${orderBy}
|
|
LIMIT ? OFFSET ?
|
|
`,
|
|
)
|
|
.all(...filtered.params, page.limit, page.offset);
|
|
|
|
res.json({
|
|
enabled: true,
|
|
has_connections: true,
|
|
sources,
|
|
accounts,
|
|
summary,
|
|
transactions: rows.map((row) => {
|
|
const decorated = decorateTransaction(row);
|
|
if (!row.spending_category_id) {
|
|
decorated.suggested_match = findMerchantMatch(
|
|
db,
|
|
row.payee || row.description || row.memo || '',
|
|
);
|
|
}
|
|
return decorated;
|
|
}),
|
|
total,
|
|
limit: page.limit,
|
|
offset: page.offset,
|
|
});
|
|
});
|
|
|
|
// POST /api/transactions/manual
|
|
router.post('/manual', (req: Req, res: Res) => {
|
|
const db = getDb();
|
|
const directMatchState = rejectDirectMatchState(req.body);
|
|
if (directMatchState) throwStandardized(directMatchState);
|
|
|
|
const validation = normalizeTransactionFields(db, req.user.id, req.body);
|
|
if (validation.error) throwStandardized(validation.error, validation.status || 400);
|
|
const tx = validation.normalized;
|
|
const source = ensureManualDataSource(db, req.user.id);
|
|
const state = resolveTransactionState(tx);
|
|
if (state.error) throwStandardized(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: Req, res: Res) => {
|
|
const db = getDb();
|
|
const directMatchState = rejectDirectMatchState(req.body);
|
|
if (directMatchState) throwStandardized(directMatchState);
|
|
|
|
const id = parseInteger(req.params.id, 'id');
|
|
if (id.error) throwStandardized(id.error);
|
|
|
|
const existing = getTransactionForUser(db, req.user.id, id.value);
|
|
if (!existing) throw NotFoundError('Transaction not found', 'id');
|
|
|
|
const validation = normalizeTransactionFields(db, req.user.id, req.body, { partial: true });
|
|
if (validation.error) throwStandardized(validation.error, validation.status || 400);
|
|
const tx = validation.normalized;
|
|
const state = resolveTransactionState(tx, existing);
|
|
if (state.error) throwStandardized(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: Req, res: Res) => {
|
|
const db = getDb();
|
|
const id = parseInteger(req.params.id, 'id');
|
|
if (id.error) throwStandardized(id.error);
|
|
|
|
const existing = getTransactionForUser(db, req.user.id, id.value);
|
|
if (!existing) throw NotFoundError('Transaction 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: Req, res: 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) {
|
|
throw toTransactionServiceError(err);
|
|
}
|
|
});
|
|
|
|
// POST /api/transactions/:id/unmatch
|
|
router.post('/:id/unmatch', (req: Req, res: Res) => {
|
|
try {
|
|
const result = unmatchTransaction(req.user.id, req.params.id);
|
|
res.json(result);
|
|
} catch (err) {
|
|
throw toTransactionServiceError(err);
|
|
}
|
|
});
|
|
|
|
// 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: Req, res: Res) => {
|
|
const matches = req.body?.matches;
|
|
if (!Array.isArray(matches) || matches.length === 0) {
|
|
throw ValidationError('matches array required');
|
|
}
|
|
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,
|
|
roundMoney(Number(bill.current_balance) - Number(payment.balance_delta)),
|
|
);
|
|
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,
|
|
);
|
|
}
|
|
markUnmatched(db, userId, txId);
|
|
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) {
|
|
// Per-item feedback: service 4xx messages are user-facing; anything
|
|
// else is masked — 500-class internals must not leak into the results.
|
|
const msg = err.status && err.status < 500 ? err.message : 'Unmatch failed';
|
|
results.push({ transaction_id: txId, ok: false, error: msg });
|
|
}
|
|
}
|
|
})();
|
|
|
|
const failed = results.filter((r) => !r.ok);
|
|
if (failed.length > 0 && failed.length === results.length) {
|
|
// Deliberate hand-rolled body: carries the per-item `results` payload the
|
|
// client renders; formatError has no extras channel (same as payments'
|
|
// DUPLICATE_SUSPECTED exception).
|
|
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: Req, res: Res) => {
|
|
try {
|
|
const result = ignoreTransaction(req.user.id, req.params.id);
|
|
res.json(result.transaction);
|
|
} catch (err) {
|
|
throw toTransactionServiceError(err);
|
|
}
|
|
});
|
|
|
|
// POST /api/transactions/:id/unignore
|
|
router.post('/:id/unignore', (req: Req, res: Res) => {
|
|
try {
|
|
const result = unignoreTransaction(req.user.id, req.params.id);
|
|
res.json(result.transaction);
|
|
} catch (err) {
|
|
throw toTransactionServiceError(err);
|
|
}
|
|
});
|
|
|
|
// GET /api/transactions/:id/merchant-match
|
|
router.get('/:id/merchant-match', (req: Req, res: Res) => {
|
|
try {
|
|
const db = getDb();
|
|
const id = parseInteger(req.params.id, 'id');
|
|
if (id.error) throwStandardized(id.error);
|
|
|
|
const existing = getTransactionForUser(db, req.user.id, id.value);
|
|
if (!existing) throw NotFoundError('Transaction not found', 'id');
|
|
|
|
const match = findMerchantMatch(
|
|
db,
|
|
existing.payee || existing.description || existing.memo || '',
|
|
);
|
|
res.json({ match });
|
|
} catch (err) {
|
|
throw toTransactionServiceError(err);
|
|
}
|
|
});
|
|
|
|
// POST /api/transactions/:id/apply-merchant-match
|
|
router.post('/:id/apply-merchant-match', (req: Req, res: Res) => {
|
|
try {
|
|
const db = getDb();
|
|
const id = parseInteger(req.params.id, 'id');
|
|
if (id.error) throwStandardized(id.error);
|
|
|
|
const existing = getTransactionForUser(db, req.user.id, id.value);
|
|
if (!existing) throw NotFoundError('Transaction not found', 'id');
|
|
|
|
const match = findMerchantMatch(
|
|
db,
|
|
existing.payee || existing.description || existing.memo || '',
|
|
);
|
|
if (!match) return res.json({ matched: false });
|
|
|
|
const categoryId = findOrCreateCategory(db, req.user.id, match.category);
|
|
categorizeTransaction(db, req.user.id, id.value, categoryId, true);
|
|
|
|
res.json({
|
|
matched: true,
|
|
category: { id: categoryId, name: match.category },
|
|
display_name: match.display_name,
|
|
});
|
|
} catch (err) {
|
|
throw toTransactionServiceError(err);
|
|
}
|
|
});
|
|
|
|
// POST /api/transactions/auto-categorize
|
|
router.post('/auto-categorize', (req: Req, res: Res) => {
|
|
try {
|
|
const db = getDb();
|
|
const result = applyMerchantStoreMatches(db, req.user.id, {
|
|
dryRun: Boolean(req.body?.dry_run),
|
|
});
|
|
res.json(result);
|
|
} catch (err) {
|
|
throw toTransactionServiceError(err);
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|