bae705aa97
- Add NFC ePassport roadmap (ICAO 9303, eIDAS) - Add TensorFlow.js edge face detection (BlazeFace) - Add structured audit logger (GDPR-compliant) - Risk scoring support Part of KYC Apple Native UX v1.1.0
1206 lines
50 KiB
JavaScript
1206 lines
50 KiB
JavaScript
/**
|
|
* ═══════════════════════════════════════════════════════════════════════════
|
|
* 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 => `
|
|
<tr>
|
|
<td>${l.description}</td>
|
|
<td style="text-align:right">${l.quantity}</td>
|
|
<td style="text-align:right">${formatCurrency(l.unit_price)}</td>
|
|
<td style="text-align:right">${l.vat_rate}%</td>
|
|
<td style="text-align:right">${formatCurrency(l.quantity * l.unit_price)}</td>
|
|
</tr>
|
|
`).join('');
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="sv">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Faktura ${invoice.invoice_number}</title>
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; color: #1a1a2e; }
|
|
.header { display: flex; justify-content: space-between; margin-bottom: 40px; }
|
|
.company { font-size: 14px; line-height: 1.6; }
|
|
.company h2 { margin: 0 0 8px 0; color: #1a1a2e; }
|
|
.invoice-meta { text-align: right; }
|
|
.invoice-meta h1 { margin: 0 0 16px 0; font-size: 28px; color: #c9a227; }
|
|
.meta-row { display: flex; justify-content: flex-end; gap: 24px; margin-bottom: 4px; }
|
|
.meta-label { color: #666; }
|
|
.meta-value { font-weight: 600; min-width: 120px; text-align: left; }
|
|
.customer { margin: 32px 0; padding: 20px; background: #f8f9fa; border-radius: 8px; }
|
|
.customer h3 { margin: 0 0 8px 0; font-size: 16px; color: #666; }
|
|
.customer-name { font-size: 18px; font-weight: 600; margin-bottom: 4px; }
|
|
table { width: 100%; border-collapse: collapse; margin: 24px 0; }
|
|
th { text-align: left; padding: 12px 8px; border-bottom: 2px solid #1a1a2e; font-size: 13px; text-transform: uppercase; color: #666; }
|
|
td { padding: 12px 8px; border-bottom: 1px solid #e9ecef; }
|
|
.totals { margin-top: 24px; border-top: 2px solid #1a1a2e; padding-top: 16px; }
|
|
.total-row { display: flex; justify-content: flex-end; gap: 24px; margin-bottom: 8px; }
|
|
.total-row.grand { font-size: 18px; font-weight: 700; color: #1a1a2e; margin-top: 12px; padding-top: 12px; border-top: 1px solid #e9ecef; }
|
|
.payment-info { margin-top: 40px; padding: 20px; background: #f8f9fa; border-radius: 8px; font-size: 14px; }
|
|
.payment-info h3 { margin: 0 0 12px 0; font-size: 14px; text-transform: uppercase; color: #666; }
|
|
.payment-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
.footer { margin-top: 40px; font-size: 12px; color: #666; text-align: center; }
|
|
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 12px; font-weight: 600; text-transform: uppercase; }
|
|
.status-draft { background: #e9ecef; color: #495057; }
|
|
.status-sent { background: #fff3cd; color: #856404; }
|
|
.status-paid { background: #d4edda; color: #155724; }
|
|
.status-overdue { background: #f8d7da; color: #721c24; }
|
|
.status-cancelled { background: #f8f9fa; color: #6c757d; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<div class="company">
|
|
<h2>${COMPANY_NAME}</h2>
|
|
Org.nr: ${COMPANY_ORG}<br>
|
|
VAT: ${COMPANY_VAT}<br>
|
|
${COMPANY_ADDR}<br>
|
|
${COMPANY_PHONE}<br>
|
|
${COMPANY_EMAIL}
|
|
</div>
|
|
<div class="invoice-meta">
|
|
<h1>FAKTURA</h1>
|
|
<div class="meta-row"><span class="meta-label">Fakturanr:</span><span class="meta-value">${invoice.invoice_number}</span></div>
|
|
<div class="meta-row"><span class="meta-label">Fakturadatum:</span><span class="meta-value">${formatDate(invoice.invoice_date)}</span></div>
|
|
<div class="meta-row"><span class="meta-label">Förfallodatum:</span><span class="meta-value">${formatDate(invoice.due_date)}</span></div>
|
|
<div class="meta-row"><span class="meta-label">Betalningsvillkor:</span><span class="meta-value">${invoice.payment_terms} dagar</span></div>
|
|
<div class="meta-row"><span class="meta-label">Status:</span><span class="meta-value"><span class="status-badge status-${invoice.status}">${invoice.status}</span></span></div>
|
|
${invoice.ocr_reference ? `<div class="meta-row"><span class="meta-label">OCR:</span><span class="meta-value">${invoice.ocr_reference}</span></div>` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="customer">
|
|
<h3>Kund</h3>
|
|
<div class="customer-name">${customer.name}</div>
|
|
${customer.org_number ? `Org.nr: ${customer.org_number}<br>` : ''}
|
|
${customer.vat_number ? `VAT: ${customer.vat_number}<br>` : ''}
|
|
${customer.address ? `${customer.address}<br>` : ''}
|
|
${customer.postal_code || customer.city ? `${customer.postal_code || ''} ${customer.city || ''}<br>` : ''}
|
|
${customer.contact_email ? `E-post: ${customer.contact_email}<br>` : ''}
|
|
${customer.contact_phone ? `Tel: ${customer.contact_phone}` : ''}
|
|
</div>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Beskrivning</th>
|
|
<th style="text-align:right">Antal</th>
|
|
<th style="text-align:right">À-pris</th>
|
|
<th style="text-align:right">Moms</th>
|
|
<th style="text-align:right">Belopp</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${linesHtml}
|
|
</tbody>
|
|
</table>
|
|
|
|
<div class="totals">
|
|
<div class="total-row"><span class="meta-label">Netto:</span><span class="meta-value">${formatCurrency(subtotal)}</span></div>
|
|
<div class="total-row"><span class="meta-label">Moms:</span><span class="meta-value">${formatCurrency(vatTotal)}</span></div>
|
|
<div class="total-row grand"><span class="meta-label">Att betala:</span><span class="meta-value">${formatCurrency(total)}</span></div>
|
|
</div>
|
|
|
|
<div class="payment-info">
|
|
<h3>Betalningsinformation</h3>
|
|
<div class="payment-grid">
|
|
<div><strong>Bank:</strong> ${COMPANY_BANK}</div>
|
|
<div><strong>Bankgiro:</strong> ${COMPANY_BG}</div>
|
|
<div><strong>Plusgiro:</strong> ${COMPANY_PG}</div>
|
|
<div><strong>IBAN:</strong> ${COMPANY_IBAN}</div>
|
|
<div><strong>BIC/SWIFT:</strong> ${COMPANY_BIC}</div>
|
|
${invoice.ocr_reference ? `<div><strong>OCR-nummer:</strong> ${invoice.ocr_reference}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
${COMPANY_NAME} | Org.nr ${COMPANY_ORG} | ${COMPANY_ADDR}<br>
|
|
Fakturan är genererad av AAMOS Ledger Engine
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
// ── 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');
|
|
}
|