bae705aa97
- 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
508 lines
22 KiB
JavaScript
508 lines
22 KiB
JavaScript
/**
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
* AAMOS Ledger Engine — SÄKER VERSION
|
|
* Port: 3250
|
|
*
|
|
* Säkerhetshårdningar implementerade:
|
|
* • JWT-autentisering (RS256/HS256) — inga hårdkodade secrets
|
|
* • RBAC — rollbaserad åtkomstkontroll (admin/accountant/viewer)
|
|
* • Input-validering med Joi — alla endpoints valideras
|
|
* • Rate limiting — skydd mot brute-force
|
|
* • Helmet — säkerhetsheaders
|
|
* • Tenant-isolering — strikt kontroll av tenant-åtkomst
|
|
* • Audit logging — alla operationer loggas
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
*/
|
|
|
|
import express from 'express';
|
|
import cors from 'cors';
|
|
import helmet from 'helmet';
|
|
import rateLimit from 'express-rate-limit';
|
|
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';
|
|
import auth from './auth.mjs';
|
|
import { schemas, validate, sanitizeString, sanitizeMetadata } from './validation.mjs';
|
|
|
|
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
const PORT = process.env.AAMOS_LEDGER_PORT || process.env.PORT || 3250;
|
|
const { Pool } = pg;
|
|
|
|
// ── Validera auth-konfiguration vid startup ──────────────────────────────────
|
|
try {
|
|
auth.validateAuthConfig();
|
|
} catch (e) {
|
|
console.error('[ledger] Startup misslyckades:', e.message);
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── 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();
|
|
|
|
// ── Säkerhetsmiddleware ──────────────────────────────────────────────────────
|
|
app.use(helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
|
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
|
scriptSrc: ["'self'"],
|
|
imgSrc: ["'self'", "data:", "blob:"],
|
|
},
|
|
},
|
|
crossOriginEmbedderPolicy: false,
|
|
}));
|
|
|
|
app.use(cors({
|
|
origin: process.env.CORS_ORIGIN || false,
|
|
credentials: true,
|
|
}));
|
|
|
|
// Rate limiting
|
|
const limiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minuter
|
|
max: 100, // max 100 requests per window
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { ok: false, error: 'För många förfrågningar — försök igen senare' },
|
|
});
|
|
app.use(limiter);
|
|
|
|
// Striktare rate limit för auth-känsliga endpoints
|
|
const authLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 10,
|
|
message: { ok: false, error: 'För många auth-försök — försök igen senare' },
|
|
});
|
|
|
|
app.use(express.json({ limit: '1mb' }));
|
|
|
|
// ── Context helper ────────────────────────────────────────────────────────────
|
|
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.tenant_id || req.headers['x-tenant-id'] || 'wavult-group',
|
|
user_id: req.auth?.user_id || req.headers['x-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,
|
|
]
|
|
);
|
|
}
|
|
|
|
// ── Auth + Tenant middleware (alla skyddade routes) ──────────────────────────
|
|
app.use('/api/ledger', auth.authenticate);
|
|
app.use('/api/ledger', auth.requireTenantMatch);
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// CHART OF ACCOUNTS
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
// GET /api/ledger/accounts — hämta kontoplan
|
|
app.get('/api/ledger/accounts', validate(schemas.listAccounts), async (req, res) => {
|
|
const tenant_id = req.tenant_id;
|
|
const coa_standard = req.validated.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) {
|
|
console.error('[ledger] GET /accounts error:', e.message);
|
|
res.status(500).json({ ok: false, error: 'Internt serverfel' });
|
|
}
|
|
});
|
|
|
|
// POST /api/ledger/accounts — lägg till konto (kräver accountant+)
|
|
app.post('/api/ledger/accounts', auth.requireRole('accountant'), validate(schemas.createAccount), async (req, res) => {
|
|
const ctx = buildCtx(req, 'account');
|
|
const v = req.validated;
|
|
|
|
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, v.account_number, sanitizeString(v.name), v.account_type, v.normal_balance,
|
|
v.coa_standard, v.parent_account || null, v.vat_code || null,
|
|
JSON.stringify(sanitizeMetadata(v.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: v.account_number, name: v.name, coa_standard: v.coa_standard });
|
|
res.status(201).json({ ok: true, account });
|
|
} catch (e) {
|
|
console.error('[ledger] POST /accounts error:', e.message);
|
|
res.status(500).json({ ok: false, error: 'Internt serverfel' });
|
|
}
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// JOURNAL — Verifikationer
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
// POST /api/ledger/journal — skapa verifikation (draft) (kräver accountant+)
|
|
app.post('/api/ledger/journal', auth.requireRole('accountant'), validate(schemas.createJournalEntry), async (req, res) => {
|
|
const ctx = buildCtx(req, 'journal_entry');
|
|
const v = req.validated;
|
|
|
|
const entryDate = new Date(v.entry_date);
|
|
const fy = v.fiscal_year || entryDate.getFullYear();
|
|
const per = v.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, v.entry_date, sanitizeString(v.description),
|
|
v.reference ? sanitizeString(v.reference) : null,
|
|
v.source_type, v.source_id || null, ctx.trace_id, ctx.correlation_id,
|
|
ctx.user_id, ctx.decision_source, JSON.stringify(sanitizeMetadata(v.metadata))]
|
|
);
|
|
const entry = rows[0];
|
|
ctx.entity_id = entry.id;
|
|
|
|
// Lägg in rader
|
|
const insertedLines = [];
|
|
for (let i = 0; i < v.lines.length; i++) {
|
|
const l = v.lines[i];
|
|
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 ? sanitizeString(l.account_name) : null,
|
|
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 ? sanitizeString(l.description) : null,
|
|
JSON.stringify(sanitizeMetadata(l.metadata || {}))]
|
|
);
|
|
insertedLines.push(lr[0]);
|
|
}
|
|
|
|
await writeAudit(ctx, 'created', null, { entry, lines: insertedLines }, client);
|
|
await client.query('COMMIT');
|
|
|
|
const totalDebit = v.lines.reduce((s, l) => s + (l.debit || 0), 0);
|
|
await hermes.emit('finance.journal.created', ctx, {
|
|
entry_id: entry.id, period: per, fiscal_year: fy,
|
|
total_debit: totalDebit, description: v.description, source_type: v.source_type,
|
|
});
|
|
|
|
res.status(201).json({ ok: true, entry, lines: insertedLines });
|
|
} catch (e) {
|
|
await client.query('ROLLBACK');
|
|
console.error('[ledger] POST /journal error:', e.message);
|
|
res.status(400).json({ ok: false, error: e.message });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
// POST /api/ledger/journal/:id/post — konterar verifikation (draft → posted) (kräver accountant+)
|
|
app.post('/api/ledger/journal/:id/post', auth.requireRole('accountant'), validate(schemas.postJournalEntry), 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');
|
|
console.error('[ledger] POST /journal/:id/post error:', e.message);
|
|
res.status(400).json({ ok: false, error: e.message });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
});
|
|
|
|
// GET /api/ledger/journal — lista verifikationer (viewer+)
|
|
app.get('/api/ledger/journal', validate(schemas.listJournalEntries), async (req, res) => {
|
|
const tenant_id = req.tenant_id;
|
|
const v = req.validated;
|
|
const conditions = ['e.tenant_id = $1'];
|
|
const params = [tenant_id];
|
|
let idx = 2;
|
|
|
|
if (v.period) { conditions.push(`e.period = $${idx++}`); params.push(v.period); }
|
|
if (v.fiscal_year) { conditions.push(`e.fiscal_year = $${idx++}`); params.push(v.fiscal_year); }
|
|
if (v.status) { conditions.push(`e.status = $${idx++}`); params.push(v.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, v.limit, v.offset]
|
|
);
|
|
res.json({ ok: true, entries: rows, count: rows.length });
|
|
} catch (e) {
|
|
console.error('[ledger] GET /journal error:', e.message);
|
|
res.status(500).json({ ok: false, error: 'Internt serverfel' });
|
|
}
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// TRIAL BALANCE — Saldobalans
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
app.get('/api/ledger/trial-balance', validate(schemas.trialBalance), async (req, res) => {
|
|
const tenant_id = req.tenant_id;
|
|
const v = req.validated;
|
|
|
|
try {
|
|
const conditions = ['e.tenant_id=$1', 'e.fiscal_year=$2', "e.status='posted'"];
|
|
const params = [tenant_id, v.fiscal_year];
|
|
if (v.period) { conditions.push(`e.period=$3`); params.push(v.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: v.fiscal_year,
|
|
period: v.period || 'all',
|
|
accounts: rows,
|
|
totals: { debit: totalDebit, credit: totalCredit, balanced: Math.abs(totalDebit - totalCredit) < 0.01 }
|
|
});
|
|
} catch (e) {
|
|
console.error('[ledger] GET /trial-balance error:', e.message);
|
|
res.status(500).json({ ok: false, error: 'Internt serverfel' });
|
|
}
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
// PERIOD MANAGEMENT
|
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
app.get('/api/ledger/periods', validate(schemas.listPeriods), async (req, res) => {
|
|
const tenant_id = req.tenant_id;
|
|
const v = req.validated;
|
|
const cond = ['tenant_id=$1'];
|
|
const params = [tenant_id];
|
|
if (v.fiscal_year) { cond.push('fiscal_year=$2'); params.push(v.fiscal_year); }
|
|
try {
|
|
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 });
|
|
} catch (e) {
|
|
console.error('[ledger] GET /periods error:', e.message);
|
|
res.status(500).json({ ok: false, error: 'Internt serverfel' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/ledger/periods/:period/close', auth.requireRole('admin'), validate(schemas.closePeriod), async (req, res) => {
|
|
const { period } = req.params;
|
|
const ctx = buildCtx(req, 'period', period);
|
|
const v = req.validated;
|
|
const fiscal_year = v.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');
|
|
console.error('[ledger] POST /periods/:period/close error:', e.message);
|
|
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 });
|
|
}
|
|
});
|
|
|
|
// ── 404 handler ───────────────────────────────────────────────────────────────
|
|
app.use((_req, res) => {
|
|
res.status(404).json({ ok: false, error: 'Endpoint hittades inte' });
|
|
});
|
|
|
|
// ── Global error handler ──────────────────────────────────────────────────────
|
|
app.use((err, _req, res, _next) => {
|
|
console.error('[ledger] Ohanterat fel:', err.message);
|
|
res.status(500).json({ ok: false, error: 'Internt serverfel' });
|
|
});
|
|
|
|
// ── 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] Auth: ${process.env.NODE_ENV === 'production' ? 'RS256' : 'HS256'}`);
|
|
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);
|
|
}
|