feat(accounting): scope the tax-report export to income-only or cost-only
Adds a Complete / Income only / Cost only selector to the readable PDF + CSV export (the on-screen report stays complete). Income-only emits just the outgoing rows + the income summary line (+ the per-rate breakdown in the PDF); cost-only emits the incoming-invoice + expense rows + the cost line and drops the income-by-rate breakdown. Useful in Liechtenstein where, under the income threshold, a flat 20% Gewinnungskosten deduction is sometimes better than actual costs — handing the Treuhänder just the income (or just the cost) basis is cleaner. Backend: renderTaxReportPdf/Csv take a `scope` param (all|income|cost) that filters report.ledger by row.type + the summary lines; the /pdf + /csv routes accept & validate `?scope=`; filenames get an income_/cost_ tag. Frontend: scope <select> beside the export buttons, threaded through buildQueryString. i18n en/de. The 20% calculation itself is intentionally NOT in-app (applied by the Treuhänder) per the scoping decision.
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -102,6 +102,8 @@ export const TaxReportPage: React.FC = () => {
|
||||
const [to, setTo] = useState(initialPeriod.to);
|
||||
const [currency, setCurrency] = useState<string>('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<ExportFormat>('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 = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={exportScope}
|
||||
onChange={(e) => setExportScope(e.target.value as 'all' | 'income' | 'cost')}
|
||||
disabled={exportsDisabled}
|
||||
aria-label={t('taxReport.export.scopeLabel', 'Export scope')}
|
||||
className={`${selectClassName} min-w-[150px]`}
|
||||
>
|
||||
<option value="all">{t('taxReport.export.scopeAll', 'Complete')}</option>
|
||||
<option value="income">{t('taxReport.export.scopeIncome', 'Income only')}</option>
|
||||
<option value="cost">{t('taxReport.export.scopeCost', 'Cost only')}</option>
|
||||
</select>
|
||||
<Button
|
||||
className="min-w-[150px]"
|
||||
variant="outline"
|
||||
|
||||
@@ -142,6 +142,9 @@ export interface TaxReportParams {
|
||||
to: string;
|
||||
currency: string;
|
||||
locale?: string;
|
||||
/** Export-only scope: 'all' (default) | 'income' | 'cost'. Ignored by the
|
||||
* on-screen report; the PDF/CSV exports filter the ledger + summary. */
|
||||
scope?: 'all' | 'income' | 'cost';
|
||||
}
|
||||
|
||||
function buildQueryString(params: TaxReportParams): string {
|
||||
@@ -151,6 +154,7 @@ function buildQueryString(params: TaxReportParams): string {
|
||||
currency: params.currency,
|
||||
});
|
||||
if (params.locale) usp.set('locale', params.locale);
|
||||
if (params.scope && params.scope !== 'all') usp.set('scope', params.scope);
|
||||
return usp.toString();
|
||||
}
|
||||
|
||||
@@ -172,7 +176,8 @@ export const taxReportService = {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = URL.createObjectURL(res.data);
|
||||
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`;
|
||||
return { url, filename };
|
||||
},
|
||||
|
||||
@@ -181,7 +186,8 @@ export const taxReportService = {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = URL.createObjectURL(res.data);
|
||||
const filename = `tax_report_${params.from}_to_${params.to}_${params.currency}.csv`;
|
||||
const scopeTag = params.scope && params.scope !== 'all' ? `${params.scope}_` : '';
|
||||
const filename = `tax_report_${scopeTag}${params.from}_to_${params.to}_${params.currency}.csv`;
|
||||
return { url, filename };
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user