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