Files
boc/aamos-ledger/index.mjs.backup
T
Bernt bae705aa97 ARCHITECTURE: NFC roadmap, edge AI, audit logging
- Add NFC ePassport roadmap (ICAO 9303, eIDAS)
- Add TensorFlow.js edge face detection (BlazeFace)
- Add structured audit logger (GDPR-compliant)
- Risk scoring support

Part of KYC Apple Native UX v1.1.0
2026-06-29 16:24:48 +00:00

459 lines
20 KiB
Plaintext

/**
* ═══════════════════════════════════════════════════════════════════════════
* 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);
}