chore: remove obsolete Android app configuration files
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s

- Deleted build.gradle.kts, gradle.properties, and gradlew files as part of the cleanup process.
- Removed local.properties and various generated files from the .gradle directory to streamline the project structure.
- Cleared out unnecessary build artifacts and intermediate files to improve project maintainability.
This commit is contained in:
Torsten Schulz (local)
2026-04-21 15:15:21 +02:00
parent c8dedb10cc
commit 41bbf81958
4144 changed files with 4975 additions and 61401 deletions

View File

@@ -0,0 +1,694 @@
import fs from 'fs';
import path from 'path';
import { Op } from 'sequelize';
import { PDFDocument, StandardFonts } from 'pdf-lib';
import sequelize from '../database.js';
import BillingTemplate from '../models/BillingTemplate.js';
import BillingTemplateField from '../models/BillingTemplateField.js';
import BillingRun from '../models/BillingRun.js';
import BillingDocument from '../models/BillingDocument.js';
import BillingDocumentValue from '../models/BillingDocumentValue.js';
import BillingUserSetting from '../models/BillingUserSetting.js';
import DiaryDate from '../models/DiaryDates.js';
import { checkAccess, getUserByToken } from '../utils/userUtils.js';
const ensureNumber = (value, fallback = 0) => {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed)) return fallback;
return Number(parsed.toFixed(2));
};
const toMonthDate = (value, endOfMonth = false) => {
const monthMatch = String(value || '').match(/^(\d{4})-(\d{2})$/);
if (!monthMatch) return null;
const year = Number(monthMatch[1]);
const month = Number(monthMatch[2]);
if (!year || month < 1 || month > 12) return null;
if (!endOfMonth) return `${monthMatch[1]}-${monthMatch[2]}-01`;
const lastDay = new Date(year, month, 0).getDate();
return `${monthMatch[1]}-${monthMatch[2]}-${String(lastDay).padStart(2, '0')}`;
};
class BillingService {
_normalizeIban(raw, withoutCountry = false) {
const cleaned = String(raw || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
if (!cleaned) return '';
if (!withoutCountry) return cleaned;
return cleaned.replace(/^[A-Z]{2}/, '');
}
_normalizeFieldName(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
}
_setPdfFieldText(field, value) {
if (value == null) return false;
const text = String(value);
if (typeof field?.setText === 'function') {
field.setText(text);
return true;
}
if (typeof field?.select === 'function') {
field.select(text);
return true;
}
return false;
}
_setPdfFieldCheckbox(field, checked) {
if (typeof field?.check === 'function' && typeof field?.uncheck === 'function') {
if (checked) field.check();
else field.uncheck();
return true;
}
// Fallback for templates where "checkbox" is implemented as text field.
if (typeof field?.setText === 'function') {
field.setText(checked ? 'X' : '');
return true;
}
return false;
}
_setByCandidates(fieldEntries, candidates, value, type = 'text') {
const normalizedCandidates = (candidates || []).map((c) => this._normalizeFieldName(c));
for (const entry of fieldEntries) {
if (!normalizedCandidates.some((candidate) => entry.normalizedName.includes(candidate))) continue;
if (type === 'checkbox') {
if (this._setPdfFieldCheckbox(entry.field, Boolean(value))) return true;
} else if (this._setPdfFieldText(entry.field, value)) {
return true;
}
}
return false;
}
_setByExactName(fieldEntries, exactName, value, type = 'text') {
const wanted = this._normalizeFieldName(exactName);
const hit = (fieldEntries || []).find((entry) => entry.normalizedName === wanted);
if (!hit) return false;
if (type === 'checkbox') return this._setPdfFieldCheckbox(hit.field, Boolean(value));
return this._setPdfFieldText(hit.field, value);
}
_removeByCandidates(form, fieldEntries, candidates = []) {
const normalizedCandidates = (candidates || []).map((c) => this._normalizeFieldName(c));
let removed = 0;
for (const entry of fieldEntries || []) {
if (!normalizedCandidates.some((candidate) => entry.normalizedName.includes(candidate))) continue;
try {
form.removeField(entry.field);
removed += 1;
} catch (error) {
// Ignore removal issues for individual fields.
}
}
return removed;
}
_toGermanDate(dateValue) {
const raw = String(dateValue || '').trim();
const m = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return raw;
return `${m[3]}.${m[2]}.${m[1]}`;
}
_formatIbanGrouped(raw) {
const cleaned = String(raw || '').replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
if (!cleaned) return '';
return cleaned.replace(/(.{4})/g, '$1 ').trim();
}
_buildFormFieldDiagnostics(fieldEntries = []) {
const checks = [
{ key: 'self_recipient_name', candidates: ['self_recipient_name', 'trainer_name', 'name_des_uebungsleiters', 'uebungsleiter', 'name'] },
{ key: 'iban', candidates: ['iban', 'konto', 'kontonummer'] },
{ key: 'location_text', candidates: ['location_text', 'ort'] },
{ key: 'document_date', candidates: ['document_date', 'datum', 'date', 'abrechnungsdatum', 'datum_abrechnung', 'ortdatum'] },
{ key: 'hours_total', candidates: ['hours_total', 'gesamtstunden', 'summe_stunden', 'uestdn', 'u_stdn', 'dauer_summe', 'dauergesamt'] },
{ key: 'hours_total_2', candidates: ['hours_total_2', 'gesamtstunden2', 'summe_stunden2', 'dauer_abrechnung', 'dauerabrechnung'] },
{ key: 'hourly_rate', candidates: ['hourly_rate', 'stunden_gehalt', 'stundensatz', 'betrag_pro_stunde', 'gehalt_stunden'] },
{ key: 'amount_total', candidates: ['amount_total', 'gesamtbetrag', 'summe_eur', 'betrag_gesamt', 'gehalt_gesamt'] },
{ key: 'same_account_checkbox', candidates: ['same_account_checkbox', 'same_account', 'gleicheskonto', 'gleiches_konto'] }
];
const found = [];
const missing = [];
for (const check of checks) {
const hasMatch = (fieldEntries || []).some((entry) => {
return check.candidates.some((candidate) => entry.normalizedName.includes(this._normalizeFieldName(candidate)));
});
if (hasMatch) found.push(check.key);
else missing.push(check.key);
}
const availableFieldNames = (fieldEntries || []).map((entry) => entry.name);
return { found, missing, availableFieldNames };
}
_fillSessionRows(fieldEntries, sessions) {
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 dateCandidates = [
`date_${rowNo}`, `datum_${rowNo}`, `datum${rowNo}`, `lesson_date_${rowNo}`, `training_date_${rowNo}`, `zeile${rowNo}datum`
];
const labelCandidates = [
`label_${rowNo}`, `bezeichner_${rowNo}`, `bezeichnung${rowNo}`, `bezeichung${rowNo}`, `zeit_${rowNo}`, `lesson_label_${rowNo}`, `training_label_${rowNo}`, `zeile${rowNo}zeit`
];
const hoursCandidates = [
`hours_${rowNo}`, `stunden_${rowNo}`, `dauer_${rowNo}`, `dauer${rowNo}`, `lesson_hours_${rowNo}`, `training_hours_${rowNo}`, `zeile${rowNo}stunden`
];
const rowDate = this._toGermanDate(row.date || '');
this._setByCandidates(fieldEntries, dateCandidates, rowDate);
this._setByCandidates(fieldEntries, labelCandidates, label);
this._setByCandidates(fieldEntries, hoursCandidates, hours);
}
}
async _renderBillingPdfFromTemplate(templatePath, outputPath, run, computedSessions, hoursTotal, amountTotal) {
const templateBytes = fs.readFileSync(templatePath);
const pdfDoc = await PDFDocument.load(templateBytes);
const form = pdfDoc.getForm();
const fieldEntries = form.getFields().map((field) => {
const name = field.getName();
return { field, name, normalizedName: this._normalizeFieldName(name) };
});
const diagnostics = this._buildFormFieldDiagnostics(fieldEntries);
if (!fieldEntries.length) {
throw new Error('Vorlagen-PDF enthält keine Formularfelder (AcroForm). Fehlende Felder: self_recipient_name, iban, location_text, document_date, hours_total, hourly_rate, amount_total, same_account_checkbox.');
}
const nameValue = run.omitSelfRecipientName ? '' : (run.selfRecipientName || '');
const ibanRawValue = run.omitIban ? '' : this._normalizeIban(run.iban, run.ibanWithoutCountry);
const ibanValue = this._formatIbanGrouped(ibanRawValue);
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 amountText = `${amountNumericText} EUR`;
const hourlyRateText = Number(run.hourlyRate || 0).toFixed(2);
const hourlyRateTextComma = hourlyRateText.replace('.', ',');
const amountNumericTextComma = amountNumericText.replace('.', ',');
this._setByCandidates(fieldEntries, ['self_recipient_name', 'trainer_name', 'name_des_uebungsleiters', 'uebungsleiter', 'name'], nameValue);
this._setByCandidates(fieldEntries, ['iban', 'konto', 'kontonummer'], ibanValue);
if (!String(ibanValue || '').trim()) {
// If IBAN is omitted/empty, remove matching form fields so no border/frame remains visible.
this._removeByCandidates(form, fieldEntries, ['iban', 'konto', 'kontonummer']);
}
this._setByCandidates(fieldEntries, ['location_text', 'ort'], locationValue);
const dateCandidates = ['document_date', 'datum', 'date', 'abrechnungsdatum', 'datum_abrechnung', 'ortdatum'];
// Prefer exact field names first, so "datum" does not accidentally hit "datum1..14".
const dateSetExact = this._setByExactName(fieldEntries, 'datum', germanDateValue)
|| this._setByExactName(fieldEntries, 'document_date', germanDateValue)
|| this._setByExactName(fieldEntries, 'ortdatum', germanDateValue)
|| this._setByExactName(fieldEntries, 'datum_abrechnung', germanDateValue)
|| this._setByExactName(fieldEntries, 'abrechnungsdatum', germanDateValue);
if (!dateSetExact) {
this._setByCandidates(fieldEntries, dateCandidates, germanDateValue);
}
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)
|| this._setByExactName(fieldEntries, 'gehalt_stunden', hourlyRateText);
const amountExactSet = this._setByExactName(fieldEntries, 'gehalt_gesamt', amountNumericTextComma)
|| this._setByExactName(fieldEntries, 'gehalt_gesamt', amountNumericText);
if (!hourlyExactSet) {
this._setByCandidates(fieldEntries, ['hourly_rate', 'stunden_gehalt', 'stundensatz', 'betrag_pro_stunde', 'gehalt_stunden'], hourlyRateText);
}
const amountSet = amountExactSet || this._setByCandidates(fieldEntries, ['gehalt_gesamt'], amountNumericText);
if (!amountSet) {
this._setByCandidates(fieldEntries, ['amount_total', 'gesamtbetrag', 'summe_eur', 'betrag_gesamt'], amountText);
}
this._setByCandidates(fieldEntries, ['same_account_checkbox', 'same_account', 'gleicheskonto', 'gleiches_konto'], Boolean(run.sameAccountCheckbox), 'checkbox');
this._fillSessionRows(fieldEntries, computedSessions);
if (!diagnostics.found.length) {
const fieldPreview = diagnostics.availableFieldNames.slice(0, 30).join(', ');
throw new Error(`Keine erwarteten Formularfelder erkannt. Verfuegbare Feldnamen: ${fieldPreview || '(leer)'}. Fehlend: ${diagnostics.missing.join(', ')}.`);
}
// Ensure visible appearances for all filled fields in final PDF.
const appearanceFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
form.updateFieldAppearances(appearanceFont);
form.flatten();
const outBytes = await pdfDoc.save();
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, outBytes);
}
async _collectTrainingSessions(clubId, periodStart, periodEnd) {
const diaryDates = await DiaryDate.findAll({
where: {
clubId,
date: { [Op.between]: [periodStart, periodEnd] },
trainingStart: { [Op.ne]: null },
trainingEnd: { [Op.ne]: null }
},
attributes: ['date', 'trainingStart', 'trainingEnd'],
order: [['date', 'ASC'], ['trainingStart', 'ASC']]
});
const sessions = [];
let totalHours = 0;
for (const entry of diaryDates) {
const start = String(entry.trainingStart || '');
const end = String(entry.trainingEnd || '');
const [sh = 0, sm = 0] = start.split(':').map((v) => Number.parseInt(v, 10) || 0);
const [eh = 0, em = 0] = end.split(':').map((v) => Number.parseInt(v, 10) || 0);
const startMinutes = sh * 60 + sm;
const endMinutes = eh * 60 + em;
if (endMinutes <= startMinutes) continue;
const durationHours = Number((((endMinutes - startMinutes) / 60)).toFixed(2));
totalHours += durationHours;
sessions.push({
date: entry.date,
startTime: start.slice(0, 5),
endTime: end.slice(0, 5),
durationHours
});
}
return {
sessions,
totalHours: Number(totalHours.toFixed(2))
};
}
async getUserSettings(userToken, clubId) {
await checkAccess(userToken, clubId);
const user = await getUserByToken(userToken);
const settings = await BillingUserSetting.findOne({ where: { clubId, userId: user.id } });
return {
status: 200,
response: {
success: true,
settings: {
lastHourlyRate: settings ? ensureNumber(settings.lastHourlyRate, 0) : null,
lastSelfRecipientName: settings?.lastSelfRecipientName || null,
lastLocationText: settings?.lastLocationText || null
}
}
};
}
async calculateHoursPreview(userToken, clubId, monthFrom, monthTo) {
await checkAccess(userToken, clubId);
const periodStart = toMonthDate(monthFrom, false);
const periodEnd = toMonthDate(monthTo, true);
if (!periodStart || !periodEnd) {
return { status: 400, response: { success: false, error: 'Monat oder Monatsbereich ungültig' } };
}
const preview = await this._collectTrainingSessions(clubId, periodStart, periodEnd);
return {
status: 200,
response: {
success: true,
computedHoursTotal: preview.totalHours,
sessions: preview.sessions
}
};
}
async listTemplates(userToken, clubId) {
await checkAccess(userToken, clubId);
const templates = await BillingTemplate.findAll({
where: { clubId },
include: [{ model: BillingTemplateField, as: 'fields', required: false }],
order: [['updatedAt', 'DESC'], [{ model: BillingTemplateField, as: 'fields' }, 'sortOrder', 'ASC']]
});
return { status: 200, response: { success: true, templates } };
}
async createTemplate(userToken, clubId, payload, file) {
await checkAccess(userToken, clubId);
if (!file) {
return { status: 400, response: { success: false, error: 'PDF-Datei fehlt' } };
}
const name = String(payload?.name || '').trim();
if (!name) {
return { status: 400, response: { success: false, error: 'Vorlagenname fehlt' } };
}
const user = await getUserByToken(userToken);
const existing = await BillingTemplate.count({ where: { clubId, name } });
const template = await BillingTemplate.create({
clubId,
name,
description: payload?.description ? String(payload.description).trim() : null,
pdfStoragePath: file.path,
pdfFilename: file.originalname,
pdfMimeType: file.mimetype || 'application/pdf',
isActive: true,
version: existing + 1,
createdByUserId: user.id
});
return { status: 201, response: { success: true, template } };
}
async deleteTemplate(userToken, templateId) {
const template = await BillingTemplate.findByPk(templateId);
if (!template) {
return { status: 404, response: { success: false, error: 'Vorlage nicht gefunden' } };
}
await checkAccess(userToken, template.clubId);
const runCount = await BillingRun.count({ where: { templateId: template.id } });
if (runCount > 0) {
return {
status: 409,
response: {
success: false,
error: 'Vorlage kann nicht gelöscht werden, da bereits Abrechnungsläufe darauf basieren.'
}
};
}
const transaction = await sequelize.transaction();
try {
await BillingTemplateField.destroy({ where: { templateId: template.id }, transaction });
await BillingTemplate.destroy({ where: { id: template.id }, transaction });
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
if (template.pdfStoragePath && fs.existsSync(template.pdfStoragePath)) {
try {
fs.unlinkSync(template.pdfStoragePath);
} catch (error) {
// Ignore file cleanup errors, DB deletion already succeeded.
}
}
return { status: 200, response: { success: true } };
}
async saveTemplateFields(userToken, templateId, fields = []) {
const template = await BillingTemplate.findByPk(templateId);
if (!template) {
return { status: 404, response: { success: false, error: 'Vorlage nicht gefunden' } };
}
await checkAccess(userToken, template.clubId);
if (!Array.isArray(fields)) {
return { status: 400, response: { success: false, error: 'Feldliste ungültig' } };
}
const transaction = await sequelize.transaction();
try {
await BillingTemplateField.destroy({ where: { templateId }, transaction });
for (let i = 0; i < fields.length; i += 1) {
const field = fields[i] || {};
if (!field.fieldKey || !field.label || !field.fieldType) continue;
await BillingTemplateField.create({
templateId,
fieldKey: String(field.fieldKey).trim(),
label: String(field.label).trim(),
fieldType: field.fieldType,
sourceType: field.sourceType || 'manual',
sourcePath: field.sourcePath || null,
constantValue: field.constantValue || null,
formatter: field.formatter || 'none',
isRequired: Boolean(field.isRequired),
mappingMode: field.mappingMode || 'overlay',
acroformFieldName: field.acroformFieldName || null,
pageNumber: field.pageNumber ?? null,
x: field.x ?? null,
y: field.y ?? null,
width: field.width ?? null,
height: field.height ?? null,
fontSize: field.fontSize ?? null,
align: field.align ?? null,
formulaExpression: field.formulaExpression || null,
tableGroup: field.tableGroup || null,
rowIndex: field.rowIndex ?? null,
sortOrder: field.sortOrder ?? i
}, { transaction });
}
await transaction.commit();
const refreshed = await BillingTemplate.findByPk(templateId, {
include: [{ model: BillingTemplateField, as: 'fields', required: false }]
});
return { status: 200, response: { success: true, template: refreshed } };
} catch (error) {
await transaction.rollback();
throw error;
}
}
async createRun(userToken, clubId, payload) {
await checkAccess(userToken, clubId);
const user = await getUserByToken(userToken);
const templateId = Number.parseInt(payload?.templateId, 10);
const template = Number.isInteger(templateId) ? await BillingTemplate.findByPk(templateId) : null;
if (!template || String(template.clubId) !== String(clubId)) {
return { status: 400, response: { success: false, error: 'Vorlage ungültig' } };
}
const periodStart = toMonthDate(payload?.monthFrom, false);
const periodEnd = toMonthDate(payload?.monthTo, true);
if (!periodStart || !periodEnd) {
return { status: 400, response: { success: false, error: 'Monat oder Monatsbereich ungültig' } };
}
const hourlyRate = ensureNumber(payload?.hourlyRate, -1);
if (hourlyRate < 0) {
return { status: 400, response: { success: false, error: 'Stunden-Gehalt fehlt oder ist ungültig' } };
}
const computed = await this._collectTrainingSessions(clubId, periodStart, periodEnd);
const computedHoursTotal = computed.totalHours;
const run = await BillingRun.create({
clubId,
templateId,
name: payload?.name ? String(payload.name).trim() : `Abrechnung ${payload?.monthFrom || ''}`,
periodStart,
periodEnd,
selfRecipientUserId: user.id,
selfRecipientName: payload?.selfRecipientName ? String(payload.selfRecipientName).trim() : user.email,
hourlyRate,
computedHoursTotal,
sessionLabel: payload?.sessionLabel ? String(payload.sessionLabel).trim() : null,
iban: payload?.iban ? String(payload.iban).trim() : null,
ibanWithoutCountry: Boolean(payload?.ibanWithoutCountry),
sameAccountCheckbox: Boolean(payload?.sameAccountCheckbox),
omitSelfRecipientName: Boolean(payload?.omitSelfRecipientName),
omitIban: Boolean(payload?.omitIban),
omitLocationText: Boolean(payload?.omitLocationText),
omitDocumentDate: Boolean(payload?.omitDocumentDate),
omitSessionLabel: Boolean(payload?.omitSessionLabel),
locationText: payload?.locationText ? String(payload.locationText).trim() : null,
documentDate: payload?.documentDate || null,
status: 'draft',
createdByUserId: user.id
});
await BillingUserSetting.upsert({
clubId,
userId: user.id,
lastHourlyRate: hourlyRate,
lastSelfRecipientName: payload?.selfRecipientName ? String(payload.selfRecipientName).trim() : user.email,
lastLocationText: payload?.locationText ? String(payload.locationText).trim() : null
});
return { status: 201, response: { success: true, run } };
}
async listRuns(userToken, clubId) {
await checkAccess(userToken, clubId);
const runs = await BillingRun.findAll({
where: { clubId },
include: [{ model: BillingDocument, as: 'documents', required: false }],
order: [['createdAt', 'DESC']]
});
return { status: 200, response: { success: true, runs } };
}
async getRunDetails(userToken, runId) {
const run = await BillingRun.findByPk(runId, {
include: [{
model: BillingDocument,
as: 'documents',
required: false,
include: [{ model: BillingDocumentValue, as: 'values', required: false }]
}]
});
if (!run) {
return { status: 404, response: { success: false, error: 'Abrechnungslauf nicht gefunden' } };
}
await checkAccess(userToken, run.clubId);
return { status: 200, response: { success: true, run } };
}
async deleteRun(userToken, runId) {
const run = await BillingRun.findByPk(runId);
if (!run) {
return { status: 404, response: { success: false, error: 'Abrechnungslauf nicht gefunden' } };
}
await checkAccess(userToken, run.clubId);
const transaction = await sequelize.transaction();
try {
const docs = await BillingDocument.findAll({
where: { runId: run.id },
attributes: ['id'],
transaction
});
const docIds = docs.map((d) => d.id);
if (docIds.length) {
await BillingDocumentValue.destroy({
where: { billingDocumentId: { [Op.in]: docIds } },
transaction
});
}
await BillingDocument.destroy({ where: { runId: run.id }, transaction });
await run.destroy({ transaction });
await transaction.commit();
return { status: 200, response: { success: true } };
} catch (error) {
await transaction.rollback();
throw error;
}
}
async generateRun(userToken, runId, payload = {}) {
const run = await BillingRun.findByPk(runId, {
include: [{ model: BillingTemplate, as: 'template', include: [{ model: BillingTemplateField, as: 'fields', required: false }] }]
});
if (!run) {
return { status: 404, response: { success: false, error: 'Abrechnungslauf nicht gefunden' } };
}
await checkAccess(userToken, run.clubId);
if (!run.template?.pdfStoragePath || !fs.existsSync(run.template.pdfStoragePath)) {
return { status: 404, response: { success: false, error: 'Vorlagen-PDF nicht gefunden' } };
}
const computed = await this._collectTrainingSessions(run.clubId, run.periodStart, run.periodEnd);
const sessionLabel = run.omitSessionLabel ? '' : String(run.sessionLabel || '');
const sessionsWithLabel = computed.sessions.map((session) => ({
...session,
label: sessionLabel
}));
const hoursTotal = ensureNumber(computed.totalHours, 0);
const amountTotal = Number((hoursTotal * ensureNumber(run.hourlyRate, 0)).toFixed(2));
const generatedFilename = `${(run.name || 'abrechnung').replace(/[^\w.-]+/g, '_')}-${run.id}.pdf`;
const generatedPath = path.resolve('uploads/billing-generated', generatedFilename);
await this._renderBillingPdfFromTemplate(
run.template.pdfStoragePath,
generatedPath,
run,
sessionsWithLabel,
hoursTotal,
amountTotal
);
const transaction = await sequelize.transaction();
try {
const existingDoc = await BillingDocument.findOne({ where: { runId: run.id }, transaction });
let document;
if (existingDoc) {
existingDoc.displayName = run.selfRecipientName;
existingDoc.status = 'generated';
existingDoc.pdfFilename = generatedFilename;
existingDoc.pdfStoragePath = generatedPath;
await existingDoc.save({ transaction });
await BillingDocumentValue.destroy({ where: { billingDocumentId: existingDoc.id }, transaction });
document = existingDoc;
} else {
document = await BillingDocument.create({
runId: run.id,
displayName: run.selfRecipientName,
status: 'generated',
pdfFilename: generatedFilename,
pdfStoragePath: generatedPath
}, { transaction });
}
const values = [
{ fieldKey: 'self_recipient_name', resolvedValue: run.selfRecipientName, resolvedSource: 'billing_run.self_recipient_name' },
{ fieldKey: 'hourly_rate', resolvedValue: String(run.hourlyRate), resolvedSource: 'billing_run.hourly_rate' },
{ fieldKey: 'hours_total', resolvedValue: String(hoursTotal), resolvedSource: 'diary_dates.sum(duration)' },
{ fieldKey: 'hours_total_2', resolvedValue: String(hoursTotal), resolvedSource: 'diary_dates.sum(duration)' },
{ fieldKey: 'iban', resolvedValue: this._normalizeIban(run.iban, run.ibanWithoutCountry), resolvedSource: 'billing_run.iban' },
{ fieldKey: 'amount_total', resolvedValue: String(amountTotal), resolvedSource: 'calculation:hours_total*hourly_rate' },
{ fieldKey: 'lessons_rows', resolvedValue: JSON.stringify(sessionsWithLabel), resolvedSource: 'diary_dates(trainingStart/trainingEnd)' },
{ fieldKey: 'lessons_count', resolvedValue: String(computed.sessions.length), resolvedSource: 'diary_dates.count' },
{ fieldKey: 'same_account_checkbox', resolvedValue: run.sameAccountCheckbox ? 'X' : '', resolvedSource: 'billing_run.same_account_checkbox' },
{ fieldKey: 'session_label', resolvedValue: sessionLabel, resolvedSource: 'billing_run.session_label' },
{ fieldKey: 'location_text', resolvedValue: run.locationText || '', resolvedSource: 'billing_run.location_text' },
{ fieldKey: 'document_date', resolvedValue: run.documentDate || '', resolvedSource: 'billing_run.document_date' }
];
for (const entry of values) {
await BillingDocumentValue.create({
billingDocumentId: document.id,
fieldKey: entry.fieldKey,
resolvedValue: entry.resolvedValue,
resolvedSource: entry.resolvedSource
}, { transaction });
}
run.status = 'generated';
run.computedHoursTotal = hoursTotal;
await run.save({ transaction });
await transaction.commit();
const generated = await BillingDocument.findByPk(document.id, {
include: [{ model: BillingDocumentValue, as: 'values', required: false }]
});
return { status: 200, response: { success: true, document: generated } };
} catch (error) {
await transaction.rollback();
throw error;
}
}
async downloadTemplatePdf(userToken, templateId) {
const template = await BillingTemplate.findByPk(templateId);
if (!template) return { status: 404, response: { success: false, error: 'Vorlage nicht gefunden' } };
await checkAccess(userToken, template.clubId);
if (!template.pdfStoragePath || !fs.existsSync(template.pdfStoragePath)) {
return { status: 404, response: { success: false, error: 'Datei nicht gefunden' } };
}
return {
status: 200,
file: {
path: path.resolve(template.pdfStoragePath),
name: template.pdfFilename,
mimeType: template.pdfMimeType || 'application/pdf'
}
};
}
async downloadGeneratedRunPdf(userToken, runId) {
const run = await BillingRun.findByPk(runId, {
include: [{ model: BillingDocument, as: 'documents', required: false }]
});
if (!run) return { status: 404, response: { success: false, error: 'Abrechnungslauf nicht gefunden' } };
await checkAccess(userToken, run.clubId);
const docs = Array.isArray(run.documents) ? run.documents : [];
const latestDoc = docs.slice().sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))[0];
if (!latestDoc?.pdfStoragePath || !fs.existsSync(latestDoc.pdfStoragePath)) {
return { status: 404, response: { success: false, error: 'Erzeugte PDF-Datei nicht gefunden' } };
}
return {
status: 200,
file: {
path: path.resolve(latestDoc.pdfStoragePath),
name: latestDoc.pdfFilename || `abrechnung-${run.id}.pdf`,
mimeType: 'application/pdf'
}
};
}
}
export default new BillingService();