471 lines
19 KiB
JavaScript
471 lines
19 KiB
JavaScript
/**
|
||
* ═══════════════════════════════════════════════════════════════════════════
|
||
* 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');
|
||
}
|