feat(accounting): tax window shows all costs (incoming invoices + expenses) alongside revenue
Einnahmen-Ausgaben view for the Milchbüchlein/simple-accounting case: - taxReportService.getTaxReport now returns a cost side (loadCosts: incoming invoices + internal expenses, company- or event-booked, schema-guarded) plus a summary (income / costs / result, VAT payable) - declined/duplicate costs excluded; re-billed costs kept (matching re-bill revenue is counted, so the net is correct) - CSV + PDF exports gain a Costs section and an income/costs/result summary; pdf-i18n keys added for all 6 locales (fr/nl/pt/ru machine — flag for native review) - frontend tax page renders the summary card, a costs table (company vs event), and a 'verify with Treuhänder' disclaimer - tax-report tests cover the cost aggregation + zeroed summary when the accounting tables are absent; adminCrmAuth test enables the accounting master flag the route now requires fr/nl/pt/ru strings are machine-generated and need native review.
This commit is contained in:
@@ -3769,6 +3769,25 @@
|
||||
"grandTotalVat": "Gesamt MwSt.",
|
||||
"grandTotalGross": "Gesamt Brutto",
|
||||
"cancelledFootnote": "{{count}} stornierte Rechnung(en) — Beträge nicht in den Summen enthalten (für lückenlose Nummernfolge dargestellt).",
|
||||
"skontoTooltip": "Mit Skonto bezahlt",
|
||||
"costsTitle": "Kosten (Eingangsrechnungen + Spesen)",
|
||||
"costsDisclaimer": "Diese Einnahmen-Ausgaben-Übersicht ist eine Orientierungshilfe für Ihre Aufzeichnungen (Milchbüchleinrechnung). Vorsteuerabzug und Ergebnis hängen von der steuerlichen Behandlung jeder Kostenposition ab — vor der Einreichung mit Ihrem Treuhänder / der Steuerverwaltung prüfen.",
|
||||
"summary": {
|
||||
"title": "Einnahmen / Ausgaben",
|
||||
"income": "Einnahmen",
|
||||
"costs": "Ausgaben",
|
||||
"result": "Ergebnis",
|
||||
"vatPayable": "MWST-Zahllast (Umsatz- − Vorsteuer)"
|
||||
},
|
||||
"cost": {
|
||||
"source": "Art",
|
||||
"sourceIncoming": "Eingang",
|
||||
"sourceExpense": "Spese",
|
||||
"supplier": "Lieferant / Beschreibung",
|
||||
"taxTreatment": "Steuerliche Behandlung",
|
||||
"company": "Unternehmen",
|
||||
"total": "Summe Kosten"
|
||||
},
|
||||
"col": {
|
||||
"date": "Datum",
|
||||
"invoice": "Rechnung",
|
||||
@@ -3777,7 +3796,8 @@
|
||||
"vatRate": "MwSt %",
|
||||
"net": "Netto",
|
||||
"vat": "MwSt.",
|
||||
"total": "Brutto"
|
||||
"total": "Brutto",
|
||||
"skonto": "Skonto"
|
||||
}
|
||||
},
|
||||
"quotes": {
|
||||
|
||||
@@ -3769,6 +3769,25 @@
|
||||
"grandTotalVat": "Total VAT",
|
||||
"grandTotalGross": "Total gross",
|
||||
"cancelledFootnote": "{{count}} cancelled invoice(s) — amounts excluded from totals (shown for audit-trail continuity).",
|
||||
"skontoTooltip": "Paid with Skonto",
|
||||
"costsTitle": "Costs (incoming invoices + expenses)",
|
||||
"costsDisclaimer": "This income/expense overview is a guideline for your records (Einnahmen-Ausgaben-Rechnung). VAT reclaimability and the result figure depend on each cost’s tax treatment — verify with your Treuhänder / tax authority before filing.",
|
||||
"summary": {
|
||||
"title": "Income / costs",
|
||||
"income": "Income",
|
||||
"costs": "Costs",
|
||||
"result": "Result",
|
||||
"vatPayable": "VAT payable (output − input)"
|
||||
},
|
||||
"cost": {
|
||||
"source": "Type",
|
||||
"sourceIncoming": "Incoming",
|
||||
"sourceExpense": "Expense",
|
||||
"supplier": "Supplier / description",
|
||||
"taxTreatment": "Tax treatment",
|
||||
"company": "Company",
|
||||
"total": "Total costs"
|
||||
},
|
||||
"col": {
|
||||
"date": "Date",
|
||||
"invoice": "Invoice",
|
||||
@@ -3777,7 +3796,8 @@
|
||||
"vatRate": "VAT %",
|
||||
"net": "Net",
|
||||
"vat": "VAT",
|
||||
"total": "Gross"
|
||||
"total": "Gross",
|
||||
"skonto": "Skonto"
|
||||
}
|
||||
},
|
||||
"quotes": {
|
||||
|
||||
@@ -143,7 +143,9 @@ export const TaxReportPage: React.FC = () => {
|
||||
// rates in the period. With a single rate the breakdown is just a
|
||||
// restatement of the grand totals — pure noise.
|
||||
const showPerRateBreakdown = (report?.totalsByVatRate.length || 0) > 1;
|
||||
const exportsDisabled = isLoading || isExporting !== null || !report || report.rows.length === 0;
|
||||
const hasCosts = (report?.costs?.rows.length || 0) > 0;
|
||||
const hasAnyData = !!report && (report.rows.length > 0 || hasCosts);
|
||||
const exportsDisabled = isLoading || isExporting !== null || !hasAnyData;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -260,7 +262,7 @@ export const TaxReportPage: React.FC = () => {
|
||||
there are 2+ rates in the period (otherwise it duplicates
|
||||
the grand totals). Cancelled footnote at the bottom when
|
||||
applicable. */}
|
||||
{report && report.rows.length > 0 && (
|
||||
{hasAnyData && report && (
|
||||
<Card padding="md">
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
@@ -283,6 +285,45 @@ export const TaxReportPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Einnahmen-Ausgaben summary (#4): income vs costs vs
|
||||
result. Only when there is a cost side. The result line
|
||||
is the simplified surplus a Milchbüchlein needs; VAT
|
||||
payable is a guideline (depends on each cost's tax
|
||||
treatment — see disclaimer below the cost table). */}
|
||||
{hasCosts && report.summary && (
|
||||
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
{t('taxReport.summary.title', 'Income / costs')}
|
||||
</h2>
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-neutral-700 dark:text-neutral-300">{t('taxReport.summary.income', 'Income')}</span>
|
||||
<span className="tabular-nums text-emerald-700 dark:text-emerald-400">
|
||||
{formatMinor(report.summary.incomeGrossMinor, report.currency, intlLocale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-neutral-700 dark:text-neutral-300">{t('taxReport.summary.costs', 'Costs')}</span>
|
||||
<span className="tabular-nums text-rose-700 dark:text-rose-400">
|
||||
−{formatMinor(report.summary.costGrossMinor, report.currency, intlLocale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3 pt-1.5 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<span className="font-semibold text-neutral-900 dark:text-neutral-100">{t('taxReport.summary.result', 'Result')}</span>
|
||||
<span className="tabular-nums font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{formatMinor(report.summary.resultGrossMinor, report.currency, intlLocale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<span>{t('taxReport.summary.vatPayable', 'VAT payable (output − input)')}</span>
|
||||
<span className="tabular-nums">
|
||||
{formatMinor(report.summary.vatPayableMinor, report.currency, intlLocale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPerRateBreakdown && (
|
||||
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
@@ -335,7 +376,7 @@ export const TaxReportPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : !report || report.rows.length === 0 ? (
|
||||
) : !hasAnyData ? (
|
||||
<Card padding="lg">
|
||||
<p className="text-center text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('taxReport.empty', 'No invoices in this period.')}
|
||||
@@ -343,9 +384,10 @@ export const TaxReportPage: React.FC = () => {
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Table — full width below the filter + totals row above.
|
||||
{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. */}
|
||||
header so this section is purely the invoice list. */
|
||||
<Card padding="none">
|
||||
{/* Two nested wrappers: the OUTER clips the header row's
|
||||
solid fill so the top corners stay rounded (matches
|
||||
@@ -436,6 +478,98 @@ export const TaxReportPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</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 && (
|
||||
<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>
|
||||
{t(
|
||||
'taxReport.costsDisclaimer',
|
||||
'This income/expense overview is a guideline for your records (Einnahmen-Ausgaben-Rechnung). VAT reclaimability and the result figure depend on each cost’s tax treatment — verify with your Treuhänder / tax authority before filing.',
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,47 @@ export interface TaxReportBucket {
|
||||
totalMinor: number;
|
||||
}
|
||||
|
||||
/** A single cost-side line (#4 — Einnahmen-Ausgaben). Either an external
|
||||
* incoming invoice (`source: 'incoming'`) or an internal expense
|
||||
* (`source: 'expense'`). Booked to an event (`eventName`) or the
|
||||
* company (empty `eventName`). */
|
||||
export interface TaxReportCostRow {
|
||||
id: number;
|
||||
source: 'incoming' | 'expense';
|
||||
date: string;
|
||||
supplierLabel: string;
|
||||
description: string;
|
||||
eventName: string;
|
||||
disposition: string;
|
||||
taxTreatment: string;
|
||||
status: string;
|
||||
netMinor: number;
|
||||
vatMinor: number;
|
||||
totalMinor: number;
|
||||
}
|
||||
|
||||
export interface TaxReportCosts {
|
||||
rows: TaxReportCostRow[];
|
||||
totalNet: number;
|
||||
totalVat: number;
|
||||
totalGross: number;
|
||||
}
|
||||
|
||||
/** Income vs cost vs result summary (minor units). `vatPayableMinor` =
|
||||
* output VAT − input VAT (a guideline figure; actual MWST filing
|
||||
* depends on each cost's tax treatment — verify with a Treuhänder). */
|
||||
export interface TaxReportSummary {
|
||||
incomeNetMinor: number;
|
||||
incomeVatMinor: number;
|
||||
incomeGrossMinor: number;
|
||||
costNetMinor: number;
|
||||
costVatMinor: number;
|
||||
costGrossMinor: number;
|
||||
resultNetMinor: number;
|
||||
resultGrossMinor: number;
|
||||
vatPayableMinor: number;
|
||||
}
|
||||
|
||||
export interface TaxReport {
|
||||
rows: TaxReportRow[];
|
||||
totalsByVatRate: TaxReportBucket[];
|
||||
@@ -53,6 +94,11 @@ export interface TaxReport {
|
||||
grandTotalVat: number;
|
||||
grandTotal: number;
|
||||
cancelledCount: number;
|
||||
/** Cost side (#4). Present when the accounting tables exist; empty
|
||||
* otherwise. */
|
||||
costs: TaxReportCosts;
|
||||
/** Income/cost/result summary (#4). */
|
||||
summary: TaxReportSummary;
|
||||
currency: string;
|
||||
period: { from: string; to: string };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user