/** * ═══════════════════════════════════════════════════════════════════════════ * AAMOS Ledger Engine — Ekonomimodulens sanning * Port: 3250 * Etapp 1 — Vertikal stängning * Refactored: Routes split, shared pool, API v1 versioning * * Principer (BYGGPLAN 0.001): * • Determinism före AI — alla beslut reproducerbara * • Audit First — varje operation genererar event + auditpost * • Tenant Isolation — kunddata korsas aldrig * • Hermes — alla ekonomiska händelser publiceras via event fabric * • Inga direktberoenden till andra moduler * ═══════════════════════════════════════════════════════════════════════════ */ import express from 'express'; import cors from 'cors'; 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 { authMiddleware } from './auth.mjs'; import jwt from 'jsonwebtoken'; import { register as registerPeriodWorkflow } from './period-workflow.mjs'; import { register as registerReconciliation } from './reconciliation.mjs'; import { register as registerSie4Export } from './sie4-export.mjs'; import { registerCheckpoints, initCheckpointEngine } from './checkpoint-engine.mjs'; // Route modules import registerAccounts from './routes/accounts.mjs'; import registerJournal from './routes/journal.mjs'; import registerPeriods from './routes/periods.mjs'; import registerReports from './routes/reports.mjs'; import registerInvoices from './routes/invoices.mjs'; import registerExport from './routes/export.mjs'; import registerHealth from './routes/health.mjs'; const __dir = dirname(fileURLToPath(import.meta.url)); const PORT = process.env.AAMOS_LEDGER_PORT || process.env.PORT || 3250; const { Pool } = pg; // ── Database ───────────────────────────────────────────────────────────────── const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: { rejectUnauthorized: false }, max: 10, idleTimeoutMillis: 30000, }); // Initialize checkpoint engine with shared pool (fixes duplicate pool) initCheckpointEngine(pool); async function initSchema() { const sql = readFileSync(join(__dir, 'schema.sql'), 'utf8'); await pool.query(sql); console.log('[ledger] Schema initialiserat'); } // ── App ─────────────────────────────────────────────────────────────────────── const app = express(); app.use(cors()); app.use((req, res, next) => { if (req.path.endsWith('/upload')) return next(); express.json()(req, res, next); }); // ── Auth Validate (unauthed, local RS256 verify) ───────────────────────────── let _ledgerPubKey = null; try { _ledgerPubKey = readFileSync('/opt/amos/data/keys/jwt-public.pem', 'utf8'); } catch {} const _ledgerJwtSecret = process.env.AMOS_JWT_SECRET || process.env.JWT_SECRET || 'y8Lf__vnWpImX2lxyPo3R0WYFEgCrtmQ3r8FcCwKjAWTNsLBKhaqIrP5oGAdt31E'; app.get('/api/auth/validate', (req, res) => { const token = req.query.token; if (!token) return res.status(400).json({ ok: false, error: 'token query param required' }); try { const verifyKey = _ledgerPubKey || _ledgerJwtSecret; const algos = _ledgerPubKey ? ['RS256'] : ['HS256']; const claims = jwt.verify(token, verifyKey, { algorithms: algos }); res.json({ ok: true, claims }); } catch (e) { res.status(401).json({ ok: false, error: e.message }); } }); app.use(authMiddleware); // E1-AUTH: JWT validation för alla övriga endpoints // ── 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.headers['x-tenant-id'] || req.body?.tenant_id || 'wavult-group', user_id: req.headers['x-user-id'] || req.body?.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, ] ); } // ══════════════════════════════════════════════════════════════════════════════ // ROUTE REGISTRATION (Refactored into modules) // ══════════════════════════════════════════════════════════════════════════════ // Health (unauthed) registerHealth(app, pool, PORT); // Core ledger routes registerAccounts(app, pool, buildCtx, writeAudit, hermes); registerJournal(app, pool, buildCtx, writeAudit, hermes); registerPeriods(app, pool); registerReports(app, pool); registerInvoices(app, pool); registerExport(app, pool); // ══════════════════════════════════════════════════════════════════════════════ // KUNDREGISTER — LandveX Customers & Contracts // ══════════════════════════════════════════════════════════════════════════════ // GET /api/ledger/customers — lista kunder med avtal app.get('/api/ledger/customers', async (req, res) => { try { const orgId = req.query.org_id || req.headers['x-tenant-id'] || 'landvex'; const { rows } = await pool.query(` SELECT c.id, c.name, c.org_number, c.vat_number, c.contact_name, c.contact_email, c.contact_phone, c.address, c.postal_code, c.city, c.country, c.payment_terms_days, c.default_vat_rate, c.default_account, c.currency, c.notes, c.active, c.created_at, json_agg( json_build_object( 'id', ct.id, 'title', ct.title, 'contract_ref', ct.contract_ref, 'amount_excl_vat', ct.amount_excl_vat, 'vat_rate', ct.vat_rate, 'billing_period', ct.billing_period, 'payment_terms_days', ct.payment_terms_days, 'invoice_description', ct.invoice_description, 'account_credit', ct.account_credit, 'status', ct.status, 'start_date', ct.start_date, 'end_date', ct.end_date ) ORDER BY ct.created_at DESC ) FILTER (WHERE ct.id IS NOT NULL) as contracts FROM landvex_customers c LEFT JOIN landvex_contracts ct ON ct.customer_id = c.id AND ct.status = 'active' WHERE c.org_id = $1 AND c.active = true GROUP BY c.id ORDER BY c.name `, [orgId]); res.json({ ok: true, customers: rows }); } catch (err) { res.status(500).json({ ok: false, error: err.message }); } }); // POST /api/ledger/customers — skapa ny kund app.post('/api/ledger/customers', async (req, res) => { try { const { name, org_number, vat_number, contact_name, contact_email, contact_phone, address, postal_code, city, country, payment_terms_days, default_vat_rate, default_account, currency, notes, org_id } = req.body; if (!name) return res.status(400).json({ ok: false, error: 'name required' }); const tenantOrgId = org_id || req.headers['x-tenant-id'] || 'landvex'; const { rows } = await pool.query( `INSERT INTO landvex_customers (org_id, name, org_number, vat_number, contact_name, contact_email, contact_phone, address, postal_code, city, country, payment_terms_days, default_vat_rate, default_account, currency, notes) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) RETURNING *`, [ tenantOrgId, name, org_number || null, vat_number || null, contact_name || null, contact_email || null, contact_phone || null, address || null, postal_code || null, city || null, country || 'SE', payment_terms_days != null ? payment_terms_days : 30, default_vat_rate != null ? default_vat_rate : 25, default_account || '3000', currency || 'SEK', notes || null ] ); res.json({ ok: true, customer: rows[0] }); } catch (err) { res.status(500).json({ ok: false, error: err.message }); } }); // ── E1-004 Reconciliation Engine ───────────────────────────────────────────── registerReconciliation(app, pool, buildCtx, writeAudit, hermes); // ── E1-005 Period Close Workflow ────────────────────────────────────────────── registerPeriodWorkflow(app, pool, buildCtx, writeAudit, hermes); // ── E1-006 SIE4 Export ───────────────────────────────────────────────────────────── registerSie4Export(app, pool, buildCtx, hermes); // Checkpoint Engine (L2-001) registerCheckpoints(app); // ══════════════════════════════════════════════════════════════════════════════ // API v1 PREFIX (backward compatibility) // All existing /api/ledger/* routes are also available under /api/v1/ledger/* // ══════════════════════════════════════════════════════════════════════════════ // Create a sub-app for v1 routes const v1App = express.Router(); // Re-register all routes on v1 router registerHealth(v1App, pool, PORT); registerAccounts(v1App, pool, buildCtx, writeAudit, hermes); registerJournal(v1App, pool, buildCtx, writeAudit, hermes); registerPeriods(v1App, pool); registerReports(v1App, pool); registerInvoices(v1App, pool); registerExport(v1App, pool); // Mount v1 routes app.use('/api/v1', v1App); // ── 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] Endpoints: /api/ledger/* · /api/v1/ledger/* (versioned)`); }); } catch (e) { console.error('[aamos-ledger] Startup misslyckades:', e.message); process.exit(1); }