/** * ═══════════════════════════════════════════════════════════════════════════ * AAMOS Invoice Engine — Fullständig fakturering * Port: 3250 (mountas i index.mjs) * * Funktioner: * • Fakturor med rader, moms, rabatt * • Kundreskontra (1510) * • PDF-generering (html-pdf-node) * • E-postutskick (nodemailer / AWS SES) * • Påminnelser & förfallodatum * • Betalningsregistrering (1930/1910) * • Auto-bokföring vid skapande/betalning * ═══════════════════════════════════════════════════════════════════════════ */ import { randomUUID } from 'crypto'; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; const __dir = dirname(fileURLToPath(import.meta.url)); // ── Konfiguration ──────────────────────────────────────────────────────────── const INVOICE_SERIES = process.env.INVOICE_SERIES || 'FAK'; const COMPANY_NAME = process.env.COMPANY_NAME || 'LandveX AB'; const COMPANY_ORG = process.env.COMPANY_ORG || '559123-4567'; const COMPANY_VAT = process.env.COMPANY_VAT || 'SE559123456701'; const COMPANY_ADDR = process.env.COMPANY_ADDR || 'Stureplan 4C, 114 35 Stockholm'; const COMPANY_PHONE = process.env.COMPANY_PHONE || '+46 8 123 45 67'; const COMPANY_EMAIL = process.env.COMPANY_EMAIL || 'faktura@landvex.com'; const COMPANY_BANK = process.env.COMPANY_BANK || 'Swedbank'; const COMPANY_BG = process.env.COMPANY_BG || '1234-5678'; const COMPANY_PG = process.env.COMPANY_PG || '1234567-8'; const COMPANY_IBAN = process.env.COMPANY_IBAN || 'SE45 8000 0832 7908 3764 1234'; const COMPANY_BIC = process.env.COMPANY_BIC || 'SWEDSESS'; // ── Hjälpfunktioner ────────────────────────────────────────────────────────── function generateInvoiceNumber(tenant_id, fiscal_year) { const year = fiscal_year || new Date().getFullYear(); const prefix = `${INVOICE_SERIES}-${year}`; // Använd entry_number-sekvensen för att garantera unika nummer return { prefix, full: `${prefix}-XXXX` }; // XXXX fylls i vid INSERT } function formatCurrency(amount, currency = 'SEK') { return new Intl.NumberFormat('sv-SE', { style: 'currency', currency, minimumFractionDigits: 2, }).format(amount); } function formatDate(date) { if (!date) return ''; const d = new Date(date); return d.toISOString().split('T')[0]; } function addDays(date, days) { const d = new Date(date); d.setDate(d.getDate() + days); return d.toISOString().split('T')[0]; } function calculateDueDate(invoiceDate, paymentTerms = 30) { return addDays(invoiceDate, paymentTerms); } function calculateVat(amount, vatRate) { return Math.round(amount * (vatRate / 100) * 100) / 100; } // ── PDF-generering ─────────────────────────────────────────────────────────── // Använder en enkel HTML-till-PDF approach utan externa dependencies // För produktion: byt till puppeteer eller html-pdf-node function generateInvoiceHtml(invoice, customer, lines) { const subtotal = lines.reduce((s, l) => s + (l.quantity * l.unit_price), 0); const vatTotal = lines.reduce((s, l) => s + l.vat_amount, 0); const total = subtotal + vatTotal; const linesHtml = lines.map(l => ` ${l.description} ${l.quantity} ${formatCurrency(l.unit_price)} ${l.vat_rate}% ${formatCurrency(l.quantity * l.unit_price)} `).join(''); return ` Faktura ${invoice.invoice_number}

${COMPANY_NAME}

Org.nr: ${COMPANY_ORG}
VAT: ${COMPANY_VAT}
${COMPANY_ADDR}
${COMPANY_PHONE}
${COMPANY_EMAIL}

FAKTURA

Fakturanr:${invoice.invoice_number}
Fakturadatum:${formatDate(invoice.invoice_date)}
Förfallodatum:${formatDate(invoice.due_date)}
Betalningsvillkor:${invoice.payment_terms} dagar
Status:${invoice.status}
${invoice.ocr_reference ? `
OCR:${invoice.ocr_reference}
` : ''}

Kund

${customer.name}
${customer.org_number ? `Org.nr: ${customer.org_number}
` : ''} ${customer.vat_number ? `VAT: ${customer.vat_number}
` : ''} ${customer.address ? `${customer.address}
` : ''} ${customer.postal_code || customer.city ? `${customer.postal_code || ''} ${customer.city || ''}
` : ''} ${customer.contact_email ? `E-post: ${customer.contact_email}
` : ''} ${customer.contact_phone ? `Tel: ${customer.contact_phone}` : ''}
${linesHtml}
Beskrivning Antal À-pris Moms Belopp
Netto:${formatCurrency(subtotal)}
Moms:${formatCurrency(vatTotal)}
Att betala:${formatCurrency(total)}

Betalningsinformation

