Files
boc/invoice-engine.mjs
T

1206 lines
50 KiB
JavaScript
Raw Normal View History

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