commit 19d55c545e78b7f65213a053c34570ad7765354b Author: Bernt Date: Wed Jun 17 06:34:57 2026 +0000 Initial commit: aamos-ledger source + CLAUDE.md context diff --git a/aamos-ledger/.dockerignore b/aamos-ledger/.dockerignore new file mode 100644 index 000000000..040aa17f2 --- /dev/null +++ b/aamos-ledger/.dockerignore @@ -0,0 +1,3 @@ +node_modules/ +.git/ +*.md diff --git a/aamos-ledger/.gitignore b/aamos-ledger/.gitignore new file mode 100644 index 000000000..5e86967c1 --- /dev/null +++ b/aamos-ledger/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.bak +*.bak-* diff --git a/aamos-ledger/CLAUDE.md b/aamos-ledger/CLAUDE.md new file mode 100644 index 000000000..3a891bab9 --- /dev/null +++ b/aamos-ledger/CLAUDE.md @@ -0,0 +1,147 @@ +# CLAUDE.md — aamos-ledger + +> AI-kontext för alla som jobbar i detta repo. + +## Vad är detta? + +**aamos-ledger** är LandveX AB:s bokföringsmotor — en komplett, egenutvecklad redovisningsmotor med: +- Dubbel bokföring (debet = kredit, alltid) +- BAS 2024 kontoplan (svensk standard) +- SIE4-import och export +- Multi-tenant isolering (tenant_id på varje rad) +- Full audit trail (varje operation sparas i `ledger_audit_log`) +- Hermes event-publicering (alla ekonomiska händelser) + +**Port:** 3250 +**Tenant för LandveX AB:** `landvex` +**Tjänstnamn (systemd):** `aamos-ledger` + +--- + +## Arkitektur + +``` +index.mjs — Express-app, alla API-routes +schema.sql — PostgreSQL-schema (kör vid start via initSchema()) +hermes.mjs — Event-publishing till Hermes fabric +sie4-export.mjs — SIE4-exportlogik +Dockerfile.migrate — DB-migration container +package.json — Node ESM-modul +``` + +### Databasschema (nyckelltabeller) + +| Tabell | Beskrivning | +|--------|------------| +| `ledger_accounts` | Kontoplan (BAS 2024, IFRS, custom) | +| `ledger_journal_entries` | Verifikationer (IMMUTABLE efter `posted`) | +| `ledger_entry_lines` | Kontolinjer per verifikat (debet/kredit) | +| `ledger_periods` | Perioder (`open`/`closed`/`locked`) | +| `ledger_audit_log` | Audit trail för varje operation | +| `ledger_reconciliation_items` | Kontoavstämning | +| `ledger_period_close_tasks` | Periodeclose workflow | +| `ledger_vat_returns` | Momsdeklarationer | +| `ledger_fiscal_years` | Räkenskapsår | + +--- + +## API-design + +Alla endpoints kräver auth (JWT eller API-key). Tenant sätts via: +- Header: `X-Tenant-ID: landvex` +- Body: `{ "tenant_id": "landvex" }` + +Exempel: +```bash +GET /api/accounts?tenant=landvex +POST /api/journal-entries +GET /api/periods?tenant=landvex +POST /api/sie4/export +``` + +Trace-propagering via headers: +- `X-Trace-ID` — propageras genom hela systemet +- `X-Correlation-ID` — korrelation per request-träd +- `X-User-ID` — vem som triggar + +--- + +## Designprinciper (OBRYTBARA) + +1. **Determinism före AI** — alla bokföringsbeslut reproducerbara +2. **Audit First** — VARJE operation genererar auditpost +3. **Tenant Isolation** — kunddata korsas aldrig, `tenant_id` filtreras alltid +4. **Hermes** — ekonomiska händelser publiceras via event fabric +5. **Inga direktberoenden** — till andra moduler (utom Hermes via events) +6. **IMMUTABLE journal** — poster ändras ALDRIG efter `status=posted`; annullering = ny `voided`-post + +--- + +## Etapp 1 — Vertikal stängning (pågår juni 2026) + +Checklista: +- [x] Chart of Accounts (BAS 2024) +- [x] Journal Entries med audit trail +- [x] SIE4-import +- [ ] **E1-004** Reconciliation Engine +- [ ] **E1-005** Period Close Workflow +- [ ] **E1-006** SIE4 Export (komplett) +- [ ] Auth mot Identity Service +- [ ] aamos-audit-engine restart-fix +- [ ] **E1-008** Vertikal stängning acceptanstest + +--- + +## LandveX AB — Nuläge (per 2026-06-17) + +| Mått | Värde | +|------|-------| +| Journalposter totalt | ~710 | +| Alla poster | `posted` (inga drafts) | +| Obalanserade poster | **0** | +| Aktiva konton | 38 | +| Revolut-poster på 1689 (oklassificerade) | ~545 / 966 685 kr | +| Q1-moms förfallen | **202 787 kr** — OBETALD | +| Leon-fordran | **212 922 kr** (konto 1610) | +| Trygg Bil-avtal | 222 916 kr/mån t.o.m. mars 2027 | + +--- + +## Ekonomisystem — Fortnox-klonen i Ouroboros + +Live-URLer: +- **Fortnox-klonen:** https://landvex.com/ouroboros/finance/ +- **Verifikatsvy:** https://landvex.com/ouroboros/finance/ledger.html +- **CFO-dashboard:** https://landvex.com/public/cfo-dashboard.html + +Filer på disk: +- `/opt/amos/public/ouroboros/finance/index.html` +- `/opt/amos/public/ouroboros/finance/ledger.html` +- `/opt/amos/public/public/cfo-dashboard.html` + +--- + +## Kör lokalt + +```bash +cd /home/bernt/.openclaw/workspace/aamos-ledger +# eller källkoden live på servern: +sudo -n systemctl status aamos-ledger +sudo -n journalctl -u aamos-ledger -n 50 + +# Health check +curl -sk http://localhost:3250/health +``` + +--- + +## Bolagsinfo + +- **Bolag:** LandveX AB (org.nr 559141-7042, f.d. Sommarliden Holding AB) +- **Räkenskapsår:** 1 maj – 30 april +- **FY 2025/2026:** avslutat, bokslut senast 31 okt 2026 +- **Revisor:** Andreas Vretblad, KPMG, +46 70 865 67 27 + +--- + +*Senast uppdaterad: 2026-06-17* diff --git a/aamos-ledger/Dockerfile.migrate b/aamos-ledger/Dockerfile.migrate new file mode 100644 index 000000000..aec25100f --- /dev/null +++ b/aamos-ledger/Dockerfile.migrate @@ -0,0 +1,12 @@ +FROM node:20-bookworm-slim AS runtime +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* && useradd -r -u 10001 -s /usr/sbin/nologin appuser +COPY package*.json ./ +RUN npm install --omit=dev --no-audit --no-fund 2>/dev/null || npm install --production --no-audit --no-fund +COPY . . +USER appuser +ENV PORT=3250 NODE_ENV=production +EXPOSE 3250 +HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 CMD curl -fsS http://127.0.0.1:3250/health || exit 1 +CMD ["node","index.mjs"] diff --git a/aamos-ledger/hermes.mjs b/aamos-ledger/hermes.mjs new file mode 100644 index 000000000..886bf2b89 --- /dev/null +++ b/aamos-ledger/hermes.mjs @@ -0,0 +1,141 @@ +/** + * hermes.mjs — AAMOS Hermes Event Fabric adapter + * ───────────────────────────────────────────────────────────────────────────── + * Thin adapter between domain modules and Hermes. + * Publishes events via Redis pub/sub (primary) with JSONL file fallback. + * + * Event envelope: + * { + * id: string — UUIDv4 + * trace_id: string — propagated across the full operation chain + * correlation_id:string — groups related events (e.g. one invoice = many events) + * event_type: string — e.g. 'finance.journal.posted' + * source: string — service name, e.g. 'aamos-ledger' + * tenant_id: string — org/tenant isolation + * user_id: string — actor + * entity_type: string — e.g. 'journal_entry' + * entity_id: string — UUID of the affected entity + * decision_source: string — 'system' | 'user' | 'agent' (audit principle) + * payload: object — event-specific data + * ts: string — ISO-8601 UTC + * schema_version: string — '1.0' + * } + * + * Usage: + * import { hermes } from './hermes.mjs'; + * await hermes.emit('finance.journal.posted', ctx, { entry_id, amount }); + * ───────────────────────────────────────────────────────────────────────────── + */ + +import { createClient } from 'redis'; +import { appendFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { randomUUID } from 'crypto'; + +const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379'; +const HERMES_CHANNEL = process.env.HERMES_CHANNEL || 'aamos:hermes'; +// Cloud-native: default till skrivbar /tmp, ej host-path. JSONL-fallback är +// best-effort — får ALDRIG krascha tjänsten (Kafka/Redis är primär transport). +const LOG_DIR = process.env.HERMES_LOG_DIR || '/tmp/hermes'; +const LOG_FILE = join(LOG_DIR, 'events.jsonl'); +const SERVICE = process.env.SERVICE_NAME || 'aamos-unknown'; + +let jsonlEnabled = true; +try { + mkdirSync(LOG_DIR, { recursive: true }); +} catch (e) { + jsonlEnabled = false; + console.warn(`[hermes] JSONL-fallback inaktiverad (kan ej skapa ${LOG_DIR}): ${e.message}`); +} + +// ── Redis client (lazy, non-blocking) ──────────────────────────────────────── +let redis = null; +let redisReady = false; + +async function getRedis() { + if (redisReady) return redis; + try { + redis = createClient({ url: REDIS_URL }); + redis.on('error', (e) => { + redisReady = false; + console.warn(`[hermes] Redis error (fallback to JSONL): ${e.message}`); + }); + redis.on('ready', () => { redisReady = true; }); + await redis.connect(); + redisReady = true; + } catch (e) { + console.warn(`[hermes] Redis unavailable (fallback to JSONL): ${e.message}`); + redis = null; + } + return redis; +} + +// ── Event factory ───────────────────────────────────────────────────────────── +function makeEvent(event_type, ctx = {}, payload = {}) { + return { + id: randomUUID(), + trace_id: ctx.trace_id || randomUUID(), + correlation_id: ctx.correlation_id || randomUUID(), + event_type, + source: SERVICE, + tenant_id: ctx.tenant_id || 'wavult-group', + user_id: ctx.user_id || 'system', + entity_type: ctx.entity_type || null, + entity_id: ctx.entity_id || null, + decision_source: ctx.decision_source || 'system', + payload, + ts: new Date().toISOString(), + schema_version: '1.0', + }; +} + +// ── Publish ─────────────────────────────────────────────────────────────────── +async function emit(event_type, ctx = {}, payload = {}) { + const event = makeEvent(event_type, ctx, payload); + const raw = JSON.stringify(event); + + // JSONL best-effort (durable fallback + queryable log) — aldrig blockerande + if (jsonlEnabled) { + try { + appendFileSync(LOG_FILE, raw + '\n'); + } catch (e) { + console.error('[hermes] JSONL write failed:', e.message); + } + } + + // Publish to Redis pub/sub + const client = await getRedis(); + if (client && redisReady) { + try { + await client.publish(HERMES_CHANNEL, raw); + } catch (e) { + console.warn('[hermes] Redis publish failed (JSONL still written):', e.message); + } + } + + return event; +} + +// ── Subscribe (for services that consume events) ────────────────────────────── +async function subscribe(handler) { + const client = await getRedis(); + if (!client) { + console.warn('[hermes] subscribe: Redis unavailable, no live events'); + return null; + } + const sub = client.duplicate(); + await sub.connect(); + await sub.subscribe(HERMES_CHANNEL, (raw) => { + try { + const event = JSON.parse(raw); + handler(event); + } catch (e) { + console.warn('[hermes] subscribe parse error:', e.message); + } + }); + console.log(`[hermes] subscribed to ${HERMES_CHANNEL}`); + return sub; +} + +export const hermes = { emit, subscribe, makeEvent }; +export default hermes; diff --git a/aamos-ledger/index.mjs b/aamos-ledger/index.mjs new file mode 100644 index 000000000..a5895d01d --- /dev/null +++ b/aamos-ledger/index.mjs @@ -0,0 +1,458 @@ +/** + * ═══════════════════════════════════════════════════════════════════════════ + * AAMOS Ledger Engine — Ekonomimodulens sanning + * Port: 3250 + * Etapp 1 — Vertikal stängning + * + * 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'; + +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, +}); + +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(express.json()); + +// ── Context helper ──────────────────────────────────────────────────────────── +// Bygger trace-kontext från inkommande request. +// Propagerar trace_id om den redan finns (t.ex. från anropande service). +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, + ] + ); +} + +// ══════════════════════════════════════════════════════════════════════════════ +// CHART OF ACCOUNTS +// ══════════════════════════════════════════════════════════════════════════════ + +// GET /api/ledger/accounts — hämta kontoplan +app.get('/api/ledger/accounts', async (req, res) => { + const tenant_id = req.headers['x-tenant-id'] || 'wavult-group'; + const coa_standard = req.query.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) { + res.status(500).json({ ok: false, error: e.message }); + } +}); + +// POST /api/ledger/accounts — lägg till konto +app.post('/api/ledger/accounts', async (req, res) => { + const ctx = buildCtx(req, 'account'); + const { account_number, name, account_type, normal_balance, + coa_standard = 'BAS', parent_account, vat_code, metadata = {} } = req.body; + + if (!account_number || !name || !account_type || !normal_balance) { + return res.status(400).json({ ok: false, error: 'account_number, name, account_type, normal_balance krävs' }); + } + if (!['asset','liability','equity','revenue','expense'].includes(account_type)) { + return res.status(400).json({ ok: false, error: 'Ogiltigt account_type' }); + } + + 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, account_number, name, account_type, normal_balance, + coa_standard, parent_account, vat_code, JSON.stringify(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, name, coa_standard }); + res.status(201).json({ ok: true, account }); + } catch (e) { + res.status(500).json({ ok: false, error: e.message }); + } +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// JOURNAL — Verifikationer +// ══════════════════════════════════════════════════════════════════════════════ + +// POST /api/ledger/journal — skapa verifikation (draft) +app.post('/api/ledger/journal', async (req, res) => { + const ctx = buildCtx(req, 'journal_entry'); + const { + entry_date, description, reference, source_type = 'manual', + source_id, lines = [], period, fiscal_year, metadata = {} + } = req.body; + + if (!entry_date || !description || lines.length < 2) { + return res.status(400).json({ ok: false, error: 'entry_date, description, minst 2 rader krävs' }); + } + + // Validera dubbel bokföring + const totalDebit = lines.reduce((s, l) => s + (parseFloat(l.debit) || 0), 0); + const totalCredit = lines.reduce((s, l) => s + (parseFloat(l.credit) || 0), 0); + if (Math.abs(totalDebit - totalCredit) > 0.01) { + return res.status(400).json({ + ok: false, + error: `Dubbelbokföring bruten: debet ${totalDebit.toFixed(2)} ≠ kredit ${totalCredit.toFixed(2)}` + }); + } + + const entryDate = new Date(entry_date); + const fy = fiscal_year || entryDate.getFullYear(); + const per = 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, entry_date, description, reference, + source_type, source_id, ctx.trace_id, ctx.correlation_id, + ctx.user_id, ctx.decision_source, JSON.stringify(metadata)] + ); + const entry = rows[0]; + ctx.entity_id = entry.id; + + // Lägg in rader + const insertedLines = []; + for (let i = 0; i < lines.length; i++) { + const l = lines[i]; + if (!l.account_number) { + throw new Error(`Rad ${i+1}: account_number saknas`); + } + if ((l.debit == null) === (l.credit == null)) { + throw new Error(`Rad ${i+1}: ange antingen debit ELLER credit, inte båda/ingen`); + } + 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, + 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 || null, JSON.stringify(l.metadata || {})] + ); + insertedLines.push(lr[0]); + } + + await writeAudit(ctx, 'created', null, { entry, lines: insertedLines }, client); + await client.query('COMMIT'); + + await hermes.emit('finance.journal.created', ctx, { + entry_id: entry.id, period: per, fiscal_year: fy, + total_debit: totalDebit, description, source_type, + }); + + res.status(201).json({ ok: true, entry, lines: insertedLines }); + } catch (e) { + await client.query('ROLLBACK'); + res.status(400).json({ ok: false, error: e.message }); + } finally { + client.release(); + } +}); + +// POST /api/ledger/journal/:id/post — konterar verifikation (draft → posted) +app.post('/api/ledger/journal/:id/post', 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'); + res.status(400).json({ ok: false, error: e.message }); + } finally { + client.release(); + } +}); + +// GET /api/ledger/journal — lista verifikationer +app.get('/api/ledger/journal', async (req, res) => { + const tenant_id = req.headers['x-tenant-id'] || 'wavult-group'; + const { period, fiscal_year, status, limit = 50, offset = 0 } = req.query; + const conditions = ['e.tenant_id = $1']; + const params = [tenant_id]; + let idx = 2; + + if (period) { conditions.push(`e.period = $${idx++}`); params.push(period); } + if (fiscal_year) { conditions.push(`e.fiscal_year = $${idx++}`); params.push(parseInt(fiscal_year)); } + if (status) { conditions.push(`e.status = $${idx++}`); params.push(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, parseInt(limit), parseInt(offset)] + ); + res.json({ ok: true, entries: rows, count: rows.length }); + } catch (e) { + res.status(500).json({ ok: false, error: e.message }); + } +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// TRIAL BALANCE — Saldobalans +// ══════════════════════════════════════════════════════════════════════════════ +app.get('/api/ledger/trial-balance', async (req, res) => { + const tenant_id = req.headers['x-tenant-id'] || 'wavult-group'; + const { period, fiscal_year } = req.query; + if (!fiscal_year) return res.status(400).json({ ok: false, error: 'fiscal_year krävs' }); + + try { + const conditions = ['e.tenant_id=$1', 'e.fiscal_year=$2', "e.status='posted'"]; + const params = [tenant_id, parseInt(fiscal_year)]; + if (period) { conditions.push(`e.period=$3`); params.push(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: parseInt(fiscal_year), + period: period || 'all', + accounts: rows, + totals: { debit: totalDebit, credit: totalCredit, balanced: Math.abs(totalDebit - totalCredit) < 0.01 } + }); + } catch (e) { + res.status(500).json({ ok: false, error: e.message }); + } +}); + +// ══════════════════════════════════════════════════════════════════════════════ +// PERIOD MANAGEMENT +// ══════════════════════════════════════════════════════════════════════════════ +app.get('/api/ledger/periods', async (req, res) => { + const tenant_id = req.headers['x-tenant-id'] || 'wavult-group'; + const { fiscal_year } = req.query; + const cond = ['tenant_id=$1']; + const params = [tenant_id]; + if (fiscal_year) { cond.push('fiscal_year=$2'); params.push(parseInt(fiscal_year)); } + 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 }); +}); + +app.post('/api/ledger/periods/:period/close', async (req, res) => { + const { period } = req.params; + const ctx = buildCtx(req, 'period', period); + const fiscal_year = req.body.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'); + 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 }); + } +}); + +// ── 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/accounts · /api/ledger/journal · /api/ledger/trial-balance · /api/ledger/periods`); + }); +} catch (e) { + console.error('[aamos-ledger] Startup misslyckades:', e.message); + process.exit(1); +} diff --git a/aamos-ledger/package.json b/aamos-ledger/package.json new file mode 100644 index 000000000..9092093b1 --- /dev/null +++ b/aamos-ledger/package.json @@ -0,0 +1,18 @@ +{ + "name": "aamos-ledger", + "version": "0.1.0", + "description": "AAMOS Ledger Engine — Ekonomimodulens sanning. Etapp 1 – Vertikal stängning.", + "type": "module", + "main": "index.mjs", + "scripts": { + "start": "node index.mjs", + "dev": "node --watch index.mjs" + }, + "keywords": ["aamos", "ledger", "finance", "double-entry", "accounting", "wavult"], + "dependencies": { + "cors": "^2.8.5", + "express": "^4.18.2", + "pg": "^8.11.3", + "redis": "^4.6.13" + } +} diff --git a/aamos-ledger/schema.sql b/aamos-ledger/schema.sql new file mode 100644 index 000000000..04edd1061 --- /dev/null +++ b/aamos-ledger/schema.sql @@ -0,0 +1,158 @@ +-- ═══════════════════════════════════════════════════════════════════════════ +-- AAMOS Ledger Engine — PostgreSQL Schema +-- Etapp 1 — Vertikal stängning +-- Principer: Audit First · Tenant Isolation · Deterministic · Reproducible +-- ═══════════════════════════════════════════════════════════════════════════ + +-- ── Chart of Accounts (Kontoplan) ─────────────────────────────────────────── +-- Separerad från Rule Engine per arkitekturprincipen. +-- Stöder: BAS (SE), IFRS, US GAAP, lokala kontoplaner +CREATE TABLE IF NOT EXISTS ledger_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id TEXT NOT NULL, + account_number TEXT NOT NULL, -- t.ex. '1910', '3000' + name TEXT NOT NULL, + account_type TEXT NOT NULL, -- 'asset'|'liability'|'equity'|'revenue'|'expense' + normal_balance TEXT NOT NULL, -- 'debit'|'credit' + coa_standard TEXT NOT NULL DEFAULT 'BAS', -- 'BAS'|'IFRS'|'USGAAP'|'custom' + parent_account TEXT, -- för kontostruktur/gruppering + vat_code TEXT, -- koppling till momsregel + is_active BOOLEAN NOT NULL DEFAULT TRUE, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, account_number, coa_standard) +); + +-- ── Journal Entries (Verifikationer) ──────────────────────────────────────── +-- Ledgerns sanning. IMMUTABLE efter posted. +-- Alla fält för full spårbarhet per BYGGPLAN 0.001. +CREATE TABLE IF NOT EXISTS ledger_journal_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id TEXT NOT NULL, + entry_number BIGINT, -- löpnummer per tenant+räkenskapsår + fiscal_year INTEGER NOT NULL, -- t.ex. 2026 + period TEXT NOT NULL, -- 'YYYY-MM' + entry_date DATE NOT NULL, + description TEXT NOT NULL, + reference TEXT, -- externt ref (fakturanr, kvittonr) + source_type TEXT NOT NULL, -- 'manual'|'import_sie'|'import_csv'|'bank'|'system' + source_id TEXT, -- ID i källsystemet + status TEXT NOT NULL DEFAULT 'draft', -- 'draft'|'posted'|'voided' + void_reason TEXT, + voided_at TIMESTAMPTZ, + voided_by TEXT, + -- Observability / Trace + trace_id TEXT NOT NULL, + correlation_id TEXT NOT NULL, + user_id TEXT NOT NULL, + decision_source TEXT NOT NULL DEFAULT 'user', -- 'user'|'system'|'agent' + -- Metadata + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + posted_at TIMESTAMPTZ, + CONSTRAINT chk_status CHECK (status IN ('draft','posted','voided')), + CONSTRAINT chk_source CHECK (source_type IN ('manual','import_sie','import_csv','bank','system','agent')) +); + +CREATE SEQUENCE IF NOT EXISTS ledger_entry_number_seq START 1; + +-- ── Journal Lines (Verifikationsrader) ────────────────────────────────────── +-- Dubbelbokföring: sum(debit) == sum(credit) enforced i applikationskoden. +CREATE TABLE IF NOT EXISTS ledger_journal_lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entry_id UUID NOT NULL REFERENCES ledger_journal_entries(id) ON DELETE CASCADE, + tenant_id TEXT NOT NULL, + line_number INTEGER NOT NULL, -- rad 1, 2, 3… + account_number TEXT NOT NULL, + account_name TEXT, -- denormaliserat för rapporter + debit NUMERIC(18,2), + credit NUMERIC(18,2), + currency TEXT NOT NULL DEFAULT 'SEK', + amount_base NUMERIC(18,2), -- belopp i basvaluta + vat_code TEXT, -- momsregel vid tidpunkten + vat_amount NUMERIC(18,2), + cost_center TEXT, + project_code TEXT, + description TEXT, + metadata JSONB NOT NULL DEFAULT '{}', + CONSTRAINT chk_debit_credit CHECK ( + (debit IS NOT NULL AND credit IS NULL) OR + (debit IS NULL AND credit IS NOT NULL) + ), + CONSTRAINT chk_positive_debit CHECK (debit IS NULL OR debit > 0), + CONSTRAINT chk_positive_credit CHECK (credit IS NULL OR credit > 0) +); + +-- ── Audit Log ──────────────────────────────────────────────────────────────── +-- Varje förändring i ledgern genererar en auditpost. +-- APPEND ONLY — aldrig DELETE eller UPDATE. +CREATE TABLE IF NOT EXISTS ledger_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id TEXT NOT NULL, + trace_id TEXT NOT NULL, + correlation_id TEXT NOT NULL, + user_id TEXT NOT NULL, + entity_type TEXT NOT NULL, -- 'journal_entry'|'account'|'period' + entity_id TEXT NOT NULL, + action TEXT NOT NULL, -- 'created'|'posted'|'voided'|'imported' + decision_source TEXT NOT NULL, + before_state JSONB, + after_state JSONB, + ip_address TEXT, + session_id TEXT, + ts TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── Periods ────────────────────────────────────────────────────────────────── +-- Workflow-styrd periodstängning. +CREATE TABLE IF NOT EXISTS ledger_periods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id TEXT NOT NULL, + fiscal_year INTEGER NOT NULL, + period TEXT NOT NULL, -- 'YYYY-MM' + status TEXT NOT NULL DEFAULT 'open', -- 'open'|'review'|'approved'|'closed' + opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + closed_at TIMESTAMPTZ, + closed_by TEXT, + workflow_id UUID, -- referens till workflow engine + trial_balance JSONB, -- snapshot vid stängning + trace_id TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}', + UNIQUE (tenant_id, fiscal_year, period), + CONSTRAINT chk_period_status CHECK (status IN ('open','review','approved','closed')) +); + +-- ── Indexes ─────────────────────────────────────────────────────────────────── +CREATE INDEX IF NOT EXISTS idx_journal_tenant_period + ON ledger_journal_entries (tenant_id, fiscal_year, period); + +CREATE INDEX IF NOT EXISTS idx_journal_status + ON ledger_journal_entries (tenant_id, status); + +CREATE INDEX IF NOT EXISTS idx_journal_date + ON ledger_journal_entries (tenant_id, entry_date); + +CREATE INDEX IF NOT EXISTS idx_journal_reference + ON ledger_journal_entries (tenant_id, reference); + +CREATE INDEX IF NOT EXISTS idx_lines_entry + ON ledger_journal_lines (entry_id); + +CREATE INDEX IF NOT EXISTS idx_lines_account + ON ledger_journal_lines (tenant_id, account_number); + +CREATE INDEX IF NOT EXISTS idx_audit_entity + ON ledger_audit_log (tenant_id, entity_type, entity_id); + +CREATE INDEX IF NOT EXISTS idx_audit_trace + ON ledger_audit_log (trace_id); + +CREATE INDEX IF NOT EXISTS idx_accounts_tenant + ON ledger_accounts (tenant_id, account_number); + +CREATE INDEX IF NOT EXISTS idx_periods_tenant + ON ledger_periods (tenant_id, fiscal_year, period); + +-- ── BAS 2024 — Bas-konton för Wavult Group (seed) ─────────────────────────── +-- Läggs in via seed-script, inte här. Schema är rent. diff --git a/aamos-ledger/sie4-export.mjs b/aamos-ledger/sie4-export.mjs new file mode 100644 index 000000000..1ea045179 --- /dev/null +++ b/aamos-ledger/sie4-export.mjs @@ -0,0 +1,470 @@ +/** + * ═══════════════════════════════════════════════════════════════════════════ + * AAMOS Ledger — SIE4 Export + * Exporterar bokföringsdata i SIE4-format (svensk standard, typ 4) + * + * Endpoints: + * GET /api/ledger/export/sie4 → SIE4-fil (text/plain download) + * GET /api/ledger/export/sie4/preview → JSON-förhandsgranskning + * + * SIE4 standard: https://sie.se/ + * Teckenset: UTF-8 (moderna program accepterar detta; CP437/PC8 är äldre norm) + * Radslut: CRLF per SIE-standard + * ═══════════════════════════════════════════════════════════════════════════ + */ + +// ── Hjälpfunktioner ────────────────────────────────────────────────────────── + +/** + * Mappar source_type till SIE4-serie: + * A = manuella verifikationer + * B = bankimport + * S = SIE-import + */ +function sourceTypeToSeries(sourceType) { + switch (sourceType) { + case 'import_bank': + case 'bank': + return 'B'; + case 'import_sie': + return 'S'; + default: + return 'A'; + } +} + +/** + * Formaterar Date eller ISO-sträng till YYYYMMDD (SIE-datumformat). + */ +function fmtDate(d) { + if (!d) return ''; + const dt = typeof d === 'string' ? new Date(d) : d; + const y = dt.getUTCFullYear(); + const m = String(dt.getUTCMonth() + 1).padStart(2, '0'); + const day = String(dt.getUTCDate()).padStart(2, '0'); + return `${y}${m}${day}`; +} + +/** + * Formaterar numeriskt belopp till SIE4-format (punkt, 2 decimaler). + * Debet = positivt, Kredit = negativt. + */ +function fmtAmount(n) { + return (parseFloat(n) || 0).toFixed(2); +} + +/** + * Escapar citattecken i SIE4-strängar: " → \" + */ +function escapeStr(s) { + if (s == null) return ''; + return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +// ── Databas-hämtning ───────────────────────────────────────────────────────── + +/** + * Hämtar all data som behövs för en SIE4-export. + * + * @param {object} pool - pg.Pool + * @param {string} tenant_id + * @param {number} fiscal_year + * @param {string|null} period - "YYYY-MM" eller null (= hela räkenskapsåret) + * @param {boolean} include_unposted + * @returns {Promise} { entries, accountMap, accountNumbers, ibMap, ubMap, dateFrom, dateTo } + */ +async function fetchExportData(pool, tenant_id, fiscal_year, period, include_unposted) { + // ── Datumintervall ────────────────────────────────────────────────────────── + let dateFromUtc, dateToUtc; + if (period) { + const [py, pm] = period.split('-').map(Number); + dateFromUtc = new Date(Date.UTC(py, pm - 1, 1)); + dateToUtc = new Date(Date.UTC(py, pm, 0)); // sista dagen i månaden + } else { + dateFromUtc = new Date(Date.UTC(fiscal_year, 0, 1)); // 1 jan + dateToUtc = new Date(Date.UTC(fiscal_year, 11, 31)); // 31 dec + } + const dateFromStr = dateFromUtc.toISOString().split('T')[0]; + const dateToStr = dateToUtc.toISOString().split('T')[0]; + + // ── Verifikationer med rader ──────────────────────────────────────────────── + const statusFilter = include_unposted ? ['draft', 'posted'] : ['posted']; + const statusPlaceholders = statusFilter.map((_, i) => `$${i + 3}`).join(','); + const dateFromIdx = statusFilter.length + 3; + const dateToIdx = statusFilter.length + 4; + + const entriesSQL = ` + SELECT + e.id, + e.entry_number, + e.entry_date, + e.description, + e.reference, + e.source_type, + e.status, + e.posted_at, + e.created_at, + json_agg( + json_build_object( + 'account_number', l.account_number, + 'account_name', l.account_name, + 'debit', l.debit, + 'credit', l.credit, + 'description', l.description + ) ORDER BY l.line_number + ) AS lines + FROM ledger_journal_entries e + JOIN ledger_journal_lines l ON l.entry_id = e.id + WHERE e.tenant_id = $1 + AND e.fiscal_year = $2 + AND e.status IN (${statusPlaceholders}) + AND e.entry_date >= $${dateFromIdx} + AND e.entry_date <= $${dateToIdx} + GROUP BY e.id + ORDER BY e.entry_date ASC, + e.entry_number ASC NULLS LAST, + e.created_at ASC + `; + const { rows: entries } = await pool.query(entriesSQL, [ + tenant_id, fiscal_year, ...statusFilter, dateFromStr, dateToStr, + ]); + + // ── Samla berörda kontonummer ─────────────────────────────────────────────── + const accountNumberSet = new Set(); + for (const e of entries) { + for (const l of (e.lines || [])) { + if (l.account_number) accountNumberSet.add(l.account_number); + } + } + const accountNumbers = Array.from(accountNumberSet).sort(); + + // ── Kontoinformation ──────────────────────────────────────────────────────── + const accountMap = {}; + if (accountNumbers.length > 0) { + const placeholders = accountNumbers.map((_, i) => `$${i + 2}`).join(','); + const { rows: acctRows } = await pool.query( + `SELECT account_number, name, account_type, normal_balance + FROM ledger_accounts + WHERE tenant_id = $1 AND account_number IN (${placeholders})`, + [tenant_id, ...accountNumbers] + ); + for (const a of acctRows) { + accountMap[a.account_number] = a; + } + } + + // ── IB — ingångssaldo (alla poster INNAN perioden startar) ────────────────── + // IB = kumulativt nettosaldo t.o.m. dagen FÖRE periodens start + const ibDate = new Date(dateFromUtc.getTime() - 86_400_000); // -1 dag + const ibDateStr = ibDate.toISOString().split('T')[0]; + + const ibMap = {}; + if (accountNumbers.length > 0) { + const placeholders = accountNumbers.map((_, i) => `$${i + 3}`).join(','); + const { rows: ibRows } = await pool.query( + `SELECT + l.account_number, + 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 e.tenant_id = $1 + AND e.status = 'posted' + AND e.entry_date <= $2 + AND l.account_number IN (${placeholders}) + GROUP BY l.account_number`, + [tenant_id, ibDateStr, ...accountNumbers] + ); + for (const r of ibRows) { + ibMap[r.account_number] = parseFloat(r.balance) || 0; + } + } + + // ── UB — utgångssaldo (alla poster T.O.M. periodens slut) ────────────────── + const ubMap = {}; + if (accountNumbers.length > 0) { + const placeholders = accountNumbers.map((_, i) => `$${i + 3}`).join(','); + const { rows: ubRows } = await pool.query( + `SELECT + l.account_number, + 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 e.tenant_id = $1 + AND e.status = 'posted' + AND e.entry_date <= $2 + AND l.account_number IN (${placeholders}) + GROUP BY l.account_number`, + [tenant_id, dateToStr, ...accountNumbers] + ); + for (const r of ubRows) { + ubMap[r.account_number] = parseFloat(r.balance) || 0; + } + } + + return { entries, accountMap, accountNumbers, ibMap, ubMap, dateFrom: dateFromStr, dateTo: dateToStr }; +} + +// ── SIE4-textgenerator ─────────────────────────────────────────────────────── + +/** + * Bygger SIE4-textfilen från exportdata och alternativ. + * + * @param {object} data - Returnerat från fetchExportData + * @param {object} opts - { fiscal_year, org_nr, company_name } + * @returns {string} - SIE4-text med CRLF-radslut + */ +function buildSIE4(data, opts) { + const { entries, accountMap, accountNumbers, ibMap, ubMap } = data; + const { fiscal_year, org_nr, company_name } = opts; + + // Dagens datum för #GEN + const now = new Date(); + const genDate = [ + now.getUTCFullYear(), + String(now.getUTCMonth() + 1).padStart(2, '0'), + String(now.getUTCDate()).padStart(2, '0'), + ].join(''); + + const fyStart = `${fiscal_year}0101`; + const fyEnd = `${fiscal_year}1231`; + + const out = []; + + // ── Huvudblock ───────────────────────────────────────────────────────────── + out.push('#FLAGGA 0'); + out.push('#PROGRAM "AAMOS Ledger" 1.0'); + out.push('#FORMAT PC8'); + out.push(`#GEN ${genDate} system`); + out.push('#SIETYP 4'); + if (org_nr) out.push(`#ORGNR ${org_nr}`); + if (company_name) out.push(`#FNAMN "${escapeStr(company_name)}"`); + out.push(`#RAR 0 ${fyStart} ${fyEnd}`); + out.push(''); + + // ── Kontoplan (#KONTO) ────────────────────────────────────────────────────── + for (const acctNr of accountNumbers) { + const acct = accountMap[acctNr]; + const name = acct ? acct.name : acctNr; + out.push(`#KONTO ${acctNr} "${escapeStr(name)}"`); + } + out.push(''); + + // ── Saldon (#IB / #UB / #RES) ────────────────────────────────────────────── + // Balansräkningskonton → #IB + #UB + // Resultaträkningskonton → #RES (periodens netto: UB − IB) + for (const acctNr of accountNumbers) { + const acct = accountMap[acctNr]; + const ib = ibMap[acctNr] || 0; + const ub = ubMap[acctNr] || 0; + + if (acct && (acct.account_type === 'revenue' || acct.account_type === 'expense')) { + out.push(`#RES 0 ${acctNr} ${fmtAmount(ub - ib)}`); + } else { + out.push(`#IB 0 ${acctNr} ${fmtAmount(ib)}`); + out.push(`#UB 0 ${acctNr} ${fmtAmount(ub)}`); + } + } + out.push(''); + + // ── Verifikationer (#VER med #TRANS) ─────────────────────────────────────── + let fallbackIdx = 1; + for (const entry of entries) { + const series = sourceTypeToSeries(entry.source_type); + const verNr = entry.entry_number != null ? String(entry.entry_number) : String(fallbackIdx); + fallbackIdx++; + + const verDate = fmtDate(entry.entry_date); + const regDate = entry.posted_at ? fmtDate(entry.posted_at) : verDate; + const desc = escapeStr(entry.description); + + out.push(`#VER ${series} ${verNr} ${verDate} "${desc}" ${regDate}`); + out.push('{'); + + for (const l of (entry.lines || [])) { + // Debet = positivt, Kredit = negativt i SIE4 + const amount = (parseFloat(l.debit) || 0) - (parseFloat(l.credit) || 0); + out.push(`#TRANS ${l.account_number} {} ${fmtAmount(amount)}`); + } + + out.push('}'); + } + + // SIE4-standard: CRLF radslut + return out.join('\r\n') + '\r\n'; +} + +// ── Route-registrering ─────────────────────────────────────────────────────── + +/** + * Registrerar SIE4 export-endpoints på Express-appen. + * + * @param {import('express').Application} app + * @param {import('pg').Pool} pool + * @param {Function} buildCtx - Context-byggare från index.mjs (används ej — read-only export) + * @param {object} hermes - Event-fabric (används ej — read-only export) + */ +export function register(app, pool, buildCtx, hermes) { + + // ── GET /api/ledger/export/sie4 ──────────────────────────────────────────── + // Returnerar SIE4-fil som nedladdningsbar bifogad fil (text/plain). + // + // Query params: + // fiscal_year (krävs) t.ex. 2026 + // period (valfritt) t.ex. 2026-01; utelämnad = hela räkenskapsåret + // org_nr (valfritt) organisationsnummer + // company_name (valfritt) företagsnamn + // include_unposted (valfritt, default "false") inkludera draft-poster + app.get('/api/ledger/export/sie4', async (req, res) => { + const tenant_id = req.headers['x-tenant-id'] || 'wavult-group'; + const { + fiscal_year, + period = null, + org_nr = null, + company_name = null, + include_unposted = 'false', + } = req.query; + + if (!fiscal_year) { + return res.status(400).json({ ok: false, error: 'fiscal_year krävs' }); + } + const fy = parseInt(fiscal_year, 10); + if (isNaN(fy) || fy < 1900 || fy > 2100) { + return res.status(400).json({ ok: false, error: 'Ogiltigt fiscal_year' }); + } + if (period && !/^\d{4}-\d{2}$/.test(period)) { + return res.status(400).json({ ok: false, error: 'period måste vara i format YYYY-MM' }); + } + + const incUnposted = include_unposted === 'true'; + + try { + console.log(`[sie4-export] Export: tenant=${tenant_id} fy=${fy} period=${period ?? 'hela året'} unposted=${incUnposted}`); + + const data = await fetchExportData(pool, tenant_id, fy, period, incUnposted); + const sieText = buildSIE4(data, { fiscal_year: fy, org_nr, company_name }); + const filename = period ? `SIE4_${period}.se` : `SIE4_${fy}.se`; + + console.log( + `[sie4-export] OK: ${data.entries.length} verifikationer, ` + + `${data.accountNumbers.length} konton → ${filename}` + ); + + res.set('Content-Type', 'text/plain; charset=utf-8'); + res.set('Content-Disposition', `attachment; filename="${filename}"`); + res.send(sieText); + + } catch (e) { + console.error('[sie4-export] Export misslyckades:', e.message); + res.status(500).json({ ok: false, error: e.message }); + } + }); + + // ── GET /api/ledger/export/sie4/preview ─────────────────────────────────── + // Returnerar JSON med metadata + max 20 verifikationer för förhandsgranskning. + // Samma query-params som /sie4. + app.get('/api/ledger/export/sie4/preview', async (req, res) => { + const tenant_id = req.headers['x-tenant-id'] || 'wavult-group'; + const { + fiscal_year, + period = null, + org_nr = null, + company_name = null, + include_unposted = 'false', + } = req.query; + + if (!fiscal_year) { + return res.status(400).json({ ok: false, error: 'fiscal_year krävs' }); + } + const fy = parseInt(fiscal_year, 10); + if (isNaN(fy) || fy < 1900 || fy > 2100) { + return res.status(400).json({ ok: false, error: 'Ogiltigt fiscal_year' }); + } + if (period && !/^\d{4}-\d{2}$/.test(period)) { + return res.status(400).json({ ok: false, error: 'period måste vara i format YYYY-MM' }); + } + + const incUnposted = include_unposted === 'true'; + + try { + console.log(`[sie4-export] Preview: tenant=${tenant_id} fy=${fy} period=${period ?? 'hela året'}`); + + const data = await fetchExportData(pool, tenant_id, fy, period, incUnposted); + + // Förhandsgranskning (max 20 verifikationer) + let fallbackIdx = 1; + const previewEntries = data.entries.slice(0, 20).map(e => { + const verNr = e.entry_number != null ? String(e.entry_number) : String(fallbackIdx); + fallbackIdx++; + return { + series: sourceTypeToSeries(e.source_type), + ver_nr: verNr, + date: fmtDate(e.entry_date), + reg_date: e.posted_at ? fmtDate(e.posted_at) : fmtDate(e.entry_date), + description: e.description, + source_type: e.source_type, + status: e.status, + lines: (e.lines || []).map(l => ({ + account_number: l.account_number, + account_name: l.account_name || null, + amount: (parseFloat(l.debit) || 0) - (parseFloat(l.credit) || 0), + })), + }; + }); + + // Kontosaldo-sammanfattning + const accountSummary = data.accountNumbers.map(acctNr => { + const acct = data.accountMap[acctNr]; + const ib = data.ibMap[acctNr] || 0; + const ub = data.ubMap[acctNr] || 0; + const isResultat = acct && (acct.account_type === 'revenue' || acct.account_type === 'expense'); + return { + account_number: acctNr, + name: acct ? acct.name : null, + account_type: acct ? acct.account_type : 'unknown', + sie4_type: isResultat ? 'RES' : 'IB/UB', + ib: isResultat ? null : ib, + ub: isResultat ? null : ub, + res: isResultat ? (ub - ib) : null, + }; + }); + + // Totaler för balans-check + const totalDebit = data.entries.reduce( + (s, e) => s + (e.lines || []).reduce((ls, l) => ls + (parseFloat(l.debit) || 0), 0), 0 + ); + const totalCredit = data.entries.reduce( + (s, e) => s + (e.lines || []).reduce((ls, l) => ls + (parseFloat(l.credit) || 0), 0), 0 + ); + + res.json({ + ok: true, + meta: { + tenant_id, + fiscal_year: fy, + period: period ?? `${fy} (hela räkenskapsåret)`, + date_from: data.dateFrom, + date_to: data.dateTo, + org_nr: org_nr ?? null, + company_name: company_name ?? null, + include_unposted: incUnposted, + entry_count: data.entries.length, + account_count: data.accountNumbers.length, + total_debit: totalDebit, + total_credit: totalCredit, + balanced: Math.abs(totalDebit - totalCredit) < 0.01, + }, + accounts: accountSummary, + preview_entries: previewEntries, + note: data.entries.length > 20 + ? `Visar 20 av ${data.entries.length} verifikationer` + : `Visar alla ${data.entries.length} verifikationer`, + }); + + } catch (e) { + console.error('[sie4-export] Preview misslyckades:', e.message); + res.status(500).json({ ok: false, error: e.message }); + } + }); + + console.log('[aamos-ledger] SIE4 Export registrerad: /api/ledger/export/sie4 · /api/ledger/export/sie4/preview'); +}