feat(accounting): unify tax report into one signed, typed, sortable ledger

Replaces the separate revenue + costs tables with a single ledger across the
screen, CSV and PDF. Every row is typed (outgoing invoice / incoming invoice /
expense) and signed — outgoing positive, incoming + expenses negative — so
sorting by value runs income → costs and the column nets toward the Result.

- getTaxReport now returns a `ledger` array (signed, typed, date-sorted);
  legacy rows/costs/summary kept for back-compat.
- Frontend: one sortable table (click Type/Date/Party/Net/VAT/Gross), coloured
  type badges, cancelled rows greyed with lineage badges; Income/Costs/Result
  summary box unchanged.
- CSV + PDF reworked to the same unified, signed layout; PDF totals show
  Income / Costs (negative) / Result.
- i18n: en/de (frontend) + pdf-i18n (en/de real; fr/nl/pt/ru English-fallback,
  flagged for native review).

Build + node --check + JSON parse green.
This commit is contained in:
Luca
2026-06-15 17:30:55 +02:00
parent ab65a470a0
commit fd1dd81e8d
6 changed files with 340 additions and 258 deletions
+42
View File
@@ -82,6 +82,13 @@ const LABELS = {
tax_col_total: 'Gross',
tax_col_status: 'Status',
tax_col_skonto: 'Skonto',
tax_col_type: 'Type',
tax_col_reference: 'Reference',
tax_col_party: 'Customer / supplier',
tax_col_tax: 'Tax',
tax_type_outgoing: 'Outgoing invoice',
tax_type_incoming: 'Incoming invoice',
tax_type_expense: 'Expense',
tax_status_cancelled: 'Cancelled',
tax_totals_by_rate: 'Totals by VAT rate',
tax_grand_total_net: 'Total net',
@@ -202,6 +209,13 @@ const LABELS = {
tax_col_total: 'Brutto',
tax_col_status: 'Status',
tax_col_skonto: 'Skonto',
tax_col_type: 'Typ',
tax_col_reference: 'Referenz',
tax_col_party: 'Kunde / Lieferant',
tax_col_tax: 'Steuer',
tax_type_outgoing: 'Ausgangsrechnung',
tax_type_incoming: 'Eingangsrechnung',
tax_type_expense: 'Aufwand',
tax_status_cancelled: 'Storniert',
tax_totals_by_rate: 'Summen nach MwSt-Satz',
tax_grand_total_net: 'Gesamt Netto',
@@ -315,6 +329,13 @@ const LABELS = {
tax_col_total: 'Brut',
tax_col_status: 'Statut',
tax_col_skonto: 'Escompte',
tax_col_type: 'Type',
tax_col_reference: 'Reference',
tax_col_party: 'Customer / supplier',
tax_col_tax: 'Tax',
tax_type_outgoing: 'Outgoing invoice',
tax_type_incoming: 'Incoming invoice',
tax_type_expense: 'Expense',
tax_status_cancelled: 'Annulée',
tax_totals_by_rate: 'Totaux par taux de TVA',
tax_grand_total_net: 'Total net',
@@ -397,6 +418,13 @@ const LABELS = {
tax_col_total: 'Bruto',
tax_col_status: 'Status',
tax_col_skonto: 'Korting',
tax_col_type: 'Type',
tax_col_reference: 'Reference',
tax_col_party: 'Customer / supplier',
tax_col_tax: 'Tax',
tax_type_outgoing: 'Outgoing invoice',
tax_type_incoming: 'Incoming invoice',
tax_type_expense: 'Expense',
tax_status_cancelled: 'Geannuleerd',
tax_totals_by_rate: 'Totalen per btw-tarief',
tax_grand_total_net: 'Totaal netto',
@@ -479,6 +507,13 @@ const LABELS = {
tax_col_total: 'Bruto',
tax_col_status: 'Estado',
tax_col_skonto: 'Desconto',
tax_col_type: 'Type',
tax_col_reference: 'Reference',
tax_col_party: 'Customer / supplier',
tax_col_tax: 'Tax',
tax_type_outgoing: 'Outgoing invoice',
tax_type_incoming: 'Incoming invoice',
tax_type_expense: 'Expense',
tax_status_cancelled: 'Cancelada',
tax_totals_by_rate: 'Totais por taxa de IVA',
tax_grand_total_net: 'Total líquido',
@@ -561,6 +596,13 @@ const LABELS = {
tax_col_total: 'Брутто',
tax_col_status: 'Статус',
tax_col_skonto: 'Скидка',
tax_col_type: 'Type',
tax_col_reference: 'Reference',
tax_col_party: 'Customer / supplier',
tax_col_tax: 'Tax',
tax_type_outgoing: 'Outgoing invoice',
tax_type_incoming: 'Incoming invoice',
tax_type_expense: 'Expense',
tax_status_cancelled: 'Аннулирован',
tax_totals_by_rate: 'Итоги по ставкам НДС',
tax_grand_total_net: 'Итого нетто',
+129 -135
View File
@@ -495,6 +495,54 @@ async function getTaxReport({ from, to, currency, includeCosts = true } = {}) {
vatPayableMinor: grandTotalVat - costs.totalVat,
};
// Unified ledger (#5 — one typed, signed, sortable list). Outgoing
// invoices carry POSITIVE amounts; incoming invoices + expenses are
// NEGATIVE so sorting by value runs income → costs and the column
// nets toward the Result. The legacy `rows` / `costs` shapes are
// kept above for back-compat; this is the new canonical surface for
// the on-screen table + PDF/CSV exports.
const ledger = [
...rows.map((r) => ({
key: `out-${r.id}`,
type: 'outgoing',
date: r.issueDate,
reference: r.invoiceNumber,
party: r.customerLabel || '',
eventName: r.eventName || '',
vatRate: r.vatRate,
taxTreatment: null,
status: r.status,
isCancelled: r.isCancelled,
isReissue: r.isReissue,
kind: r.kind,
skontoApplied: r.skontoApplied,
skontoAmountMinor: r.skontoAmountMinor,
netMinor: r.netMinor,
vatMinor: r.vatMinor,
totalMinor: r.totalMinor,
})),
...costs.rows.map((c) => ({
key: `${c.source}-${c.id}`,
type: c.source === 'incoming' ? 'incoming' : 'expense',
date: c.date,
reference: c.description || '',
party: c.supplierLabel || '',
eventName: c.eventName || '',
vatRate: null,
taxTreatment: c.taxTreatment || 'domestic',
status: c.status,
isCancelled: false,
isReissue: false,
kind: null,
skontoApplied: false,
skontoAmountMinor: 0,
netMinor: -Math.abs(c.netMinor),
vatMinor: -Math.abs(c.vatMinor),
totalMinor: -Math.abs(c.totalMinor),
})),
];
ledger.sort((a, b) => String(a.date || '').localeCompare(String(b.date || '')));
return {
rows,
totalsByVatRate,
@@ -505,6 +553,7 @@ async function getTaxReport({ from, to, currency, includeCosts = true } = {}) {
costs,
costsError,
summary,
ledger,
currency: cur,
period: { from, to },
};
@@ -566,21 +615,19 @@ async function loadRenderContext(locale) {
// uncluttered with just "R-2026-0001" — easier to scan for an
// auditor looking at the sequence.
const TAX_TABLE_COLS = [
{ key: 'idx', labelKey: 'tax_col_no', width: 26, align: 'right' },
{ key: 'date', labelKey: 'tax_col_date', width: 60, align: 'left' },
{ key: 'invoice', labelKey: 'tax_col_invoice', width: 100, align: 'left' },
{ key: 'customer', labelKey: 'tax_col_customer', width: 132, align: 'left' },
{ key: 'event', labelKey: 'tax_col_event', width: 95, align: 'left' },
{ key: 'vatRate', labelKey: 'tax_col_vat_rate', width: 42, align: 'right' },
{ key: 'idx', labelKey: 'tax_col_no', width: 22, align: 'right' },
{ key: 'type', labelKey: 'tax_col_type', width: 58, align: 'left' },
{ key: 'date', labelKey: 'tax_col_date', width: 56, align: 'left' },
{ key: 'reference', labelKey: 'tax_col_reference', width: 88, align: 'left' },
{ key: 'party', labelKey: 'tax_col_party', width: 116, align: 'left' },
{ key: 'event', labelKey: 'tax_col_event', width: 86, align: 'left' },
{ key: 'tax', labelKey: 'tax_col_tax', width: 64, align: 'left' },
{ key: 'net', labelKey: 'tax_col_net', width: 70, align: 'right' },
{ key: 'vat', labelKey: 'tax_col_vat', width: 60, align: 'right' },
{ key: 'vat', labelKey: 'tax_col_vat', width: 58, align: 'right' },
{ key: 'total', labelKey: 'tax_col_total', width: 80, align: 'right' },
// Skonto column (migration 126) — blank for non-Skonto rows so the
// column reads quietly until it has data. Shrunk neighbouring text
// columns slightly to make space without going over the landscape
// content width.
{ key: 'skonto', labelKey: 'tax_col_skonto', width: 56, align: 'right' },
{ key: 'status', labelKey: 'tax_col_status', width: 58, align: 'left' },
// column reads quietly until it has data.
{ key: 'skonto', labelKey: 'tax_col_skonto', width: 50, align: 'right' },
];
function colX(leftMargin, index) {
@@ -615,22 +662,31 @@ function formatVatRate(rate, locale) {
return `${formatted} %`;
}
function rowCellValues(row, idx, locale, dateFormat) {
function rowCellValues(row, idx, locale, dateFormat, currency) {
const intlLocale = locale === 'de' ? 'de-CH' : 'en-GB';
const typeLabel = t(
locale,
row.type === 'outgoing' ? 'tax_type_outgoing'
: row.type === 'incoming' ? 'tax_type_incoming'
: 'tax_type_expense',
);
const reference = row.isCancelled
? `${row.reference || ''} (${t(locale, 'tax_status_cancelled')})`
: (row.reference || '');
return {
idx: String(idx),
date: formatDate(row.issueDate, dateFormat),
invoice: row.invoiceNumber, // no inline "(Cancelled)" — keep the column tidy; status is its own column
customer: row.customerLabel || '',
type: typeLabel,
date: formatDate(row.date, dateFormat),
reference,
party: row.party || '',
event: row.eventName || '',
vatRate: formatVatRate(row.vatRate, locale),
net: formatMinor(row.netMinor, row.currency, intlLocale),
vat: formatMinor(row.vatMinor, row.currency, intlLocale),
total: formatMinor(row.totalMinor, row.currency, intlLocale),
tax: row.type === 'outgoing' ? formatVatRate(row.vatRate, locale) : (row.taxTreatment || ''),
net: formatMinor(row.netMinor, currency, intlLocale),
vat: formatMinor(row.vatMinor, currency, intlLocale),
total: formatMinor(row.totalMinor, currency, intlLocale),
skonto: row.skontoApplied
? formatMinor(row.skontoAmountMinor, row.currency, intlLocale)
? formatMinor(row.skontoAmountMinor, currency, intlLocale)
: '',
status: row.isCancelled ? t(locale, 'tax_status_cancelled') : '',
};
}
@@ -699,7 +755,7 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
const tableBottomLimit = page.height - page.marginBottom - 110; // leave room for totals
const tableWidth = TAX_TABLE_COLS.reduce((s, c) => s + c.width, 0);
if (report.rows.length === 0) {
if (report.ledger.length === 0) {
doc.font(fonts.body).fontSize(10).fillColor('#555')
.text(t(useLocale, 'tax_no_invoices'), leftMargin, y + 6, {
width: tableWidth, align: 'center',
@@ -722,7 +778,7 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
// wrap (they're either ints or money strings whose width we
// budget for) — only text cells (customer, event, invoice,
// status) opt into natural wrapping.
const isWrappable = (col) => ['invoice', 'customer', 'event', 'status'].includes(col.key);
const isWrappable = (col) => ['type', 'reference', 'party', 'event', 'tax'].includes(col.key);
const measureCellHeight = (value, col) => {
const s = safeStr(value);
if (!s) return 0;
@@ -735,9 +791,9 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
return doc.heightOfString(s, opts);
};
for (let i = 0; i < report.rows.length; i += 1) {
const row = report.rows[i];
const cells = rowCellValues(row, i + 1, useLocale, renderCtx.dateFormat);
for (let i = 0; i < report.ledger.length; i += 1) {
const row = report.ledger[i];
const cells = rowCellValues(row, i + 1, useLocale, renderCtx.dateFormat, report.currency);
// Set the font BEFORE measuring so heightOfString reads the
// exact rendering state we'll use for doc.text below.
@@ -800,9 +856,10 @@ 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 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;
// Header (16) + one line per VAT bucket (13) + divider (8) +
// three income/costs/result summary rows (39) + a 12pt cushion.
const summaryHeight = 8 + (3 * 13);
const totalsHeightEstimate = 16 + (report.totalsByVatRate.length * 13) + 12 + summaryHeight;
const footerReserve = 24; // 12 above + 12 of page-number text room
if (y + 12 + totalsHeightEstimate + footerReserve > page.height - page.marginBottom) {
doc.addPage({
@@ -836,48 +893,25 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
totalsX + 270, ty, { width: 90, align: 'right' });
ty += 13;
}
// Divider above grand totals.
// Divider above the income / costs / result summary.
doc.moveTo(totalsX, ty + 2).lineTo(totalsX + totalsBoxWidth, ty + 2)
.lineWidth(0.6).strokeColor('#000').stroke();
ty += 6;
doc.font(fonts.bold);
doc.text(t(useLocale, 'tax_grand_total_net'), totalsX, ty, { width: 170, align: 'left' });
doc.text(formatMinor(report.grandTotalNet, report.currency, intlLocale),
totalsX + 175, ty, { width: 90, align: 'right' });
ty += 13;
doc.text(t(useLocale, 'tax_grand_total_vat'), totalsX, ty, { width: 170, align: 'left' });
doc.text(formatMinor(report.grandTotalVat, report.currency, intlLocale),
totalsX + 175, ty, { width: 90, align: 'right' });
ty += 13;
doc.text(t(useLocale, 'tax_grand_total_gross'), totalsX, ty, { width: 170, align: 'left' });
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)) {
// Income / Costs / Result summary (mirrors the on-screen summary
// box). Costs are shown NEGATIVE so the Result reads as a plain
// sum of the column. Net / VAT / Gross across the three lines.
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' });
const summaryLine = (labelKey, netMinor, vatMinor, grossMinor, bold) => {
doc.font(bold ? fonts.bold : fonts.body).fontSize(9).fillColor('#000');
doc.text(t(useLocale, labelKey), totalsX, ty, { width: 80, align: 'left' });
doc.text(formatMinor(netMinor, report.currency, intlLocale), totalsX + 80, ty, { width: 90, align: 'right' });
doc.text(formatMinor(vatMinor, report.currency, intlLocale), totalsX + 175, 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;
}
summaryLine('tax_summary_income', s.incomeNetMinor, s.incomeVatMinor, s.incomeGrossMinor, false);
summaryLine('tax_summary_costs', -Math.abs(s.costNetMinor), -Math.abs(s.costVatMinor), -Math.abs(s.costGrossMinor), false);
summaryLine('tax_summary_result', s.resultNetMinor, s.vatPayableMinor, s.resultGrossMinor, true);
// Cancelled footnote (bottom-left). Only when there are any.
if (report.cancelledCount > 0) {
@@ -935,22 +969,6 @@ async function renderTaxReportCsv({ from, to, currency, locale } = {}) {
const report = await getTaxReport({ from, to, currency });
const useLocale = locale || 'en';
const headers = [
t(useLocale, 'tax_col_no'),
t(useLocale, 'tax_col_date'),
t(useLocale, 'tax_col_invoice'),
t(useLocale, 'tax_col_customer'),
t(useLocale, 'tax_col_event'),
t(useLocale, 'tax_col_vat_rate'),
`${t(useLocale, 'tax_col_net')} (${report.currency})`,
`${t(useLocale, 'tax_col_vat')} (${report.currency})`,
`${t(useLocale, 'tax_col_total')} (${report.currency})`,
t(useLocale, 'tax_status_cancelled'),
// Migration 126 — Skonto export. `tax_col_skonto` is the discount
// amount in major units; admin's accountant reconciles the line.
`${t(useLocale, 'tax_col_skonto')} (${report.currency})`,
];
const escape = (cell) => {
const s = cell === null || cell === undefined ? '' : String(cell);
// RFC 4180: wrap in quotes when the value contains comma, quote,
@@ -960,78 +978,54 @@ async function renderTaxReportCsv({ from, to, currency, locale } = {}) {
const minorToDotDecimal = (m) => ((Number(m) || 0) / 100).toFixed(2);
const lines = [headers.map(escape).join(',')];
report.rows.forEach((row, i) => {
lines.push([
i + 1,
row.issueDate,
row.invoiceNumber,
row.customerLabel,
row.eventName,
Number(row.vatRate).toFixed(2),
minorToDotDecimal(row.netMinor),
minorToDotDecimal(row.vatMinor),
minorToDotDecimal(row.totalMinor),
row.isCancelled ? '1' : '0',
row.skontoApplied ? minorToDotDecimal(row.skontoAmountMinor) : '',
].map(escape).join(','));
});
// Trailing totals row: blank cells + grand totals at the end so
// the column alignment matches the data rows when opened in Excel.
lines.push('');
lines.push([
'', '', '',
t(useLocale, 'tax_grand_total_gross'),
'', '',
minorToDotDecimal(report.grandTotalNet),
minorToDotDecimal(report.grandTotalVat),
minorToDotDecimal(report.grandTotal),
'', '',
].map(escape).join(','));
const typeLabelKey = (type) => (
type === 'outgoing' ? 'tax_type_outgoing'
: type === 'incoming' ? 'tax_type_incoming'
: 'tax_type_expense'
);
// 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 = [
// ONE unified ledger table. Amounts are already signed in the ledger
// (outgoing positive, costs negative) — emitted as-is.
const headers = [
t(useLocale, 'tax_col_no'),
t(useLocale, 'tax_col_type'),
t(useLocale, 'tax_col_date'),
t(useLocale, 'tax_cost_col_source'),
t(useLocale, 'tax_cost_col_supplier'),
t(useLocale, 'tax_col_reference'),
t(useLocale, 'tax_col_party'),
t(useLocale, 'tax_col_event'),
t(useLocale, 'tax_cost_col_tax_treatment'),
t(useLocale, 'tax_col_tax'),
`${t(useLocale, 'tax_col_net')} (${report.currency})`,
`${t(useLocale, 'tax_col_vat')} (${report.currency})`,
`${t(useLocale, 'tax_col_total')} (${report.currency})`,
// Migration 126 — Skonto export. `tax_col_skonto` is the discount
// amount in major units; admin's accountant reconciles the line.
`${t(useLocale, 'tax_col_skonto')} (${report.currency})`,
];
lines.push(costHeaders.map(escape).join(','));
costs.rows.forEach((row, i) => {
const lines = [headers.map(escape).join(',')];
report.ledger.forEach((row, i) => {
const reference = row.isCancelled
? `${row.reference || ''} (${t(useLocale, 'tax_status_cancelled')})`
: (row.reference || '');
const tax = row.type === 'outgoing'
? Number(row.vatRate).toFixed(2)
: (row.taxTreatment || '');
lines.push([
i + 1,
t(useLocale, typeLabelKey(row.type)),
row.date,
t(useLocale, row.source === 'incoming' ? 'tax_cost_source_incoming' : 'tax_cost_source_expense'),
row.supplierLabel || row.description || '',
row.eventName || '',
row.taxTreatment || '',
reference,
row.party,
row.eventName,
tax,
minorToDotDecimal(row.netMinor),
minorToDotDecimal(row.vatMinor),
minorToDotDecimal(row.totalMinor),
row.skontoApplied ? minorToDotDecimal(row.skontoAmountMinor) : '',
].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.
// Trailing blank line, then the income / costs / result summary block.
const summary = report.summary;
if (summary) {
lines.push('');
+10 -1
View File
@@ -3966,6 +3966,11 @@
"company": "Unternehmen",
"total": "Summe Kosten"
},
"type": {
"outgoing": "Ausgangsrechnung",
"incoming": "Eingangsrechnung",
"expense": "Aufwand"
},
"col": {
"date": "Datum",
"invoice": "Rechnung",
@@ -3975,7 +3980,11 @@
"net": "Netto",
"vat": "MwSt.",
"total": "Brutto",
"skonto": "Skonto"
"skonto": "Skonto",
"type": "Typ",
"reference": "Referenz",
"party": "Kunde / Lieferant",
"tax": "Steuer"
}
},
"quotes": {
+10 -1
View File
@@ -3966,6 +3966,11 @@
"company": "Company",
"total": "Total costs"
},
"type": {
"outgoing": "Outgoing invoice",
"incoming": "Incoming invoice",
"expense": "Expense"
},
"col": {
"date": "Date",
"invoice": "Invoice",
@@ -3975,7 +3980,11 @@
"net": "Net",
"vat": "VAT",
"total": "Gross",
"skonto": "Skonto"
"skonto": "Skonto",
"type": "Type",
"reference": "Reference",
"party": "Customer / supplier",
"tax": "Tax"
}
},
"quotes": {
+109 -108
View File
@@ -96,6 +96,9 @@ export const TaxReportPage: React.FC = () => {
const [to, setTo] = useState(initialPeriod.to);
const [currency, setCurrency] = useState<string>('CHF');
const [isExporting, setIsExporting] = useState<'pdf' | 'csv' | null>(null);
// Unified-ledger sort (#5). Defaults to date ascending — matches the
// server-side order so the first paint is stable.
const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' }>({ key: 'date', dir: 'asc' });
const onPresetChange = (next: PeriodPreset) => {
setPreset(next);
@@ -147,6 +150,40 @@ export const TaxReportPage: React.FC = () => {
const hasAnyData = !!report && (report.rows.length > 0 || hasCosts);
const exportsDisabled = isLoading || isExporting !== null || !hasAnyData;
// Whether the disclaimer + the "tax treatment" semantics apply: any
// cost row in the ledger (type !== 'outgoing').
const ledgerHasCosts = !!report && report.ledger.some((r) => r.type !== 'outgoing');
// Sorted COPY of the ledger. Numeric sort for the signed amount
// columns (so income sits above costs ascending), localeCompare for
// date / party / type. Toggling a header flips the direction.
const sortedLedger = useMemo(() => {
if (!report) return [];
const copy = [...report.ledger];
const { key, dir } = sort;
const factor = dir === 'asc' ? 1 : -1;
const numericKeys: Record<string, 'netMinor' | 'vatMinor' | 'totalMinor'> = {
net: 'netMinor', vat: 'vatMinor', gross: 'totalMinor',
};
copy.sort((a, b) => {
if (key in numericKeys) {
const f = numericKeys[key];
return (a[f] - b[f]) * factor;
}
const av = key === 'party' ? a.party : key === 'type' ? a.type : a.date;
const bv = key === 'party' ? b.party : key === 'type' ? b.type : b.date;
return String(av || '').localeCompare(String(bv || '')) * factor;
});
return copy;
}, [report, sort]);
const toggleSort = (key: string) => {
setSort((prev) => prev.key === key
? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
: { key, dir: 'asc' });
};
const sortIndicator = (key: string) => (sort.key === key ? (sort.dir === 'asc' ? ' ▲' : ' ▼') : '');
return (
<div className="space-y-6">
{/* Top row — filter card on the left (stacked rows, narrower
@@ -396,10 +433,10 @@ export const TaxReportPage: React.FC = () => {
</Card>
) : (
<>
{report && report.rows.length > 0 && (
/* Table — full width below the filter + totals row above.
The totals card now lives in the top-right of the page
header so this section is purely the invoice list. */
{report && report.ledger.length > 0 && (
/* Unified ledger (#5) — one typed, signed, sortable table.
Outgoing invoices are positive; incoming invoices + expenses
negative so the amount columns net toward the Result. */
<Card padding="none">
{/* Two nested wrappers: the OUTER clips the header row's
solid fill so the top corners stay rounded (matches
@@ -413,29 +450,65 @@ export const TaxReportPage: React.FC = () => {
<thead className="bg-neutral-50 dark:bg-neutral-900 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-2 py-2 text-right font-medium w-10">#</th>
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.col.date', 'Date')}</th>
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.col.invoice', 'Invoice')}</th>
<th className="px-2 py-2 text-left font-medium">{t('taxReport.col.customer', 'Customer')}</th>
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">
<button type="button" onClick={() => toggleSort('type')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
{t('taxReport.col.type', 'Type')}{sortIndicator('type')}
</button>
</th>
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">
<button type="button" onClick={() => toggleSort('date')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
{t('taxReport.col.date', 'Date')}{sortIndicator('date')}
</button>
</th>
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.col.reference', 'Reference')}</th>
<th className="px-2 py-2 text-left font-medium">
<button type="button" onClick={() => toggleSort('party')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
{t('taxReport.col.party', 'Customer / supplier')}{sortIndicator('party')}
</button>
</th>
<th className="px-2 py-2 text-left font-medium">{t('taxReport.col.event', 'Event')}</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.vatRate', 'VAT %')}</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.net', 'Net')}</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.vat', 'VAT')}</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.total', 'Gross')}</th>
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.col.tax', 'Tax')}</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">
<button type="button" onClick={() => toggleSort('net')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
{t('taxReport.col.net', 'Net')}{sortIndicator('net')}
</button>
</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">
<button type="button" onClick={() => toggleSort('vat')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
{t('taxReport.col.vat', 'VAT')}{sortIndicator('vat')}
</button>
</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">
<button type="button" onClick={() => toggleSort('gross')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
{t('taxReport.col.total', 'Gross')}{sortIndicator('gross')}
</button>
</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.skonto', 'Skonto')}</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{report.rows.map((row, i) => (
{sortedLedger.map((row, i) => (
<tr
key={row.id}
key={row.key}
className={row.isCancelled
? 'text-neutral-400 dark:text-neutral-500 italic'
: 'text-neutral-900 dark:text-neutral-100'}
>
<td className="px-2 py-1.5 text-right tabular-nums">{i + 1}</td>
<td className="px-2 py-1.5 whitespace-nowrap tabular-nums">{fmtDate(row.issueDate.slice(0, 10))}</td>
<td className="px-2 py-1.5 whitespace-nowrap">
<span className="font-medium">{row.invoiceNumber}</span>
<span className={`inline-block px-1.5 py-0.5 text-[10px] uppercase tracking-wider rounded font-semibold not-italic ${
row.type === 'outgoing'
? 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300'
: row.type === 'incoming'
? 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
}`}>
{t(`taxReport.type.${row.type}`, row.type)}
</span>
</td>
<td className="px-2 py-1.5 whitespace-nowrap tabular-nums">{fmtDate(String(row.date).slice(0, 10))}</td>
<td className="px-2 py-1.5 whitespace-nowrap">
<span className="font-medium">{row.reference}</span>
{row.isCancelled && (
<span className="ml-2 inline-block px-1.5 py-0.5 text-[10px] uppercase tracking-wider rounded bg-neutral-200 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 font-semibold not-italic">
{t('taxReport.statusCancelled', 'Cancelled')}
@@ -443,8 +516,8 @@ export const TaxReportPage: React.FC = () => {
)}
{/* Storno + Reissue lineage markers — parity
with the admin invoices list so the same
colour scheme distinguishes the three row
kinds at a glance across both surfaces. */}
colour scheme distinguishes the row kinds at
a glance across both surfaces. */}
{row.kind === 'storno' && (
<span className="ml-2 inline-block px-1.5 py-0.5 text-[10px] uppercase tracking-wider rounded bg-purple-100 text-purple-800 font-semibold not-italic">
{t('bills.kind.storno', 'Storno')}
@@ -455,23 +528,26 @@ export const TaxReportPage: React.FC = () => {
{t('bills.kind.reissue', 'Reissue')}
</span>
)}
{row.replacedByInvoiceNumber && (
<span className="ml-1 text-xs text-neutral-500 dark:text-neutral-400 not-italic">
{row.replacedByInvoiceNumber}
</span>
)}
</td>
<td className="px-2 py-1.5 truncate max-w-[180px]" title={row.customerLabel}>{row.customerLabel}</td>
<td className="px-2 py-1.5 truncate max-w-[180px]" title={row.eventName}>{row.eventName}</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">{Number(row.vatRate).toFixed(1)}%</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
{formatMinor(row.netMinor, row.currency, intlLocale)}
<td className="px-2 py-1.5 truncate max-w-[180px]" title={row.party}>{row.party}</td>
<td className="px-2 py-1.5 truncate max-w-[180px]" title={row.eventName}>
{row.eventName || (row.type !== 'outgoing'
? <span className="text-neutral-400 dark:text-neutral-500">{t('taxReport.cost.company', 'Company')}</span>
: '')}
</td>
<td className="px-2 py-1.5 whitespace-nowrap">
{row.type === 'outgoing'
? <span className="tabular-nums">{Number(row.vatRate).toFixed(1)}%</span>
: <span className="text-xs text-neutral-500 dark:text-neutral-400">{row.taxTreatment}</span>}
</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
{formatMinor(row.vatMinor, row.currency, intlLocale)}
{formatMinor(row.netMinor, report.currency, intlLocale)}
</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
{formatMinor(row.vatMinor, report.currency, intlLocale)}
</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap font-medium">
{formatMinor(row.totalMinor, row.currency, intlLocale)}
{formatMinor(row.totalMinor, report.currency, intlLocale)}
</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap"
title={row.skontoApplied
@@ -479,7 +555,7 @@ export const TaxReportPage: React.FC = () => {
: undefined}>
{row.skontoApplied ? (
<span className="text-teal-700 dark:text-teal-300">
{formatMinor(row.skontoAmountMinor, row.currency, intlLocale)}
{formatMinor(row.skontoAmountMinor, report.currency, intlLocale)}
</span>
) : ''}
</td>
@@ -492,86 +568,11 @@ export const TaxReportPage: React.FC = () => {
</Card>
)}
{/* Cost side (#4) — incoming invoices + expenses, company or
event-booked. Shown as its own table beneath the revenue
list so the Einnahmen-Ausgaben picture is complete on one
page. */}
{hasCosts && report && (
<Card padding="none">
<div className="px-3 pt-3 pb-1">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{t('taxReport.costsTitle', 'Costs (incoming invoices + expenses)')}
</h2>
</div>
<div className="rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-900 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-2 py-2 text-right font-medium w-10">#</th>
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.col.date', 'Date')}</th>
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.cost.source', 'Type')}</th>
<th className="px-2 py-2 text-left font-medium">{t('taxReport.cost.supplier', 'Supplier / description')}</th>
<th className="px-2 py-2 text-left font-medium">{t('taxReport.col.event', 'Event')}</th>
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.cost.taxTreatment', 'Tax treatment')}</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.net', 'Net')}</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.vat', 'VAT')}</th>
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.total', 'Gross')}</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800 text-neutral-900 dark:text-neutral-100">
{report.costs.rows.map((row, i) => (
<tr key={`${row.source}-${row.id}`}>
<td className="px-2 py-1.5 text-right tabular-nums">{i + 1}</td>
<td className="px-2 py-1.5 whitespace-nowrap tabular-nums">{fmtDate(String(row.date).slice(0, 10))}</td>
<td className="px-2 py-1.5 whitespace-nowrap">
<span className={`inline-block px-1.5 py-0.5 text-[10px] uppercase tracking-wider rounded font-semibold ${
row.source === 'incoming'
? 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
}`}>
{row.source === 'incoming'
? t('taxReport.cost.sourceIncoming', 'Incoming')
: t('taxReport.cost.sourceExpense', 'Expense')}
</span>
</td>
<td className="px-2 py-1.5 truncate max-w-[220px]" title={row.supplierLabel || row.description}>
{row.supplierLabel || row.description || '—'}
</td>
<td className="px-2 py-1.5 truncate max-w-[160px]" title={row.eventName}>
{row.eventName || <span className="text-neutral-400 dark:text-neutral-500">{t('taxReport.cost.company', 'Company')}</span>}
</td>
<td className="px-2 py-1.5 whitespace-nowrap text-xs text-neutral-500 dark:text-neutral-400">{row.taxTreatment}</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
{formatMinor(row.netMinor, report.currency, intlLocale)}
</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
{formatMinor(row.vatMinor, report.currency, intlLocale)}
</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap font-medium">
{formatMinor(row.totalMinor, report.currency, intlLocale)}
</td>
</tr>
))}
</tbody>
<tfoot className="border-t-2 border-neutral-300 dark:border-neutral-700 font-semibold text-neutral-900 dark:text-neutral-100">
<tr>
<td className="px-2 py-2" colSpan={6}>{t('taxReport.cost.total', 'Total costs')}</td>
<td className="px-2 py-2 text-right tabular-nums whitespace-nowrap">{formatMinor(report.costs.totalNet, report.currency, intlLocale)}</td>
<td className="px-2 py-2 text-right tabular-nums whitespace-nowrap">{formatMinor(report.costs.totalVat, report.currency, intlLocale)}</td>
<td className="px-2 py-2 text-right tabular-nums whitespace-nowrap">{formatMinor(report.costs.totalGross, report.currency, intlLocale)}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</Card>
)}
{/* Legal disclaimer — tax figures are a guideline. Per project
rule: any surface touching tax/financial output must point
the user at a professional. */}
{hasCosts && (
the user at a professional. Shown whenever the ledger holds
any cost row. */}
{ledgerHasCosts && (
<p className="flex items-start gap-2 text-xs text-neutral-500 dark:text-neutral-400">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>
@@ -87,6 +87,30 @@ export interface TaxReportSummary {
vatPayableMinor: number;
}
/** A single row of the unified ledger (#5). Outgoing invoices carry
* POSITIVE amounts; incoming invoices + expenses are NEGATIVE so the
* amount columns net toward the Result and a value-sort runs
* income → costs. `type` is the discriminator. */
export interface TaxLedgerRow {
key: string;
type: 'outgoing' | 'incoming' | 'expense';
date: string;
reference: string;
party: string;
eventName: string;
vatRate: number | null;
taxTreatment: string | null;
status: string;
isCancelled: boolean;
isReissue: boolean;
kind: string | null;
skontoApplied: boolean;
skontoAmountMinor: number;
netMinor: number;
vatMinor: number;
totalMinor: number;
}
export interface TaxReport {
rows: TaxReportRow[];
totalsByVatRate: TaxReportBucket[];
@@ -101,6 +125,9 @@ export interface TaxReport {
costsError?: string | null;
/** Income/cost/result summary (#4). */
summary: TaxReportSummary;
/** Unified, signed, typed ledger (#5) — the canonical surface for the
* on-screen table + exports. Sorted by date ascending server-side. */
ledger: TaxLedgerRow[];
currency: string;
period: { from: string; to: string };
}