feat(accounting): tax window shows all costs (incoming invoices + expenses) alongside revenue
Einnahmen-Ausgaben view for the Milchbüchlein/simple-accounting case: - taxReportService.getTaxReport now returns a cost side (loadCosts: incoming invoices + internal expenses, company- or event-booked, schema-guarded) plus a summary (income / costs / result, VAT payable) - declined/duplicate costs excluded; re-billed costs kept (matching re-bill revenue is counted, so the net is correct) - CSV + PDF exports gain a Costs section and an income/costs/result summary; pdf-i18n keys added for all 6 locales (fr/nl/pt/ru machine — flag for native review) - frontend tax page renders the summary card, a costs table (company vs event), and a 'verify with Treuhänder' disclaimer - tax-report tests cover the cost aggregation + zeroed summary when the accounting tables are absent; adminCrmAuth test enables the accounting master flag the route now requires fr/nl/pt/ru strings are machine-generated and need native review.
This commit is contained in:
@@ -89,6 +89,18 @@ const LABELS = {
|
||||
tax_grand_total_gross: 'Total gross',
|
||||
tax_cancelled_footnote: '{count} cancelled invoice(s) — amounts excluded from totals (shown for audit-trail continuity).',
|
||||
tax_no_invoices: 'No invoices in this period.',
|
||||
tax_costs_section: 'Costs (incoming invoices + expenses)',
|
||||
tax_summary_section: 'Summary (income / costs)',
|
||||
tax_cost_col_source: 'Type',
|
||||
tax_cost_col_supplier: 'Supplier / description',
|
||||
tax_cost_col_tax_treatment: 'Tax treatment',
|
||||
tax_cost_source_incoming: 'Incoming invoice',
|
||||
tax_cost_source_expense: 'Expense',
|
||||
tax_cost_total: 'Total costs',
|
||||
tax_summary_income: 'Income',
|
||||
tax_summary_costs: 'Costs',
|
||||
tax_summary_result: 'Result',
|
||||
tax_summary_vat_payable: 'VAT payable (output − input)',
|
||||
// Contracts (migration 130). Section labels stay in sync with the
|
||||
// SECTIONS_ORDER enum in contractService.
|
||||
contract_title: 'Contract',
|
||||
@@ -197,6 +209,18 @@ const LABELS = {
|
||||
tax_grand_total_gross: 'Gesamt Brutto',
|
||||
tax_cancelled_footnote: '{count} stornierte Rechnung(en) — Beträge nicht in den Summen enthalten (für lückenlose Nummernfolge dargestellt).',
|
||||
tax_no_invoices: 'Keine Rechnungen in diesem Zeitraum.',
|
||||
tax_costs_section: 'Kosten (Eingangsrechnungen + Spesen)',
|
||||
tax_summary_section: 'Zusammenfassung (Einnahmen / Ausgaben)',
|
||||
tax_cost_col_source: 'Art',
|
||||
tax_cost_col_supplier: 'Lieferant / Beschreibung',
|
||||
tax_cost_col_tax_treatment: 'Steuerliche Behandlung',
|
||||
tax_cost_source_incoming: 'Eingangsrechnung',
|
||||
tax_cost_source_expense: 'Spese',
|
||||
tax_cost_total: 'Summe Kosten',
|
||||
tax_summary_income: 'Einnahmen',
|
||||
tax_summary_costs: 'Ausgaben',
|
||||
tax_summary_result: 'Ergebnis',
|
||||
tax_summary_vat_payable: 'MWST-Zahllast (Umsatz- − Vorsteuer)',
|
||||
contract_title: 'Vertrag',
|
||||
contract_number_label: 'Vertragsnummer',
|
||||
section_basics: 'Vertragsgrundlagen',
|
||||
@@ -298,6 +322,18 @@ const LABELS = {
|
||||
tax_grand_total_gross: 'Total brut',
|
||||
tax_cancelled_footnote: '{count} facture(s) annulée(s) — montants exclus des totaux (affichés pour la continuité de la piste d\'audit).',
|
||||
tax_no_invoices: 'Aucune facture sur cette période.',
|
||||
tax_costs_section: 'Charges (factures entrantes + frais)',
|
||||
tax_summary_section: 'Résumé (revenus / charges)',
|
||||
tax_cost_col_source: 'Type',
|
||||
tax_cost_col_supplier: 'Fournisseur / description',
|
||||
tax_cost_col_tax_treatment: 'Traitement fiscal',
|
||||
tax_cost_source_incoming: 'Facture entrante',
|
||||
tax_cost_source_expense: 'Frais',
|
||||
tax_cost_total: 'Total des charges',
|
||||
tax_summary_income: 'Revenus',
|
||||
tax_summary_costs: 'Charges',
|
||||
tax_summary_result: 'Résultat',
|
||||
tax_summary_vat_payable: 'TVA à payer (collectée − déductible)',
|
||||
},
|
||||
nl: {
|
||||
// Machine-translated, flagged for native review.
|
||||
@@ -368,6 +404,18 @@ const LABELS = {
|
||||
tax_grand_total_gross: 'Totaal bruto',
|
||||
tax_cancelled_footnote: '{count} geannuleerde factu(u)r(en) — bedragen uitgesloten van totalen (getoond voor continuïteit van het audit-spoor).',
|
||||
tax_no_invoices: 'Geen facturen in deze periode.',
|
||||
tax_costs_section: 'Kosten (inkomende facturen + onkosten)',
|
||||
tax_summary_section: 'Samenvatting (inkomsten / kosten)',
|
||||
tax_cost_col_source: 'Type',
|
||||
tax_cost_col_supplier: 'Leverancier / omschrijving',
|
||||
tax_cost_col_tax_treatment: 'Fiscale behandeling',
|
||||
tax_cost_source_incoming: 'Inkomende factuur',
|
||||
tax_cost_source_expense: 'Onkosten',
|
||||
tax_cost_total: 'Totale kosten',
|
||||
tax_summary_income: 'Inkomsten',
|
||||
tax_summary_costs: 'Kosten',
|
||||
tax_summary_result: 'Resultaat',
|
||||
tax_summary_vat_payable: 'Te betalen btw (af − voor)',
|
||||
},
|
||||
pt: {
|
||||
// Machine-translated, flagged for native review.
|
||||
@@ -438,6 +486,18 @@ const LABELS = {
|
||||
tax_grand_total_gross: 'Total bruto',
|
||||
tax_cancelled_footnote: '{count} fatura(s) cancelada(s) — valores excluídos dos totais (apresentados para continuidade do rastro de auditoria).',
|
||||
tax_no_invoices: 'Sem faturas neste período.',
|
||||
tax_costs_section: 'Custos (faturas recebidas + despesas)',
|
||||
tax_summary_section: 'Resumo (receitas / custos)',
|
||||
tax_cost_col_source: 'Tipo',
|
||||
tax_cost_col_supplier: 'Fornecedor / descrição',
|
||||
tax_cost_col_tax_treatment: 'Tratamento fiscal',
|
||||
tax_cost_source_incoming: 'Fatura recebida',
|
||||
tax_cost_source_expense: 'Despesa',
|
||||
tax_cost_total: 'Total de custos',
|
||||
tax_summary_income: 'Receitas',
|
||||
tax_summary_costs: 'Custos',
|
||||
tax_summary_result: 'Resultado',
|
||||
tax_summary_vat_payable: 'IVA a pagar (cobrado − dedutível)',
|
||||
},
|
||||
ru: {
|
||||
// Machine-translated, flagged for native review.
|
||||
@@ -508,6 +568,18 @@ const LABELS = {
|
||||
tax_grand_total_gross: 'Итого брутто',
|
||||
tax_cancelled_footnote: '{count} аннулированных счёт(а/ов) — суммы исключены из итогов (показаны для непрерывности аудиторской цепочки).',
|
||||
tax_no_invoices: 'Нет счетов за этот период.',
|
||||
tax_costs_section: 'Расходы (входящие счета + издержки)',
|
||||
tax_summary_section: 'Итоги (доходы / расходы)',
|
||||
tax_cost_col_source: 'Тип',
|
||||
tax_cost_col_supplier: 'Поставщик / описание',
|
||||
tax_cost_col_tax_treatment: 'Налоговый режим',
|
||||
tax_cost_source_incoming: 'Входящий счёт',
|
||||
tax_cost_source_expense: 'Расход',
|
||||
tax_cost_total: 'Итого расходы',
|
||||
tax_summary_income: 'Доходы',
|
||||
tax_summary_costs: 'Расходы',
|
||||
tax_summary_result: 'Результат',
|
||||
tax_summary_vat_payable: 'НДС к уплате (исходящий − входящий)',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -157,6 +157,161 @@ async function loadSkontoMap(invoiceIds) {
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cost side of the Milchbüchlein view (Einnahmen-Ausgaben-Rechnung).
|
||||
*
|
||||
* Aggregates the two cost entities the Accounting feature tracks, both
|
||||
* keyed on an accrual date inside [from, to] and scoped to `cur`:
|
||||
*
|
||||
* 1. incoming invoices (`inbound_documents`) — external supplier
|
||||
* payables. Accrual date = invoice_date, falling back to created_at.
|
||||
* Excludes declined + duplicate rows (not real costs).
|
||||
* 2. expenses (`expenses`) — internal own-costs (mileage / per-diem /
|
||||
* amount). Accrual date = created_at (no separate invoice date on
|
||||
* internal expenses). Excludes declined status + duplikat/abgelehnt
|
||||
* disposition.
|
||||
*
|
||||
* Both book to an event OR the company (event_id NULL = company); the
|
||||
* report surfaces every cost regardless so the total is the full
|
||||
* outflow for the period. Re-billed costs intentionally stay IN — the
|
||||
* matching re-bill revenue is already counted on the income side, so
|
||||
* keeping both sides nets correctly (a pure pass-through cancels out).
|
||||
*
|
||||
* Currency: the report is single-currency. Incoming invoices match on
|
||||
* their own `currency`. Internal expenses are stored in CHF base
|
||||
* (chf_amount_minor) plus an optional original-currency amount — for a
|
||||
* CHF report we use the CHF base; for a foreign-currency report we match
|
||||
* the expense's original_currency and use original_amount_minor.
|
||||
*
|
||||
* Tables are schema-guarded: a DB without the accounting migrations
|
||||
* yields an empty cost side rather than throwing.
|
||||
*
|
||||
* Returns { rows, totalNet, totalVat, totalGross } in minor units.
|
||||
*/
|
||||
function normMinor(v) { return ensureInt(v); }
|
||||
|
||||
async function loadCosts({ from, to, cur }) {
|
||||
const rows = [];
|
||||
let totalNet = 0;
|
||||
let totalVat = 0;
|
||||
let totalGross = 0;
|
||||
|
||||
const push = (r) => {
|
||||
rows.push(r);
|
||||
totalNet += r.netMinor;
|
||||
totalVat += r.vatMinor;
|
||||
totalGross += r.totalMinor;
|
||||
};
|
||||
|
||||
// 1) Incoming invoices (external supplier payables).
|
||||
if (await db.schema.hasTable('inbound_documents')) {
|
||||
const inbound = await db('inbound_documents')
|
||||
.leftJoin('events', 'inbound_documents.event_id', 'events.id')
|
||||
.whereRaw('date(COALESCE(inbound_documents.invoice_date, inbound_documents.created_at)) BETWEEN ? AND ?', [from, to])
|
||||
.where('inbound_documents.currency', cur)
|
||||
.whereNotIn('inbound_documents.status', ['declined', 'duplicate'])
|
||||
.orderByRaw('COALESCE(inbound_documents.invoice_date, inbound_documents.created_at) asc')
|
||||
.select(
|
||||
'inbound_documents.id',
|
||||
'inbound_documents.invoice_date',
|
||||
'inbound_documents.created_at',
|
||||
'inbound_documents.supplier_name',
|
||||
'inbound_documents.description',
|
||||
'inbound_documents.disposition',
|
||||
'inbound_documents.tax_treatment',
|
||||
'inbound_documents.status',
|
||||
'inbound_documents.event_id',
|
||||
'inbound_documents.net_amount_minor',
|
||||
'inbound_documents.vat_amount_minor',
|
||||
'inbound_documents.total_amount_minor',
|
||||
'events.event_name as event_name',
|
||||
);
|
||||
for (const r of inbound) {
|
||||
const vat = normMinor(r.vat_amount_minor);
|
||||
let total = normMinor(r.total_amount_minor);
|
||||
let net = normMinor(r.net_amount_minor);
|
||||
if (!total && (net || vat)) total = net + vat;
|
||||
if (!net && total) net = total - vat;
|
||||
push({
|
||||
id: r.id,
|
||||
source: 'incoming',
|
||||
date: r.invoice_date || r.created_at,
|
||||
supplierLabel: (r.supplier_name && String(r.supplier_name).trim()) || '',
|
||||
description: r.description || '',
|
||||
eventName: r.event_id ? (r.event_name || '') : '',
|
||||
disposition: r.disposition || '',
|
||||
taxTreatment: r.tax_treatment || 'domestic',
|
||||
status: r.status || '',
|
||||
netMinor: net,
|
||||
vatMinor: vat,
|
||||
totalMinor: total,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Internal expenses (own-costs).
|
||||
if (await db.schema.hasTable('expenses')) {
|
||||
const isChf = cur === 'CHF';
|
||||
const q = db('expenses')
|
||||
.leftJoin('events', 'expenses.event_id', 'events.id')
|
||||
.whereRaw('date(expenses.created_at) BETWEEN ? AND ?', [from, to])
|
||||
.whereNot('expenses.status', 'declined')
|
||||
.whereNotIn('expenses.disposition', ['duplikat', 'abgelehnt']);
|
||||
// CHF report includes every expense (all carry a CHF base). A
|
||||
// foreign-currency report matches the expense's original currency.
|
||||
if (!isChf) q.where('expenses.original_currency', cur);
|
||||
const expenses = await q
|
||||
.orderBy('expenses.created_at', 'asc')
|
||||
.select(
|
||||
'expenses.id',
|
||||
'expenses.created_at',
|
||||
'expenses.supplier_name',
|
||||
'expenses.description',
|
||||
'expenses.disposition',
|
||||
'expenses.tax_treatment',
|
||||
'expenses.status',
|
||||
'expenses.event_id',
|
||||
'expenses.original_currency',
|
||||
'expenses.original_amount_minor',
|
||||
'expenses.chf_amount_minor',
|
||||
'expenses.net_amount_minor',
|
||||
'expenses.vat_amount_minor',
|
||||
'expenses.gross_amount_minor',
|
||||
'events.event_name as event_name',
|
||||
);
|
||||
for (const r of expenses) {
|
||||
const vat = normMinor(r.vat_amount_minor);
|
||||
let net = normMinor(r.net_amount_minor);
|
||||
let total = normMinor(r.gross_amount_minor);
|
||||
// Fallback to the single stored amount when net/vat/gross are not
|
||||
// broken out (internal mileage/per-diem expenses carry only a base
|
||||
// amount, no VAT split).
|
||||
const base = isChf ? normMinor(r.chf_amount_minor) : normMinor(r.original_amount_minor);
|
||||
if (!total) total = (net || vat) ? net + vat : base;
|
||||
if (!net) net = total - vat;
|
||||
push({
|
||||
id: r.id,
|
||||
source: 'expense',
|
||||
date: r.created_at,
|
||||
supplierLabel: (r.supplier_name && String(r.supplier_name).trim()) || '',
|
||||
description: r.description || '',
|
||||
eventName: r.event_id ? (r.event_name || '') : '',
|
||||
disposition: r.disposition || '',
|
||||
taxTreatment: r.tax_treatment || 'domestic',
|
||||
status: r.status || '',
|
||||
netMinor: net,
|
||||
vatMinor: vat,
|
||||
totalMinor: total,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Stable chronological order across both sources.
|
||||
rows.sort((a, b) => String(a.date || '').localeCompare(String(b.date || '')));
|
||||
|
||||
return { rows, totalNet, totalVat, totalGross };
|
||||
}
|
||||
|
||||
/**
|
||||
* The main entry point.
|
||||
*
|
||||
@@ -166,8 +321,13 @@ async function loadSkontoMap(invoiceIds) {
|
||||
* required and must match `invoices.currency` exactly — mixing
|
||||
* currencies in one report is unsound for tax filing, so the API
|
||||
* forces a single-currency view.
|
||||
*
|
||||
* `includeCosts` (default true) adds the Einnahmen-Ausgaben cost side
|
||||
* (incoming invoices + expenses) plus a `summary` block (income vs cost
|
||||
* vs result, and VAT payable = output VAT − input VAT). Pass false to
|
||||
* get the legacy revenue-only shape.
|
||||
*/
|
||||
async function getTaxReport({ from, to, currency } = {}) {
|
||||
async function getTaxReport({ from, to, currency, includeCosts = true } = {}) {
|
||||
if (!from || !to) {
|
||||
throw new Error('getTaxReport: `from` and `to` are required (YYYY-MM-DD)');
|
||||
}
|
||||
@@ -283,6 +443,28 @@ async function getTaxReport({ from, to, currency } = {}) {
|
||||
|
||||
const totalsByVatRate = Array.from(byRate.values()).sort((a, b) => a.vatRate - b.vatRate);
|
||||
|
||||
// Cost side (Einnahmen-Ausgaben). Optional so legacy callers that
|
||||
// only want the revenue listing can opt out.
|
||||
const costs = includeCosts
|
||||
? await loadCosts({ from, to, cur })
|
||||
: { rows: [], totalNet: 0, totalVat: 0, totalGross: 0 };
|
||||
|
||||
// Summary: income vs cost vs result. Result = a simplified
|
||||
// Einnahmen-Ausgaben surplus (net basis); vatPayable = output VAT
|
||||
// minus input VAT (a guideline figure — actual MWST filing depends
|
||||
// on tax_treatment per cost; verify with your Treuhänder).
|
||||
const summary = {
|
||||
incomeNetMinor: grandTotalNet,
|
||||
incomeVatMinor: grandTotalVat,
|
||||
incomeGrossMinor: grandTotal,
|
||||
costNetMinor: costs.totalNet,
|
||||
costVatMinor: costs.totalVat,
|
||||
costGrossMinor: costs.totalGross,
|
||||
resultNetMinor: grandTotalNet - costs.totalNet,
|
||||
resultGrossMinor: grandTotal - costs.totalGross,
|
||||
vatPayableMinor: grandTotalVat - costs.totalVat,
|
||||
};
|
||||
|
||||
return {
|
||||
rows,
|
||||
totalsByVatRate,
|
||||
@@ -290,6 +472,8 @@ async function getTaxReport({ from, to, currency } = {}) {
|
||||
grandTotalVat,
|
||||
grandTotal,
|
||||
cancelledCount,
|
||||
costs,
|
||||
summary,
|
||||
currency: cur,
|
||||
period: { from, to },
|
||||
};
|
||||
@@ -585,7 +769,9 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
|
||||
// otherwise PDFKit auto-paginates mid-totals, creating phantom
|
||||
// pages whose footer ends up at unexpected Y positions on the
|
||||
// subsequent bufferedPageRange loop.
|
||||
const totalsHeightEstimate = 16 + (report.totalsByVatRate.length * 13) + 8 + 39 + 12;
|
||||
const hasCostSummary = report.summary && (report.costs?.rows?.length || report.costs?.totalGross);
|
||||
const summaryHeight = hasCostSummary ? (10 + 6 + (3 * 13) + 12 + 12) : 0;
|
||||
const totalsHeightEstimate = 16 + (report.totalsByVatRate.length * 13) + 8 + 39 + 12 + summaryHeight;
|
||||
const footerReserve = 24; // 12 above + 12 of page-number text room
|
||||
if (y + 12 + totalsHeightEstimate + footerReserve > page.height - page.marginBottom) {
|
||||
doc.addPage({
|
||||
@@ -636,6 +822,32 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
|
||||
doc.text(formatMinor(report.grandTotal, report.currency, intlLocale),
|
||||
totalsX + 270, ty, { width: 90, align: 'right' });
|
||||
|
||||
// Einnahmen-Ausgaben summary (income vs costs vs result). Only
|
||||
// rendered when the report carries a cost side. Compact 4-line
|
||||
// block beneath the revenue grand totals.
|
||||
if (report.summary && (report.costs?.rows?.length || report.costs?.totalGross)) {
|
||||
const s = report.summary;
|
||||
ty += 10;
|
||||
doc.moveTo(totalsX, ty).lineTo(totalsX + totalsBoxWidth, ty)
|
||||
.lineWidth(0.6).strokeColor('#000').stroke();
|
||||
ty += 6;
|
||||
const summaryLine = (labelKey, netMinor, grossMinor, bold) => {
|
||||
doc.font(bold ? fonts.bold : fonts.body).fontSize(9);
|
||||
doc.text(t(useLocale, labelKey), totalsX, ty, { width: 170, align: 'left' });
|
||||
doc.text(formatMinor(netMinor, report.currency, intlLocale), totalsX + 80, ty, { width: 90, align: 'right' });
|
||||
doc.text(formatMinor(grossMinor, report.currency, intlLocale), totalsX + 270, ty, { width: 90, align: 'right' });
|
||||
ty += 13;
|
||||
};
|
||||
summaryLine('tax_summary_income', s.incomeNetMinor, s.incomeGrossMinor, false);
|
||||
summaryLine('tax_summary_costs', s.costNetMinor, s.costGrossMinor, false);
|
||||
summaryLine('tax_summary_result', s.resultNetMinor, s.resultGrossMinor, true);
|
||||
doc.font(fonts.body).fontSize(8).fillColor('#555')
|
||||
.text(`${t(useLocale, 'tax_summary_vat_payable')}: ${formatMinor(s.vatPayableMinor, report.currency, intlLocale)}`,
|
||||
totalsX, ty, { width: totalsBoxWidth, align: 'left' });
|
||||
doc.fillColor('#000');
|
||||
ty += 12;
|
||||
}
|
||||
|
||||
// Cancelled footnote (bottom-left). Only when there are any.
|
||||
if (report.cancelledCount > 0) {
|
||||
doc.font(fonts.body).fontSize(8).fillColor('#555')
|
||||
@@ -746,6 +958,62 @@ async function renderTaxReportCsv({ from, to, currency, locale } = {}) {
|
||||
'', '',
|
||||
].map(escape).join(','));
|
||||
|
||||
// Cost side (Einnahmen-Ausgaben). Appended below the revenue block as
|
||||
// its own labelled section so the accountant gets income + costs +
|
||||
// result in one file.
|
||||
const costs = report.costs || { rows: [], totalNet: 0, totalVat: 0, totalGross: 0 };
|
||||
if (costs.rows.length || costs.totalGross) {
|
||||
lines.push('');
|
||||
lines.push(escape(t(useLocale, 'tax_costs_section')));
|
||||
const costHeaders = [
|
||||
t(useLocale, 'tax_col_no'),
|
||||
t(useLocale, 'tax_col_date'),
|
||||
t(useLocale, 'tax_cost_col_source'),
|
||||
t(useLocale, 'tax_cost_col_supplier'),
|
||||
t(useLocale, 'tax_col_event'),
|
||||
t(useLocale, 'tax_cost_col_tax_treatment'),
|
||||
`${t(useLocale, 'tax_col_net')} (${report.currency})`,
|
||||
`${t(useLocale, 'tax_col_vat')} (${report.currency})`,
|
||||
`${t(useLocale, 'tax_col_total')} (${report.currency})`,
|
||||
];
|
||||
lines.push(costHeaders.map(escape).join(','));
|
||||
costs.rows.forEach((row, i) => {
|
||||
lines.push([
|
||||
i + 1,
|
||||
row.date,
|
||||
t(useLocale, row.source === 'incoming' ? 'tax_cost_source_incoming' : 'tax_cost_source_expense'),
|
||||
row.supplierLabel || row.description || '',
|
||||
row.eventName || '',
|
||||
row.taxTreatment || '',
|
||||
minorToDotDecimal(row.netMinor),
|
||||
minorToDotDecimal(row.vatMinor),
|
||||
minorToDotDecimal(row.totalMinor),
|
||||
].map(escape).join(','));
|
||||
});
|
||||
lines.push([
|
||||
'', '', '',
|
||||
t(useLocale, 'tax_cost_total'),
|
||||
'', '',
|
||||
minorToDotDecimal(costs.totalNet),
|
||||
minorToDotDecimal(costs.totalVat),
|
||||
minorToDotDecimal(costs.totalGross),
|
||||
].map(escape).join(','));
|
||||
}
|
||||
|
||||
// Summary block: income vs costs vs result + VAT payable.
|
||||
const summary = report.summary;
|
||||
if (summary) {
|
||||
lines.push('');
|
||||
lines.push(escape(t(useLocale, 'tax_summary_section')));
|
||||
const sline = (labelKey, net, vat, gross) => lines.push([
|
||||
'', '', '', t(useLocale, labelKey), '', '',
|
||||
minorToDotDecimal(net), minorToDotDecimal(vat), minorToDotDecimal(gross),
|
||||
].map(escape).join(','));
|
||||
sline('tax_summary_income', summary.incomeNetMinor, summary.incomeVatMinor, summary.incomeGrossMinor);
|
||||
sline('tax_summary_costs', summary.costNetMinor, summary.costVatMinor, summary.costGrossMinor);
|
||||
sline('tax_summary_result', summary.resultNetMinor, summary.vatPayableMinor, summary.resultGrossMinor);
|
||||
}
|
||||
|
||||
const content = lines.join('\r\n') + '\r\n';
|
||||
const filename = `tax_report_${report.period.from}_to_${report.period.to}_${report.currency}.csv`;
|
||||
return { content, filename, contentType: 'text/csv; charset=utf-8' };
|
||||
@@ -756,5 +1024,5 @@ module.exports = {
|
||||
renderTaxReportPdf,
|
||||
renderTaxReportCsv,
|
||||
// Exposed for unit tests.
|
||||
_internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate },
|
||||
_internal: { grossUpLateFee, computeReportedAmounts, buildCustomerLabel, formatVatRate, loadCosts },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user