/** * ═══════════════════════════════════════════════════════════════════════════ * AAMOS Ledger Engine — SÄKER VERSION * Port: 3250 * * Säkerhetshårdningar implementerade: * • JWT-autentisering (RS256/HS256) — inga hårdkodade secrets * • RBAC — rollbaserad åtkomstkontroll (admin/accountant/viewer) * • Input-validering med Joi — alla endpoints valideras * • Rate limiting — skydd mot brute-force * • Helmet — säkerhetsheaders * • Tenant-isolering — strikt kontroll av tenant-åtkomst * • Audit logging — alla operationer loggas * ═══════════════════════════════════════════════════════════════════════════ */ import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import pg from 'pg'; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { randomUUID } from 'crypto'; import hermes from './hermes.mjs'; import auth from './auth.mjs'; import { schemas, validate, sanitizeString, sanitizeMetadata } from './validation.mjs'; const __dir = dirname(fileURLToPath(import.meta.url)); const PORT = process.env.AAMOS_LEDGER_PORT || process.env.PORT || 3250; const { Pool } = pg; // ── Validera auth-konfiguration vid startup ────────────────────────────────── try { auth.validateAuthConfig(); } catch (e) { console.error('[ledger] Startup misslyckades:', e.message); process.exit(1); } // ── Database ───────────────────────────────────────────────────────────────── const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: false, max: 10, idleTimeoutMillis: 30000, }); async function initSchema() { const sql = readFileSync(join(__dir, 'schema.sql'), 'utf8'); await pool.query(sql); console.log('[ledger] Schema initialiserat'); } // ── App ─────────────────────────────────────────────────────────────────────── const app = express(); // ── Säkerhetsmiddleware ────────────────────────────────────────────────────── app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], fontSrc: ["'self'", "https://fonts.gstatic.com"], scriptSrc: ["'self'"], imgSrc: ["'self'", "data:", "blob:"], }, }, crossOriginEmbedderPolicy: false, })); app.use(cors({ origin: process.env.CORS_ORIGIN || false, credentials: true, })); // Rate limiting const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minuter max: 100, // max 100 requests per window standardHeaders: true, legacyHeaders: false, message: { ok: false, error: 'För många förfrågningar — försök igen senare' }, }); app.use(limiter); // Striktare rate limit för auth-känsliga endpoints const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, message: { ok: false, error: 'För många auth-försök — försök igen senare' }, }); app.use(express.json({ limit: '1mb' })); // ── Context helper ──────────────────────────────────────────────────────────── function buildCtx(req, entityType = null, entityId = null, decisionSource = 'user') { return { trace_id: req.headers['x-trace-id'] || randomUUID(), correlation_id: req.headers['x-correlation-id'] || randomUUID(), tenant_id: req.tenant_id || req.headers['x-tenant-id'] || 'wavult-group', user_id: req.auth?.user_id || req.headers['x-user-id'] || 'system', entity_type: entityType, entity_id: entityId, decision_source: decisionSource, }; } // ── Audit helper ────────────────────────────────────────────────────────────── async function writeAudit(ctx, action, before = null, after = null, client = pool) { await client.query( `INSERT INTO ledger_audit_log (tenant_id, trace_id, correlation_id, user_id, entity_type, entity_id, action, decision_source, before_state, after_state) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, [ ctx.tenant_id, ctx.trace_id, ctx.correlation_id, ctx.user_id, ctx.entity_type, ctx.entity_id, action, ctx.decision_source, before ? JSON.stringify(before) : null, after ? JSON.stringify(after) : null, ] ); } // ── Auth + Tenant middleware (alla skyddade routes) ────────────────────────── app.use('/api/ledger', auth.authenticate); app.use('/api/ledger', auth.requireTenantMatch); // ══════════════════════════════════════════════════════════════════════════════ // CHART OF ACCOUNTS // ══════════════════════════════════════════════════════════════════════════════ // GET /api/ledger/accounts — hämta kontoplan app.get('/api/ledger/accounts', validate(schemas.listAccounts), async (req, res) => { const tenant_id = req.tenant_id; const coa_standard = req.validated.standard || 'BAS'; try { const { rows } = await pool.query( `SELECT * FROM ledger_accounts WHERE tenant_id = $1 AND coa_standard = $2 AND is_active = TRUE ORDER BY account_number`, [tenant_id, coa_standard] ); res.json({ ok: true, accounts: rows, count: rows.length }); } catch (e) { console.error('[ledger] GET /accounts error:', e.message); res.status(500).json({ ok: false, error: 'Internt serverfel' }); } }); // POST /api/ledger/accounts — lägg till konto (kräver accountant+) app.post('/api/ledger/accounts', auth.requireRole('accountant'), validate(schemas.createAccount), async (req, res) => { const ctx = buildCtx(req, 'account'); const v = req.validated; try { const { rows } = await pool.query( `INSERT INTO ledger_accounts (tenant_id, account_number, name, account_type, normal_balance, coa_standard, parent_account, vat_code, metadata) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (tenant_id, account_number, coa_standard) DO UPDATE SET name=EXCLUDED.name, account_type=EXCLUDED.account_type, updated_at=NOW() RETURNING *`, [ctx.tenant_id, v.account_number, sanitizeString(v.name), v.account_type, v.normal_balance, v.coa_standard, v.parent_account || null, v.vat_code || null, JSON.stringify(sanitizeMetadata(v.metadata))] ); const account = rows[0]; ctx.entity_id = account.id; await writeAudit(ctx, 'created', null, account); await hermes.emit('finance.account.created', ctx, { account_number: v.account_number, name: v.name, coa_standard: v.coa_standard }); res.status(201).json({ ok: true, account }); } catch (e) { console.error('[ledger] POST /accounts error:', e.message); res.status(500).json({ ok: false, error: 'Internt serverfel' }); } }); // ══════════════════════════════════════════════════════════════════════════════ // JOURNAL — Verifikationer // ══════════════════════════════════════════════════════════════════════════════ // POST /api/ledger/journal — skapa verifikation (draft) (kräver accountant+) app.post('/api/ledger/journal', auth.requireRole('accountant'), validate(schemas.createJournalEntry), async (req, res) => { const ctx = buildCtx(req, 'journal_entry'); const v = req.validated; const entryDate = new Date(v.entry_date); const fy = v.fiscal_year || entryDate.getFullYear(); const per = v.period || `${fy}-${String(entryDate.getMonth() + 1).padStart(2,'0')}`; const client = await pool.connect(); try { await client.query('BEGIN'); const { rows } = await client.query( `INSERT INTO ledger_journal_entries (tenant_id, fiscal_year, period, entry_date, description, reference, source_type, source_id, status, trace_id, correlation_id, user_id, decision_source, metadata) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'draft',$9,$10,$11,$12,$13) RETURNING *`, [ctx.tenant_id, fy, per, v.entry_date, sanitizeString(v.description), v.reference ? sanitizeString(v.reference) : null, v.source_type, v.source_id || null, ctx.trace_id, ctx.correlation_id, ctx.user_id, ctx.decision_source, JSON.stringify(sanitizeMetadata(v.metadata))] ); const entry = rows[0]; ctx.entity_id = entry.id; // Lägg in rader const insertedLines = []; for (let i = 0; i < v.lines.length; i++) { const l = v.lines[i]; const { rows: lr } = await client.query( `INSERT INTO ledger_journal_lines (entry_id, tenant_id, line_number, account_number, account_name, debit, credit, currency, amount_base, vat_code, vat_amount, cost_center, project_code, description, metadata) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`, [entry.id, ctx.tenant_id, i+1, l.account_number, l.account_name ? sanitizeString(l.account_name) : null, l.debit || null, l.credit || null, l.currency || 'SEK', l.amount_base || (l.debit || l.credit), l.vat_code || null, l.vat_amount || null, l.cost_center || null, l.project_code || null, l.description ? sanitizeString(l.description) : null, JSON.stringify(sanitizeMetadata(l.metadata || {}))] ); insertedLines.push(lr[0]); } await writeAudit(ctx, 'created', null, { entry, lines: insertedLines }, client); await client.query('COMMIT'); const totalDebit = v.lines.reduce((s, l) => s + (l.debit || 0), 0); await hermes.emit('finance.journal.created', ctx, { entry_id: entry.id, period: per, fiscal_year: fy, total_debit: totalDebit, description: v.description, source_type: v.source_type, }); res.status(201).json({ ok: true, entry, lines: insertedLines }); } catch (e) { await client.query('ROLLBACK'); console.error('[ledger] POST /journal error:', e.message); res.status(400).json({ ok: false, error: e.message }); } finally { client.release(); } }); // POST /api/ledger/journal/:id/post — konterar verifikation (draft → posted) (kräver accountant+) app.post('/api/ledger/journal/:id/post', auth.requireRole('accountant'), validate(schemas.postJournalEntry), async (req, res) => { const { id } = req.params; const ctx = buildCtx(req, 'journal_entry', id); const client = await pool.connect(); try { await client.query('BEGIN'); const { rows } = await client.query( `SELECT * FROM ledger_journal_entries WHERE id=$1 AND tenant_id=$2`, [id, ctx.tenant_id] ); if (!rows.length) return res.status(404).json({ ok: false, error: 'Verifikation hittades inte' }); const before = rows[0]; if (before.status !== 'draft') { return res.status(409).json({ ok: false, error: `Kan inte kontera: status är '${before.status}'` }); } // Hämta rader för re-validering const { rows: lines } = await client.query( `SELECT * FROM ledger_journal_lines WHERE entry_id=$1 ORDER BY line_number`, [id] ); const td = lines.reduce((s,l) => s + (parseFloat(l.debit) || 0), 0); const tc = lines.reduce((s,l) => s + (parseFloat(l.credit) || 0), 0); if (Math.abs(td - tc) > 0.01) throw new Error(`Dubbelbokföring bruten vid kontering: ${td} ≠ ${tc}`); // Tilldela löpnummer const { rows: seqRows } = await client.query( `SELECT nextval('ledger_entry_number_seq') AS num` ); const entry_number = seqRows[0].num; const { rows: updated } = await client.query( `UPDATE ledger_journal_entries SET status='posted', posted_at=NOW(), entry_number=$1 WHERE id=$2 AND tenant_id=$3 RETURNING *`, [entry_number, id, ctx.tenant_id] ); const after = updated[0]; await writeAudit(ctx, 'posted', before, after, client); await client.query('COMMIT'); await hermes.emit('finance.journal.posted', ctx, { entry_id: id, entry_number, period: after.period, fiscal_year: after.fiscal_year, }); res.json({ ok: true, entry: after }); } catch (e) { await client.query('ROLLBACK'); console.error('[ledger] POST /journal/:id/post error:', e.message); res.status(400).json({ ok: false, error: e.message }); } finally { client.release(); } }); // GET /api/ledger/journal — lista verifikationer (viewer+) app.get('/api/ledger/journal', validate(schemas.listJournalEntries), async (req, res) => { const tenant_id = req.tenant_id; const v = req.validated; const conditions = ['e.tenant_id = $1']; const params = [tenant_id]; let idx = 2; if (v.period) { conditions.push(`e.period = $${idx++}`); params.push(v.period); } if (v.fiscal_year) { conditions.push(`e.fiscal_year = $${idx++}`); params.push(v.fiscal_year); } if (v.status) { conditions.push(`e.status = $${idx++}`); params.push(v.status); } try { const { rows } = await pool.query( `SELECT e.*, json_agg(l ORDER BY l.line_number) AS lines FROM ledger_journal_entries e LEFT JOIN ledger_journal_lines l ON l.entry_id = e.id WHERE ${conditions.join(' AND ')} GROUP BY e.id ORDER BY e.entry_date DESC, e.created_at DESC LIMIT $${idx++} OFFSET $${idx}`, [...params, v.limit, v.offset] ); res.json({ ok: true, entries: rows, count: rows.length }); } catch (e) { console.error('[ledger] GET /journal error:', e.message); res.status(500).json({ ok: false, error: 'Internt serverfel' }); } }); // ══════════════════════════════════════════════════════════════════════════════ // TRIAL BALANCE — Saldobalans // ══════════════════════════════════════════════════════════════════════════════ app.get('/api/ledger/trial-balance', validate(schemas.trialBalance), async (req, res) => { const tenant_id = req.tenant_id; const v = req.validated; try { const conditions = ['e.tenant_id=$1', 'e.fiscal_year=$2', "e.status='posted'"]; const params = [tenant_id, v.fiscal_year]; if (v.period) { conditions.push(`e.period=$3`); params.push(v.period); } const { rows } = await pool.query( `SELECT l.account_number, MAX(l.account_name) AS account_name, COALESCE(SUM(l.debit), 0) AS total_debit, COALESCE(SUM(l.credit), 0) AS total_credit, COALESCE(SUM(l.debit), 0) - COALESCE(SUM(l.credit), 0) AS balance FROM ledger_journal_lines l JOIN ledger_journal_entries e ON e.id = l.entry_id WHERE ${conditions.join(' AND ')} GROUP BY l.account_number ORDER BY l.account_number`, params ); const totalDebit = rows.reduce((s,r) => s + parseFloat(r.total_debit), 0); const totalCredit = rows.reduce((s,r) => s + parseFloat(r.total_credit), 0); res.json({ ok: true, fiscal_year: v.fiscal_year, period: v.period || 'all', accounts: rows, totals: { debit: totalDebit, credit: totalCredit, balanced: Math.abs(totalDebit - totalCredit) < 0.01 } }); } catch (e) { console.error('[ledger] GET /trial-balance error:', e.message); res.status(500).json({ ok: false, error: 'Internt serverfel' }); } }); // ══════════════════════════════════════════════════════════════════════════════ // PERIOD MANAGEMENT // ══════════════════════════════════════════════════════════════════════════════ app.get('/api/ledger/periods', validate(schemas.listPeriods), async (req, res) => { const tenant_id = req.tenant_id; const v = req.validated; const cond = ['tenant_id=$1']; const params = [tenant_id]; if (v.fiscal_year) { cond.push('fiscal_year=$2'); params.push(v.fiscal_year); } try { const { rows } = await pool.query( `SELECT * FROM ledger_periods WHERE ${cond.join(' AND ')} ORDER BY fiscal_year, period`, params ); res.json({ ok: true, periods: rows }); } catch (e) { console.error('[ledger] GET /periods error:', e.message); res.status(500).json({ ok: false, error: 'Internt serverfel' }); } }); app.post('/api/ledger/periods/:period/close', auth.requireRole('admin'), validate(schemas.closePeriod), async (req, res) => { const { period } = req.params; const ctx = buildCtx(req, 'period', period); const v = req.validated; const fiscal_year = v.fiscal_year || parseInt(period.split('-')[0]); const client = await pool.connect(); try { await client.query('BEGIN'); // Kräver att alla entries är posted const { rows: drafts } = await client.query( `SELECT COUNT(*) AS cnt FROM ledger_journal_entries WHERE tenant_id=$1 AND period=$2 AND status='draft'`, [ctx.tenant_id, period] ); if (parseInt(drafts[0].cnt) > 0) { throw new Error(`${drafts[0].cnt} utkast finns kvar — kontera dem innan periodstängning`); } // Hämta trial balance som snapshot const { rows: tb } = await client.query( `SELECT l.account_number, COALESCE(SUM(l.debit),0) AS total_debit, COALESCE(SUM(l.credit),0) AS total_credit FROM ledger_journal_lines l JOIN ledger_journal_entries e ON e.id=l.entry_id WHERE e.tenant_id=$1 AND e.period=$2 AND e.status='posted' GROUP BY l.account_number`, [ctx.tenant_id, period] ); const { rows: updated } = await client.query( `INSERT INTO ledger_periods (tenant_id, fiscal_year, period, status, closed_at, closed_by, trial_balance, trace_id, metadata) VALUES ($1,$2,$3,'closed',NOW(),$4,$5,$6,$7) ON CONFLICT (tenant_id, fiscal_year, period) DO UPDATE SET status='closed', closed_at=NOW(), closed_by=EXCLUDED.closed_by, trial_balance=EXCLUDED.trial_balance RETURNING *`, [ctx.tenant_id, fiscal_year, period, ctx.user_id, JSON.stringify(tb), ctx.trace_id, JSON.stringify({})] ); await writeAudit(ctx, 'period_closed', null, updated[0], client); await client.query('COMMIT'); await hermes.emit('finance.period.closed', ctx, { period, fiscal_year, closed_by: ctx.user_id, entry_count: tb.length, }); res.json({ ok: true, period: updated[0] }); } catch (e) { await client.query('ROLLBACK'); console.error('[ledger] POST /periods/:period/close error:', e.message); res.status(400).json({ ok: false, error: e.message }); } finally { client.release(); } }); // ── Health ──────────────────────────────────────────────────────────────────── app.get('/health', async (_req, res) => { try { await pool.query('SELECT 1'); res.json({ ok: true, service: 'aamos-ledger', port: PORT, db: 'connected' }); } catch (e) { res.status(503).json({ ok: false, service: 'aamos-ledger', db: 'disconnected', error: e.message }); } }); // ── 404 handler ─────────────────────────────────────────────────────────────── app.use((_req, res) => { res.status(404).json({ ok: false, error: 'Endpoint hittades inte' }); }); // ── Global error handler ────────────────────────────────────────────────────── app.use((err, _req, res, _next) => { console.error('[ledger] Ohanterat fel:', err.message); res.status(500).json({ ok: false, error: 'Internt serverfel' }); }); // ── Start ───────────────────────────────────────────────────────────────────── try { await initSchema(); app.listen(PORT, () => { console.log(`[aamos-ledger] Ledger Engine på :${PORT}`); console.log(`[aamos-ledger] Hermes → redis://127.0.0.1:6379 (kanal: aamos:hermes)`); console.log(`[aamos-ledger] Auth: ${process.env.NODE_ENV === 'production' ? 'RS256' : 'HS256'}`); console.log(`[aamos-ledger] Endpoints: /api/ledger/accounts · /api/ledger/journal · /api/ledger/trial-balance · /api/ledger/periods`); }); } catch (e) { console.error('[aamos-ledger] Startup misslyckades:', e.message); process.exit(1); }