Files
boc/aamos-ledger-refactor/checkpoint-engine.mjs
T

301 lines
13 KiB
JavaScript
Raw Normal View History

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