301 lines
13 KiB
JavaScript
301 lines
13 KiB
JavaScript
|
|
/**
|
|||
|
|
* 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 });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|