/** * AAMOS Bank CSV Import Service * E1-003 * Port: 3252 * * Importerar banktransaktioner (CSV) från svenska banker och skapar * draft journal entries i Ledger Engine (port 3250). */ import express from 'express'; import cors from 'cors'; import { randomUUID } from 'crypto'; import { parseCSV, detectBank, SUPPORTED_FORMATS } from './parsers.mjs'; const PORT = parseInt(process.env.PORT || '3252', 10); const LEDGER_BASE = process.env.LEDGER_URL || 'http://127.0.0.1:3250'; const app = express(); app.use(cors()); app.use(express.json({ limit: '20mb' })); // ── Health ──────────────────────────────────────────────────────────────────── app.get('/health', (_req, res) => { res.json({ ok: true, service: 'aamos-bank-import', port: PORT, ledger: LEDGER_BASE }); }); // ── GET /api/bank/formats ───────────────────────────────────────────────────── app.get('/api/bank/formats', (_req, res) => { res.json({ ok: true, formats: SUPPORTED_FORMATS }); }); // ── POST /api/bank/import ───────────────────────────────────────────────────── app.post('/api/bank/import', async (req, res) => { const { bank = 'auto', csv_content, account_number = '1910', tenant_id, dry_run = false, debit_account_positive = '3000', // kredit vid positiva belopp (inkomst) credit_account_negative = '4000', // debet vid negativa belopp (utgift) } = req.body ?? {}; if (!csv_content || typeof csv_content !== 'string') { return res.status(400).json({ ok: false, error: 'csv_content (string) krävs' }); } const correlation_id = randomUUID(); const trace_id = randomUUID(); const resolved_tenant = tenant_id || req.headers['x-tenant-id'] || 'wavult-group'; // ── Parse CSV ────────────────────────────────────────────────────────────── let parsed; let resolvedBank; try { parsed = parseCSV(bank, csv_content); resolvedBank = parsed.bank; } catch (err) { return res.status(400).json({ ok: false, error: `CSV parse-fel: ${err.message}`, correlation_id, trace_id, }); } const rows = parsed.rows; const rows_parsed = rows.length; if (rows_parsed === 0) { return res.json({ ok: true, bank: resolvedBank, rows_parsed: 0, rows_imported: 0, skipped: 0, errors: [], correlation_id, trace_id, dry_run, }); } // ── Dry run — returnera utan att importera ───────────────────────────────── if (dry_run) { return res.json({ ok: true, dry_run: true, bank: resolvedBank, rows_parsed, rows_imported: 0, skipped: 0, errors: [], preview: rows.slice(0, 5), correlation_id, trace_id, }); } // ── Live import — posta varje rad som en journal entry ───────────────────── let rows_imported = 0; let skipped = 0; const errors = []; for (const row of rows) { try { // Validera datum if (!row.date || !/^\d{4}-\d{2}-\d{2}/.test(row.date)) { skipped++; errors.push({ row: row.raw, reason: `Ogiltigt datum: "${row.date}"` }); continue; } const amount = row.amount; if (amount == null || amount === 0) { skipped++; errors.push({ row: row.raw, reason: 'Belopp är noll eller saknas' }); continue; } const absAmount = Math.abs(amount).toFixed(2); /* * Positiva belopp (inkomst/insättning): * Debet: 1910 (bank), Kredit: 3000 (intäkt) * * Negativa belopp (utgift/uttag): * Debet: 4000 (kostnad), Kredit: 1910 (bank) * * Ledger kräver ANTINGEN debit ELLER credit per rad (ej båda). */ let lines; if (amount > 0) { lines = [ { account_number: account_number, debit: absAmount, description: row.description }, { account_number: debit_account_positive, credit: absAmount, description: row.description }, ]; } else { lines = [ { account_number: credit_account_negative, debit: absAmount, description: row.description }, { account_number: account_number, credit: absAmount, description: row.description }, ]; } // Extrahera period från datum (YYYY-MM) const entryDate = row.date.substring(0, 10); const fy = entryDate.substring(0, 4); const period = entryDate.substring(0, 7); const body = { entry_date: entryDate, description: row.description || `Banktransaktion ${entryDate}`, reference: row.raw?.substring(0, 200), source_type: 'import_csv', source_id: `${resolvedBank}:${correlation_id}:${rows_imported + skipped}`, lines, period, fiscal_year: parseInt(fy, 10), metadata: { bank: resolvedBank, original_amount: amount, balance: row.balance, correlation_id, import_service: 'aamos-bank-import', }, }; const response = await fetch(`${LEDGER_BASE}/api/ledger/journal`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Trace-Id': trace_id, 'X-User-Id': 'bank-import', 'X-Tenant-Id': resolved_tenant, 'X-Correlation-Id': correlation_id, }, body: JSON.stringify(body), }); const result = await response.json(); if (!response.ok || !result.ok) { skipped++; errors.push({ row: row.raw, reason: result.error || `HTTP ${response.status}`, details: result, }); } else { rows_imported++; } } catch (err) { skipped++; errors.push({ row: row.raw, reason: err.message }); } } res.json({ ok: true, bank: resolvedBank, rows_parsed, rows_imported, skipped, errors: errors.slice(0, 50), // max 50 errors i svaret correlation_id, trace_id, dry_run: false, }); }); // ── Start ───────────────────────────────────────────────────────────────────── app.listen(PORT, () => { console.log(`[aamos-bank-import] Lyssnar på port ${PORT}`); console.log(`[aamos-bank-import] Ledger Engine: ${LEDGER_BASE}`); });