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_total: 'Gross',
tax_col_status: 'Status', tax_col_status: 'Status',
tax_col_skonto: 'Skonto', 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_status_cancelled: 'Cancelled',
tax_totals_by_rate: 'Totals by VAT rate', tax_totals_by_rate: 'Totals by VAT rate',
tax_grand_total_net: 'Total net', tax_grand_total_net: 'Total net',
@@ -202,6 +209,13 @@ const LABELS = {
tax_col_total: 'Brutto', tax_col_total: 'Brutto',
tax_col_status: 'Status', tax_col_status: 'Status',
tax_col_skonto: 'Skonto', 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_status_cancelled: 'Storniert',
tax_totals_by_rate: 'Summen nach MwSt-Satz', tax_totals_by_rate: 'Summen nach MwSt-Satz',
tax_grand_total_net: 'Gesamt Netto', tax_grand_total_net: 'Gesamt Netto',
@@ -315,6 +329,13 @@ const LABELS = {
tax_col_total: 'Brut', tax_col_total: 'Brut',
tax_col_status: 'Statut', tax_col_status: 'Statut',
tax_col_skonto: 'Escompte', 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_status_cancelled: 'Annulée',
tax_totals_by_rate: 'Totaux par taux de TVA', tax_totals_by_rate: 'Totaux par taux de TVA',
tax_grand_total_net: 'Total net', tax_grand_total_net: 'Total net',
@@ -397,6 +418,13 @@ const LABELS = {
tax_col_total: 'Bruto', tax_col_total: 'Bruto',
tax_col_status: 'Status', tax_col_status: 'Status',
tax_col_skonto: 'Korting', 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_status_cancelled: 'Geannuleerd',
tax_totals_by_rate: 'Totalen per btw-tarief', tax_totals_by_rate: 'Totalen per btw-tarief',
tax_grand_total_net: 'Totaal netto', tax_grand_total_net: 'Totaal netto',
@@ -479,6 +507,13 @@ const LABELS = {
tax_col_total: 'Bruto', tax_col_total: 'Bruto',
tax_col_status: 'Estado', tax_col_status: 'Estado',
tax_col_skonto: 'Desconto', 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_status_cancelled: 'Cancelada',
tax_totals_by_rate: 'Totais por taxa de IVA', tax_totals_by_rate: 'Totais por taxa de IVA',
tax_grand_total_net: 'Total líquido', tax_grand_total_net: 'Total líquido',
@@ -561,6 +596,13 @@ const LABELS = {
tax_col_total: 'Брутто', tax_col_total: 'Брутто',
tax_col_status: 'Статус', tax_col_status: 'Статус',
tax_col_skonto: 'Скидка', 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_status_cancelled: 'Аннулирован',
tax_totals_by_rate: 'Итоги по ставкам НДС', tax_totals_by_rate: 'Итоги по ставкам НДС',
tax_grand_total_net: 'Итого нетто', tax_grand_total_net: 'Итого нетто',
+142 -148
View File
@@ -495,6 +495,54 @@ async function getTaxReport({ from, to, currency, includeCosts = true } = {}) {
vatPayableMinor: grandTotalVat - costs.totalVat, 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 { return {
rows, rows,
totalsByVatRate, totalsByVatRate,
@@ -505,6 +553,7 @@ async function getTaxReport({ from, to, currency, includeCosts = true } = {}) {
costs, costs,
costsError, costsError,
summary, summary,
ledger,
currency: cur, currency: cur,
period: { from, to }, period: { from, to },
}; };
@@ -566,21 +615,19 @@ async function loadRenderContext(locale) {
// uncluttered with just "R-2026-0001" — easier to scan for an // uncluttered with just "R-2026-0001" — easier to scan for an
// auditor looking at the sequence. // auditor looking at the sequence.
const TAX_TABLE_COLS = [ const TAX_TABLE_COLS = [
{ key: 'idx', labelKey: 'tax_col_no', width: 26, align: 'right' }, { key: 'idx', labelKey: 'tax_col_no', width: 22, align: 'right' },
{ key: 'date', labelKey: 'tax_col_date', width: 60, align: 'left' }, { key: 'type', labelKey: 'tax_col_type', width: 58, align: 'left' },
{ key: 'invoice', labelKey: 'tax_col_invoice', width: 100, align: 'left' }, { key: 'date', labelKey: 'tax_col_date', width: 56, align: 'left' },
{ key: 'customer', labelKey: 'tax_col_customer', width: 132, align: 'left' }, { key: 'reference', labelKey: 'tax_col_reference', width: 88, align: 'left' },
{ key: 'event', labelKey: 'tax_col_event', width: 95, align: 'left' }, { key: 'party', labelKey: 'tax_col_party', width: 116, align: 'left' },
{ key: 'vatRate', labelKey: 'tax_col_vat_rate', width: 42, align: 'right' }, { key: 'event', labelKey: 'tax_col_event', width: 86, align: 'left' },
{ key: 'net', labelKey: 'tax_col_net', width: 70, align: 'right' }, { key: 'tax', labelKey: 'tax_col_tax', width: 64, align: 'left' },
{ key: 'vat', labelKey: 'tax_col_vat', width: 60, align: 'right' }, { key: 'net', labelKey: 'tax_col_net', width: 70, align: 'right' },
{ key: 'total', labelKey: 'tax_col_total', width: 80, 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 // Skonto column (migration 126) — blank for non-Skonto rows so the
// column reads quietly until it has data. Shrunk neighbouring text // column reads quietly until it has data.
// columns slightly to make space without going over the landscape { key: 'skonto', labelKey: 'tax_col_skonto', width: 50, align: 'right' },
// content width.
{ key: 'skonto', labelKey: 'tax_col_skonto', width: 56, align: 'right' },
{ key: 'status', labelKey: 'tax_col_status', width: 58, align: 'left' },
]; ];
function colX(leftMargin, index) { function colX(leftMargin, index) {
@@ -615,22 +662,31 @@ function formatVatRate(rate, locale) {
return `${formatted} %`; return `${formatted} %`;
} }
function rowCellValues(row, idx, locale, dateFormat) { function rowCellValues(row, idx, locale, dateFormat, currency) {
const intlLocale = locale === 'de' ? 'de-CH' : 'en-GB'; 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 { return {
idx: String(idx), idx: String(idx),
date: formatDate(row.issueDate, dateFormat), type: typeLabel,
invoice: row.invoiceNumber, // no inline "(Cancelled)" — keep the column tidy; status is its own column date: formatDate(row.date, dateFormat),
customer: row.customerLabel || '', reference,
party: row.party || '',
event: row.eventName || '', event: row.eventName || '',
vatRate: formatVatRate(row.vatRate, locale), tax: row.type === 'outgoing' ? formatVatRate(row.vatRate, locale) : (row.taxTreatment || ''),
net: formatMinor(row.netMinor, row.currency, intlLocale), net: formatMinor(row.netMinor, currency, intlLocale),
vat: formatMinor(row.vatMinor, row.currency, intlLocale), vat: formatMinor(row.vatMinor, currency, intlLocale),
total: formatMinor(row.totalMinor, row.currency, intlLocale), total: formatMinor(row.totalMinor, currency, intlLocale),
skonto: row.skontoApplied 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 tableBottomLimit = page.height - page.marginBottom - 110; // leave room for totals
const tableWidth = TAX_TABLE_COLS.reduce((s, c) => s + c.width, 0); 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') doc.font(fonts.body).fontSize(10).fillColor('#555')
.text(t(useLocale, 'tax_no_invoices'), leftMargin, y + 6, { .text(t(useLocale, 'tax_no_invoices'), leftMargin, y + 6, {
width: tableWidth, align: 'center', 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 // wrap (they're either ints or money strings whose width we
// budget for) — only text cells (customer, event, invoice, // budget for) — only text cells (customer, event, invoice,
// status) opt into natural wrapping. // 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 measureCellHeight = (value, col) => {
const s = safeStr(value); const s = safeStr(value);
if (!s) return 0; if (!s) return 0;
@@ -735,9 +791,9 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
return doc.heightOfString(s, opts); return doc.heightOfString(s, opts);
}; };
for (let i = 0; i < report.rows.length; i += 1) { for (let i = 0; i < report.ledger.length; i += 1) {
const row = report.rows[i]; const row = report.ledger[i];
const cells = rowCellValues(row, i + 1, useLocale, renderCtx.dateFormat); const cells = rowCellValues(row, i + 1, useLocale, renderCtx.dateFormat, report.currency);
// Set the font BEFORE measuring so heightOfString reads the // Set the font BEFORE measuring so heightOfString reads the
// exact rendering state we'll use for doc.text below. // 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 // otherwise PDFKit auto-paginates mid-totals, creating phantom
// pages whose footer ends up at unexpected Y positions on the // pages whose footer ends up at unexpected Y positions on the
// subsequent bufferedPageRange loop. // subsequent bufferedPageRange loop.
const hasCostSummary = report.summary && (report.costs?.rows?.length || report.costs?.totalGross); // Header (16) + one line per VAT bucket (13) + divider (8) +
const summaryHeight = hasCostSummary ? (10 + 6 + (3 * 13) + 12 + 12) : 0; // three income/costs/result summary rows (39) + a 12pt cushion.
const totalsHeightEstimate = 16 + (report.totalsByVatRate.length * 13) + 8 + 39 + 12 + summaryHeight; 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 const footerReserve = 24; // 12 above + 12 of page-number text room
if (y + 12 + totalsHeightEstimate + footerReserve > page.height - page.marginBottom) { if (y + 12 + totalsHeightEstimate + footerReserve > page.height - page.marginBottom) {
doc.addPage({ doc.addPage({
@@ -836,48 +893,25 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) {
totalsX + 270, ty, { width: 90, align: 'right' }); totalsX + 270, ty, { width: 90, align: 'right' });
ty += 13; ty += 13;
} }
// Divider above grand totals. // Divider above the income / costs / result summary.
doc.moveTo(totalsX, ty + 2).lineTo(totalsX + totalsBoxWidth, ty + 2) doc.moveTo(totalsX, ty + 2).lineTo(totalsX + totalsBoxWidth, ty + 2)
.lineWidth(0.6).strokeColor('#000').stroke(); .lineWidth(0.6).strokeColor('#000').stroke();
ty += 6; ty += 6;
doc.font(fonts.bold); // Income / Costs / Result summary (mirrors the on-screen summary
doc.text(t(useLocale, 'tax_grand_total_net'), totalsX, ty, { width: 170, align: 'left' }); // box). Costs are shown NEGATIVE so the Result reads as a plain
doc.text(formatMinor(report.grandTotalNet, report.currency, intlLocale), // sum of the column. Net / VAT / Gross across the three lines.
totalsX + 175, ty, { width: 90, align: 'right' }); const s = report.summary;
ty += 13; const summaryLine = (labelKey, netMinor, vatMinor, grossMinor, bold) => {
doc.text(t(useLocale, 'tax_grand_total_vat'), totalsX, ty, { width: 170, align: 'left' }); doc.font(bold ? fonts.bold : fonts.body).fontSize(9).fillColor('#000');
doc.text(formatMinor(report.grandTotalVat, report.currency, intlLocale), doc.text(t(useLocale, labelKey), totalsX, ty, { width: 80, align: 'left' });
totalsX + 175, ty, { width: 90, align: 'right' }); doc.text(formatMinor(netMinor, report.currency, intlLocale), totalsX + 80, ty, { width: 90, align: 'right' });
ty += 13; doc.text(formatMinor(vatMinor, report.currency, intlLocale), totalsX + 175, ty, { width: 90, align: 'right' });
doc.text(t(useLocale, 'tax_grand_total_gross'), totalsX, ty, { width: 170, align: 'left' }); doc.text(formatMinor(grossMinor, report.currency, intlLocale), totalsX + 270, ty, { width: 90, align: 'right' });
doc.text(formatMinor(report.grandTotal, report.currency, intlLocale), ty += 13;
totalsX + 270, ty, { width: 90, align: 'right' }); };
summaryLine('tax_summary_income', s.incomeNetMinor, s.incomeVatMinor, s.incomeGrossMinor, false);
// Einnahmen-Ausgaben summary (income vs costs vs result). Only summaryLine('tax_summary_costs', -Math.abs(s.costNetMinor), -Math.abs(s.costVatMinor), -Math.abs(s.costGrossMinor), false);
// rendered when the report carries a cost side. Compact 4-line summaryLine('tax_summary_result', s.resultNetMinor, s.vatPayableMinor, s.resultGrossMinor, true);
// 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. // Cancelled footnote (bottom-left). Only when there are any.
if (report.cancelledCount > 0) { if (report.cancelledCount > 0) {
@@ -935,22 +969,6 @@ async function renderTaxReportCsv({ from, to, currency, locale } = {}) {
const report = await getTaxReport({ from, to, currency }); const report = await getTaxReport({ from, to, currency });
const useLocale = locale || 'en'; 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 escape = (cell) => {
const s = cell === null || cell === undefined ? '' : String(cell); const s = cell === null || cell === undefined ? '' : String(cell);
// RFC 4180: wrap in quotes when the value contains comma, quote, // 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 minorToDotDecimal = (m) => ((Number(m) || 0) / 100).toFixed(2);
const typeLabelKey = (type) => (
type === 'outgoing' ? 'tax_type_outgoing'
: type === 'incoming' ? 'tax_type_incoming'
: 'tax_type_expense'
);
// 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_col_reference'),
t(useLocale, 'tax_col_party'),
t(useLocale, 'tax_col_event'),
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})`,
];
const lines = [headers.map(escape).join(',')]; const lines = [headers.map(escape).join(',')];
report.rows.forEach((row, i) => { 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([ lines.push([
i + 1, i + 1,
row.issueDate, t(useLocale, typeLabelKey(row.type)),
row.invoiceNumber, row.date,
row.customerLabel, reference,
row.party,
row.eventName, row.eventName,
Number(row.vatRate).toFixed(2), tax,
minorToDotDecimal(row.netMinor), minorToDotDecimal(row.netMinor),
minorToDotDecimal(row.vatMinor), minorToDotDecimal(row.vatMinor),
minorToDotDecimal(row.totalMinor), minorToDotDecimal(row.totalMinor),
row.isCancelled ? '1' : '0',
row.skontoApplied ? minorToDotDecimal(row.skontoAmountMinor) : '', row.skontoApplied ? minorToDotDecimal(row.skontoAmountMinor) : '',
].map(escape).join(',')); ].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(','));
// Cost side (Einnahmen-Ausgaben). Appended below the revenue block as // Trailing blank line, then the income / costs / result summary block.
// 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; const summary = report.summary;
if (summary) { if (summary) {
lines.push(''); lines.push('');
+10 -1
View File
@@ -3966,6 +3966,11 @@
"company": "Unternehmen", "company": "Unternehmen",
"total": "Summe Kosten" "total": "Summe Kosten"
}, },
"type": {
"outgoing": "Ausgangsrechnung",
"incoming": "Eingangsrechnung",
"expense": "Aufwand"
},
"col": { "col": {
"date": "Datum", "date": "Datum",
"invoice": "Rechnung", "invoice": "Rechnung",
@@ -3975,7 +3980,11 @@
"net": "Netto", "net": "Netto",
"vat": "MwSt.", "vat": "MwSt.",
"total": "Brutto", "total": "Brutto",
"skonto": "Skonto" "skonto": "Skonto",
"type": "Typ",
"reference": "Referenz",
"party": "Kunde / Lieferant",
"tax": "Steuer"
} }
}, },
"quotes": { "quotes": {
+10 -1
View File
@@ -3966,6 +3966,11 @@
"company": "Company", "company": "Company",
"total": "Total costs" "total": "Total costs"
}, },
"type": {
"outgoing": "Outgoing invoice",
"incoming": "Incoming invoice",
"expense": "Expense"
},
"col": { "col": {
"date": "Date", "date": "Date",
"invoice": "Invoice", "invoice": "Invoice",
@@ -3975,7 +3980,11 @@
"net": "Net", "net": "Net",
"vat": "VAT", "vat": "VAT",
"total": "Gross", "total": "Gross",
"skonto": "Skonto" "skonto": "Skonto",
"type": "Type",
"reference": "Reference",
"party": "Customer / supplier",
"tax": "Tax"
} }
}, },
"quotes": { "quotes": {
+109 -108
View File
@@ -96,6 +96,9 @@ export const TaxReportPage: React.FC = () => {
const [to, setTo] = useState(initialPeriod.to); const [to, setTo] = useState(initialPeriod.to);
const [currency, setCurrency] = useState<string>('CHF'); const [currency, setCurrency] = useState<string>('CHF');
const [isExporting, setIsExporting] = useState<'pdf' | 'csv' | null>(null); 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) => { const onPresetChange = (next: PeriodPreset) => {
setPreset(next); setPreset(next);
@@ -147,6 +150,40 @@ export const TaxReportPage: React.FC = () => {
const hasAnyData = !!report && (report.rows.length > 0 || hasCosts); const hasAnyData = !!report && (report.rows.length > 0 || hasCosts);
const exportsDisabled = isLoading || isExporting !== null || !hasAnyData; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Top row — filter card on the left (stacked rows, narrower {/* Top row — filter card on the left (stacked rows, narrower
@@ -396,10 +433,10 @@ export const TaxReportPage: React.FC = () => {
</Card> </Card>
) : ( ) : (
<> <>
{report && report.rows.length > 0 && ( {report && report.ledger.length > 0 && (
/* Table — full width below the filter + totals row above. /* Unified ledger (#5) — one typed, signed, sortable table.
The totals card now lives in the top-right of the page Outgoing invoices are positive; incoming invoices + expenses
header so this section is purely the invoice list. */ negative so the amount columns net toward the Result. */
<Card padding="none"> <Card padding="none">
{/* Two nested wrappers: the OUTER clips the header row's {/* Two nested wrappers: the OUTER clips the header row's
solid fill so the top corners stay rounded (matches 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"> <thead className="bg-neutral-50 dark:bg-neutral-900 text-neutral-700 dark:text-neutral-300">
<tr> <tr>
<th className="px-2 py-2 text-right font-medium w-10">#</th> <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">
<th className="px-2 py-2 text-left font-medium whitespace-nowrap">{t('taxReport.col.invoice', 'Invoice')}</th> <button type="button" onClick={() => toggleSort('type')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
<th className="px-2 py-2 text-left font-medium">{t('taxReport.col.customer', 'Customer')}</th> {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-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-left font-medium whitespace-nowrap">{t('taxReport.col.tax', 'Tax')}</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">
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.vat', 'VAT')}</th> <button type="button" onClick={() => toggleSort('net')} className="font-medium hover:text-primary-600 dark:hover:text-primary-400">
<th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.total', 'Gross')}</th> {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> <th className="px-2 py-2 text-right font-medium whitespace-nowrap">{t('taxReport.col.skonto', 'Skonto')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800"> <tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{report.rows.map((row, i) => ( {sortedLedger.map((row, i) => (
<tr <tr
key={row.id} key={row.key}
className={row.isCancelled className={row.isCancelled
? 'text-neutral-400 dark:text-neutral-500 italic' ? 'text-neutral-400 dark:text-neutral-500 italic'
: 'text-neutral-900 dark:text-neutral-100'} : '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 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"> <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 && ( {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"> <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')} {t('taxReport.statusCancelled', 'Cancelled')}
@@ -443,8 +516,8 @@ export const TaxReportPage: React.FC = () => {
)} )}
{/* Storno + Reissue lineage markers — parity {/* Storno + Reissue lineage markers — parity
with the admin invoices list so the same with the admin invoices list so the same
colour scheme distinguishes the three row colour scheme distinguishes the row kinds at
kinds at a glance across both surfaces. */} a glance across both surfaces. */}
{row.kind === 'storno' && ( {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"> <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')} {t('bills.kind.storno', 'Storno')}
@@ -455,23 +528,26 @@ export const TaxReportPage: React.FC = () => {
{t('bills.kind.reissue', 'Reissue')} {t('bills.kind.reissue', 'Reissue')}
</span> </span>
)} )}
{row.replacedByInvoiceNumber && (
<span className="ml-1 text-xs text-neutral-500 dark:text-neutral-400 not-italic">
{row.replacedByInvoiceNumber}
</span>
)}
</td> </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.party}>{row.party}</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 truncate max-w-[180px]" title={row.eventName}>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">{Number(row.vatRate).toFixed(1)}%</td> {row.eventName || (row.type !== 'outgoing'
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap"> ? <span className="text-neutral-400 dark:text-neutral-500">{t('taxReport.cost.company', 'Company')}</span>
{formatMinor(row.netMinor, row.currency, intlLocale)} : '')}
</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>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap"> <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>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap font-medium"> <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>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap" <td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap"
title={row.skontoApplied title={row.skontoApplied
@@ -479,7 +555,7 @@ export const TaxReportPage: React.FC = () => {
: undefined}> : undefined}>
{row.skontoApplied ? ( {row.skontoApplied ? (
<span className="text-teal-700 dark:text-teal-300"> <span className="text-teal-700 dark:text-teal-300">
{formatMinor(row.skontoAmountMinor, row.currency, intlLocale)} {formatMinor(row.skontoAmountMinor, report.currency, intlLocale)}
</span> </span>
) : ''} ) : ''}
</td> </td>
@@ -492,86 +568,11 @@ export const TaxReportPage: React.FC = () => {
</Card> </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 {/* Legal disclaimer — tax figures are a guideline. Per project
rule: any surface touching tax/financial output must point rule: any surface touching tax/financial output must point
the user at a professional. */} the user at a professional. Shown whenever the ledger holds
{hasCosts && ( any cost row. */}
{ledgerHasCosts && (
<p className="flex items-start gap-2 text-xs text-neutral-500 dark:text-neutral-400"> <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" /> <AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span> <span>
@@ -87,6 +87,30 @@ export interface TaxReportSummary {
vatPayableMinor: number; 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 { export interface TaxReport {
rows: TaxReportRow[]; rows: TaxReportRow[];
totalsByVatRate: TaxReportBucket[]; totalsByVatRate: TaxReportBucket[];
@@ -101,6 +125,9 @@ export interface TaxReport {
costsError?: string | null; costsError?: string | null;
/** Income/cost/result summary (#4). */ /** Income/cost/result summary (#4). */
summary: TaxReportSummary; 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; currency: string;
period: { from: string; to: string }; period: { from: string; to: string };
} }