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}`));