Files
boc/aamos-ledger-refactor/checkpoint-engine.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

301 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AAMOS Ledger — Checkpoint Engine v2 (refactored)
* Fullständig kontroll: kassa, skulder, löner, moms, AGA, AWS, fordran, perioder, balans
*
* C001 Moms förfallen (2650)
* C002 Personalskatt obetalda (2710)
* C003 AGA obetalda (2731)
* C004 Oöverförd utgående moms kvar i 2611
* C005 Fordran >60 dagar (klass 16xx)
* C006 Konto 2990 residual
* C007 Konto 1689 oklassificerat
* C008 Öppen period >35 dagar
* C009 Förra månaden ej stängd >10 dagar
* C010 AWS/IT-kostnad >100k i månaden (6230)
* C011 Burn rate vs kassa — månader kvar
* C012 Balans D≠C (nödbromsen)
*/
// Pool is now injected via parameter instead of creating a new one
let _pool = null;
function getPool() {
if (!_pool) throw new Error('Checkpoint engine not initialized: pool not set. Call initCheckpointEngine(pool) first.');
return _pool;
}
export function initCheckpointEngine(pool) {
_pool = pool;
}
async function q(sql, params=[]) {
const { rows } = await getPool().query(sql, params);
return rows;
}
function num(v) { return parseFloat(v || 0); }
function fmt(n) { return Math.round(n).toLocaleString('sv-SE') + ' kr'; }
export async function runCheckpoints(tenantId = 'landvex') {
const tasks = [];
const now = new Date();
// ── Hämta alla nyckeltal i ett svep ─────────────────────────────────────
const skulder = await q(`
SELECT l.account_number,
COALESCE(SUM(COALESCE(l.credit,0)-COALESCE(l.debit,0)),0) AS skuld
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 l.account_number IN ('2650','2710','2731','2611','2615','1689','2990')
GROUP BY l.account_number`, [tenantId]);
const sk = {};
for (const r of skulder) sk[r.account_number] = num(r.skuld);
// Kassa (alla likvidakonton)
const kassaRows = await q(`
SELECT COALESCE(SUM(COALESCE(l.debit,0)-COALESCE(l.credit,0)),0) AS kassa
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 l.account_number IN ('1930','1932','1934','1935')`, [tenantId]);
const kassa = num(kassaRows[0]?.kassa);
// Burn rate — snitt senaste 3 månaderna
const burnRows = await q(`
SELECT AVG(period_cost) AS avg_burn FROM (
SELECT e.period, COALESCE(SUM(COALESCE(l.debit,0)-COALESCE(l.credit,0)),0) AS period_cost
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 LEFT(l.account_number,1) IN ('4','5','6','7')
AND e.period >= to_char(NOW()-INTERVAL '3 months','YYYY-MM')
GROUP BY e.period
) s`, [tenantId]);
const burnRate = num(burnRows[0]?.avg_burn);
// IT/AWS kostnad senaste månaden
const currentPeriod = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`;
const awsRows = await q(`
SELECT COALESCE(SUM(COALESCE(l.debit,0)-COALESCE(l.credit,0)),0) AS kostnad
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 l.account_number='6230' AND e.period=$2`, [tenantId, currentPeriod]);
const awsThisMonth = num(awsRows[0]?.kostnad);
// Fordran klass 16xx
const fordranRows = await q(`
SELECT l.account_number,
MAX(COALESCE(a.name,l.account_name)) AS name,
COALESCE(SUM(COALESCE(l.debit,0)-COALESCE(l.credit,0)),0) AS bal,
MIN(e.entry_date) AS earliest
FROM ledger_journal_lines l
JOIN ledger_journal_entries e ON e.id=l.entry_id
LEFT JOIN ledger_accounts a ON a.tenant_id=e.tenant_id AND a.account_number=l.account_number AND a.coa_standard='BAS'
WHERE e.tenant_id=$1 AND l.account_number LIKE '16%' AND e.status='posted'
GROUP BY l.account_number
HAVING COALESCE(SUM(COALESCE(l.debit,0)-COALESCE(l.credit,0)),0)>100`, [tenantId]);
// Öppna perioder
const openPeriods = await q(
`SELECT period, opened_at FROM ledger_periods WHERE tenant_id=$1 AND status='open'`, [tenantId]);
// Total balans
const balRows = await q(`
SELECT COALESCE(SUM(COALESCE(l.debit,0)),0) AS td,
COALESCE(SUM(COALESCE(l.credit,0)),0) AS tc
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'`, [tenantId]);
const td = num(balRows[0]?.td), tc = num(balRows[0]?.tc);
// ── C001: Moms förfallen ──────────────────────────────────────────────────
const momsSkuld = num(sk['2650']);
if (momsSkuld > 100) {
tasks.push({
id: 'C001-moms', severity: 'critical',
title: `Momsdeklaration att betala: ${fmt(momsSkuld)}`,
description: `Konto 2650. Deklarera och betala till Skatteverket omgående. Förseningsavgift tillkommer. Bokför sedan: Debit 2650 / Kredit 1930.`,
amount: momsSkuld, account: '2650',
action_url: 'https://skatteverket.se/foretagochorganisationer/moms',
});
}
// ── C002: Personalskatt (2710) ────────────────────────────────────────────
const persSk = num(sk['2710']);
if (persSk > 100) {
tasks.push({
id: 'C002-personalskatt', severity: 'critical',
title: `Personalskatt att betala: ${fmt(persSk)}`,
description: `Konto 2710. Betalas till Skatteverket senast 12:e månaden efter löneutbetalning. Bokför: Debit 2710 / Kredit 1930.`,
amount: persSk, account: '2710',
});
}
// ── C003: Arbetsgivaravgift (2731) ────────────────────────────────────────
const agaSk = num(sk['2731']);
if (agaSk > 100) {
tasks.push({
id: 'C003-aga', severity: 'critical',
title: `Arbetsgivaravgift att betala: ${fmt(agaSk)}`,
description: `Konto 2731. Betalas tillsammans med personalskatt. Bokför: Debit 2731 / Kredit 1930.`,
amount: agaSk, account: '2731',
});
}
// ── C004: Oöverförd utgående moms i 2611 ─────────────────────────────────
const moms2611 = num(sk['2611']);
if (moms2611 > 1000) {
tasks.push({
id: 'C004-moms2611', severity: 'high',
title: `Oöverförd utgående moms i 2611: ${fmt(moms2611)}`,
description: `Konto 2611 har ${fmt(moms2611)} som ej omförts till 2650. Kräver momsöverföring vid periodens slut.`,
amount: moms2611, account: '2611',
});
}
// ── C005: Fordran >60 dagar ────────────────────────────────────────────────
for (const f of fordranRows) {
const days = Math.floor((now - new Date(f.earliest)) / 86400000);
const bal = num(f.bal);
const sev = days > 90 ? 'critical' : days > 60 ? 'high' : 'medium';
if (days > 60) {
tasks.push({
id: `C005-${f.account_number}`, severity: sev,
title: `Fordran ${f.account_number} (${f.name || ''}): ${fmt(bal)}${days} dagar`,
description: `Fordran är ${days} dagar gammal. Kräv betalning eller avskriv.`,
amount: bal, account: f.account_number,
});
}
}
// ── C006: Konto 2990 residual ─────────────────────────────────────────────
const omfRows = await q(`
SELECT COALESCE(SUM(COALESCE(l.debit,0)-COALESCE(l.credit,0)),0) AS bal
FROM ledger_journal_lines l JOIN ledger_journal_entries e ON e.id=l.entry_id
WHERE e.tenant_id=$1 AND l.account_number='2990' AND e.status='posted'`, [tenantId]);
const real2990 = Math.abs(num(omfRows[0]?.bal));
if (real2990 > 500) {
tasks.push({
id: 'C006-2990', severity: 'medium',
title: `Omföringskonto 2990: ${fmt(real2990)} residual`,
description: `Ska alltid vara 0. Kontrollera Nordea→Revolut-matchning.`,
amount: real2990, account: '2990',
});
}
// ── C007: Konto 1689 oklassificerat ──────────────────────────────────────
const obs1689Rows = await q(`
SELECT COALESCE(SUM(COALESCE(l.debit,0)-COALESCE(l.credit,0)),0) AS bal
FROM ledger_journal_lines l JOIN ledger_journal_entries e ON e.id=l.entry_id
WHERE e.tenant_id=$1 AND l.account_number='1689' AND e.status='posted'`, [tenantId]);
const bal1689 = Math.abs(num(obs1689Rows[0]?.bal));
if (bal1689 > 1) {
tasks.push({
id: 'C007-1689', severity: 'high',
title: `Observationskonto 1689: ${fmt(bal1689)} oklassificerat`,
description: `Poster utan styrkt kontering. Kategorisera omgående.`,
amount: bal1689, account: '1689',
});
}
// ── C008: Öppen period >35 dagar ─────────────────────────────────────────
for (const p of openPeriods) {
const days = Math.floor((now - new Date(p.opened_at)) / 86400000);
if (days > 35) {
tasks.push({
id: `C008-${p.period}`, severity: 'high',
title: `Period ${p.period} ej stängd (${days} dagar)`,
description: `Stäng via period-close workflow: submit-review → approve → close.`,
period: p.period, action_url: `/api/ledger/periods/${p.period}/submit-review`,
});
}
}
// ── C009: Förra månaden ej stängd >10 dagar ───────────────────────────────
if (now.getDate() > 10) {
const lm = new Date(now.getFullYear(), now.getMonth()-1, 1);
const lastPeriod = `${lm.getFullYear()}-${String(lm.getMonth()+1).padStart(2,'0')}`;
const prev = await q(`SELECT status FROM ledger_periods WHERE tenant_id=$1 AND period=$2`, [tenantId, lastPeriod]);
if (prev.length > 0 && prev[0].status !== 'closed') {
tasks.push({
id: `C009-${lastPeriod}`, severity: 'medium',
title: `Period ${lastPeriod} ej stängd (${now.getDate()} dagar in i ${currentPeriod})`,
description: `Bör stängas inom 10 dagar efter månadsslut.`,
period: lastPeriod,
});
}
}
// ── C010: IT/AWS-kostnad hög denna månad ─────────────────────────────────
if (awsThisMonth > 80000) {
const sev = awsThisMonth > 150000 ? 'critical' : awsThisMonth > 100000 ? 'high' : 'medium';
tasks.push({
id: 'C010-aws', severity: sev,
title: `IT/molntjänst (6230) ${currentPeriod}: ${fmt(awsThisMonth)}`,
description: `AWS + AI-kostnader denna månad. Mål: under 80 000 kr/mån. Granska prenumerationer.`,
amount: awsThisMonth, account: '6230',
});
}
// ── C011: Burn rate vs kassa ──────────────────────────────────────────────
if (burnRate > 0) {
const forfalletSkulder = momsSkuld + persSk + Math.max(0, agaSk);
const kassaEfterSkulder = kassa - forfalletSkulder;
const manaderkvar = burnRate > 0 ? kassaEfterSkulder / burnRate : 999;
if (manaderkvar < 3) {
tasks.push({
id: 'C011-runway', severity: manaderkvar < 1 ? 'critical' : 'high',
title: `Runway: ${manaderkvar.toFixed(1)} månader kassa (burn ${fmt(burnRate)}/mån)`,
description: `Kassa ${fmt(kassa)} skulder ${fmt(forfalletSkulder)} = ${fmt(kassaEfterSkulder)} netto. Burn rate snitt 3 mån.`,
amount: kassaEfterSkulder,
});
}
tasks.push({
id: 'C011-info', severity: 'info',
title: `Kassa: ${fmt(kassa)} | Skulder: ${fmt(momsSkuld + persSk + Math.max(0, agaSk))} | Burn: ${fmt(burnRate)}/mån`,
description: `Nordea ${fmt(198921.93)} + Revolut SEK ${fmt(176598.27)} + USD ${fmt(126314.78)}. Kassa efter skulder: ${fmt(kassa - momsSkuld - persSk - Math.max(0, agaSk))}.`,
amount: kassa,
});
}
// ── C012: Balans nödbroms ─────────────────────────────────────────────────
if (Math.abs(td - tc) > 1) {
tasks.push({
id: 'C012-balance', severity: 'critical',
title: `OBALANS: diff ${(td-tc).toFixed(2)} kr — omedelbar utredning`,
description: `Dubbel bokföring är bruten. Debet ${td.toFixed(2)} ≠ Kredit ${tc.toFixed(2)}.`,
amount: Math.abs(td-tc),
});
}
const order = { critical:0, high:1, medium:2, info:3, low:4 };
return tasks.sort((a,b) => (order[a.severity]??9) - (order[b.severity]??9));
}
export function registerCheckpoints(app) {
app.get('/api/ledger/tasks', async (req, res) => {
const tenant_id = req.headers['x-tenant-id'] || req.query.tenant || 'wavult-group';
try {
const tasks = await runCheckpoints(tenant_id);
const critical = tasks.filter(t => t.severity === 'critical').length;
const high = tasks.filter(t => t.severity === 'high').length;
const actionable = tasks.filter(t => t.severity !== 'info');
res.json({
ok: true, tenant_id,
generated_at: new Date().toISOString(),
count: actionable.length,
has_critical: critical > 0,
summary: { critical, high, medium: tasks.filter(t=>t.severity==='medium').length },
tasks,
});
} catch(e) {
res.status(500).json({ ok: false, error: e.message });
}
});
}