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:
@@ -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: 'Итого нетто',
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user