diff --git a/backend/services/billingService.js b/backend/services/billingService.js index c39523b9..4d233cde 100644 --- a/backend/services/billingService.js +++ b/backend/services/billingService.js @@ -30,6 +30,31 @@ const toMonthDate = (value, endOfMonth = false) => { }; class BillingService { + _normalizeLocale(locale) { + const fallbackLocale = 'de-DE'; + const raw = String(locale || '').trim(); + if (!raw) return fallbackLocale; + const normalized = raw.replace('_', '-'); + try { + const [resolved] = Intl.NumberFormat.supportedLocalesOf([normalized]); + if (resolved) return resolved; + const baseLanguage = normalized.split('-')[0]; + const [resolvedBase] = Intl.NumberFormat.supportedLocalesOf([baseLanguage]); + return resolvedBase || fallbackLocale; + } catch (error) { + return fallbackLocale; + } + } + + _formatNumberForLocale(value, locale, digits = 2) { + const numberValue = ensureNumber(value, 0); + return new Intl.NumberFormat(this._normalizeLocale(locale), { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + useGrouping: false + }).format(numberValue); + } + _resolveExistingFilePath(storedPath) { const rawPath = String(storedPath || '').trim(); if (!rawPath) return null; @@ -189,14 +214,14 @@ class BillingService { return { found, missing, availableFieldNames }; } - _fillSessionRows(fieldEntries, sessions) { + _fillSessionRows(fieldEntries, sessions, locale = 'de-DE') { if (!Array.isArray(sessions) || !sessions.length) return; const rows = sessions.slice(0, 40); for (let idx = 0; idx < rows.length; idx += 1) { const rowNo = idx + 1; const row = rows[idx]; const label = row.label || `${row.startTime || ''}-${row.endTime || ''}`.trim(); - const hours = Number(row.durationHours || 0).toFixed(2); + const hours = this._formatNumberForLocale(row.durationHours || 0, locale, 2); const dateCandidates = [ `date_${rowNo}`, `datum_${rowNo}`, `datum${rowNo}`, `lesson_date_${rowNo}`, `training_date_${rowNo}`, `zeile${rowNo}datum` @@ -215,7 +240,7 @@ class BillingService { } } - async _renderBillingPdfFromTemplate(templatePath, outputPath, run, computedSessions, hoursTotal, amountTotal) { + async _renderBillingPdfFromTemplate(templatePath, outputPath, run, computedSessions, hoursTotal, amountTotal, locale = 'de-DE') { const templateBytes = fs.readFileSync(templatePath); const pdfDoc = await PDFDocument.load(templateBytes); const form = pdfDoc.getForm(); @@ -234,12 +259,10 @@ class BillingService { const locationValue = run.omitLocationText ? '' : (run.locationText || ''); const dateValue = run.omitDocumentDate ? '' : (run.documentDate || ''); const germanDateValue = this._toGermanDate(dateValue); - const hoursText = Number(hoursTotal || 0).toFixed(2); - const amountNumericText = Number(amountTotal || 0).toFixed(2); + const hoursText = this._formatNumberForLocale(hoursTotal || 0, locale, 2); + const amountNumericText = this._formatNumberForLocale(amountTotal || 0, locale, 2); const amountText = `${amountNumericText} EUR`; - const hourlyRateText = Number(run.hourlyRate || 0).toFixed(2); - const hourlyRateTextComma = hourlyRateText.replace('.', ','); - const amountNumericTextComma = amountNumericText.replace('.', ','); + const hourlyRateText = this._formatNumberForLocale(run.hourlyRate || 0, locale, 2); this._setByCandidates(fieldEntries, ['self_recipient_name', 'trainer_name', 'name_des_uebungsleiters', 'uebungsleiter', 'name'], nameValue); this._setByCandidates(fieldEntries, ['iban', 'konto', 'kontonummer'], ibanValue); @@ -261,9 +284,9 @@ class BillingService { this._setByCandidates(fieldEntries, ['hours_total', 'gesamtstunden', 'summe_stunden', 'uestdn', 'u_stdn', 'dauer_summe', 'dauergesamt'], hoursText); this._setByCandidates(fieldEntries, ['hours_total_2', 'gesamtstunden2', 'summe_stunden2', 'dauer_abrechnung', 'dauerabrechnung'], hoursText); // First: explicit exact matches for this TSV Bonames form. - const hourlyExactSet = this._setByExactName(fieldEntries, 'gehalt_stunden', hourlyRateTextComma) + const hourlyExactSet = this._setByExactName(fieldEntries, 'gehalt_stunden', hourlyRateText) || this._setByExactName(fieldEntries, 'gehalt_stunden', hourlyRateText); - const amountExactSet = this._setByExactName(fieldEntries, 'gehalt_gesamt', amountNumericTextComma) + const amountExactSet = this._setByExactName(fieldEntries, 'gehalt_gesamt', amountNumericText) || this._setByExactName(fieldEntries, 'gehalt_gesamt', amountNumericText); if (!hourlyExactSet) { @@ -275,7 +298,7 @@ class BillingService { } this._setByCandidates(fieldEntries, ['same_account_checkbox', 'same_account', 'gleicheskonto', 'gleiches_konto'], Boolean(run.sameAccountCheckbox), 'checkbox'); - this._fillSessionRows(fieldEntries, computedSessions); + this._fillSessionRows(fieldEntries, computedSessions, locale); if (!diagnostics.found.length) { const fieldPreview = diagnostics.availableFieldNames.slice(0, 30).join(', '); @@ -632,6 +655,7 @@ class BillingService { }); return { status: 404, response: { success: false, error: 'Vorlagen-PDF nicht gefunden' } }; } + const numberLocale = this._normalizeLocale(payload?.locale || payload?.language || payload?.lang); const computed = await this._collectTrainingSessions(run.clubId, run.periodStart, run.periodEnd); const sessionLabel = run.omitSessionLabel ? '' : String(run.sessionLabel || ''); const sessionsWithLabel = computed.sessions.map((session) => ({ @@ -649,7 +673,8 @@ class BillingService { run, sessionsWithLabel, hoursTotal, - amountTotal + amountTotal, + numberLocale ); const transaction = await sequelize.transaction(); diff --git a/frontend/src/views/BillingView.vue b/frontend/src/views/BillingView.vue index f67befb7..e2255487 100644 --- a/frontend/src/views/BillingView.vue +++ b/frontend/src/views/BillingView.vue @@ -1041,7 +1041,8 @@ export default { }); const runId = createResponse.data?.run?.id; if (runId) { - const generateResponse = await apiClient.post(`/billing/runs/${runId}/generate`, {}); + const locale = this.$i18n?.locale || 'de-DE'; + const generateResponse = await apiClient.post(`/billing/runs/${runId}/generate`, { locale }); if (!generateResponse.data?.success) { throw new Error(generateResponse.data?.error || 'PDF-Erzeugung fehlgeschlagen'); } @@ -1061,7 +1062,8 @@ export default { this.generatingRunIds.push(runId); this.feedback = { message: '', type: 'info' }; try { - const response = await apiClient.post(`/billing/runs/${runId}/generate`, {}); + const locale = this.$i18n?.locale || 'de-DE'; + const response = await apiClient.post(`/billing/runs/${runId}/generate`, { locale }); if (!response.data?.success) { throw new Error(response.data?.error || 'PDF-Erzeugung fehlgeschlagen'); } @@ -1078,7 +1080,8 @@ export default { this.generatingRunIds.push(runId); this.feedback = { message: '', type: 'info' }; try { - const response = await apiClient.post(`/billing/runs/${runId}/generate`, {}); + const locale = this.$i18n?.locale || 'de-DE'; + const response = await apiClient.post(`/billing/runs/${runId}/generate`, { locale }); if (!response.data?.success) { throw new Error(response.data?.error || 'PDF-Erzeugung fehlgeschlagen'); }