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