/** * period-workflow.mjs — AAMOS Ledger Period Close Workflow * ───────────────────────────────────────────────────────────────────────────── * Implementerar komplett workflow-state machine för periodhantering: * * open ──submit-review──► review ──approve──► approved ──close──► closed * ▲ │ * └─reject─┘ * approved/review ──reopen──► open * * Exporterar: register(app, pool, buildCtx, writeAudit, hermes) * ───────────────────────────────────────────────────────────────────────────── */ import { createHash } from 'crypto'; import { createRequire } from 'module'; import { readFileSync } from 'fs'; const _require = createRequire(import.meta.url); const _jwt = _require('jsonwebtoken'); // RS256 private key for internal system-to-system JWT signing let _ledgerPrivKey = null; try { _ledgerPrivKey = readFileSync('/opt/amos/data/keys/jwt-private.pem', 'utf8'); } catch {} const _JWT_SECRET = process.env.AMOS_JWT_SECRET || process.env.JWT_SECRET || 'y8Lf__vnWpImX2lxyPo3R0WYFEgCrtmQ3r8FcCwKjAWTNsLBKhaqIrP5oGAdt31E'; // ── Private helpers ─────────────────────────────────────────────────────────── /** * Hämtar en period-rad med tenant-isolation. * @returns {object|null} */ async function fetchPeriod(client, tenant_id, fiscal_year, period) { const { rows } = await client.query( `SELECT * FROM ledger_periods WHERE tenant_id=$1 AND fiscal_year=$2 AND period=$3`, [tenant_id, fiscal_year, period] ); return rows[0] || null; } /** * Beräknar trial balance för perioden (baserat på posted entries). * @returns {{ accounts: Array, totalDebit: number, totalCredit: number, balanced: boolean }} */ async function buildTrialBalance(client, tenant_id, period) { const { rows } = 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 ORDER BY l.account_number`, [tenant_id, period] ); const totalDebit = rows.reduce((s, r) => s + parseFloat(r.total_debit), 0); const totalCredit = rows.reduce((s, r) => s + parseFloat(r.total_credit), 0); return { accounts: rows, totalDebit, totalCredit, balanced: Math.abs(totalDebit - totalCredit) < 0.01, }; } /** * Härleder fiscal_year från body-fält eller period-strängen (t.ex. '2026-01' → 2026). */ function getFiscalYear(body, period) { return body.fiscal_year ? parseInt(body.fiscal_year) : parseInt(period.split('-')[0]); } /** * Beräknar nettointäkt (SEK) från BAS intäktskonton 3xxx för perioden. * Intäktskonton krediteras vid intäkt → net_revenue = summa kredit - summa debet. * @returns {{ net_revenue_sek: number, snapshot: Array }} */ async function calcNetRevenue(client, tenant_id, period) { const { rows } = await client.query( `SELECT l.account_number, COALESCE(SUM(l.credit), 0) AS total_credit, COALESCE(SUM(l.debit), 0) AS total_debit 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' AND l.account_number::text LIKE '3%' GROUP BY l.account_number ORDER BY l.account_number`, [tenant_id, period] ); const net_revenue_sek = rows.reduce( (s, r) => s + (parseFloat(r.total_credit) - parseFloat(r.total_debit)), 0 ); return { net_revenue_sek, snapshot: rows }; } /** * Skickar revenue-sync till billing-tjänsten (best-effort, blockerar ej stängning). * POST http://localhost:3100/api/ouroboros/billing/revenue-sync/:org_id */ async function callRevenueSyncBilling({ tenant_id, fiscal_year, net_revenue_sek, sie4_hash, snapshot_data }) { const url = `http://localhost:3100/api/ouroboros/billing/revenue-sync/${encodeURIComponent(tenant_id)}`; const body = JSON.stringify({ fiscal_year, net_revenue_sek, sie4_hash, snapshot_data }); // Generera internt system-JWT för billing-anropet let systemToken; try { const signKey = _ledgerPrivKey || _JWT_SECRET; const signOpts = _ledgerPrivKey ? { algorithm: 'RS256', expiresIn: '5m' } : { algorithm: 'HS256', expiresIn: '5m' }; systemToken = _jwt.sign( { sub: 'aamos-ledger', org_id: tenant_id, roles: ['admin'], iss: 'wavult', source: 'ouroboros-system' }, signKey, signOpts ); } catch (signErr) { console.warn('[period-workflow] JWT signing failed for revenue-sync:', signErr.message); systemToken = null; } const authHeader = systemToken ? { 'Authorization': `Bearer ${systemToken}` } : {}; try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-internal-source': 'ouroboros-system', 'x-tenant-id': tenant_id, ...authHeader, }, body, signal: AbortSignal.timeout(10_000), }); const text = await res.text(); let json; try { json = JSON.parse(text); } catch { json = { raw: text }; } return { ok: res.ok, status: res.status, body: json }; } catch (err) { // Nätverksfel eller timeout — logga men blockera ej return { ok: false, error: err.message }; } } // ── Module export ───────────────────────────────────────────────────────────── export function register(app, pool, buildCtx, writeAudit, hermes) { // ════════════════════════════════════════════════════════════════════════════ // POST /api/ledger/periods/:period/open // Skapar/öppnar en period (idempotent om redan öppen) // Body: { fiscal_year } // ════════════════════════════════════════════════════════════════════════════ app.post('/api/ledger/periods/:period/open', async (req, res) => { const { period } = req.params; const ctx = buildCtx(req, 'period', period); const fiscal_year = getFiscalYear(req.body, period); const client = await pool.connect(); try { await client.query('BEGIN'); // Insert med ON CONFLICT DO NOTHING — idempotent om perioden redan är öppen const { rows: inserted } = await client.query( `INSERT INTO ledger_periods (tenant_id, fiscal_year, period, status, opened_at, trace_id, metadata) VALUES ($1, $2, $3, 'open', NOW(), $4, $5) ON CONFLICT (tenant_id, fiscal_year, period) DO NOTHING RETURNING *`, [ctx.tenant_id, fiscal_year, period, ctx.trace_id, JSON.stringify({})] ); const isNew = inserted.length > 0; const periodRow = isNew ? inserted[0] : await fetchPeriod(client, ctx.tenant_id, fiscal_year, period); if (!periodRow) { throw new Error('Period kunde inte skapas — okänt fel'); } // Om raden redan finns med annan status → 409 if (periodRow.status !== 'open') { await client.query('ROLLBACK'); return res.status(409).json({ ok: false, error: `Period har redan status '${periodRow.status}'. Använd reopen-endpointen för att återgå till open.`, }); } // Skriv audit endast för faktisk skapelse (inte idempotent re-fetch) if (isNew) { await writeAudit(ctx, 'period_opened', null, periodRow, client); } await client.query('COMMIT'); if (isNew) { await hermes.emit('finance.period.opened', ctx, { period, fiscal_year }); } res.status(isNew ? 201 : 200).json({ ok: true, period: periodRow, transition: 'created→open', }); } catch (e) { await client.query('ROLLBACK'); res.status(400).json({ ok: false, error: e.message }); } finally { client.release(); } }); // ════════════════════════════════════════════════════════════════════════════ // POST /api/ledger/periods/:period/submit-review // Transition: open → review // Kontroller: inga draft-entries, trial balance i balans, status='open' // ════════════════════════════════════════════════════════════════════════════ app.post('/api/ledger/periods/:period/submit-review', async (req, res) => { const { period } = req.params; const ctx = buildCtx(req, 'period', period); const fiscal_year = getFiscalYear(req.body, period); const client = await pool.connect(); try { await client.query('BEGIN'); const periodRow = await fetchPeriod(client, ctx.tenant_id, fiscal_year, period); if (!periodRow) { return res.status(404).json({ ok: false, error: 'Period hittades inte' }); } if (periodRow.status !== 'open') { await client.query('ROLLBACK'); return res.status(409).json({ ok: false, error: `Transition open→review kräver status='open', nuvarande status är '${periodRow.status}'`, }); } // Kontroll 1: inga draft-verifikationer const { rows: draftCheck } = 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(draftCheck[0].cnt) > 0) { await client.query('ROLLBACK'); return res.status(422).json({ ok: false, error: `${draftCheck[0].cnt} draft-verifikation(er) finns kvar — kontera dem innan granskningsstart`, }); } // Kontroll 2: trial balance måste vara i balans const tb = await buildTrialBalance(client, ctx.tenant_id, period); if (!tb.balanced) { await client.query('ROLLBACK'); return res.status(422).json({ ok: false, error: `Saldobalansen är inte i balans: debet ${tb.totalDebit.toFixed(2)} ≠ kredit ${tb.totalCredit.toFixed(2)}`, }); } // Spara checklist-snapshot i metadata const checklist = { checks: { no_drafts: true, balanced: true }, submitted_at: new Date().toISOString(), submitted_by: ctx.user_id, }; const newMetadata = { ...(periodRow.metadata || {}), workflow: checklist }; const { rows: updated } = await client.query( `UPDATE ledger_periods SET status='review', metadata=$1 WHERE tenant_id=$2 AND fiscal_year=$3 AND period=$4 RETURNING *`, [JSON.stringify(newMetadata), ctx.tenant_id, fiscal_year, period] ); await writeAudit(ctx, 'period_submitted_review', periodRow, updated[0], client); await client.query('COMMIT'); await hermes.emit('finance.period.review_submitted', ctx, { period, fiscal_year, submitted_by: ctx.user_id, checks: checklist.checks, }); res.json({ ok: true, period: updated[0], transition: 'open→review' }); } catch (e) { await client.query('ROLLBACK'); res.status(500).json({ ok: false, error: e.message }); } finally { client.release(); } }); // ════════════════════════════════════════════════════════════════════════════ // POST /api/ledger/periods/:period/approve // Transition: review → approved // Spara trial balance snapshot vid godkännande // ════════════════════════════════════════════════════════════════════════════ app.post('/api/ledger/periods/:period/approve', async (req, res) => { const { period } = req.params; const ctx = buildCtx(req, 'period', period); const fiscal_year = getFiscalYear(req.body, period); const client = await pool.connect(); try { await client.query('BEGIN'); const periodRow = await fetchPeriod(client, ctx.tenant_id, fiscal_year, period); if (!periodRow) { return res.status(404).json({ ok: false, error: 'Period hittades inte' }); } if (periodRow.status !== 'review') { await client.query('ROLLBACK'); return res.status(409).json({ ok: false, error: `Transition review→approved kräver status='review', nuvarande status är '${periodRow.status}'`, }); } // Snapshot av trial balance vid godkännande const tb = await buildTrialBalance(client, ctx.tenant_id, period); const newMetadata = { ...(periodRow.metadata || {}), approval: { approved_at: new Date().toISOString(), approved_by: ctx.user_id, trial_balance_snapshot: tb.accounts, }, }; const { rows: updated } = await client.query( `UPDATE ledger_periods SET status='approved', trial_balance=$1, metadata=$2 WHERE tenant_id=$3 AND fiscal_year=$4 AND period=$5 RETURNING *`, [JSON.stringify(tb.accounts), JSON.stringify(newMetadata), ctx.tenant_id, fiscal_year, period] ); await writeAudit(ctx, 'period_approved', periodRow, updated[0], client); await client.query('COMMIT'); await hermes.emit('finance.period.approved', ctx, { period, fiscal_year, approved_by: ctx.user_id, }); res.json({ ok: true, period: updated[0], transition: 'review→approved' }); } catch (e) { await client.query('ROLLBACK'); res.status(500).json({ ok: false, error: e.message }); } finally { client.release(); } }); // ════════════════════════════════════════════════════════════════════════════ // POST /api/ledger/periods/:period/reject // Transition: review → open // Body: { reason } — krävs // ════════════════════════════════════════════════════════════════════════════ app.post('/api/ledger/periods/:period/reject', async (req, res) => { const { period } = req.params; const ctx = buildCtx(req, 'period', period); const fiscal_year = getFiscalYear(req.body, period); const { reason } = req.body; if (!reason || !String(reason).trim()) { return res.status(400).json({ ok: false, error: 'reason krävs för avvisning' }); } const client = await pool.connect(); try { await client.query('BEGIN'); const periodRow = await fetchPeriod(client, ctx.tenant_id, fiscal_year, period); if (!periodRow) { return res.status(404).json({ ok: false, error: 'Period hittades inte' }); } if (periodRow.status !== 'review') { await client.query('ROLLBACK'); return res.status(409).json({ ok: false, error: `Transition review→open kräver status='review', nuvarande status är '${periodRow.status}'`, }); } const newMetadata = { ...(periodRow.metadata || {}), rejection: { rejected_at: new Date().toISOString(), rejected_by: ctx.user_id, reason: String(reason).trim(), }, }; const { rows: updated } = await client.query( `UPDATE ledger_periods SET status='open', metadata=$1 WHERE tenant_id=$2 AND fiscal_year=$3 AND period=$4 RETURNING *`, [JSON.stringify(newMetadata), ctx.tenant_id, fiscal_year, period] ); await writeAudit(ctx, 'period_rejected', periodRow, updated[0], client); await client.query('COMMIT'); await hermes.emit('finance.period.rejected', ctx, { period, fiscal_year, rejected_by: ctx.user_id, reason: String(reason).trim(), }); res.json({ ok: true, period: updated[0], transition: 'review→open' }); } catch (e) { await client.query('ROLLBACK'); res.status(500).json({ ok: false, error: e.message }); } finally { client.release(); } }); // ════════════════════════════════════════════════════════════════════════════ // POST /api/ledger/periods/:period/close // Transition: approved → closed // Kräver status='approved' — returnerar samma format som original-endpointen // Revenue-sync hook: anropar billing-tjänsten med intäktsdata efter stängning // ════════════════════════════════════════════════════════════════════════════ app.post('/api/ledger/periods/:period/close', async (req, res) => { const { period } = req.params; const ctx = buildCtx(req, 'period', period); const fiscal_year = getFiscalYear(req.body, period); const client = await pool.connect(); try { await client.query('BEGIN'); const periodRow = await fetchPeriod(client, ctx.tenant_id, fiscal_year, period); // Perioden måste existera och ha status='approved' if (!periodRow || periodRow.status !== 'approved') { await client.query('ROLLBACK'); const currentStatus = periodRow ? `'${periodRow.status}'` : 'inte skapad'; return res.status(409).json({ ok: false, error: `Periodstängning kräver status='approved', nuvarande status är ${currentStatus}`, }); } // Belt-and-suspenders: inga draft-verifikationer ska finnas const { rows: draftCheck } = 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(draftCheck[0].cnt) > 0) { await client.query('ROLLBACK'); return res.status(422).json({ ok: false, error: `${draftCheck[0].cnt} utkast finns kvar — kontera dem innan periodstängning`, }); } // Snapshot av trial balance vid stängning const tb = await buildTrialBalance(client, ctx.tenant_id, period); // ── Revenue-sync: beräkna nettointäkt från 3xxx-konton ──────────────── const { net_revenue_sek, snapshot: revenueSnapshot } = await calcNetRevenue( client, ctx.tenant_id, period ); // Generera sie4_hash som SHA-256 av snapshot-data (deterministisk) const sie4_hash = createHash('sha256') .update(JSON.stringify({ tenant_id: ctx.tenant_id, fiscal_year, period, accounts: tb.accounts })) .digest('hex'); const { rows: updated } = await client.query( `UPDATE ledger_periods SET status='closed', closed_at=NOW(), closed_by=$1, trial_balance=$2 WHERE tenant_id=$3 AND fiscal_year=$4 AND period=$5 RETURNING *`, [ctx.user_id, JSON.stringify(tb.accounts), ctx.tenant_id, fiscal_year, period] ); await writeAudit(ctx, 'period_closed', periodRow, 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.accounts.length, net_revenue_sek, }); // ── Revenue-sync hook (best-effort, utanför transaktion) ─────────────── // Anropas efter commit så att periodstängningen inte rullas tillbaka vid fel. const syncResult = await callRevenueSyncBilling({ tenant_id: ctx.tenant_id, fiscal_year, net_revenue_sek, sie4_hash, snapshot_data: revenueSnapshot, }); if (!syncResult.ok) { console.warn( `[period-workflow] revenue-sync VARNING: tenant=${ctx.tenant_id} period=${period}`, syncResult.error || syncResult.body ); } else { console.log( `[period-workflow] revenue-sync OK: tenant=${ctx.tenant_id} period=${period}`, `net_revenue_sek=${net_revenue_sek.toFixed(2)}` ); } // Samma response-format som befintlig close-endpoint + sync-info res.json({ ok: true, period: updated[0], revenue_sync: { called: true, ok: syncResult.ok, net_revenue_sek, sie4_hash, billing_status: syncResult.status ?? null, billing_body: syncResult.body ?? syncResult.error ?? null, }, }); } catch (e) { await client.query('ROLLBACK'); res.status(400).json({ ok: false, error: e.message }); } finally { client.release(); } }); // ════════════════════════════════════════════════════════════════════════════ // POST /api/ledger/periods/:period/reopen // Transition: approved/review → open // Body: { reason } — krävs. Ej tillåtet om status='closed' // ════════════════════════════════════════════════════════════════════════════ app.post('/api/ledger/periods/:period/reopen', async (req, res) => { const { period } = req.params; const ctx = buildCtx(req, 'period', period); const fiscal_year = getFiscalYear(req.body, period); const { reason } = req.body; if (!reason || !String(reason).trim()) { return res.status(400).json({ ok: false, error: 'reason krävs för återöppning' }); } const client = await pool.connect(); try { await client.query('BEGIN'); const periodRow = await fetchPeriod(client, ctx.tenant_id, fiscal_year, period); if (!periodRow) { return res.status(404).json({ ok: false, error: 'Period hittades inte' }); } // Stängda perioder får aldrig återöppnas if (periodRow.status === 'closed') { await client.query('ROLLBACK'); return res.status(409).json({ ok: false, error: 'Stängd period kan inte återöppnas', }); } // Måste vara approved eller review if (!['approved', 'review'].includes(periodRow.status)) { await client.query('ROLLBACK'); return res.status(409).json({ ok: false, error: `Återöppning kräver status='approved' eller 'review', nuvarande status är '${periodRow.status}'`, }); } const fromStatus = periodRow.status; const newMetadata = { ...(periodRow.metadata || {}), reopen: { reopened_at: new Date().toISOString(), reopened_by: ctx.user_id, from_status: fromStatus, reason: String(reason).trim(), }, }; const { rows: updated } = await client.query( `UPDATE ledger_periods SET status='open', metadata=$1 WHERE tenant_id=$2 AND fiscal_year=$3 AND period=$4 RETURNING *`, [JSON.stringify(newMetadata), ctx.tenant_id, fiscal_year, period] ); await writeAudit(ctx, 'period_reopened', periodRow, updated[0], client); await client.query('COMMIT'); await hermes.emit('finance.period.reopened', ctx, { period, fiscal_year, reopened_by: ctx.user_id, from_status: fromStatus, reason: String(reason).trim(), }); res.json({ ok: true, period: updated[0], transition: `${fromStatus}→open` }); } catch (e) { await client.query('ROLLBACK'); res.status(500).json({ ok: false, error: e.message }); } finally { client.release(); } }); // ════════════════════════════════════════════════════════════════════════════ // GET /api/ledger/periods/:period/status // Returnerar period + workflow history från audit log + checklist status // Query: ?fiscal_year=2026 (valfritt, härleds annars från period-strängen) // ════════════════════════════════════════════════════════════════════════════ app.get('/api/ledger/periods/:period/status', async (req, res) => { const { period } = req.params; const tenant_id = req.headers['x-tenant-id'] || req.query.tenant_id || 'wavult-group'; const fiscal_year = req.query.fiscal_year ? parseInt(req.query.fiscal_year) : parseInt(period.split('-')[0]); try { const { rows: periodRows } = await pool.query( `SELECT * FROM ledger_periods WHERE tenant_id=$1 AND fiscal_year=$2 AND period=$3`, [tenant_id, fiscal_year, period] ); if (!periodRows.length) { return res.status(404).json({ ok: false, error: 'Period hittades inte' }); } const periodRecord = periodRows[0]; // Audit log för denna period (entity_id = period-strängen, entity_type = 'period') const { rows: auditRows } = await pool.query( `SELECT action, user_id, decision_source, ts AS created_at, before_state, after_state FROM ledger_audit_log WHERE tenant_id=$1 AND entity_type='period' AND entity_id=$2 ORDER BY ts ASC`, [tenant_id, period] ); // Checklist-status från metadata (satt av submit-review) const workflow = (periodRecord.metadata || {}).workflow || {}; const checklist = { no_drafts: workflow.checks?.no_drafts || false, balanced: workflow.checks?.balanced || false, submitted_at: workflow.submitted_at || null, submitted_by: workflow.submitted_by || null, }; res.json({ ok: true, period: periodRecord, workflow_history: auditRows, checklist, }); } catch (e) { res.status(500).json({ ok: false, error: e.message }); } }); }