Files
michaelswanson 860d5f55cc Initial commit
2026-06-25 19:58:40 +00:00

1873 lines
66 KiB
JavaScript
Executable File

import express from 'express';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import crypto from 'crypto';
import { getDb, nextId } from './db.js';
import {
createSession,
deleteSession,
fetchAspsps,
getAccountBalances,
getAccountDetails,
getAllTransactions,
getSession,
publicEnableBankingConfig,
startAuthorization,
} from './enableBanking.js';
import {
getAuthSession,
getPublicAuthSettings,
handleOidcCallback,
handleOidcLogin,
handleOidcLogout,
renderLoginPage,
requireApiAuth,
updateAuthSettings,
} from './oidc.js';
import {
chatWithOllama,
ensureAiConfig,
listOllamaModels,
publicAiConfig,
suggestTransactionCategory,
} from './ollama.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 7843;
let enableBankingSyncInProgress = false;
let fxCache = { fetchedAt: 0, data: null };
app.set('trust proxy', 1);
app.use(cors(
process.env.APP_BASE_URL
? { origin: process.env.APP_BASE_URL, credentials: true }
: {} // fallback: ingen APP_BASE_URL satt (t.ex. lokal utveckling)
));
app.use(express.json({ limit: '5mb' })); // tillåt bilduppladdning men sätt ett tak
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 50, standardHeaders: true, legacyHeaders: false });
const aiLimiter = rateLimit({ windowMs: 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false });
app.use(['/auth', '/login', '/api/auth'], authLimiter);
app.use('/api/ai', aiLimiter);
if (process.env.NODE_ENV === 'production') {
app.use(express.static(join(__dirname, 'public')));
}
app.get('/login', (req, res) => {
renderLoginPage(req, res).catch(error => {
res.status(500).send(`Kunde inte visa inloggningssidan: ${error.message}`);
});
});
app.get('/auth/callback', (req, res) => {
handleOidcCallback(req, res).catch(error => {
res.status(500).send(`Pocket ID-inloggningen misslyckades: ${error.message}`);
});
});
app.get('/auth/login', (req, res) => {
handleOidcLogin(req, res).catch(error => {
res.status(500).send(`Kunde inte starta Pocket ID-inloggningen: ${error.message}`);
});
});
app.get('/enablebanking/auth_callback', (req, res) => {
getDb()
.then(async db => {
const config = ensureEnableBankingConfig(db);
const code = req.query.code ? String(req.query.code) : '';
const state = req.query.state ? String(req.query.state) : '';
// Skriv bara metadata om callbacken hör till en auktorisering vi själva startat.
if (!state || config.pending_state !== state) {
return res.redirect('/installningar');
}
const params = new URLSearchParams();
if (code) params.set('code', code);
if (state) params.set('state', state);
config.last_callback_at = new Date().toISOString();
config.last_callback_url = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
config.last_callback_code_present = Boolean(code);
config.last_callback_state = state;
config.last_callback_exchange_error = !code
? 'Callback nådde appen utan code.'
: '';
await save(db);
const suffix = params.toString() ? `?${params.toString()}` : '';
res.redirect(`/installningar${suffix}`);
})
.catch(error => {
res.status(500).send(error.message);
});
});
app.get('/api/auth/session', (req, res) => {
getAuthSession(req)
.then(session => res.json(session))
.catch(error => res.status(500).json({ error: error.message }));
});
app.get('/api/auth/settings', (req, res) => {
getPublicAuthSettings()
.then(async settings => {
const session = await getAuthSession(req);
if (session.enabled && !session.authenticated) {
res.status(401).json({ error: 'Autentisering krävs' });
return;
}
res.json(settings);
})
.catch(error => res.status(500).json({ error: error.message }));
});
app.put('/api/auth/settings', (req, res) => {
getPublicAuthSettings()
.then(async settings => {
const session = await getAuthSession(req);
const setupOk = process.env.SETUP_TOKEN
&& req.get('x-setup-token') === process.env.SETUP_TOKEN;
// När auth är på krävs inloggad session. När den är av krävs setup-token,
// så att en öppen instans inte kan kapas av första bästa besökare.
const authorized = settings.enabled ? session.authenticated : setupOk;
if (!authorized) {
res.status(401).json({ error: 'Autentisering krävs' });
return null;
}
return updateAuthSettings(req.body || {});
})
.then(settings => {
if (settings) res.json(settings);
})
.catch(error => res.status(500).json({ error: error.message }));
});
app.get('/api/auth/login', (req, res) => {
handleOidcLogin(req, res).catch(error => {
res.status(500).send(`Kunde inte starta Pocket ID-inloggningen: ${error.message}`);
});
});
app.post('/api/auth/logout', (req, res) => {
handleOidcLogout(req, res).catch(error => {
res.status(500).json({ error: error.message });
});
});
app.use('/api', requireApiAuth);
function withCat(db, bill) {
const cat = db.data.categories.find(c => c.id === bill.category_id);
return { ...bill, category_name: cat?.name, category_color: cat?.color, category_sort: cat?.sort_order };
}
function normalizeSubscription(input) {
return {
name: input.name?.trim(),
amount: parseFloat(input.amount),
category_id: parseInt(input.category_id),
billing_day: input.billing_day ? parseInt(input.billing_day) : null,
provider: input.provider?.trim() || null,
notes: input.notes?.trim() || null,
original_amount: input.original_amount != null ? parseFloat(input.original_amount) : null,
original_currency: input.original_currency?.trim() || 'SEK',
exchange_rate: input.exchange_rate != null ? parseFloat(input.exchange_rate) : null,
exchange_rate_date: input.exchange_rate_date || null,
is_active: input.is_active === undefined ? 1 : (input.is_active ? 1 : 0),
};
}
function formatCurrency(amount, currency = 'SEK') {
if (amount == null || Number.isNaN(Number(amount))) return 'okänt';
try {
return new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: currency || 'SEK',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(Number(amount));
} catch {
return `${Number(amount).toFixed(2)} ${currency || 'SEK'}`;
}
}
function normalizeCategory(input, fallbackSortOrder = 99) {
return {
name: input.name?.trim(),
color: input.color?.trim() || '#64748B',
sort_order: Number.isFinite(parseInt(input.sort_order)) ? parseInt(input.sort_order) : fallbackSortOrder,
};
}
function ensureEnableBankingConfig(db) {
db.data.banking ||= {};
db.data.banking.enable_banking ||= {};
db.data.banking.enable_banking.account_aliases ||= {};
db.data.banking.enable_banking.transaction_categories ||= {};
db.data.banking.enable_banking.accounts ||= [];
db.data.banking.enable_banking.transactions ||= [];
return db.data.banking.enable_banking;
}
function normalizeAiSettings(input = {}) {
return {
enabled: !!input.enabled,
base_url: String(input.base_url || '').trim() || 'http://host.docker.internal:11434',
model: String(input.model || '').trim(),
vision_model: String(input.vision_model || '').trim(),
system_prompt: String(input.system_prompt || '').trim(),
include_budget_context: input.include_budget_context !== false,
include_banking_context: input.include_banking_context !== false,
};
}
function ensureAiConversationStore(db) {
db.data.ai ||= {};
db.data.ai.conversations ||= [];
return db.data.ai.conversations;
}
function ensureAppSettings(db) {
db.data.app_settings ||= {};
db.data.app_settings.finance_profile ||= {};
db.data.app_settings.account_view ||= {};
db.data.app_settings.notifications ||= {};
const financeProfile = db.data.app_settings.finance_profile;
financeProfile.salary_day_of_month ??= 25;
financeProfile.buffer_days_target ??= 7;
financeProfile.salary_account_uid ??= '';
financeProfile.recurring_income_note ??= '';
const accountView = db.data.app_settings.account_view;
accountView.primary_account_uid ??= '';
accountView.include_savings_in_ai ??= false;
accountView.hide_zero_balance_accounts ??= false;
const notifications = db.data.app_settings.notifications;
notifications.ntfy_enabled ??= false;
notifications.ntfy_base_url ??= 'https://ntfy.sh';
notifications.ntfy_topic ??= '';
notifications.ntfy_access_token ??= '';
notifications.ntfy_title ??= 'Enkelbudget';
notifications.ntfy_tags ??= 'money_with_wings,bank';
notifications.ntfy_click_url ??= '';
notifications.ntfy_priority ??= 3;
notifications.notify_new_transactions ??= true;
notifications.include_pending_transactions ??= false;
notifications.minimum_transaction_amount ??= 0;
notifications.last_error ??= '';
notifications.last_sent_at ??= null;
return db.data.app_settings;
}
function publicAppSettings(settings) {
return {
finance_profile: {
salary_day_of_month: settings.finance_profile.salary_day_of_month,
buffer_days_target: settings.finance_profile.buffer_days_target,
salary_account_uid: settings.finance_profile.salary_account_uid,
recurring_income_note: settings.finance_profile.recurring_income_note,
},
account_view: {
primary_account_uid: settings.account_view.primary_account_uid,
include_savings_in_ai: settings.account_view.include_savings_in_ai,
hide_zero_balance_accounts: settings.account_view.hide_zero_balance_accounts,
},
notifications: {
ntfy_enabled: settings.notifications.ntfy_enabled,
ntfy_base_url: settings.notifications.ntfy_base_url,
ntfy_topic: settings.notifications.ntfy_topic,
ntfy_title: settings.notifications.ntfy_title,
ntfy_tags: settings.notifications.ntfy_tags,
ntfy_click_url: settings.notifications.ntfy_click_url,
ntfy_priority: settings.notifications.ntfy_priority,
notify_new_transactions: settings.notifications.notify_new_transactions,
include_pending_transactions: settings.notifications.include_pending_transactions,
minimum_transaction_amount: settings.notifications.minimum_transaction_amount,
has_ntfy_access_token: Boolean(String(settings.notifications.ntfy_access_token || '').trim()),
last_error: settings.notifications.last_error || '',
last_sent_at: settings.notifications.last_sent_at || null,
},
};
}
function normalizeAppSettings(input = {}, existing = null) {
const current = existing || {
finance_profile: {},
account_view: {},
notifications: {},
};
const notificationsInput = input.notifications || {};
const incomingAccessToken = typeof notificationsInput.ntfy_access_token === 'string'
? notificationsInput.ntfy_access_token.trim()
: null;
return {
finance_profile: {
salary_day_of_month: Math.min(31, Math.max(1, parseInt(input.finance_profile?.salary_day_of_month) || 25)),
buffer_days_target: Math.min(60, Math.max(0, parseInt(input.finance_profile?.buffer_days_target) || 7)),
salary_account_uid: String(input.finance_profile?.salary_account_uid || '').trim(),
recurring_income_note: String(input.finance_profile?.recurring_income_note || '').trim(),
},
account_view: {
primary_account_uid: String(input.account_view?.primary_account_uid || '').trim(),
include_savings_in_ai: input.account_view?.include_savings_in_ai === true,
hide_zero_balance_accounts: input.account_view?.hide_zero_balance_accounts === true,
},
notifications: {
ntfy_enabled: notificationsInput.ntfy_enabled === true,
ntfy_base_url: String(notificationsInput.ntfy_base_url || '').trim() || 'https://ntfy.sh',
ntfy_topic: String(notificationsInput.ntfy_topic || '').trim(),
ntfy_title: String(notificationsInput.ntfy_title || '').trim() || 'Enkelbudget',
ntfy_tags: String(notificationsInput.ntfy_tags || '').trim(),
ntfy_click_url: String(notificationsInput.ntfy_click_url || '').trim(),
ntfy_priority: Math.min(5, Math.max(1, parseInt(notificationsInput.ntfy_priority, 10) || 3)),
ntfy_access_token: incomingAccessToken
? incomingAccessToken
: (notificationsInput.clear_ntfy_access_token
? ''
: String(current.notifications?.ntfy_access_token || '')),
notify_new_transactions: notificationsInput.notify_new_transactions !== false,
include_pending_transactions: notificationsInput.include_pending_transactions === true,
minimum_transaction_amount: Math.max(0, Number(notificationsInput.minimum_transaction_amount) || 0),
last_error: String(current.notifications?.last_error || ''),
last_sent_at: current.notifications?.last_sent_at || null,
},
};
}
function formatNotificationTransaction(transaction, config) {
const accountName = (config.accounts || []).find(account => account.uid === transaction.account_uid)?.alias
|| (config.accounts || []).find(account => account.uid === transaction.account_uid)?.name
|| 'Konto';
const amount = formatCurrency(Math.abs(Number(transaction.amount) || 0), transaction.currency || 'SEK');
const direction = String(transaction.credit_debit_indicator || '').toUpperCase() === 'CRDT' || Number(transaction.amount) > 0
? 'In'
: 'Ut';
const label = [
transaction.creditor_name,
transaction.debtor_name,
transaction.note,
...(Array.isArray(transaction.remittance_information) ? transaction.remittance_information : []),
].filter(Boolean).join(' ').trim() || 'Transaktion';
return `${direction}: ${label}\n${amount} · ${accountName}\n${transaction.booking_date || transaction.transaction_date || 'okänt datum'}`;
}
async function sendNtfyMessage(appSettings, messageText, options = {}) {
const notifications = appSettings.notifications;
const baseUrl = String(notifications.ntfy_base_url || '').trim().replace(/\/+$/, '');
const topic = String(notifications.ntfy_topic || '').trim();
const accessToken = String(notifications.ntfy_access_token || '').trim();
const defaultTitle = String(notifications.ntfy_title || '').trim() || 'Enkelbudget';
const defaultTags = String(notifications.ntfy_tags || '').trim();
const defaultClickUrl = String(notifications.ntfy_click_url || '').trim();
const defaultPriority = Math.min(5, Math.max(1, parseInt(notifications.ntfy_priority, 10) || 3));
if (!notifications.ntfy_enabled || !baseUrl || !topic) {
throw new Error('ntfy är inte komplett konfigurerat.');
}
const url = `${baseUrl}/${encodeURIComponent(topic)}`;
const headers = {
'Content-Type': 'text/plain; charset=utf-8',
};
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
const title = String(options.title || '').trim() || defaultTitle;
if (title) {
headers.Title = title;
}
const priority = Math.min(5, Math.max(1, parseInt(options.priority, 10) || defaultPriority));
if (priority) {
headers.Priority = String(priority);
}
const tags = String(options.tags || '').trim() || defaultTags;
if (tags) {
headers.Tags = tags;
}
const clickUrl = String(options.clickUrl || '').trim() || defaultClickUrl;
if (clickUrl) {
headers.Click = clickUrl;
}
const response = await fetch(url, {
method: 'POST',
headers,
body: messageText,
});
if (!response.ok) {
const details = await response.text();
throw new Error(`ntfy svarade med ${response.status}${details ? `: ${details}` : ''}`);
}
const responseText = await response.text();
if (!responseText) return { ok: true };
try {
return JSON.parse(responseText);
} catch {
return { ok: true, raw: responseText };
}
}
function summarizeConversationTitle(message) {
const compact = String(message || '').replace(/\s+/g, ' ').trim();
if (!compact) return 'Ny AI-chatt';
return compact.length > 64 ? `${compact.slice(0, 61)}...` : compact;
}
function sanitizeHistoryAttachment(image = {}) {
return {
name: String(image.name || 'bild'),
mime_type: String(image.mime_type || 'image/png'),
};
}
function publicAiConversation(conversation) {
return {
id: conversation.id,
title: conversation.title,
created_at: conversation.created_at,
updated_at: conversation.updated_at,
preview: conversation.preview || '',
message_count: Array.isArray(conversation.messages) ? conversation.messages.length : 0,
messages: (conversation.messages || []).map(message => ({
id: message.id,
role: message.role,
content: message.content,
attachments: message.attachments || [],
model: message.model || null,
created_at: message.created_at,
context_preview: message.context_preview || '',
context_meta: message.context_meta || null,
})),
};
}
function dateDaysAgo(days) {
const value = new Date();
value.setDate(value.getDate() - days);
return value.toISOString().slice(0, 10);
}
function normalizeAccountIds(session) {
const rawAccounts = session?.accounts_data?.length
? session.accounts_data
: Array.isArray(session?.accounts)
? session.accounts.map(account => typeof account === 'string' ? { uid: account } : account)
: [];
return rawAccounts.map(account => ({
uid: account.uid || account.account_id || account.id,
identification_hash: account.identification_hash || null,
})).filter(account => account.uid);
}
function buildTransactionSignature(transaction) {
const description = [
transaction.creditor_name,
transaction.debtor_name,
transaction.note,
...(Array.isArray(transaction.remittance_information) ? transaction.remittance_information : []),
].filter(Boolean).join('|');
return [
transaction.account_uid || '',
String(transaction.status || '').toLowerCase(),
transaction.booking_date || '',
transaction.transaction_date || '',
transaction.value_date || '',
transaction.amount ?? '',
transaction.currency || '',
transaction.credit_debit_indicator || '',
transaction.entry_reference || '',
transaction.reference_number || '',
description,
].join('::');
}
function buildNotificationDedupKey(transaction) {
const description = [
transaction.creditor_name,
transaction.debtor_name,
transaction.note,
...(Array.isArray(transaction.remittance_information) ? transaction.remittance_information : []),
].filter(Boolean).join('|');
return [
transaction.account_uid || '',
transaction.booking_date || transaction.transaction_date || transaction.value_date || '',
transaction.transaction_date || transaction.booking_date || transaction.value_date || '',
transaction.amount ?? '',
transaction.currency || '',
transaction.credit_debit_indicator || '',
transaction.entry_reference || '',
transaction.reference_number || '',
description,
].join('::');
}
function getTransactionDescription(transaction) {
return [
transaction.creditor_name,
transaction.debtor_name,
transaction.note,
...(Array.isArray(transaction.remittance_information) ? transaction.remittance_information : []),
].filter(Boolean).join(' ').trim() || 'Transaktion';
}
function getTransactionMerchantKey(transaction) {
return getTransactionDescription(transaction)
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[*_,.:;()/-]+/g, ' ')
.replace(/\b\d+\b/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
const GENERATED_CATEGORY_COLORS = [
'#2563EB',
'#059669',
'#D97706',
'#7C3AED',
'#DC2626',
'#0F766E',
'#EA580C',
'#4F46E5',
];
function findCategoryByName(categories, name) {
const normalizedName = String(name || '').trim().toLowerCase();
if (!normalizedName) return null;
return categories.find(category => String(category.name || '').trim().toLowerCase() === normalizedName) || null;
}
function buildMerchantCategoryContext(config, categories, transaction) {
const merchantKey = getTransactionMerchantKey(transaction);
const categoryAssignments = config.transaction_categories || {};
const categoriesById = new Map(categories.map(category => [category.id, category]));
const relatedTransactions = (config.transactions || []).filter(item => getTransactionMerchantKey(item) === merchantKey);
const categoryStats = new Map();
for (const item of relatedTransactions) {
const assignment = categoryAssignments[item.uid];
const category = assignment?.category_id ? categoriesById.get(assignment.category_id) : null;
if (!category) continue;
const existing = categoryStats.get(category.id) || {
category_id: category.id,
category_name: category.name,
count: 0,
examples: [],
};
existing.count += 1;
if (existing.examples.length < 4) {
existing.examples.push({
amount: item.amount,
currency: item.currency || 'SEK',
date: item.booking_date || item.transaction_date || null,
text: getTransactionDescription(item),
});
}
categoryStats.set(category.id, existing);
}
const categorizedTransactions = [...categoryStats.values()]
.sort((a, b) => b.count - a.count || a.category_name.localeCompare(b.category_name));
const recentExamples = relatedTransactions
.slice()
.sort((a, b) => (b.booking_date || b.transaction_date || '').localeCompare(a.booking_date || a.transaction_date || ''))
.slice(0, 6)
.map(item => ({
amount: item.amount,
currency: item.currency || 'SEK',
date: item.booking_date || item.transaction_date || null,
text: getTransactionDescription(item),
current_category: categoriesById.get(categoryAssignments[item.uid]?.category_id)?.name || null,
}));
return {
merchant_key: merchantKey || null,
merchant_label: getTransactionDescription(transaction),
related_transaction_count: relatedTransactions.length,
categorized_history: categorizedTransactions,
recent_examples: recentExamples,
guidance: categorizedTransactions.length > 0
? 'Prioritera användarens tidigare kategoriseringar för samma handlare framför generella antaganden om varumärket.'
: 'Ingen tidigare kategorisering finns för samma handlare. Resonera då utifrån text, belopp och användarens svenska privatekonomi.',
};
}
function ensureSuggestedCategory(db, suggestion) {
if (suggestion?.category_id) {
return db.data.categories.find(category => category.id === suggestion.category_id) || null;
}
const suggestedName = String(suggestion?.new_category_name || '').trim();
if (!suggestedName) return null;
const existingCategory = findCategoryByName(db.data.categories, suggestedName);
if (existingCategory) {
return existingCategory;
}
const nextSortOrder = db.data.categories.reduce((max, category) => Math.max(max, category.sort_order ?? 0), 0) + 1;
const color = GENERATED_CATEGORY_COLORS[(db.data.categories.length || 0) % GENERATED_CATEGORY_COLORS.length];
const createdCategory = {
id: nextId(db, 'categories'),
...normalizeCategory({ name: suggestedName, color, sort_order: nextSortOrder }, nextSortOrder),
};
db.data.categories.push(createdCategory);
return createdCategory;
}
async function getFxRates() {
const oneHour = 60 * 60 * 1000;
if (fxCache.data && Date.now() - fxCache.fetchedAt < oneHour) return fxCache.data;
const response = await fetch('https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml');
if (!response.ok) throw new Error(`Kunde inte hämta växelkurser (${response.status})`);
const xml = await response.text();
const dateMatch = xml.match(/time=['"](\d{4}-\d{2}-\d{2})['"]/);
const rateMatches = [...xml.matchAll(/currency=['"]([A-Z]{3})['"]\s+rate=['"]([0-9.]+)['"]/g)];
const rates = { EUR: 1, SEK: null };
for (const [, currency, rate] of rateMatches) {
rates[currency] = parseFloat(rate);
}
if (!rates.SEK) throw new Error('ECB-kurs för SEK saknas');
fxCache = {
fetchedAt: Date.now(),
data: {
base: 'EUR',
date: dateMatch?.[1] || new Date().toISOString().slice(0, 10),
rates,
},
};
return fxCache.data;
}
async function syncEnableBanking(db) {
const config = ensureEnableBankingConfig(db);
const appSettings = ensureAppSettings(db);
const hadPreviousSync = Boolean(config.last_sync_at);
if (!config.enabled) throw new Error('Enable Banking är inte aktiverat');
if (!config.application_id?.trim()) throw new Error('Applikations-ID saknas');
if (!config.private_key_pem?.trim()) throw new Error('Private key saknas');
if (!config.accounts?.length) throw new Error('Ingen aktiv banksession finns ännu');
const dateFrom = config.last_sync_at
? dateDaysAgo(Math.max(1, parseInt(config.incremental_sync_days) || 7))
: (config.import_from_date || dateDaysAgo(90));
const dateTo = new Date().toISOString().slice(0, 10);
const importedTransactions = [];
const newlyDiscoveredTransactions = [];
const syncedAccounts = [];
const transactionWarnings = [];
const detailWarnings = [];
const balanceWarnings = [];
for (const accountRef of config.accounts) {
const accountId = accountRef.uid;
let details = {
uid: accountId,
name: accountRef.name || accountRef.alias || `Konto ${syncedAccounts.length + 1}`,
product: accountRef.product || null,
details: accountRef.identification_hash || null,
account_id: {
iban: accountRef.iban || null,
},
currency: accountRef.currency || null,
};
let balances = Array.isArray(accountRef.balances)
? { balances: accountRef.balances }
: null;
try {
const remoteDetails = await getAccountDetails(config, accountId);
details = {
...details,
...remoteDetails,
account_id: {
iban: remoteDetails?.account_id?.iban || details.account_id?.iban || null,
},
};
} catch (error) {
const detailEndpointFailed = error.path === `/accounts/${accountId}/details`;
if (!detailEndpointFailed) {
throw error;
}
detailWarnings.push({
account_uid: accountId,
message: String(error.message || ''),
});
}
try {
balances = await getAccountBalances(config, accountId);
} catch (error) {
const balanceEndpointFailed = error.path === `/accounts/${accountId}/balances`;
if (!balanceEndpointFailed) {
throw error;
}
balanceWarnings.push({
account_uid: accountId,
account_name: details.name || details.product || details.details || 'Konto',
message: String(error.message || ''),
});
}
let transactions = [];
try {
transactions = await getAllTransactions(config, accountId, { date_from: dateFrom, date_to: dateTo });
} catch (error) {
const normalizedMessage = String(error.message || '');
const isWrongParams = error.status === 400 || error.status === 422;
if (!isWrongParams) {
throw error;
}
transactionWarnings.push({
account_uid: accountId,
account_name: details.name || details.product || details.details || 'Konto',
message: normalizedMessage,
});
}
syncedAccounts.push({
uid: details.uid || accountId,
name: details.name || details.product || details.details || 'Konto',
alias: config.account_aliases?.[details.uid || accountId] || config.account_aliases?.[accountId] || '',
iban: details.account_id?.iban || null,
currency: details.currency || balances?.balances?.[0]?.balance_amount?.currency || null,
balances: balances?.balances ?? [],
last_synced_at: new Date().toISOString(),
});
for (const transaction of transactions) {
const fallbackUid = [
accountId,
transaction.status || 'unknown',
transaction.transaction_id || '',
transaction.entry_reference || '',
transaction.reference_number || '',
transaction.booking_date || '',
transaction.transaction_date || '',
transaction.transaction_amount?.amount || '',
].join(':');
importedTransactions.push({
uid: transaction.transaction_id || fallbackUid,
account_uid: accountId,
booking_date: transaction.booking_date || null,
value_date: transaction.value_date || null,
transaction_date: transaction.transaction_date || null,
amount: transaction.transaction_amount?.amount ? parseFloat(transaction.transaction_amount.amount) : null,
currency: transaction.transaction_amount?.currency || null,
credit_debit_indicator: transaction.credit_debit_indicator || null,
status: transaction.status || null,
is_pending: String(transaction.status || '').toLowerCase() === 'pending',
entry_reference: transaction.entry_reference || null,
reference_number: transaction.reference_number || null,
remittance_information: transaction.remittance_information || [],
creditor_name: transaction.creditor?.name || null,
debtor_name: transaction.debtor?.name || null,
note: transaction.note || null,
raw: transaction,
synced_at: new Date().toISOString(),
});
}
}
const previousTransactions = [...(config.transactions || [])];
const transactionMap = new Map();
const signatureMap = new Map();
for (const transaction of (config.transactions || [])) {
transactionMap.set(transaction.uid, transaction);
signatureMap.set(buildTransactionSignature(transaction), transaction.uid);
}
for (const transaction of importedTransactions) {
const signature = buildTransactionSignature(transaction);
const existingUid = transactionMap.has(transaction.uid)
? transaction.uid
: signatureMap.get(signature);
const targetUid = existingUid || transaction.uid;
const isNewTransaction = !existingUid;
transactionMap.set(targetUid, {
...transactionMap.get(targetUid),
...transaction,
uid: targetUid,
});
signatureMap.set(signature, targetUid);
if (isNewTransaction) {
newlyDiscoveredTransactions.push({
...transaction,
uid: targetUid,
});
}
}
config.accounts = syncedAccounts;
config.transactions = [...transactionMap.values()]
.sort((a, b) => (b.booking_date || '').localeCompare(a.booking_date || '') || (b.synced_at || '').localeCompare(a.synced_at || ''))
.slice(0, 1000);
config.status = 'connected';
config.last_sync_at = new Date().toISOString();
config.last_error = transactionWarnings.length > 0
? `Vissa transaktioner kunde inte hämtas för ${transactionWarnings.length} konto(n).`
: balanceWarnings.length > 0
? `Saldo kunde inte hämtas för ${balanceWarnings.length} konto(n), men synken fortsatte med övriga data.`
: detailWarnings.length > 0
? `Kontodetaljer kunde inte hämtas för ${detailWarnings.length} konto(n), men synken fortsatte med övriga data.`
: null;
config.last_sync_summary = {
imported_transactions: importedTransactions.length,
new_transactions: newlyDiscoveredTransactions.length,
known_transactions: config.transactions.length,
accounts: syncedAccounts.length,
date_from: dateFrom,
date_to: dateTo,
detail_warnings: detailWarnings,
balance_warnings: balanceWarnings,
transaction_warnings: transactionWarnings,
};
const shouldNotifyTransactions = hadPreviousSync
&& appSettings.notifications.ntfy_enabled
&& appSettings.notifications.notify_new_transactions;
if (shouldNotifyTransactions) {
const minimumAmount = Number(appSettings.notifications.minimum_transaction_amount) || 0;
const includePending = appSettings.notifications.include_pending_transactions === true;
const priorNotificationKeys = new Set(previousTransactions.map(buildNotificationDedupKey));
const notificationCandidates = newlyDiscoveredTransactions.filter(transaction => {
const absoluteAmount = Math.abs(Number(transaction.amount) || 0);
const isPending = String(transaction.status || '').toLowerCase() === 'pending';
if (!includePending && isPending) return false;
const alreadySeenEquivalent = priorNotificationKeys.has(buildNotificationDedupKey(transaction));
if (alreadySeenEquivalent) return false;
return absoluteAmount >= minimumAmount;
});
if (notificationCandidates.length > 0) {
try {
const preview = notificationCandidates.slice(0, 6).map(transaction => `• ${formatNotificationTransaction(transaction, config)}`).join('\n\n');
const extra = notificationCandidates.length > 6
? `\n\n...och ${notificationCandidates.length - 6} till.`
: '';
const transactionsUrl = process.env.APP_BASE_URL
? `${process.env.APP_BASE_URL.replace(/\/+$/, '')}/transaktioner`
: '';
await sendNtfyMessage(
appSettings,
`Nya banktransaktioner i Enkelbudget\n\n${preview}${extra}`,
{
title: appSettings.notifications.ntfy_title || 'Enkelbudget',
priority: appSettings.notifications.ntfy_priority || 3,
tags: appSettings.notifications.ntfy_tags || 'money_with_wings,bank',
clickUrl: appSettings.notifications.ntfy_click_url || transactionsUrl,
}
);
appSettings.notifications.last_error = '';
appSettings.notifications.last_sent_at = new Date().toISOString();
} catch (error) {
appSettings.notifications.last_error = String(error.message || 'Okänt ntfy-fel');
}
}
}
await save(db);
return publicEnableBankingConfig(config);
}
async function triggerEnableBankingSync() {
if (enableBankingSyncInProgress) return;
enableBankingSyncInProgress = true;
try {
const db = await getDb();
const config = ensureEnableBankingConfig(db);
if (!config.enabled || !config.auto_sync_enabled || !config.application_id || !config.private_key_pem || !config.accounts?.length) {
return;
}
const intervalMinutes = Math.max(5, parseInt(config.sync_interval_minutes) || 360);
const lastSyncAt = config.last_sync_at ? new Date(config.last_sync_at).getTime() : 0;
if (lastSyncAt && Date.now() - lastSyncAt < intervalMinutes * 60 * 1000) {
return;
}
await syncEnableBanking(db);
} catch (error) {
const db = await getDb();
const config = ensureEnableBankingConfig(db);
config.status = 'attention';
config.last_error = error.message;
await save(db);
} finally {
enableBankingSyncInProgress = false;
}
}
async function save(db) {
await db.write();
}
app.get('/api/months', async (req, res) => {
const db = await getDb();
const months = db.data.months.map(m => {
const inc = db.data.income.filter(i => i.budget_month_id === m.id);
const bills = db.data.bills.filter(b => b.budget_month_id === m.id);
return {
...m,
total_income: inc.reduce((s, i) => s + i.amount, 0),
total_bills: bills.reduce((s, b) => s + b.amount, 0),
bill_count: bills.length,
paid_count: bills.filter(b => b.is_paid).length,
};
}).sort((a, b) => b.year !== a.year ? b.year - a.year : b.month - a.month);
res.json(months);
});
app.get('/api/months/:year/:month', async (req, res) => {
const year = parseInt(req.params.year);
const month = parseInt(req.params.month);
const db = await getDb();
let m = db.data.months.find(x => x.year === year && x.month === month);
if (!m) {
m = { id: nextId(db, 'months'), year, month, notes: null };
db.data.months.push(m);
await save(db);
}
const income = db.data.income.filter(i => i.budget_month_id === m.id);
const bills = db.data.bills
.filter(b => b.budget_month_id === m.id)
.sort((a, b) => {
const catA = db.data.categories.find(c => c.id === a.category_id)?.sort_order ?? 99;
const catB = db.data.categories.find(c => c.id === b.category_id)?.sort_order ?? 99;
return catA !== catB ? catA - catB : a.id - b.id;
})
.map(b => withCat(db, b));
res.json({ ...m, income, bills, categories: db.data.categories });
});
app.get('/api/integrations/enable-banking', async (req, res) => {
const db = await getDb();
const config = ensureEnableBankingConfig(db);
res.json(publicEnableBankingConfig(config));
});
app.put('/api/integrations/enable-banking/transactions/:transactionId', async (req, res) => {
const db = await getDb();
const config = ensureEnableBankingConfig(db);
const transactionId = String(req.params.transactionId || '').trim();
const categoryIdRaw = req.body?.category_id;
const categoryId = categoryIdRaw === null || categoryIdRaw === '' || categoryIdRaw === undefined
? null
: parseInt(categoryIdRaw);
const transaction = (config.transactions || []).find(item => item.uid === transactionId);
if (!transaction) {
return res.status(404).json({ error: 'Transaktionen hittades inte' });
}
if (categoryId != null && !db.data.categories.some(category => category.id === categoryId)) {
return res.status(400).json({ error: 'Ogiltig kategori' });
}
config.transaction_categories ||= {};
if (categoryId == null) {
delete config.transaction_categories[transactionId];
} else {
config.transaction_categories[transactionId] = {
category_id: categoryId,
source: String(req.body?.source || 'manual'),
confidence: req.body?.confidence != null ? Number(req.body.confidence) : null,
reason: String(req.body?.reason || '').trim() || '',
updated_at: new Date().toISOString(),
};
}
await save(db);
res.json(publicEnableBankingConfig(config));
});
app.post('/api/integrations/enable-banking/transactions/:transactionId/suggest-category', async (req, res) => {
const db = await getDb();
const config = ensureEnableBankingConfig(db);
const aiConfig = ensureAiConfig(db);
const transactionId = String(req.params.transactionId || '').trim();
const transaction = (config.transactions || []).find(item => item.uid === transactionId);
if (!transaction) {
return res.status(404).json({ error: 'Transaktionen hittades inte' });
}
const categories = [...(db.data.categories || [])].sort((a, b) => (a.sort_order ?? 99) - (b.sort_order ?? 99));
if (categories.length === 0) {
return res.status(400).json({ error: 'Inga kategorier finns att välja mellan ännu' });
}
try {
const suggestion = await suggestTransactionCategory(
aiConfig,
categories,
transaction,
buildMerchantCategoryContext(config, categories, transaction)
);
const category = ensureSuggestedCategory(db, suggestion);
if (!category) {
return res.status(400).json({ error: 'AI:n kunde inte ge ett tydligt kategoriförslag.' });
}
await save(db);
res.json({
...suggestion,
category_id: category.id,
category_name: category.name,
category,
created_category: suggestion.new_category_name
? String(suggestion.new_category_name).trim().toLowerCase() === String(category.name || '').trim().toLowerCase()
: false,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/integrations/enable-banking/transactions/:transactionId/bulk-suggest-category', async (req, res) => {
const db = await getDb();
const config = ensureEnableBankingConfig(db);
const aiConfig = ensureAiConfig(db);
const transactionId = String(req.params.transactionId || '').trim();
const sourceTransaction = (config.transactions || []).find(item => item.uid === transactionId);
if (!sourceTransaction) {
return res.status(404).json({ error: 'Transaktionen hittades inte' });
}
const categories = [...(db.data.categories || [])].sort((a, b) => (a.sort_order ?? 99) - (b.sort_order ?? 99));
if (categories.length === 0) {
return res.status(400).json({ error: 'Inga kategorier finns att välja mellan ännu' });
}
try {
const suggestion = await suggestTransactionCategory(
aiConfig,
categories,
sourceTransaction,
buildMerchantCategoryContext(config, categories, sourceTransaction)
);
const category = ensureSuggestedCategory(db, suggestion);
if (!category) {
return res.status(400).json({ error: 'AI:n kunde inte ge ett tydligt kategoriförslag.' });
}
const merchantKey = getTransactionMerchantKey(sourceTransaction);
const candidates = (config.transactions || []).filter(item => getTransactionMerchantKey(item) === merchantKey);
config.transaction_categories ||= {};
const updatedAt = new Date().toISOString();
for (const transaction of candidates) {
config.transaction_categories[transaction.uid] = {
category_id: category.id,
source: 'ai-bulk',
confidence: suggestion.confidence,
reason: suggestion.reason,
updated_at: updatedAt,
};
}
await save(db);
res.json({
suggestion: {
...suggestion,
category_id: category.id,
category_name: category.name,
},
category,
merchant_key: merchantKey,
affected_count: candidates.length,
config: publicEnableBankingConfig(config),
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/fx/latest', async (req, res) => {
try {
const data = await getFxRates();
res.json(data);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/ai/settings', async (req, res) => {
const db = await getDb();
const config = ensureAiConfig(db);
res.json(publicAiConfig(config));
});
app.put('/api/ai/settings', async (req, res) => {
const db = await getDb();
const config = ensureAiConfig(db);
Object.assign(config, normalizeAiSettings(req.body || {}));
await save(db);
res.json(publicAiConfig(config));
});
app.get('/api/ai/models', async (req, res) => {
const db = await getDb();
const config = ensureAiConfig(db);
const requestedBaseUrl = String(req.query.base_url || '').trim();
const effectiveConfig = requestedBaseUrl
? { ...config, base_url: requestedBaseUrl }
: config;
try {
const models = await listOllamaModels(effectiveConfig);
res.json({
base_url: publicAiConfig(effectiveConfig).base_url,
models,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/app/settings', async (req, res) => {
const db = await getDb();
const settings = ensureAppSettings(db);
res.json(publicAppSettings(settings));
});
app.put('/api/app/settings', async (req, res) => {
const db = await getDb();
const current = ensureAppSettings(db);
const normalized = normalizeAppSettings(req.body || {}, current);
db.data.app_settings = normalized;
await save(db);
res.json(publicAppSettings(db.data.app_settings));
});
app.post('/api/app/settings/test-ntfy', async (req, res) => {
const db = await getDb();
const settings = ensureAppSettings(db);
const testSettings = normalizeAppSettings(req.body || {}, settings);
try {
await sendNtfyMessage(
testSettings,
`Test från Enkelbudget\n\nntfy-notiserna är korrekt kopplade.\nTid: ${new Date().toLocaleString('sv-SE')}`,
{
title: testSettings.notifications.ntfy_title || 'Enkelbudget test',
priority: testSettings.notifications.ntfy_priority || 3,
tags: testSettings.notifications.ntfy_tags || 'test_tube,money_with_wings',
clickUrl: testSettings.notifications.ntfy_click_url
|| (process.env.APP_BASE_URL ? `${process.env.APP_BASE_URL.replace(/\/+$/, '')}/installningar` : ''),
}
);
settings.notifications.last_error = '';
settings.notifications.last_sent_at = new Date().toISOString();
await save(db);
res.json({ ok: true, settings: publicAppSettings(settings) });
} catch (error) {
settings.notifications.last_error = String(error.message || 'Okänt ntfy-fel');
await save(db);
res.status(500).json({ error: settings.notifications.last_error, settings: publicAppSettings(settings) });
}
});
app.get('/api/ai/conversations', async (req, res) => {
const db = await getDb();
const conversations = ensureAiConversationStore(db)
.slice()
.sort((a, b) => String(b.updated_at || '').localeCompare(String(a.updated_at || '')))
.map(publicAiConversation);
res.json({ conversations });
});
app.post('/api/ai/conversations', async (req, res) => {
const db = await getDb();
const conversations = ensureAiConversationStore(db);
const now = new Date().toISOString();
const conversation = {
id: crypto.randomUUID(),
title: 'Ny AI-chatt',
preview: '',
created_at: now,
updated_at: now,
messages: [],
};
conversations.unshift(conversation);
await save(db);
res.json(publicAiConversation(conversation));
});
app.delete('/api/ai/conversations/:id', async (req, res) => {
const db = await getDb();
const conversations = ensureAiConversationStore(db);
const conversationId = String(req.params.id || '').trim();
const index = conversations.findIndex(conversation => conversation.id === conversationId);
if (index < 0) {
return res.status(404).json({ error: 'AI-chatten hittades inte' });
}
conversations.splice(index, 1);
await save(db);
res.json({ ok: true });
});
app.post('/api/ai/chat', async (req, res) => {
const db = await getDb();
const config = ensureAiConfig(db);
const conversations = ensureAiConversationStore(db);
const message = String(req.body?.message || '').trim();
const images = Array.isArray(req.body?.images) ? req.body.images : [];
const conversationId = String(req.body?.conversation_id || '').trim();
const existingConversation = conversationId
? conversations.find(item => item.id === conversationId)
: null;
if (!config.enabled) {
return res.status(400).json({ error: 'Ollama-integrationen är inte aktiverad ännu' });
}
if (!message) {
return res.status(400).json({ error: 'Frågan kan inte vara tom' });
}
try {
const result = await chatWithOllama(config, db, {
message,
images,
conversation: existingConversation?.messages || [],
});
const now = new Date().toISOString();
let conversation = existingConversation;
if (!conversation) {
conversation = {
id: crypto.randomUUID(),
title: summarizeConversationTitle(message),
preview: '',
created_at: now,
updated_at: now,
messages: [],
};
conversations.unshift(conversation);
}
const userEntry = {
id: crypto.randomUUID(),
role: 'user',
content: message,
attachments: images.map(sanitizeHistoryAttachment),
created_at: now,
};
const assistantEntry = {
id: crypto.randomUUID(),
role: 'assistant',
content: result.message || '',
attachments: [],
model: result.model,
created_at: result.created_at || now,
context_preview: result.context_preview || '',
context_meta: result.context_meta || null,
};
conversation.messages ||= [];
conversation.messages.push(userEntry, assistantEntry);
conversation.title = conversation.messages.length <= 2
? summarizeConversationTitle(message)
: (conversation.title || summarizeConversationTitle(message));
conversation.preview = assistantEntry.content || userEntry.content || '';
conversation.updated_at = assistantEntry.created_at || now;
await save(db);
res.json({
...result,
conversation_id: conversation.id,
user_entry: userEntry,
assistant_entry: assistantEntry,
conversation: publicAiConversation(conversation),
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.put('/api/integrations/enable-banking', async (req, res) => {
const db = await getDb();
const config = ensureEnableBankingConfig(db);
const {
enabled,
notes,
application_id,
private_key_pem,
clear_private_key,
redirect_url,
country,
psu_type,
auto_sync_enabled,
sync_interval_minutes,
import_from_date,
incremental_sync_days,
} = req.body;
config.enabled = !!enabled;
config.notes = notes?.trim() || '';
config.application_id = application_id?.trim() || '';
config.redirect_url = redirect_url?.trim() || '';
config.country = country?.trim() || 'SE';
config.psu_type = psu_type === 'business' ? 'business' : 'personal';
config.auto_sync_enabled = !!auto_sync_enabled;
config.sync_interval_minutes = Math.max(5, parseInt(sync_interval_minutes) || 360);
config.import_from_date = import_from_date || null;
config.incremental_sync_days = Math.max(1, parseInt(incremental_sync_days) || 7);
if (typeof private_key_pem === 'string' && private_key_pem.trim()) {
config.private_key_pem = private_key_pem.trim();
} else if (clear_private_key) {
config.private_key_pem = '';
}
if (!config.session_id) {
config.status = 'not_connected';
config.institution = '';
}
await save(db);
res.json(publicEnableBankingConfig(config));
});
app.put('/api/integrations/enable-banking/accounts/:accountId', async (req, res) => {
const db = await getDb();
const config = ensureEnableBankingConfig(db);
const accountId = String(req.params.accountId || '').trim();
const alias = String(req.body?.alias || '').trim();
if (!accountId) {
return res.status(400).json({ error: 'Konto-ID saknas' });
}
config.account_aliases ||= {};
if (alias) {
config.account_aliases[accountId] = alias;
} else {
delete config.account_aliases[accountId];
}
config.accounts = (config.accounts || []).map(account => (
account.uid === accountId
? { ...account, alias }
: account
));
await save(db);
res.json(publicEnableBankingConfig(config));
});
app.get('/api/integrations/enable-banking/aspsps', async (req, res) => {
const db = await getDb();
const config = ensureEnableBankingConfig(db);
const country = (req.query.country || config.country || 'SE').toString().toUpperCase();
try {
const data = await fetchAspsps(config, country);
const aspsps = (data?.aspsps ?? []).map(aspsp => ({
name: aspsp.name,
country: aspsp.country,
logo: aspsp.logo || null,
maximum_consent_validity: aspsp.maximum_consent_validity || null,
psu_types: aspsp.auth_methods?.map(method => method.psu_type).filter(Boolean) || [],
}));
res.json({ country, aspsps });
} catch (error) {
res.status(error.status || 500).json({ error: error.message, details: error.details || null });
}
});
app.post('/api/integrations/enable-banking/connect', async (req, res) => {
const db = await getDb();
const config = ensureEnableBankingConfig(db);
const { aspsp_name, country, redirect_url, psu_type } = req.body;
const chosenCountry = (country || config.country || 'SE').toString().toUpperCase();
const chosenRedirectUrl = redirect_url?.trim() || config.redirect_url?.trim();
if (!chosenRedirectUrl) return res.status(400).json({ error: 'Redirect URL saknas' });
try {
const aspspResponse = await fetchAspsps(config, chosenCountry);
const aspsp = (aspspResponse?.aspsps ?? []).find(item => item.name === aspsp_name);
if (!aspsp) return res.status(404).json({ error: 'Banken hittades inte' });
const maxConsentSeconds = Math.min(aspsp.maximum_consent_validity || 60 * 60 * 24 * 90, 60 * 60 * 24 * 90);
const validUntil = new Date(Date.now() + maxConsentSeconds * 1000).toISOString();
const state = crypto.randomUUID();
const authResponse = await startAuthorization(config, {
access: { valid_until: validUntil },
aspsp: {
name: aspsp.name,
country: aspsp.country,
},
state,
redirect_url: chosenRedirectUrl,
psu_type: psu_type === 'business' ? 'business' : (config.psu_type || 'personal'),
});
config.pending_state = state;
config.last_callback_at = null;
config.last_callback_url = '';
config.last_callback_code_present = false;
config.last_callback_state = '';
config.last_callback_exchange_at = null;
config.last_callback_exchange_error = '';
config.country = chosenCountry;
config.redirect_url = chosenRedirectUrl;
config.psu_type = psu_type === 'business' ? 'business' : (config.psu_type || 'personal');
config.institution = aspsp.name;
config.last_error = null;
await save(db);
res.json({ url: authResponse.url, state });
} catch (error) {
config.status = 'attention';
config.last_error = error.message;
await save(db);
res.status(error.status || 500).json({ error: error.message, details: error.details || null });
}
});
app.post('/api/integrations/enable-banking/exchange', async (req, res) => {
const db = await getDb();
const config = ensureEnableBankingConfig(db);
const { code, state } = req.body;
config.last_callback_exchange_at = new Date().toISOString();
if (!code) return res.status(400).json({ error: 'Code saknas' });
if (!state || config.pending_state !== state) {
config.last_callback_exchange_error = `Ogiltig state för Enable Banking. Väntade ${config.pending_state || 'ingen'}, fick ${state || 'ingen'}.`;
await save(db);
return res.status(400).json({ error: 'Ogiltig state för Enable Banking' });
}
try {
const createdSession = await createSession(config, code);
const sessionId = createdSession?.session_id || createdSession?.id;
const session = sessionId ? await getSession(config, sessionId) : createdSession;
const normalizedAccounts = normalizeAccountIds(session);
config.pending_state = null;
config.session_id = sessionId || config.session_id || null;
config.session_created_at = session?.created || new Date().toISOString();
config.session_expires_at = session?.access?.valid_until || null;
config.accounts = normalizedAccounts;
config.status = 'connected';
config.enabled = true;
config.institution = session?.aspsp?.name || config.institution;
config.country = session?.aspsp?.country || config.country;
config.last_error = null;
config.last_callback_exchange_error = '';
await save(db);
await syncEnableBanking(db);
res.json(publicEnableBankingConfig(config));
} catch (error) {
config.status = 'attention';
config.last_error = error.message;
config.last_callback_exchange_error = error.message;
await save(db);
res.status(error.status || 500).json({ error: error.message, details: error.details || null });
}
});
app.post('/api/integrations/enable-banking/sync', async (req, res) => {
const db = await getDb();
try {
const result = await syncEnableBanking(db);
res.json(result);
} catch (error) {
const config = ensureEnableBankingConfig(db);
config.status = 'attention';
config.last_error = error.message;
await save(db);
res.status(error.status || 500).json({ error: error.message, details: error.details || null });
}
});
app.delete('/api/integrations/enable-banking/session', async (req, res) => {
const db = await getDb();
const config = ensureEnableBankingConfig(db);
try {
if (config.session_id) {
await deleteSession(config, config.session_id);
}
} catch {
// Ignore remote delete errors, local disconnect should still proceed.
}
config.pending_state = null;
config.last_callback_state = '';
config.last_callback_code_present = false;
config.session_id = null;
config.session_created_at = null;
config.session_expires_at = null;
config.accounts = [];
config.transactions = [];
config.last_sync_summary = null;
config.last_sync_at = null;
config.status = 'not_connected';
config.institution = '';
config.last_error = null;
await save(db);
res.json(publicEnableBankingConfig(config));
});
app.post('/api/months/:id/income', async (req, res) => {
const { name, amount, type = 'salary', notes } = req.body;
const db = await getDb();
const item = { id: nextId(db, 'income'), budget_month_id: parseInt(req.params.id), name, amount: parseFloat(amount), type, notes: notes || null };
db.data.income.push(item);
await save(db);
res.json(item);
});
app.put('/api/income/:id', async (req, res) => {
const { name, amount, type, notes } = req.body;
const db = await getDb();
const idx = db.data.income.findIndex(i => i.id === parseInt(req.params.id));
if (idx < 0) return res.status(404).json({ error: 'Not found' });
db.data.income[idx] = { ...db.data.income[idx], name, amount: parseFloat(amount), type, notes: notes || null };
await save(db);
res.json(db.data.income[idx]);
});
app.delete('/api/income/:id', async (req, res) => {
const db = await getDb();
db.data.income = db.data.income.filter(i => i.id !== parseInt(req.params.id));
await save(db);
res.json({ ok: true });
});
app.get('/api/categories', async (req, res) => {
const db = await getDb();
res.json(db.data.categories.sort((a, b) => a.sort_order - b.sort_order));
});
app.post('/api/categories', async (req, res) => {
const db = await getDb();
const nextSortOrder = db.data.categories.reduce((max, category) => Math.max(max, category.sort_order ?? 0), 0) + 1;
const category = {
id: nextId(db, 'categories'),
...normalizeCategory(req.body, nextSortOrder),
};
db.data.categories.push(category);
await save(db);
res.json(category);
});
app.put('/api/categories/:id', async (req, res) => {
const db = await getDb();
const idx = db.data.categories.findIndex(category => category.id === parseInt(req.params.id));
if (idx < 0) return res.status(404).json({ error: 'Not found' });
db.data.categories[idx] = {
...db.data.categories[idx],
...normalizeCategory(req.body, db.data.categories[idx].sort_order),
};
await save(db);
res.json(db.data.categories[idx]);
});
app.delete('/api/categories/:id', async (req, res) => {
const db = await getDb();
const categoryId = parseInt(req.params.id);
const inUseByBills = db.data.bills.some(bill => bill.category_id === categoryId);
const inUseBySubscriptions = db.data.subscriptions.some(subscription => subscription.category_id === categoryId && subscription.is_active);
const transactionCategoryMap = db.data.banking?.enable_banking?.transaction_categories || {};
const inUseByTransactions = Object.values(transactionCategoryMap).some(entry => entry?.category_id === categoryId);
if (inUseByBills || inUseBySubscriptions || inUseByTransactions) {
return res.status(409).json({ error: 'Category in use' });
}
db.data.categories = db.data.categories.filter(category => category.id !== categoryId);
await save(db);
res.json({ ok: true });
});
app.get('/api/subscriptions', async (req, res) => {
const db = await getDb();
res.json(
db.data.subscriptions
.filter(subscription => subscription.is_active)
.sort((a, b) => (a.billing_day ?? 99) - (b.billing_day ?? 99) || a.name.localeCompare(b.name))
);
});
app.post('/api/subscriptions', async (req, res) => {
const db = await getDb();
const subscription = {
id: nextId(db, 'subscriptions'),
...normalizeSubscription(req.body),
created_at: new Date().toISOString(),
};
db.data.subscriptions.push(subscription);
await save(db);
res.json(subscription);
});
app.put('/api/subscriptions/:id', async (req, res) => {
const db = await getDb();
const idx = db.data.subscriptions.findIndex(subscription => subscription.id === parseInt(req.params.id));
if (idx < 0) return res.status(404).json({ error: 'Not found' });
db.data.subscriptions[idx] = {
...db.data.subscriptions[idx],
...normalizeSubscription(req.body),
};
await save(db);
res.json(db.data.subscriptions[idx]);
});
app.delete('/api/subscriptions/:id', async (req, res) => {
const db = await getDb();
const idx = db.data.subscriptions.findIndex(subscription => subscription.id === parseInt(req.params.id));
if (idx < 0) return res.status(404).json({ error: 'Not found' });
db.data.subscriptions[idx].is_active = 0;
await save(db);
res.json({ ok: true });
});
app.post('/api/subscriptions/:id/add-to-month', async (req, res) => {
const db = await getDb();
const subscriptionId = parseInt(req.params.id);
const monthId = parseInt(req.body.monthId);
const subscription = db.data.subscriptions.find(item => item.id === subscriptionId && item.is_active);
if (!subscription) return res.status(404).json({ error: 'Subscription not found' });
const existingBill = db.data.bills.find(bill => bill.budget_month_id === monthId && bill.subscription_id === subscriptionId);
if (existingBill) return res.json(withCat(db, existingBill));
const bill = {
id: nextId(db, 'bills'),
budget_month_id: monthId,
category_id: subscription.category_id,
name: subscription.name,
amount: subscription.amount,
is_paid: 0,
due_date: subscription.billing_day ? String(subscription.billing_day).padStart(2, '0') : null,
notes: subscription.notes || subscription.provider || null,
subscription_id: subscription.id,
source: 'subscription',
};
db.data.bills.push(bill);
await save(db);
res.json(withCat(db, bill));
});
app.post('/api/subscriptions/add-missing-to-month', async (req, res) => {
const db = await getDb();
const monthId = parseInt(req.body.monthId);
const activeSubscriptions = db.data.subscriptions.filter(subscription => subscription.is_active);
const createdBills = [];
for (const subscription of activeSubscriptions) {
const exists = db.data.bills.some(bill => bill.budget_month_id === monthId && bill.subscription_id === subscription.id);
if (exists) continue;
const bill = {
id: nextId(db, 'bills'),
budget_month_id: monthId,
category_id: subscription.category_id,
name: subscription.name,
amount: subscription.amount,
is_paid: 0,
due_date: subscription.billing_day ? String(subscription.billing_day).padStart(2, '0') : null,
notes: subscription.notes || subscription.provider || null,
subscription_id: subscription.id,
source: 'subscription',
};
db.data.bills.push(bill);
createdBills.push(withCat(db, bill));
}
await save(db);
res.json(createdBills);
});
app.post('/api/months/:id/bills', async (req, res) => {
const { category_id, name, amount, is_paid = 0, due_date, notes } = req.body;
const db = await getDb();
const bill = {
id: nextId(db, 'bills'),
budget_month_id: parseInt(req.params.id),
category_id: parseInt(category_id),
name,
amount: parseFloat(amount),
is_paid: is_paid ? 1 : 0,
due_date: due_date || null,
notes: notes || null,
};
db.data.bills.push(bill);
await save(db);
res.json(withCat(db, bill));
});
app.put('/api/bills/:id', async (req, res) => {
const { category_id, name, amount, is_paid, due_date, notes } = req.body;
const db = await getDb();
const idx = db.data.bills.findIndex(b => b.id === parseInt(req.params.id));
if (idx < 0) return res.status(404).json({ error: 'Not found' });
db.data.bills[idx] = { ...db.data.bills[idx], category_id: parseInt(category_id), name, amount: parseFloat(amount), is_paid: is_paid ? 1 : 0, due_date: due_date || null, notes: notes || null };
await save(db);
res.json(withCat(db, db.data.bills[idx]));
});
app.patch('/api/bills/:id/toggle', async (req, res) => {
const db = await getDb();
const idx = db.data.bills.findIndex(b => b.id === parseInt(req.params.id));
if (idx < 0) return res.status(404).json({ error: 'Not found' });
db.data.bills[idx].is_paid = db.data.bills[idx].is_paid ? 0 : 1;
await save(db);
res.json(withCat(db, db.data.bills[idx]));
});
app.delete('/api/bills/:id', async (req, res) => {
const db = await getDb();
db.data.bills = db.data.bills.filter(b => b.id !== parseInt(req.params.id));
await save(db);
res.json({ ok: true });
});
app.get('/api/loans', async (req, res) => {
const db = await getDb();
res.json(db.data.loans.sort((a, b) => b.is_active - a.is_active || a.id - b.id));
});
app.post('/api/loans', async (req, res) => {
const { name, original_amount, current_balance, monthly_payment, interest_rate = 0, start_date, notes } = req.body;
const db = await getDb();
const loan = {
id: nextId(db, 'loans'),
name,
original_amount: parseFloat(original_amount),
current_balance: parseFloat(current_balance ?? original_amount),
monthly_payment: parseFloat(monthly_payment),
interest_rate: parseFloat(interest_rate),
start_date: start_date || null,
notes: notes || null,
is_active: 1,
created_at: new Date().toISOString(),
};
db.data.loans.push(loan);
await save(db);
res.json(loan);
});
app.put('/api/loans/:id', async (req, res) => {
const { name, original_amount, current_balance, monthly_payment, interest_rate, start_date, notes, is_active } = req.body;
const db = await getDb();
const idx = db.data.loans.findIndex(l => l.id === parseInt(req.params.id));
if (idx < 0) return res.status(404).json({ error: 'Not found' });
db.data.loans[idx] = { ...db.data.loans[idx], name, original_amount: parseFloat(original_amount), current_balance: parseFloat(current_balance), monthly_payment: parseFloat(monthly_payment), interest_rate: parseFloat(interest_rate ?? 0), start_date: start_date || null, notes: notes || null, is_active: is_active ? 1 : 0 };
await save(db);
res.json(db.data.loans[idx]);
});
app.delete('/api/loans/:id', async (req, res) => {
const db = await getDb();
const idx = db.data.loans.findIndex(l => l.id === parseInt(req.params.id));
if (idx >= 0) {
db.data.loans[idx].is_active = 0;
await save(db);
}
res.json({ ok: true });
});
app.get('/api/loans/:id/payments', async (req, res) => {
const db = await getDb();
const loanId = parseInt(req.params.id);
res.json(db.data.payments.filter(p => p.loan_id === loanId).sort((a, b) => b.payment_date.localeCompare(a.payment_date)));
});
app.post('/api/loans/:id/payments', async (req, res) => {
const { amount, payment_date, budget_month_id, notes } = req.body;
const db = await getDb();
const loanIdx = db.data.loans.findIndex(l => l.id === parseInt(req.params.id));
if (loanIdx < 0) return res.status(404).json({ error: 'Not found' });
const payment = {
id: nextId(db, 'payments'),
loan_id: parseInt(req.params.id),
budget_month_id: budget_month_id || null,
amount: parseFloat(amount),
payment_date: payment_date || new Date().toISOString().split('T')[0],
notes: notes || null,
};
db.data.payments.push(payment);
db.data.loans[loanIdx].current_balance = Math.max(0, db.data.loans[loanIdx].current_balance - payment.amount);
await save(db);
res.json({ payment, loan: db.data.loans[loanIdx] });
});
if (process.env.NODE_ENV === 'production') {
app.get('*', (req, res) => {
getAuthSession(req)
.then(session => {
if (session.enabled && !session.authenticated) {
res.redirect(`/login?returnTo=${encodeURIComponent(req.originalUrl || '/')}`);
return;
}
res.sendFile(join(__dirname, 'public', 'index.html'));
})
.catch(error => {
res.status(500).send(error.message);
});
});
}
setInterval(() => {
triggerEnableBankingSync().catch(() => {});
}, 60 * 1000);
app.listen(PORT, () => console.log(`Enkelbudget backend on :${PORT}`));