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

471 lines
19 KiB
JavaScript
Raw Normal View History

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