diff --git a/backend/src/routes/adminTaxReport.js b/backend/src/routes/adminTaxReport.js index dff08597..a78f1542 100644 --- a/backend/src/routes/adminTaxReport.js +++ b/backend/src/routes/adminTaxReport.js @@ -65,11 +65,15 @@ const QUERY_VALIDATORS = [ ]; function parseParams(req) { + // scope (export only): all | income | cost. Anything else → 'all'. + const rawScope = String(req.query.scope || 'all'); + const scope = ['all', 'income', 'cost'].includes(rawScope) ? rawScope : 'all'; return { from: req.query.from, to: req.query.to, currency: String(req.query.currency || '').toUpperCase(), locale: req.query.locale || undefined, + scope, }; } @@ -94,7 +98,8 @@ router.get( validateRequest(req); const params = parseParams(req); const buffer = await taxReportService.renderTaxReportPdf(params); - const filename = `tax_report_${params.from}_to_${params.to}_${params.currency}.pdf`; + const scopeTag = params.scope && params.scope !== 'all' ? `${params.scope}_` : ''; + const filename = `tax_report_${scopeTag}${params.from}_to_${params.to}_${params.currency}.pdf`; res.set('Content-Type', 'application/pdf'); res.set('Content-Disposition', `attachment; filename="${filename}"`); res.set('Content-Length', String(buffer.length)); diff --git a/backend/src/services/taxReportService.js b/backend/src/services/taxReportService.js index 31ebda84..63a5c384 100644 --- a/backend/src/services/taxReportService.js +++ b/backend/src/services/taxReportService.js @@ -735,8 +735,29 @@ function rowCellValues(row, idx, locale, dateFormat, currency) { * Currency is required and used to scope the data (same contract as * getTaxReport). Locale defaults to the business profile's default. */ -async function renderTaxReportPdf({ from, to, currency, locale } = {}) { +// Export scope (PR/Liechtenstein follow-up): the readable PDF/CSV exports can be +// limited to the income or the cost side — handy when the Treuhänder only needs +// one basis (e.g. income for the 20%-Gewinnungskosten flat deduction). The +// on-screen report is unaffected; only the exports filter. +const TAX_EXPORT_SCOPES = ['all', 'income', 'cost']; +function normalizeScope(scope) { + return TAX_EXPORT_SCOPES.includes(scope) ? scope : 'all'; +} +function scopeLedger(ledger, scope) { + if (scope === 'income') return (ledger || []).filter((r) => r.type === 'outgoing'); + if (scope === 'cost') return (ledger || []).filter((r) => r.type === 'incoming' || r.type === 'expense'); + return ledger || []; +} + +async function renderTaxReportPdf({ from, to, currency, locale, scope } = {}) { const report = await getTaxReport({ from, to, currency }); + const xScope = normalizeScope(scope); + report.ledger = scopeLedger(report.ledger, xScope); + // The per-rate breakdown is income-only — drop it from a cost-only export. + if (xScope === 'cost') report.totalsByVatRate = []; + const showIncome = xScope !== 'cost'; + const showCosts = xScope !== 'income'; + const showResult = xScope === 'all'; const renderCtx = await loadRenderContext(locale); const useLocale = renderCtx.locale; const intlLocale = useLocale === 'de' ? 'de-CH' : 'en-GB'; @@ -946,9 +967,9 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) { doc.text(formatMinor(grossMinor, report.currency, intlLocale), totalsX + 270, ty, { width: 90, align: 'right' }); ty += 13; }; - 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); + if (showIncome) summaryLine('tax_summary_income', s.incomeNetMinor, s.incomeVatMinor, s.incomeGrossMinor, !showResult); + if (showCosts) summaryLine('tax_summary_costs', -Math.abs(s.costNetMinor), -Math.abs(s.costVatMinor), -Math.abs(s.costGrossMinor), !showResult); + if (showResult) summaryLine('tax_summary_result', s.resultNetMinor, s.vatPayableMinor, s.resultGrossMinor, true); // Cancelled footnote (bottom-left). Only when there are any. if (report.cancelledCount > 0) { @@ -1002,8 +1023,10 @@ async function renderTaxReportPdf({ from, to, currency, locale } = {}) { * renderTaxReportCsv({ from, to, currency, locale }) * → Promise<{ content, filename, contentType }> */ -async function renderTaxReportCsv({ from, to, currency, locale } = {}) { +async function renderTaxReportCsv({ from, to, currency, locale, scope } = {}) { const report = await getTaxReport({ from, to, currency }); + const xScope = normalizeScope(scope); + report.ledger = scopeLedger(report.ledger, xScope); const useLocale = locale || 'en'; const escape = (cell) => { @@ -1082,13 +1105,14 @@ async function renderTaxReportCsv({ from, to, currency, locale } = {}) { '', '', '', t(useLocale, labelKey), '', '', minorToDotDecimal(net), minorToDotDecimal(vat), minorToDotDecimal(gross), ].map(escape).join(',')); - sline('tax_summary_income', summary.incomeNetMinor, summary.incomeVatMinor, summary.incomeGrossMinor); - sline('tax_summary_costs', summary.costNetMinor, summary.costVatMinor, summary.costGrossMinor); - sline('tax_summary_result', summary.resultNetMinor, summary.vatPayableMinor, summary.resultGrossMinor); + if (xScope !== 'cost') sline('tax_summary_income', summary.incomeNetMinor, summary.incomeVatMinor, summary.incomeGrossMinor); + if (xScope !== 'income') sline('tax_summary_costs', summary.costNetMinor, summary.costVatMinor, summary.costGrossMinor); + if (xScope === 'all') sline('tax_summary_result', summary.resultNetMinor, summary.vatPayableMinor, summary.resultGrossMinor); } const content = lines.join('\r\n') + '\r\n'; - const filename = `tax_report_${report.period.from}_to_${report.period.to}_${report.currency}.csv`; + const scopeTag = xScope === 'all' ? '' : `${xScope}_`; + const filename = `tax_report_${scopeTag}${report.period.from}_to_${report.period.to}_${report.currency}.csv`; return { content, filename, contentType: 'text/csv; charset=utf-8' }; } diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index f3f90e36..65aa2ff9 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3953,6 +3953,10 @@ "export": { "reportTitle": "Bericht", "reportHint": "Lesbare Liste — für Ihre Unterlagen.", + "scopeLabel": "Export-Umfang", + "scopeAll": "Vollständig", + "scopeIncome": "Nur Einnahmen", + "scopeCost": "Nur Kosten", "journalTitle": "Buchungsjournal" }, "exportCsv": "CSV exportieren", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 86281af7..e09e37d7 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3953,6 +3953,10 @@ "export": { "reportTitle": "Report", "reportHint": "Readable list — for your own records.", + "scopeLabel": "Export scope", + "scopeAll": "Complete", + "scopeIncome": "Income only", + "scopeCost": "Cost only", "journalTitle": "Accounting journal" }, "exportCsv": "Export CSV", diff --git a/frontend/src/pages/admin/clients/TaxReportPage.tsx b/frontend/src/pages/admin/clients/TaxReportPage.tsx index d74aca29..3f95448f 100644 --- a/frontend/src/pages/admin/clients/TaxReportPage.tsx +++ b/frontend/src/pages/admin/clients/TaxReportPage.tsx @@ -102,6 +102,8 @@ export const TaxReportPage: React.FC = () => { const [to, setTo] = useState(initialPeriod.to); const [currency, setCurrency] = useState('CHF'); const [isExporting, setIsExporting] = useState<'pdf' | 'csv' | 'ledger' | null>(null); + // Export-only scope (income/cost split). The on-screen report stays complete. + const [exportScope, setExportScope] = useState<'all' | 'income' | 'cost'>('all'); // Treuhänder (collective-journal) export — same period/currency as the // report; target tool picks the import format (generic / Banana / bexio). const [ledgerFormat, setLedgerFormat] = useState('generic'); @@ -132,9 +134,10 @@ export const TaxReportPage: React.FC = () => { const handleExport = async (format: 'pdf' | 'csv') => { setIsExporting(format); try { + const exportParams = { ...params, scope: exportScope }; const { url, filename } = format === 'pdf' - ? await taxReportService.downloadPdfUrl(params) - : await taxReportService.downloadCsvUrl(params); + ? await taxReportService.downloadPdfUrl(exportParams) + : await taxReportService.downloadCsvUrl(exportParams); triggerBrowserDownload(url, filename); } catch (err) { toast.error(t('taxReport.exportFailed', 'Export failed. Please try again.')); @@ -313,6 +316,17 @@ export const TaxReportPage: React.FC = () => {
+