1873 lines
66 KiB
JavaScript
Executable File
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}`));
|