Files
boc/aamos-ledger/sie4-export.mjs
T

471 lines
19 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 — SIE4 Export
* Exporterar bokföringsdata i SIE4-format (svensk standard, typ 4)
*
* Endpoints:
* GET /api/ledger/export/sie4 → SIE4-fil (text/plain download)
* GET /api/ledger/export/sie4/preview → JSON-förhandsgranskning
*
* SIE4 standard: https://sie.se/
* Teckenset: UTF-8 (moderna program accepterar detta; CP437/PC8 är äldre norm)
* Radslut: CRLF per SIE-standard
* ═══════════════════════════════════════════════════════════════════════════
*/
// ── Hjälpfunktioner ──────────────────────────────────────────────────────────
/**
* Mappar source_type till SIE4-serie:
* A = manuella verifikationer
* B = bankimport
* S = SIE-import
*/
function sourceTypeToSeries(sourceType) {
switch (sourceType) {
case 'import_bank':
case 'bank':
return 'B';
case 'import_sie':
return 'S';
default:
return 'A';
}
}
/**
* Formaterar Date eller ISO-sträng till YYYYMMDD (SIE-datumformat).
*/
function fmtDate(d) {
if (!d) return '';
const dt = typeof d === 'string' ? new Date(d) : d;
const y = dt.getUTCFullYear();
const m = String(dt.getUTCMonth() + 1).padStart(2, '0');
const day = String(dt.getUTCDate()).padStart(2, '0');
return `${y}${m}${day}`;
}
/**
* Formaterar numeriskt belopp till SIE4-format (punkt, 2 decimaler).
* Debet = positivt, Kredit = negativt.
*/
function fmtAmount(n) {
return (parseFloat(n) || 0).toFixed(2);
}
/**
* Escapar citattecken i SIE4-strängar: " → \"
*/
function escapeStr(s) {
if (s == null) return '';
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
// ── Databas-hämtning ─────────────────────────────────────────────────────────
/**
* Hämtar all data som behövs för en SIE4-export.
*
* @param {object} pool - pg.Pool
* @param {string} tenant_id
* @param {number} fiscal_year
* @param {string|null} period - "YYYY-MM" eller null (= hela räkenskapsåret)
* @param {boolean} include_unposted
* @returns {Promise<object>} { entries, accountMap, accountNumbers, ibMap, ubMap, dateFrom, dateTo }
*/
async function fetchExportData(pool, tenant_id, fiscal_year, period, include_unposted) {
// ── Datumintervall ──────────────────────────────────────────────────────────
let dateFromUtc, dateToUtc;
if (period) {
const [py, pm] = period.split('-').map(Number);
dateFromUtc = new Date(Date.UTC(py, pm - 1, 1));
dateToUtc = new Date(Date.UTC(py, pm, 0)); // sista dagen i månaden
} else {
dateFromUtc = new Date(Date.UTC(fiscal_year, 0, 1)); // 1 jan
dateToUtc = new Date(Date.UTC(fiscal_year, 11, 31)); // 31 dec
}
const dateFromStr = dateFromUtc.toISOString().split('T')[0];
const dateToStr = dateToUtc.toISOString().split('T')[0];
// ── Verifikationer med rader ────────────────────────────────────────────────
const statusFilter = include_unposted ? ['draft', 'posted'] : ['posted'];
const statusPlaceholders = statusFilter.map((_, i) => `$${i + 3}`).join(',');
const dateFromIdx = statusFilter.length + 3;
const dateToIdx = statusFilter.length + 4;
const entriesSQL = `
SELECT
e.id,
e.entry_number,
e.entry_date,
e.description,
e.reference,
e.source_type,
e.status,
e.posted_at,
e.created_at,
json_agg(
json_build_object(
'account_number', l.account_number,
'account_name', l.account_name,
'debit', l.debit,
'credit', l.credit,
'description', l.description
) ORDER BY l.line_number
) AS lines
FROM ledger_journal_entries e
JOIN ledger_journal_lines l ON l.entry_id = e.id
WHERE e.tenant_id = $1
AND e.fiscal_year = $2
AND e.status IN (${statusPlaceholders})
AND e.entry_date >= $${dateFromIdx}
AND e.entry_date <= $${dateToIdx}
GROUP BY e.id
ORDER BY e.entry_date ASC,
e.entry_number ASC NULLS LAST,
e.created_at ASC
`;
const { rows: entries } = await pool.query(entriesSQL, [
tenant_id, fiscal_year, ...statusFilter, dateFromStr, dateToStr,
]);
// ── Samla berörda kontonummer ───────────────────────────────────────────────
const accountNumberSet = new Set();
for (const e of entries) {
for (const l of (e.lines || [])) {
if (l.account_number) accountNumberSet.add(l.account_number);
}
}
const accountNumbers = Array.from(accountNumberSet).sort();
// ── Kontoinformation ────────────────────────────────────────────────────────
const accountMap = {};
if (accountNumbers.length > 0) {
const placeholders = accountNumbers.map((_, i) => `$${i + 2}`).join(',');
const { rows: acctRows } = await pool.query(
`SELECT account_number, name, account_type, normal_balance
FROM ledger_accounts
WHERE tenant_id = $1 AND account_number IN (${placeholders})`,
[tenant_id, ...accountNumbers]
);
for (const a of acctRows) {
accountMap[a.account_number] = a;
}
}
// ── IB — ingångssaldo (alla poster INNAN perioden startar) ──────────────────
// IB = kumulativt nettosaldo t.o.m. dagen FÖRE periodens start
const ibDate = new Date(dateFromUtc.getTime() - 86_400_000); // -1 dag
const ibDateStr = ibDate.toISOString().split('T')[0];
const ibMap = {};
if (accountNumbers.length > 0) {
const placeholders = accountNumbers.map((_, i) => `$${i + 3}`).join(',');
const { rows: ibRows } = await pool.query(
`SELECT
l.account_number,
COALESCE(SUM(l.debit), 0) - COALESCE(SUM(l.credit), 0) AS balance
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 e.entry_date <= $2
AND l.account_number IN (${placeholders})
GROUP BY l.account_number`,
[tenant_id, ibDateStr, ...accountNumbers]
);
for (const r of ibRows) {
ibMap[r.account_number] = parseFloat(r.balance) || 0;
}
}
// ── UB — utgångssaldo (alla poster T.O.M. periodens slut) ──────────────────
const ubMap = {};
if (accountNumbers.length > 0) {
const placeholders = accountNumbers.map((_, i) => `$${i + 3}`).join(',');
const { rows: ubRows } = await pool.query(
`SELECT
l.account_number,
COALESCE(SUM(l.debit), 0) - COALESCE(SUM(l.credit), 0) AS balance
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 e.entry_date <= $2
AND l.account_number IN (${placeholders})
GROUP BY l.account_number`,
[tenant_id, dateToStr, ...accountNumbers]
);
for (const r of ubRows) {
ubMap[r.account_number] = parseFloat(r.balance) || 0;
}
}
return { entries, accountMap, accountNumbers, ibMap, ubMap, dateFrom: dateFromStr, dateTo: dateToStr };
}
// ── SIE4-textgenerator ───────────────────────────────────────────────────────
/**
* Bygger SIE4-textfilen från exportdata och alternativ.
*
* @param {object} data - Returnerat från fetchExportData
* @param {object} opts - { fiscal_year, org_nr, company_name }
* @returns {string} - SIE4-text med CRLF-radslut
*/
function buildSIE4(data, opts) {
const { entries, accountMap, accountNumbers, ibMap, ubMap } = data;
const { fiscal_year, org_nr, company_name } = opts;
// Dagens datum för #GEN
const now = new Date();
const genDate = [
now.getUTCFullYear(),
String(now.getUTCMonth() + 1).padStart(2, '0'),
String(now.getUTCDate()).padStart(2, '0'),
].join('');
const fyStart = `${fiscal_year}0101`;
const fyEnd = `${fiscal_year}1231`;
const out = [];
// ── Huvudblock ─────────────────────────────────────────────────────────────
out.push('#FLAGGA 0');
out.push('#PROGRAM "AAMOS Ledger" 1.0');
out.push('#FORMAT PC8');
out.push(`#GEN ${genDate} system`);
out.push('#SIETYP 4');
if (org_nr) out.push(`#ORGNR ${org_nr}`);
if (company_name) out.push(`#FNAMN "${escapeStr(company_name)}"`);
out.push(`#RAR 0 ${fyStart} ${fyEnd}`);
out.push('');
// ── Kontoplan (#KONTO) ──────────────────────────────────────────────────────
for (const acctNr of accountNumbers) {
const acct = accountMap[acctNr];
const name = acct ? acct.name : acctNr;
out.push(`#KONTO ${acctNr} "${escapeStr(name)}"`);
}
out.push('');
// ── Saldon (#IB / #UB / #RES) ──────────────────────────────────────────────
// Balansräkningskonton → #IB + #UB
// Resultaträkningskonton → #RES (periodens netto: UB IB)
for (const acctNr of accountNumbers) {
const acct = accountMap[acctNr];
const ib = ibMap[acctNr] || 0;
const ub = ubMap[acctNr] || 0;
if (acct && (acct.account_type === 'revenue' || acct.account_type === 'expense')) {
out.push(`#RES 0 ${acctNr} ${fmtAmount(ub - ib)}`);
} else {
out.push(`#IB 0 ${acctNr} ${fmtAmount(ib)}`);
out.push(`#UB 0 ${acctNr} ${fmtAmount(ub)}`);
}
}
out.push('');
// ── Verifikationer (#VER med #TRANS) ───────────────────────────────────────
let fallbackIdx = 1;
for (const entry of entries) {
const series = sourceTypeToSeries(entry.source_type);
const verNr = entry.entry_number != null ? String(entry.entry_number) : String(fallbackIdx);
fallbackIdx++;
const verDate = fmtDate(entry.entry_date);
const regDate = entry.posted_at ? fmtDate(entry.posted_at) : verDate;
const desc = escapeStr(entry.description);
out.push(`#VER ${series} ${verNr} ${verDate} "${desc}" ${regDate}`);
out.push('{');
for (const l of (entry.lines || [])) {
// Debet = positivt, Kredit = negativt i SIE4
const amount = (parseFloat(l.debit) || 0) - (parseFloat(l.credit) || 0);
out.push(`#TRANS ${l.account_number} {} ${fmtAmount(amount)}`);
}
out.push('}');
}
// SIE4-standard: CRLF radslut
return out.join('\r\n') + '\r\n';
}
// ── Route-registrering ───────────────────────────────────────────────────────
/**
* Registrerar SIE4 export-endpoints på Express-appen.
*
* @param {import('express').Application} app
* @param {import('pg').Pool} pool
* @param {Function} buildCtx - Context-byggare från index.mjs (används ej — read-only export)
* @param {object} hermes - Event-fabric (används ej — read-only export)
*/
export function register(app, pool, buildCtx, hermes) {
// ── GET /api/ledger/export/sie4 ────────────────────────────────────────────
// Returnerar SIE4-fil som nedladdningsbar bifogad fil (text/plain).
//
// Query params:
// fiscal_year (krävs) t.ex. 2026
// period (valfritt) t.ex. 2026-01; utelämnad = hela räkenskapsåret
// org_nr (valfritt) organisationsnummer
// company_name (valfritt) företagsnamn
// include_unposted (valfritt, default "false") inkludera draft-poster
app.get('/api/ledger/export/sie4', async (req, res) => {
const tenant_id = req.headers['x-tenant-id'] || 'wavult-group';
const {
fiscal_year,
period = null,
org_nr = null,
company_name = null,
include_unposted = 'false',
} = req.query;
if (!fiscal_year) {
return res.status(400).json({ ok: false, error: 'fiscal_year krävs' });
}
const fy = parseInt(fiscal_year, 10);
if (isNaN(fy) || fy < 1900 || fy > 2100) {
return res.status(400).json({ ok: false, error: 'Ogiltigt fiscal_year' });
}
if (period && !/^\d{4}-\d{2}$/.test(period)) {
return res.status(400).json({ ok: false, error: 'period måste vara i format YYYY-MM' });
}
const incUnposted = include_unposted === 'true';
try {
console.log(`[sie4-export] Export: tenant=${tenant_id} fy=${fy} period=${period ?? 'hela året'} unposted=${incUnposted}`);
const data = await fetchExportData(pool, tenant_id, fy, period, incUnposted);
const sieText = buildSIE4(data, { fiscal_year: fy, org_nr, company_name });
const filename = period ? `SIE4_${period}.se` : `SIE4_${fy}.se`;
console.log(
`[sie4-export] OK: ${data.entries.length} verifikationer, ` +
`${data.accountNumbers.length} konton → ${filename}`
);
res.set('Content-Type', 'text/plain; charset=utf-8');
res.set('Content-Disposition', `attachment; filename="${filename}"`);
res.send(sieText);
} catch (e) {
console.error('[sie4-export] Export misslyckades:', e.message);
res.status(500).json({ ok: false, error: e.message });
}
});
// ── GET /api/ledger/export/sie4/preview ───────────────────────────────────
// Returnerar JSON med metadata + max 20 verifikationer för förhandsgranskning.
// Samma query-params som /sie4.
app.get('/api/ledger/export/sie4/preview', async (req, res) => {
const tenant_id = req.headers['x-tenant-id'] || 'wavult-group';
const {
fiscal_year,
period = null,
org_nr = null,
company_name = null,
include_unposted = 'false',
} = req.query;
if (!fiscal_year) {
return res.status(400).json({ ok: false, error: 'fiscal_year krävs' });
}
const fy = parseInt(fiscal_year, 10);
if (isNaN(fy) || fy < 1900 || fy > 2100) {
return res.status(400).json({ ok: false, error: 'Ogiltigt fiscal_year' });
}
if (period && !/^\d{4}-\d{2}$/.test(period)) {
return res.status(400).json({ ok: false, error: 'period måste vara i format YYYY-MM' });
}
const incUnposted = include_unposted === 'true';
try {
console.log(`[sie4-export] Preview: tenant=${tenant_id} fy=${fy} period=${period ?? 'hela året'}`);
const data = await fetchExportData(pool, tenant_id, fy, period, incUnposted);
// Förhandsgranskning (max 20 verifikationer)
let fallbackIdx = 1;
const previewEntries = data.entries.slice(0, 20).map(e => {
const verNr = e.entry_number != null ? String(e.entry_number) : String(fallbackIdx);
fallbackIdx++;
return {
series: sourceTypeToSeries(e.source_type),
ver_nr: verNr,
date: fmtDate(e.entry_date),
reg_date: e.posted_at ? fmtDate(e.posted_at) : fmtDate(e.entry_date),
description: e.description,
source_type: e.source_type,
status: e.status,
lines: (e.lines || []).map(l => ({
account_number: l.account_number,
account_name: l.account_name || null,
amount: (parseFloat(l.debit) || 0) - (parseFloat(l.credit) || 0),
})),
};
});
// Kontosaldo-sammanfattning
const accountSummary = data.accountNumbers.map(acctNr => {
const acct = data.accountMap[acctNr];
const ib = data.ibMap[acctNr] || 0;
const ub = data.ubMap[acctNr] || 0;
const isResultat = acct && (acct.account_type === 'revenue' || acct.account_type === 'expense');
return {
account_number: acctNr,
name: acct ? acct.name : null,
account_type: acct ? acct.account_type : 'unknown',
sie4_type: isResultat ? 'RES' : 'IB/UB',
ib: isResultat ? null : ib,
ub: isResultat ? null : ub,
res: isResultat ? (ub - ib) : null,
};
});
// Totaler för balans-check
const totalDebit = data.entries.reduce(
(s, e) => s + (e.lines || []).reduce((ls, l) => ls + (parseFloat(l.debit) || 0), 0), 0
);
const totalCredit = data.entries.reduce(
(s, e) => s + (e.lines || []).reduce((ls, l) => ls + (parseFloat(l.credit) || 0), 0), 0
);
res.json({
ok: true,
meta: {
tenant_id,
fiscal_year: fy,
period: period ?? `${fy} (hela räkenskapsåret)`,
date_from: data.dateFrom,
date_to: data.dateTo,
org_nr: org_nr ?? null,
company_name: company_name ?? null,
include_unposted: incUnposted,
entry_count: data.entries.length,
account_count: data.accountNumbers.length,
total_debit: totalDebit,
total_credit: totalCredit,
balanced: Math.abs(totalDebit - totalCredit) < 0.01,
},
accounts: accountSummary,
preview_entries: previewEntries,
note: data.entries.length > 20
? `Visar 20 av ${data.entries.length} verifikationer`
: `Visar alla ${data.entries.length} verifikationer`,
});
} catch (e) {
console.error('[sie4-export] Preview misslyckades:', e.message);
res.status(500).json({ ok: false, error: e.message });
}
});
console.log('[aamos-ledger] SIE4 Export registrerad: /api/ledger/export/sie4 · /api/ledger/export/sie4/preview');
}