Initial commit: aamos-ledger source + CLAUDE.md context

This commit is contained in:
Bernt
2026-06-17 06:34:57 +00:00
commit 19d55c545e
9 changed files with 1410 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.git/
*.md
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.bak
*.bak-*
+147
View File
@@ -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*
+12
View File
@@ -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"]
+141
View File
@@ -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;
+458
View File
@@ -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);
}
+18
View File
@@ -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"
}
}
+158
View File
@@ -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.
+470
View File
@@ -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<object>} { 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');
}