Files
boc/aamos-ledger-refactor/routes/journal.mjs
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

250 lines
10 KiB
JavaScript

/**
* routes/journal.mjs — Journal entries (verifikationer)
*/
export default function registerJournal(app, pool, buildCtx, writeAudit, hermes) {
// 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');
// Kontrollera att perioden inte är stängd
const { rows: periodCheck } = await client.query(
`SELECT status FROM ledger_periods WHERE tenant_id=$1 AND period=$2`,
[ctx.tenant_id, per]
);
if (periodCheck.length > 0 && periodCheck[0].status === 'closed') {
await client.query('ROLLBACK');
client.release();
return res.status(409).json({ ok: false, error: `Period ${per} är stängd — inga nya verifikationer tillåtna`, code: 'PERIOD_CLOSED' });
}
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, period_from, period_to, fiscal_year, status, source_type, search, limit = 50, offset = 0 } = req.query;
const conditions = ['e.tenant_id = $1'];
const params = [tenant_id];
let idx = 2;
if (fiscal_year) { conditions.push(`e.fiscal_year = $${idx++}`); params.push(parseInt(fiscal_year)); }
if (period) { conditions.push(`e.period = $${idx++}`); params.push(period); }
else if (period_from && period_to) {
conditions.push(`e.period >= $${idx++}`); params.push(period_from);
conditions.push(`e.period <= $${idx++}`); params.push(period_to);
} else if (period_from) { conditions.push(`e.period >= $${idx++}`); params.push(period_from); }
else if (period_to) { conditions.push(`e.period <= $${idx++}`); params.push(period_to); }
if (status) { conditions.push(`e.status = $${idx++}`); params.push(status); }
if (source_type) { conditions.push(`e.source_type = $${idx++}`); params.push(source_type); }
if (search) { conditions.push(`(e.description ILIKE $${idx} OR e.reference ILIKE $${idx} OR EXISTS (SELECT 1 FROM ledger_journal_lines ll WHERE ll.entry_id = e.id AND (ll.account_number ILIKE $${idx} OR ll.account_name ILIKE $${idx})))`); idx++; params.push(`%${search}%`); }
try {
const whereClause = conditions.join(" AND ");
const [{ rows }, countRes] = await Promise.all([
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 ${whereClause}
GROUP BY e.id
ORDER BY e.entry_date DESC, e.created_at DESC
LIMIT $${idx++} OFFSET $${idx}`,
[...params, parseInt(limit), parseInt(offset)]
),
pool.query(
`SELECT COUNT(DISTINCT e.id) AS total
FROM ledger_journal_entries e
LEFT JOIN ledger_journal_lines l ON l.entry_id = e.id
WHERE ${whereClause}`,
params
)
]);
const total = parseInt(countRes.rows[0].total);
res.json({ ok: true, entries: rows, count: rows.length, total, pages: Math.ceil(total / parseInt(limit)), offset: parseInt(offset), limit: parseInt(limit) });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
});
// POST /api/ledger/journal/:id/void — makulera verifikation
app.post('/api/ledger/journal/:id/void', async (req, res) => {
const { id } = req.params;
const ctx = buildCtx(req, 'journal_entry', id);
const { reason = 'Manuell makulering' } = req.body;
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[0]) {
await client.query('ROLLBACK');
return res.status(404).json({ ok: false, error: 'Verifikation ej funnen' });
}
const entry = rows[0];
if (entry.status === 'voided') {
await client.query('ROLLBACK');
return res.status(409).json({ ok: false, error: 'Verifikation är redan makulerad' });
}
const before = { status: entry.status };
await client.query(
`UPDATE ledger_journal_entries SET status='voided', metadata=metadata || $1::jsonb WHERE id=$2`,
[JSON.stringify({ voided_at: new Date().toISOString(), voided_reason: reason, voided_by: ctx.user_id }), id]
);
await writeAudit(ctx, 'journal.void', before, { status: 'voided', reason }, client);
await client.query('COMMIT');
await hermes.emit('ledger.journal.voided', ctx, { entry_id: id, reason });
res.json({ ok: true, entry_id: id, status: 'voided' });
} catch (e) {
await client.query('ROLLBACK');
res.status(500).json({ ok: false, error: e.message });
} finally {
client.release();
}
});
}