chore: remove obsolete Android app configuration files
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
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:
694
backend/services/billingService.js
Normal file
694
backend/services/billingService.js
Normal 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();
|
||||
Reference in New Issue
Block a user