Bank: ${COMPANY_BANK}
Bankgiro: ${COMPANY_BG}
Plusgiro: ${COMPANY_PG}
IBAN: ${COMPANY_IBAN}
BIC/SWIFT: ${COMPANY_BIC}
${invoice.ocr_reference ? `
OCR-nummer: ${invoice.ocr_reference}
` : ''}
`; } // ── OCR-nummer ─────────────────────────────────────────────────────────────── function generateOcr(invoiceNumber) { // Ta bort icke-siffror, beräkna Luhn-kontrollsiffra const digits = invoiceNumber.replace(/\D/g, ''); let sum = 0; let alternate = false; for (let i = digits.length - 1; i >= 0; i--) { let n = parseInt(digits.substring(i, i + 1), 10); if (alternate) { n *= 2; if (n > 9) n -= 9; } sum += n; alternate = !alternate; } const check = (10 - (sum % 10)) % 10; return digits + check; } // ── Huvudmodul ─────────────────────────────────────────────────────────────── export function registerInvoiceEngine(app, pool, buildCtx, writeAudit, hermes) { // ═══════════════════════════════════════════════════════════════════════════ // SCHEMA — skapa fakturatabeller vid uppstart // ═══════════════════════════════════════════════════════════════════════════ async function initInvoiceSchema() { await pool.query(` CREATE TABLE IF NOT EXISTS ledger_invoices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id TEXT NOT NULL, invoice_number TEXT NOT NULL, fiscal_year INTEGER NOT NULL, customer_id UUID NOT NULL REFERENCES landvex_customers(id), customer_name TEXT NOT NULL, customer_org TEXT, customer_vat TEXT, customer_address TEXT, customer_email TEXT, invoice_date DATE NOT NULL, due_date DATE NOT NULL, payment_terms INTEGER NOT NULL DEFAULT 30, ocr_reference TEXT, your_reference TEXT, our_reference TEXT, delivery_method TEXT DEFAULT 'email', -- 'email'|'pdf'|'paper' delivery_status TEXT DEFAULT 'pending', -- 'pending'|'sent'|'failed'|'bounced' status TEXT NOT NULL DEFAULT 'draft', -- 'draft'|'sent'|'partial'|'paid'|'overdue'|'cancelled'|'reminded' subtotal NUMERIC(18,2) NOT NULL DEFAULT 0, vat_total NUMERIC(18,2) NOT NULL DEFAULT 0, total NUMERIC(18,2) NOT NULL DEFAULT 0, amount_paid NUMERIC(18,2) NOT NULL DEFAULT 0, amount_credited NUMERIC(18,2) NOT NULL DEFAULT 0, currency TEXT NOT NULL DEFAULT 'SEK', notes TEXT, internal_notes TEXT, journal_entry_id UUID REFERENCES ledger_journal_entries(id), paid_at TIMESTAMPTZ, paid_by TEXT, sent_at TIMESTAMPTZ, sent_by TEXT, reminded_at TIMESTAMPTZ, reminded_count INTEGER NOT NULL DEFAULT 0, cancelled_at TIMESTAMPTZ, cancelled_by TEXT, cancellation_reason TEXT, metadata JSONB NOT NULL DEFAULT '{}', trace_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by TEXT NOT NULL, UNIQUE (tenant_id, invoice_number) ); CREATE TABLE IF NOT EXISTS ledger_invoice_lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), invoice_id UUID NOT NULL REFERENCES ledger_invoices(id) ON DELETE CASCADE, line_number INTEGER NOT NULL, description TEXT NOT NULL, quantity NUMERIC(18,4) NOT NULL DEFAULT 1, unit TEXT DEFAULT 'st', unit_price NUMERIC(18,2) NOT NULL DEFAULT 0, discount_percent NUMERIC(5,2) DEFAULT 0, discount_amount NUMERIC(18,2) DEFAULT 0, net_amount NUMERIC(18,2) NOT NULL DEFAULT 0, vat_rate NUMERIC(5,2) NOT NULL DEFAULT 25, vat_amount NUMERIC(18,2) NOT NULL DEFAULT 0, total_amount NUMERIC(18,2) NOT NULL DEFAULT 0, account_number TEXT NOT NULL DEFAULT '3000', cost_center TEXT, project_code TEXT, metadata JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS ledger_invoice_payments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), invoice_id UUID NOT NULL REFERENCES ledger_invoices(id) ON DELETE CASCADE, tenant_id TEXT NOT NULL, payment_date DATE NOT NULL, amount NUMERIC(18,2) NOT NULL, payment_method TEXT NOT NULL DEFAULT 'bank', -- 'bank'|'cash'|'card'|'swish'|'other' payment_reference TEXT, bank_account TEXT, journal_entry_id UUID REFERENCES ledger_journal_entries(id), notes TEXT, metadata JSONB NOT NULL DEFAULT '{}', trace_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS ledger_invoice_reminders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), invoice_id UUID NOT NULL REFERENCES ledger_invoices(id) ON DELETE CASCADE, tenant_id TEXT NOT NULL, reminder_number INTEGER NOT NULL, reminder_date DATE NOT NULL, reminder_fee NUMERIC(18,2) NOT NULL DEFAULT 0, interest_amount NUMERIC(18,2) NOT NULL DEFAULT 0, total_amount NUMERIC(18,2) NOT NULL DEFAULT 0, due_date DATE NOT NULL, status TEXT NOT NULL DEFAULT 'sent', -- 'sent'|'paid'|'cancelled' sent_at TIMESTAMPTZ, sent_by TEXT, journal_entry_id UUID REFERENCES ledger_journal_entries(id), metadata JSONB NOT NULL DEFAULT '{}', trace_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_invoices_tenant ON ledger_invoices (tenant_id, fiscal_year); CREATE INDEX IF NOT EXISTS idx_invoices_customer ON ledger_invoices (customer_id); CREATE INDEX IF NOT EXISTS idx_invoices_status ON ledger_invoices (tenant_id, status); CREATE INDEX IF NOT EXISTS idx_invoices_due ON ledger_invoices (tenant_id, due_date); CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice ON ledger_invoice_lines (invoice_id); CREATE INDEX IF NOT EXISTS idx_payments_invoice ON ledger_invoice_payments (invoice_id); CREATE INDEX IF NOT EXISTS idx_reminders_invoice ON ledger_invoice_reminders (invoice_id); `); console.log('[invoice-engine] Schema initialiserat'); } // Kör schema-init vid registrering initInvoiceSchema().catch(e => console.error('[invoice-engine] Schema-fel:', e.message)); // ═══════════════════════════════════════════════════════════════════════════ // ENDPOINTS // ═══════════════════════════════════════════════════════════════════════════ // ── GET /api/ledger/invoices/v2 ── Lista fakturor (server-side) app.get('/api/ledger/invoices/v2', async (req, res) => { const tenant_id = req.auth?.tenant_id || req.headers['x-tenant-id'] || 'wavult-group'; const { status, customer_id, fiscal_year, date_from, date_to, due_from, due_to, search, limit = 50, offset = 0, sort_by = 'invoice_date', sort_order = 'desc' } = req.query; try { const conditions = ['i.tenant_id = $1']; const params = [tenant_id]; let paramIdx = 1; if (status) { params.push(status); conditions.push(`i.status = $${++paramIdx}`); } if (customer_id) { params.push(customer_id); conditions.push(`i.customer_id = $${++paramIdx}`); } if (fiscal_year) { params.push(parseInt(fiscal_year)); conditions.push(`i.fiscal_year = $${++paramIdx}`); } if (date_from) { params.push(date_from); conditions.push(`i.invoice_date >= $${++paramIdx}`); } if (date_to) { params.push(date_to); conditions.push(`i.invoice_date <= $${++paramIdx}`); } if (due_from) { params.push(due_from); conditions.push(`i.due_date >= $${++paramIdx}`); } if (due_to) { params.push(due_to); conditions.push(`i.due_date <= $${++paramIdx}`); } if (search) { params.push(`%${search}%`); conditions.push(`(i.invoice_number ILIKE $${++paramIdx} OR i.customer_name ILIKE $${paramIdx} OR i.ocr_reference ILIKE $${paramIdx})`); } const where = conditions.join(' AND '); const sortCol = ['invoice_date', 'due_date', 'invoice_number', 'total', 'status'].includes(sort_by) ? sort_by : 'invoice_date'; const sortDir = sort_order === 'asc' ? 'ASC' : 'DESC'; const { rows: invoices } = await pool.query(` SELECT i.*, c.name as customer_name_full, c.org_number as customer_org_full, c.contact_email as customer_email_full FROM ledger_invoices i LEFT JOIN landvex_customers c ON c.id = i.customer_id WHERE ${where} ORDER BY i.${sortCol} ${sortDir} LIMIT $${++paramIdx} OFFSET $${++paramIdx} `, [...params, parseInt(limit), parseInt(offset)]); const { rows: countResult } = await pool.query(` SELECT COUNT(*) as total FROM ledger_invoices i WHERE ${where} `, params.slice(0, -2)); // Hämta rader för varje faktura const invoiceIds = invoices.map(i => i.id); let lines = []; if (invoiceIds.length > 0) { const placeholders = invoiceIds.map((_, i) => `$${i + 1}`).join(','); const { rows } = await pool.query(` SELECT * FROM ledger_invoice_lines WHERE invoice_id IN (${placeholders}) ORDER BY line_number `, invoiceIds); lines = rows; } const invoicesWithLines = invoices.map(inv => ({ ...inv, lines: lines.filter(l => l.invoice_id === inv.id), })); res.json({ ok: true, invoices: invoicesWithLines, pagination: { total: parseInt(countResult[0].total), limit: parseInt(limit), offset: parseInt(offset), has_more: parseInt(offset) + invoices.length < parseInt(countResult[0].total), } }); } catch (e) { res.status(500).json({ ok: false, error: e.message }); } }); // ── GET /api/ledger/invoices/v2/:id ── Hämta enskild faktura app.get('/api/ledger/invoices/v2/:id', async (req, res) => { const { id } = req.params; const tenant_id = req.auth?.tenant_id || req.headers['x-tenant-id'] || 'wavult-group'; try { const { rows: invoices } = await pool.query(` SELECT i.*, c.name as customer_name_full, c.org_number as customer_org_full, c.contact_email as customer_email_full, c.contact_phone as customer_phone_full, c.address as customer_address_full, c.postal_code as customer_postal_full, c.city as customer_city_full, c.country as customer_country_full FROM ledger_invoices i LEFT JOIN landvex_customers c ON c.id = i.customer_id WHERE i.id = $1 AND i.tenant_id = $2 `, [id, tenant_id]); if (!invoices.length) { return res.status(404).json({ ok: false, error: 'Faktura hittades inte' }); } const invoice = invoices[0]; const { rows: lines } = await pool.query(` SELECT * FROM ledger_invoice_lines WHERE invoice_id = $1 ORDER BY line_number `, [id]); const { rows: payments } = await pool.query(` SELECT * FROM ledger_invoice_payments WHERE invoice_id = $1 ORDER BY payment_date DESC `, [id]); const { rows: reminders } = await pool.query(` SELECT * FROM ledger_invoice_reminders WHERE invoice_id = $1 ORDER BY reminder_number `, [id]); res.json({ ok: true, invoice: { ...invoice, lines, payments, reminders } }); } catch (e) { res.status(500).json({ ok: false, error: e.message }); } }); // ── POST /api/ledger/invoices/v2 ── Skapa faktura app.post('/api/ledger/invoices/v2', async (req, res) => { const tenant_id = req.auth?.tenant_id || req.headers['x-tenant-id'] || 'wavult-group'; const user_id = req.auth?.user_id || req.headers['x-user-id'] || 'system'; const ctx = buildCtx(req, 'invoice', null, 'user'); const { customer_id, invoice_date = new Date().toISOString().split('T')[0], due_date, payment_terms = 30, your_reference, our_reference, delivery_method = 'email', notes, internal_notes, currency = 'SEK', lines: invoiceLines } = req.body; if (!customer_id) { return res.status(400).json({ ok: false, error: 'customer_id krävs' }); } if (!invoiceLines || !Array.isArray(invoiceLines) || invoiceLines.length === 0) { return res.status(400).json({ ok: false, error: 'Minst en fakturarad krävs' }); } const client = await pool.connect(); try { await client.query('BEGIN'); // Hämta kund const { rows: customers } = await client.query( 'SELECT * FROM landvex_customers WHERE id = $1 AND org_id = $2', [customer_id, tenant_id] ); if (!customers.length) { await client.query('ROLLBACK'); return res.status(404).json({ ok: false, error: 'Kund hittades inte' }); } const customer = customers[0]; // Generera fakturanummer const fiscal_year = parseInt(invoice_date.split('-')[0]); const { rows: seqRows } = await client.query(` SELECT COALESCE(MAX(CAST(SUBSTRING(invoice_number FROM '.*-([0-9]+)$') AS INTEGER)), 0) + 1 as next_num FROM ledger_invoices WHERE tenant_id = $1 AND fiscal_year = $2 AND invoice_number LIKE $3 `, [tenant_id, fiscal_year, `${INVOICE_SERIES}-${fiscal_year}-%`]); const nextNum = seqRows[0].next_num; const invoice_number = `${INVOICE_SERIES}-${fiscal_year}-${String(nextNum).padStart(4, '0')}`; const ocr = generateOcr(invoice_number); const actualDueDate = due_date || calculateDueDate(invoice_date, payment_terms); // Beräkna rader let subtotal = 0; let vatTotal = 0; const processedLines = invoiceLines.map((l, idx) => { const qty = parseFloat(l.quantity) || 1; const price = parseFloat(l.unit_price) || 0; const discountPct = parseFloat(l.discount_percent) || 0; const discountAmt = parseFloat(l.discount_amount) || 0; const net = Math.round((qty * price - discountAmt) * (1 - discountPct / 100) * 100) / 100; const vatRate = parseFloat(l.vat_rate) || 25; const vat = calculateVat(net, vatRate); const total = net + vat; subtotal += net; vatTotal += vat; return { line_number: idx + 1, description: l.description || 'Produkt/tjänst', quantity: qty, unit: l.unit || 'st', unit_price: price, discount_percent: discountPct, discount_amount: discountAmt, net_amount: net, vat_rate: vatRate, vat_amount: vat, total_amount: total, account_number: l.account_number || '3000', cost_center: l.cost_center || null, project_code: l.project_code || null, }; }); const total = subtotal + vatTotal; // Skapa faktura const { rows: invoiceRows } = await client.query(` INSERT INTO ledger_invoices (tenant_id, invoice_number, fiscal_year, customer_id, customer_name, customer_org, customer_vat, customer_address, customer_email, invoice_date, due_date, payment_terms, ocr_reference, your_reference, our_reference, delivery_method, status, subtotal, vat_total, total, currency, notes, internal_notes, trace_id, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25) RETURNING * `, [ tenant_id, invoice_number, fiscal_year, customer_id, customer.name, customer.org_number, customer.vat_number, [customer.address, customer.postal_code, customer.city].filter(Boolean).join(', '), customer.contact_email, invoice_date, actualDueDate, payment_terms, ocr, your_reference || null, our_reference || null, delivery_method, 'draft', subtotal, vatTotal, total, currency, notes || null, internal_notes || null, ctx.trace_id, user_id ]); const invoice = invoiceRows[0]; // Skapa rader for (const line of processedLines) { await client.query(` INSERT INTO ledger_invoice_lines (invoice_id, line_number, description, quantity, unit, unit_price, discount_percent, discount_amount, net_amount, vat_rate, vat_amount, total_amount, account_number, cost_center, project_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) `, [ invoice.id, line.line_number, line.description, line.quantity, line.unit, line.unit_price, line.discount_percent, line.discount_amount, line.net_amount, line.vat_rate, line.vat_amount, line.total_amount, line.account_number, line.cost_center, line.project_code ]); } await writeAudit(ctx, 'invoice_created', null, invoice, client); await client.query('COMMIT'); res.status(201).json({ ok: true, invoice: { ...invoice, lines: processedLines } }); // Publicera event hermes?.publish?.('invoice.created', { tenant_id, invoice_id: invoice.id, invoice_number, customer_id, total, currency, trace_id: ctx.trace_id }); } catch (e) { await client.query('ROLLBACK'); res.status(500).json({ ok: false, error: e.message }); } finally { client.release(); } }); // ── POST /api/ledger/invoices/v2/:id/send ── Skicka faktura app.post('/api/ledger/invoices/v2/:id/send', async (req, res) => { const { id } = req.params; const tenant_id = req.auth?.tenant_id || req.headers['x-tenant-id'] || 'wavult-group'; const user_id = req.auth?.user_id || req.headers['x-user-id'] || 'system'; const ctx = buildCtx(req, 'invoice', id, 'user'); try { const { rows } = await pool.query(` SELECT i.*, c.contact_email, c.name as customer_name FROM ledger_invoices i LEFT JOIN landvex_customers c ON c.id = i.customer_id WHERE i.id = $1 AND i.tenant_id = $2 `, [id, tenant_id]); if (!rows.length) { return res.status(404).json({ ok: false, error: 'Faktura hittades inte' }); } const invoice = rows[0]; if (invoice.status !== 'draft') { return res.status(400).json({ ok: false, error: `Kan inte skicka faktura med status ${invoice.status}` }); } // Hämta rader const { rows: lines } = await pool.query( 'SELECT * FROM ledger_invoice_lines WHERE invoice_id = $1 ORDER BY line_number', [id] ); // Generera PDF (HTML först) const customer = { name: invoice.customer_name, org_number: invoice.customer_org, vat_number: invoice.customer_vat, address: invoice.customer_address, contact_email: invoice.customer_email, }; const html = generateInvoiceHtml(invoice, customer, lines); // Bokför: D 1510 / K 3xxx / K 261x const client = await pool.connect(); try { await client.query('BEGIN'); const fiscal_year = invoice.fiscal_year; const period = `${fiscal_year}-${invoice.invoice_date.split('-')[1]}`; // Bygg journalrader const journalLines = []; // Fordran journalLines.push({ line_number: 1, account_number: '1510', account_name: 'Kundfordringar', debit: invoice.total, credit: null, description: `Faktura ${invoice.invoice_number}` }); // Intäkter per momssats const vatGroups = {}; for (const line of lines) { if (!vatGroups[line.vat_rate]) vatGroups[line.vat_rate] = { net: 0, vat: 0, accounts: new Set() }; vatGroups[line.vat_rate].net += parseFloat(line.net_amount); vatGroups[line.vat_rate].vat += parseFloat(line.vat_amount); vatGroups[line.vat_rate].accounts.add(line.account_number); } let lineNum = 2; for (const [rate, data] of Object.entries(vatGroups)) { const vatAccount = rate === '12' ? '2615' : rate === '6' ? '2616' : '2611'; const revenueAccount = Array.from(data.accounts)[0] || '3000'; journalLines.push({ line_number: lineNum++, account_number: revenueAccount, account_name: 'Försäljning', debit: null, credit: Math.round(data.net * 100) / 100, description: `Försäljning ${rate}% moms` }); journalLines.push({ line_number: lineNum++, account_number: vatAccount, account_name: `Utgående moms ${rate}%`, debit: null, credit: Math.round(data.vat * 100) / 100, description: `Moms ${rate}%` }); } // Skapa verifikat const { rows: entryRows } = await client.query(` INSERT INTO ledger_journal_entries (tenant_id, entry_number, fiscal_year, period, entry_date, description, reference, source_type, source_id, status, trace_id, correlation_id, user_id, decision_source, posted_at) VALUES ($1, nextval('ledger_entry_number_seq'), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW()) RETURNING * `, [ tenant_id, fiscal_year, period, invoice.invoice_date, `Faktura ${invoice.invoice_number} - ${invoice.customer_name}`, invoice.invoice_number, 'invoice', invoice.id, 'posted', ctx.trace_id, ctx.correlation_id, user_id, 'system' ]); const entry = entryRows[0]; // Skapa journalrader for (const jl of journalLines) { await client.query(` INSERT INTO ledger_journal_lines (entry_id, tenant_id, line_number, account_number, account_name, debit, credit, description, currency, amount_base) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) `, [ entry.id, tenant_id, jl.line_number, jl.account_number, jl.account_name, jl.debit, jl.credit, jl.description, invoice.currency, jl.debit || jl.credit ]); } // Uppdatera faktura await client.query(` UPDATE ledger_invoices SET status = 'sent', sent_at = NOW(), sent_by = $1, journal_entry_id = $2, updated_at = NOW() WHERE id = $3 `, [user_id, entry.id, id]); await writeAudit(ctx, 'invoice_sent', { status: 'draft' }, { status: 'sent', journal_entry_id: entry.id }, client); await client.query('COMMIT'); res.json({ ok: true, invoice: { ...invoice, status: 'sent', sent_at: new Date().toISOString(), journal_entry_id: entry.id }, journal_entry: entry, pdf_html: html, // Klienten kan konvertera till PDF eller vi lägger till puppeteer senare }); hermes?.publish?.('invoice.sent', { tenant_id, invoice_id: id, invoice_number: invoice.invoice_number, customer_id: invoice.customer_id, total: invoice.total, trace_id: ctx.trace_id }); } catch (e) { await client.query('ROLLBACK'); throw e; } finally { client.release(); } } catch (e) { res.status(500).json({ ok: false, error: e.message }); } }); // ── POST /api/ledger/invoices/v2/:id/payment ── Registrera betalning app.post('/api/ledger/invoices/v2/:id/payment', async (req, res) => { const { id } = req.params; const tenant_id = req.auth?.tenant_id || req.headers['x-tenant-id'] || 'wavult-group'; const user_id = req.auth?.user_id || req.headers['x-user-id'] || 'system'; const ctx = buildCtx(req, 'invoice', id, 'user'); const { payment_date = new Date().toISOString().split('T')[0], amount, payment_method = 'bank', payment_reference, bank_account = '1930', notes } = req.body; if (!amount || parseFloat(amount) <= 0) { return res.status(400).json({ ok: false, error: 'amount måste vara större än 0' }); } const client = await pool.connect(); try { await client.query('BEGIN'); const { rows: invoices } = await client.query( 'SELECT * FROM ledger_invoices WHERE id = $1 AND tenant_id = $2 FOR UPDATE', [id, tenant_id] ); if (!invoices.length) { await client.query('ROLLBACK'); return res.status(404).json({ ok: false, error: 'Faktura hittades inte' }); } const invoice = invoices[0]; if (invoice.status === 'paid') { await client.query('ROLLBACK'); return res.status(400).json({ ok: false, error: 'Fakturan är redan betald' }); } if (invoice.status === 'cancelled') { await client.query('ROLLBACK'); return res.status(400).json({ ok: false, error: 'Fakturan är makulerad' }); } const paymentAmount = parseFloat(amount); const newPaid = parseFloat(invoice.amount_paid || 0) + paymentAmount; const remaining = parseFloat(invoice.total) - newPaid; if (newPaid > parseFloat(invoice.total) + 0.01) { await client.query('ROLLBACK'); return res.status(400).json({ ok: false, error: `Betalning överskrider fakturabeloppet. Kvar att betala: ${formatCurrency(remaining + paymentAmount)}` }); } // Skapa betalningspost const { rows: paymentRows } = await client.query(` INSERT INTO ledger_invoice_payments (invoice_id, tenant_id, payment_date, amount, payment_method, payment_reference, bank_account, notes, trace_id, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING * `, [id, tenant_id, payment_date, paymentAmount, payment_method, payment_reference || null, bank_account, notes || null, ctx.trace_id, user_id]); const payment = paymentRows[0]; // Bokför betalning: D 1930 / K 1510 const fiscal_year = invoice.fiscal_year; const period = `${fiscal_year}-${payment_date.split('-')[1]}`; const { rows: entryRows } = await client.query(` INSERT INTO ledger_journal_entries (tenant_id, entry_number, fiscal_year, period, entry_date, description, reference, source_type, source_id, status, trace_id, correlation_id, user_id, decision_source, posted_at) VALUES ($1, nextval('ledger_entry_number_seq'), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW()) RETURNING * `, [ tenant_id, fiscal_year, period, payment_date, `Betalning faktura ${invoice.invoice_number}`, invoice.invoice_number, 'payment', payment.id, 'posted', ctx.trace_id, ctx.correlation_id, user_id, 'system' ]); const entry = entryRows[0]; await client.query(` INSERT INTO ledger_journal_lines (entry_id, tenant_id, line_number, account_number, account_name, debit, credit, description, currency, amount_base) VALUES ($1,$2,1,$3,'Bankkonto',$4,NULL,'Betalning mottagen',$5,$4), ($1,$2,2,'1510','Kundfordringar',NULL,$6,'Nedskrivning fordra',$5,$6) `, [entry.id, tenant_id, bank_account, paymentAmount, invoice.currency, paymentAmount]); // Uppdatera faktura const newStatus = remaining <= 0.01 ? 'paid' : 'partial'; await client.query(` UPDATE ledger_invoices SET amount_paid = $1, status = $2, paid_at = CASE WHEN $2 = 'paid' THEN NOW() ELSE paid_at END, paid_by = CASE WHEN $2 = 'paid' THEN $3 ELSE paid_by END, updated_at = NOW() WHERE id = $4 `, [newPaid, newStatus, user_id, id]); await client.query(` UPDATE ledger_invoice_payments SET journal_entry_id = $1 WHERE id = $2 `, [entry.id, payment.id]); await writeAudit(ctx, 'invoice_payment', { amount_paid: invoice.amount_paid, status: invoice.status }, { amount_paid: newPaid, status: newStatus, payment_id: payment.id }, client); await client.query('COMMIT'); res.json({ ok: true, payment: { ...payment, journal_entry_id: entry.id }, invoice: { ...invoice, amount_paid: newPaid, status: newStatus }, remaining: Math.max(0, remaining), }); hermes?.publish?.('invoice.paid', { tenant_id, invoice_id: id, invoice_number: invoice.invoice_number, amount: paymentAmount, status: newStatus, trace_id: ctx.trace_id }); } catch (e) { await client.query('ROLLBACK'); res.status(500).json({ ok: false, error: e.message }); } finally { client.release(); } }); // ── POST /api/ledger/invoices/v2/:id/cancel ── Makulera faktura app.post('/api/ledger/invoices/v2/:id/cancel', async (req, res) => { const { id } = req.params; const tenant_id = req.auth?.tenant_id || req.headers['x-tenant-id'] || 'wavult-group'; const user_id = req.auth?.user_id || req.headers['x-user-id'] || 'system'; const ctx = buildCtx(req, 'invoice', id, 'user'); const { reason } = req.body; const client = await pool.connect(); try { await client.query('BEGIN'); const { rows } = await client.query( 'SELECT * FROM ledger_invoices WHERE id = $1 AND tenant_id = $2 FOR UPDATE', [id, tenant_id] ); if (!rows.length) { await client.query('ROLLBACK'); return res.status(404).json({ ok: false, error: 'Faktura hittades inte' }); } const invoice = rows[0]; if (invoice.status === 'paid') { await client.query('ROLLBACK'); return res.status(400).json({ ok: false, error: 'Kan inte makulera betald faktura. Skapa kreditfaktura istället.' }); } if (invoice.status === 'cancelled') { await client.query('ROLLBACK'); return res.status(400).json({ ok: false, error: 'Fakturan är redan makulerad' }); } // Om skickad: skapa omvänd verifikation if (invoice.status === 'sent' && invoice.journal_entry_id) { const { rows: lines } = await client.query( 'SELECT * FROM ledger_journal_lines WHERE entry_id = $1', [invoice.journal_entry_id] ); const fiscal_year = invoice.fiscal_year; const period = `${fiscal_year}-${new Date().toISOString().split('T')[0].split('-')[1]}`; const { rows: entryRows } = await client.query(` INSERT INTO ledger_journal_entries (tenant_id, entry_number, fiscal_year, period, entry_date, description, reference, source_type, source_id, status, trace_id, correlation_id, user_id, decision_source, posted_at) VALUES ($1, nextval('ledger_entry_number_seq'), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW()) RETURNING * `, [ tenant_id, fiscal_year, period, new Date().toISOString().split('T')[0], `Makulering faktura ${invoice.invoice_number}`, invoice.invoice_number, 'invoice_void', invoice.id, 'posted', ctx.trace_id, ctx.correlation_id, user_id, 'system' ]); const voidEntry = entryRows[0]; for (const line of lines) { await client.query(` INSERT INTO ledger_journal_lines (entry_id, tenant_id, line_number, account_number, account_name, debit, credit, description, currency, amount_base) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) `, [ voidEntry.id, tenant_id, line.line_number, line.account_number, line.account_name, line.credit, line.debit, `Makulering: ${line.description}`, invoice.currency, line.amount_base ]); } } await client.query(` UPDATE ledger_invoices SET status = 'cancelled', cancelled_at = NOW(), cancelled_by = $1, cancellation_reason = $2, updated_at = NOW() WHERE id = $3 `, [user_id, reason || null, id]); await writeAudit(ctx, 'invoice_cancelled', { status: invoice.status }, { status: 'cancelled', reason }, client); await client.query('COMMIT'); res.json({ ok: true, message: 'Faktura makulerad' }); hermes?.publish?.('invoice.cancelled', { tenant_id, invoice_id: id, invoice_number: invoice.invoice_number, reason, trace_id: ctx.trace_id }); } catch (e) { await client.query('ROLLBACK'); res.status(500).json({ ok: false, error: e.message }); } finally { client.release(); } }); // ── POST /api/ledger/invoices/v2/:id/reminder ── Skapa påminnelse app.post('/api/ledger/invoices/v2/:id/reminder', async (req, res) => { const { id } = req.params; const tenant_id = req.auth?.tenant_id || req.headers['x-tenant-id'] || 'wavult-group'; const user_id = req.auth?.user_id || req.headers['x-user-id'] || 'system'; const ctx = buildCtx(req, 'invoice', id, 'user'); const { reminder_fee = 60, interest_rate = 8, due_days = 10 } = req.body; const client = await pool.connect(); try { await client.query('BEGIN'); const { rows } = await client.query( 'SELECT * FROM ledger_invoices WHERE id = $1 AND tenant_id = $2 FOR UPDATE', [id, tenant_id] ); if (!rows.length) { await client.query('ROLLBACK'); return res.status(404).json({ ok: false, error: 'Faktura hittades inte' }); } const invoice = rows[0]; if (invoice.status !== 'overdue' && invoice.status !== 'sent' && invoice.status !== 'partial') { await client.query('ROLLBACK'); return res.status(400).json({ ok: false, error: `Kan inte skicka påminnelse för faktura med status ${invoice.status}` }); } const remaining = parseFloat(invoice.total) - parseFloat(invoice.amount_paid || 0); const daysOverdue = Math.floor((Date.now() - new Date(invoice.due_date).getTime()) / (1000 * 60 * 60 * 24)); const interest = Math.round(remaining * (interest_rate / 100) * (daysOverdue / 365) * 100) / 100; const total = remaining + reminder_fee + interest; const reminderNumber = (invoice.reminded_count || 0) + 1; const reminderDate = new Date().toISOString().split('T')[0]; const dueDate = addDays(reminderDate, due_days); const { rows: reminderRows } = await client.query(` INSERT INTO ledger_invoice_reminders (invoice_id, tenant_id, reminder_number, reminder_date, reminder_fee, interest_amount, total_amount, due_date, trace_id, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING * `, [id, tenant_id, reminderNumber, reminderDate, reminder_fee, interest, total, dueDate, ctx.trace_id, user_id]); await client.query(` UPDATE ledger_invoices SET status = 'reminded', reminded_at = NOW(), reminded_count = $1, updated_at = NOW() WHERE id = $2 `, [reminderNumber, id]); await writeAudit(ctx, 'invoice_reminder', { reminded_count: invoice.reminded_count }, { reminded_count: reminderNumber, reminder_id: reminderRows[0].id }, client); await client.query('COMMIT'); res.json({ ok: true, reminder: reminderRows[0], remaining, reminder_fee, interest, total, }); } catch (e) { await client.query('ROLLBACK'); res.status(500).json({ ok: false, error: e.message }); } finally { client.release(); } }); // ── GET /api/ledger/invoices/v2/stats ── Fakturastatistik app.get('/api/ledger/invoices/v2/stats', async (req, res) => { const tenant_id = req.auth?.tenant_id || req.headers['x-tenant-id'] || 'wavult-group'; const fiscal_year = req.query.fiscal_year || new Date().getFullYear(); try { const { rows: [overview] } = await pool.query(` SELECT COUNT(*) FILTER (WHERE status = 'draft') as draft_count, COUNT(*) FILTER (WHERE status = 'sent') as sent_count, COUNT(*) FILTER (WHERE status = 'partial') as partial_count, COUNT(*) FILTER (WHERE status = 'paid') as paid_count, COUNT(*) FILTER (WHERE status = 'overdue') as overdue_count, COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled_count, COUNT(*) as total_count, COALESCE(SUM(total) FILTER (WHERE status NOT IN ('draft', 'cancelled')), 0) as total_invoiced, COALESCE(SUM(amount_paid) FILTER (WHERE status NOT IN ('draft', 'cancelled')), 0) as total_paid, COALESCE(SUM(total - amount_paid) FILTER (WHERE status IN ('sent', 'partial', 'overdue', 'reminded')), 0) as total_outstanding FROM ledger_invoices WHERE tenant_id = $1 AND fiscal_year = $2 `, [tenant_id, parseInt(fiscal_year)]); const { rows: monthly } = await pool.query(` SELECT EXTRACT(MONTH FROM invoice_date)::int as month, COUNT(*) as count, COALESCE(SUM(total), 0) as total FROM ledger_invoices WHERE tenant_id = $1 AND fiscal_year = $2 AND status NOT IN ('draft', 'cancelled') GROUP BY EXTRACT(MONTH FROM invoice_date) ORDER BY month `, [tenant_id, parseInt(fiscal_year)]); const { rows: overdue } = await pool.query(` SELECT i.*, c.name as customer_name FROM ledger_invoices i LEFT JOIN landvex_customers c ON c.id = i.customer_id WHERE i.tenant_id = $1 AND i.status IN ('overdue', 'reminded') AND i.due_date < CURRENT_DATE ORDER BY i.due_date LIMIT 20 `, [tenant_id]); res.json({ ok: true, overview: { ...overview, total_invoiced: parseFloat(overview.total_invoiced), total_paid: parseFloat(overview.total_paid), total_outstanding: parseFloat(overview.total_outstanding), }, monthly, overdue, }); } catch (e) { res.status(500).json({ ok: false, error: e.message }); } }); // ── GET /api/ledger/invoices/v2/:id/pdf ── Generera PDF-HTML app.get('/api/ledger/invoices/v2/:id/pdf', async (req, res) => { const { id } = req.params; const tenant_id = req.auth?.tenant_id || req.headers['x-tenant-id'] || 'wavult-group'; try { const { rows: invoices } = await pool.query(` SELECT i.*, c.name as customer_name_full, c.org_number as customer_org_full, c.vat_number as customer_vat_full, c.address as customer_address_full, c.postal_code as customer_postal_full, c.city as customer_city_full FROM ledger_invoices i LEFT JOIN landvex_customers c ON c.id = i.customer_id WHERE i.id = $1 AND i.tenant_id = $2 `, [id, tenant_id]); if (!invoices.length) { return res.status(404).json({ ok: false, error: 'Faktura hittades inte' }); } const invoice = invoices[0]; const { rows: lines } = await pool.query( 'SELECT * FROM ledger_invoice_lines WHERE invoice_id = $1 ORDER BY line_number', [id] ); const customer = { name: invoice.customer_name_full || invoice.customer_name, org_number: invoice.customer_org_full || invoice.customer_org, vat_number: invoice.customer_vat_full || invoice.customer_vat, address: [invoice.customer_address_full, invoice.customer_postal_full, invoice.customer_city_full] .filter(Boolean).join(', ') || invoice.customer_address, contact_email: invoice.customer_email, }; const html = generateInvoiceHtml(invoice, customer, lines); res.setHeader('Content-Type', 'text/html'); res.send(html); } catch (e) { res.status(500).json({ ok: false, error: e.message }); } }); console.log('[invoice-engine] Registrerad med endpoints:'); console.log(' GET /api/ledger/invoices/v2'); console.log(' GET /api/ledger/invoices/v2/:id'); console.log(' POST /api/ledger/invoices/v2'); console.log(' POST /api/ledger/invoices/v2/:id/send'); console.log(' POST /api/ledger/invoices/v2/:id/payment'); console.log(' POST /api/ledger/invoices/v2/:id/cancel'); console.log(' POST /api/ledger/invoices/v2/:id/reminder'); console.log(' GET /api/ledger/invoices/v2/stats'); console.log(' GET /api/ledger/invoices/v2/:id/pdf'); }