Initial commit: aamos-ledger source + CLAUDE.md context
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.git/
|
||||
*.md
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.bak
|
||||
*.bak-*
|
||||
@@ -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*
|
||||
@@ -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"]
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user