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:
202
backend/controllers/billingController.js
Normal file
202
backend/controllers/billingController.js
Normal file
@@ -0,0 +1,202 @@
|
||||
import fs from 'fs';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import billingService from '../services/billingService.js';
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const dir = 'uploads/billing-templates';
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname) || '.pdf';
|
||||
cb(null, `billing-template-${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase();
|
||||
const isPdf = ext === '.pdf' || (file.mimetype || '').includes('pdf');
|
||||
if (!isPdf) {
|
||||
return cb(new Error('Nur PDF-Dateien sind erlaubt.'));
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
});
|
||||
|
||||
export const uploadBillingTemplateMiddleware = upload.single('templatePdf');
|
||||
|
||||
export const listTemplates = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const result = await billingService.listTemplates(userToken, clubId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[listTemplates] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Vorlagen konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createTemplate = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const result = await billingService.createTemplate(userToken, clubId, req.body || {}, req.file);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[createTemplate] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Vorlage konnte nicht gespeichert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteTemplate = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { templateId } = req.params;
|
||||
const result = await billingService.deleteTemplate(userToken, templateId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[deleteTemplate] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Vorlage konnte nicht gelöscht werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTemplateFields = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { templateId } = req.params;
|
||||
const result = await billingService.saveTemplateFields(userToken, templateId, req.body?.fields || []);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[updateTemplateFields] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Felder konnten nicht gespeichert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createRun = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const result = await billingService.createRun(userToken, clubId, req.body || {});
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[createRun] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Abrechnungslauf konnte nicht erstellt werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const listRuns = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const result = await billingService.listRuns(userToken, clubId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[listRuns] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Abrechnungsläufe konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getRunDetails = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { runId } = req.params;
|
||||
const result = await billingService.getRunDetails(userToken, runId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[getRunDetails] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Abrechnungslauf konnte nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteRun = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { runId } = req.params;
|
||||
const result = await billingService.deleteRun(userToken, runId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[deleteRun] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Abrechnung konnte nicht gelöscht werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserSettings = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const result = await billingService.getUserSettings(userToken, clubId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[getUserSettings] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Einstellungen konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const calculateHoursPreview = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const { monthFrom, monthTo } = req.query;
|
||||
const result = await billingService.calculateHoursPreview(userToken, clubId, monthFrom, monthTo);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[calculateHoursPreview] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Stunden konnten nicht berechnet werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const generateRun = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { runId } = req.params;
|
||||
const result = await billingService.generateRun(userToken, runId, req.body || {});
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[generateRun] Error:', error);
|
||||
const message = String(error?.message || '');
|
||||
if (message.includes('Formularfelder') || message.includes('Feldnamen') || message.includes('Fehlend:')) {
|
||||
return res.status(400).json({ success: false, error: message });
|
||||
}
|
||||
res.status(500).json({ success: false, error: 'Abrechnung konnte nicht erzeugt werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadTemplatePdf = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { templateId } = req.params;
|
||||
const result = await billingService.downloadTemplatePdf(userToken, templateId);
|
||||
if (result.status !== 200) {
|
||||
return res.status(result.status).json(result.response);
|
||||
}
|
||||
res.setHeader('Content-Disposition', `inline; filename="${result.file.name}"`);
|
||||
res.setHeader('Content-Type', result.file.mimeType);
|
||||
return res.sendFile(result.file.path);
|
||||
} catch (error) {
|
||||
console.error('[downloadTemplatePdf] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'PDF konnte nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadRunPdf = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { runId } = req.params;
|
||||
const result = await billingService.downloadGeneratedRunPdf(userToken, runId);
|
||||
if (result.status !== 200) {
|
||||
return res.status(result.status).json(result.response);
|
||||
}
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${result.file.name}"`);
|
||||
res.setHeader('Content-Type', result.file.mimeType);
|
||||
return res.sendFile(result.file.path);
|
||||
} catch (error) {
|
||||
console.error('[downloadRunPdf] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Abrechnungs-PDF konnte nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
122
backend/migrations/20260420_create_billing_tables.sql
Normal file
122
backend/migrations/20260420_create_billing_tables.sql
Normal file
@@ -0,0 +1,122 @@
|
||||
-- Abrechnungsmodul: Vorlagen, Feld-Mapping, Abrechnungslauf und erzeugte Dokumente
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `billing_template` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`club_id` INT NOT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`pdf_storage_path` VARCHAR(1000) NOT NULL,
|
||||
`pdf_filename` VARCHAR(255) NOT NULL,
|
||||
`pdf_mime_type` VARCHAR(100) NOT NULL DEFAULT 'application/pdf',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`version` INT NOT NULL DEFAULT 1,
|
||||
`created_by_user_id` INT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_billing_template_club` (`club_id`),
|
||||
UNIQUE KEY `uniq_billing_template_club_name_version` (`club_id`, `name`, `version`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `billing_template_field` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`template_id` INT NOT NULL,
|
||||
`field_key` VARCHAR(120) NOT NULL,
|
||||
`label` VARCHAR(255) NOT NULL,
|
||||
`field_type` ENUM('text', 'number', 'currency', 'date', 'checkbox', 'formula', 'table_row') NOT NULL,
|
||||
`source_type` ENUM('manual', 'member', 'trainer', 'club', 'system', 'constant', 'formula') NOT NULL DEFAULT 'manual',
|
||||
`source_path` VARCHAR(255) NULL,
|
||||
`constant_value` VARCHAR(500) NULL,
|
||||
`formatter` ENUM('none', 'iban_no_country', 'date_dd_mm_yyyy', 'currency_eur_2') NOT NULL DEFAULT 'none',
|
||||
`is_required` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`mapping_mode` ENUM('acroform', 'overlay') NOT NULL DEFAULT 'overlay',
|
||||
`acroform_field_name` VARCHAR(255) NULL,
|
||||
`page_number` INT NULL,
|
||||
`x` DECIMAL(10, 2) NULL,
|
||||
`y` DECIMAL(10, 2) NULL,
|
||||
`width` DECIMAL(10, 2) NULL,
|
||||
`height` DECIMAL(10, 2) NULL,
|
||||
`font_size` DECIMAL(5, 2) NULL,
|
||||
`align` ENUM('left', 'center', 'right') NULL,
|
||||
`formula_expression` VARCHAR(1000) NULL,
|
||||
`table_group` VARCHAR(100) NULL,
|
||||
`row_index` INT NULL,
|
||||
`sort_order` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_billing_template_field_template` (`template_id`),
|
||||
UNIQUE KEY `uniq_billing_template_field_key` (`template_id`, `field_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `billing_run` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`club_id` INT NOT NULL,
|
||||
`template_id` INT NOT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`period_start` DATE NOT NULL,
|
||||
`period_end` DATE NOT NULL,
|
||||
`self_recipient_user_id` INT NOT NULL,
|
||||
`self_recipient_name` VARCHAR(255) NOT NULL,
|
||||
`hourly_rate` DECIMAL(10, 2) NOT NULL,
|
||||
`computed_hours_total` DECIMAL(10, 2) NOT NULL DEFAULT 0,
|
||||
`iban` VARCHAR(64) NULL,
|
||||
`iban_without_country` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`session_label` VARCHAR(255) NULL,
|
||||
`same_account_checkbox` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`omit_self_recipient_name` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`omit_iban` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`omit_location_text` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`omit_document_date` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`omit_session_label` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`location_text` VARCHAR(255) NULL,
|
||||
`document_date` DATE NULL,
|
||||
`status` ENUM('draft', 'generated', 'finalized', 'cancelled') NOT NULL DEFAULT 'draft',
|
||||
`created_by_user_id` INT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_billing_run_club` (`club_id`),
|
||||
KEY `idx_billing_run_template` (`template_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `billing_user_setting` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`club_id` INT NOT NULL,
|
||||
`user_id` INT NOT NULL,
|
||||
`last_hourly_rate` DECIMAL(10, 2) NOT NULL DEFAULT 0,
|
||||
`last_self_recipient_name` VARCHAR(255) NULL,
|
||||
`last_location_text` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_billing_user_setting_club_user` (`club_id`, `user_id`),
|
||||
KEY `idx_billing_user_setting_club` (`club_id`),
|
||||
KEY `idx_billing_user_setting_user` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `billing_document` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`run_id` INT NOT NULL,
|
||||
`display_name` VARCHAR(255) NOT NULL,
|
||||
`status` ENUM('draft', 'generated', 'error') NOT NULL DEFAULT 'draft',
|
||||
`pdf_storage_path` VARCHAR(1000) NULL,
|
||||
`pdf_filename` VARCHAR(255) NULL,
|
||||
`error_message` TEXT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_billing_document_run` (`run_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `billing_document_value` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`billing_document_id` INT NOT NULL,
|
||||
`field_key` VARCHAR(120) NOT NULL,
|
||||
`resolved_value` TEXT NULL,
|
||||
`resolved_source` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_billing_doc_value_doc` (`billing_document_id`),
|
||||
UNIQUE KEY `uniq_billing_doc_value_field` (`billing_document_id`, `field_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
47
backend/models/BillingDocument.js
Normal file
47
backend/models/BillingDocument.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const BillingDocument = sequelize.define('BillingDocument', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
runId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'run_id'
|
||||
},
|
||||
displayName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'display_name'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('draft', 'generated', 'error'),
|
||||
allowNull: false,
|
||||
defaultValue: 'draft'
|
||||
},
|
||||
pdfStoragePath: {
|
||||
type: DataTypes.STRING(1000),
|
||||
allowNull: true,
|
||||
field: 'pdf_storage_path'
|
||||
},
|
||||
pdfFilename: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'pdf_filename'
|
||||
},
|
||||
errorMessage: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'error_message'
|
||||
}
|
||||
}, {
|
||||
tableName: 'billing_document',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default BillingDocument;
|
||||
37
backend/models/BillingDocumentValue.js
Normal file
37
backend/models/BillingDocumentValue.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const BillingDocumentValue = sequelize.define('BillingDocumentValue', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
billingDocumentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'billing_document_id'
|
||||
},
|
||||
fieldKey: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: false,
|
||||
field: 'field_key'
|
||||
},
|
||||
resolvedValue: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'resolved_value'
|
||||
},
|
||||
resolvedSource: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'resolved_source'
|
||||
}
|
||||
}, {
|
||||
tableName: 'billing_document_value',
|
||||
underscored: true,
|
||||
timestamps: false
|
||||
});
|
||||
|
||||
export default BillingDocumentValue;
|
||||
133
backend/models/BillingRun.js
Normal file
133
backend/models/BillingRun.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const BillingRun = sequelize.define('BillingRun', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id'
|
||||
},
|
||||
templateId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'template_id'
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
periodStart: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
field: 'period_start'
|
||||
},
|
||||
periodEnd: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
field: 'period_end'
|
||||
},
|
||||
selfRecipientUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'self_recipient_user_id'
|
||||
},
|
||||
selfRecipientName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'self_recipient_name'
|
||||
},
|
||||
hourlyRate: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false,
|
||||
field: 'hourly_rate'
|
||||
},
|
||||
computedHoursTotal: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'computed_hours_total'
|
||||
},
|
||||
iban: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true
|
||||
},
|
||||
ibanWithoutCountry: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'iban_without_country'
|
||||
},
|
||||
sessionLabel: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'session_label'
|
||||
},
|
||||
sameAccountCheckbox: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'same_account_checkbox'
|
||||
},
|
||||
omitSelfRecipientName: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'omit_self_recipient_name'
|
||||
},
|
||||
omitIban: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'omit_iban'
|
||||
},
|
||||
omitLocationText: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'omit_location_text'
|
||||
},
|
||||
omitDocumentDate: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'omit_document_date'
|
||||
},
|
||||
omitSessionLabel: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'omit_session_label'
|
||||
},
|
||||
locationText: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'location_text'
|
||||
},
|
||||
documentDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'document_date'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('draft', 'generated', 'finalized', 'cancelled'),
|
||||
allowNull: false,
|
||||
defaultValue: 'draft'
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id'
|
||||
}
|
||||
}, {
|
||||
tableName: 'billing_run',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default BillingRun;
|
||||
62
backend/models/BillingTemplate.js
Normal file
62
backend/models/BillingTemplate.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const BillingTemplate = sequelize.define('BillingTemplate', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id'
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
pdfStoragePath: {
|
||||
type: DataTypes.STRING(1000),
|
||||
allowNull: false,
|
||||
field: 'pdf_storage_path'
|
||||
},
|
||||
pdfFilename: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'pdf_filename'
|
||||
},
|
||||
pdfMimeType: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: 'application/pdf',
|
||||
field: 'pdf_mime_type'
|
||||
},
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
field: 'is_active'
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id'
|
||||
}
|
||||
}, {
|
||||
tableName: 'billing_template',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default BillingTemplate;
|
||||
125
backend/models/BillingTemplateField.js
Normal file
125
backend/models/BillingTemplateField.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const BillingTemplateField = sequelize.define('BillingTemplateField', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
templateId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'template_id'
|
||||
},
|
||||
fieldKey: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: false,
|
||||
field: 'field_key'
|
||||
},
|
||||
label: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
fieldType: {
|
||||
type: DataTypes.ENUM('text', 'number', 'currency', 'date', 'checkbox', 'formula', 'table_row'),
|
||||
allowNull: false,
|
||||
field: 'field_type'
|
||||
},
|
||||
sourceType: {
|
||||
type: DataTypes.ENUM('manual', 'member', 'trainer', 'club', 'system', 'constant', 'formula'),
|
||||
allowNull: false,
|
||||
defaultValue: 'manual',
|
||||
field: 'source_type'
|
||||
},
|
||||
sourcePath: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'source_path'
|
||||
},
|
||||
constantValue: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
field: 'constant_value'
|
||||
},
|
||||
formatter: {
|
||||
type: DataTypes.ENUM('none', 'iban_no_country', 'date_dd_mm_yyyy', 'currency_eur_2'),
|
||||
allowNull: false,
|
||||
defaultValue: 'none'
|
||||
},
|
||||
isRequired: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_required'
|
||||
},
|
||||
mappingMode: {
|
||||
type: DataTypes.ENUM('acroform', 'overlay'),
|
||||
allowNull: false,
|
||||
defaultValue: 'overlay',
|
||||
field: 'mapping_mode'
|
||||
},
|
||||
acroformFieldName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'acroform_field_name'
|
||||
},
|
||||
pageNumber: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'page_number'
|
||||
},
|
||||
x: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: true
|
||||
},
|
||||
y: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: true
|
||||
},
|
||||
width: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: true
|
||||
},
|
||||
height: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: true
|
||||
},
|
||||
fontSize: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'font_size'
|
||||
},
|
||||
align: {
|
||||
type: DataTypes.ENUM('left', 'center', 'right'),
|
||||
allowNull: true
|
||||
},
|
||||
formulaExpression: {
|
||||
type: DataTypes.STRING(1000),
|
||||
allowNull: true,
|
||||
field: 'formula_expression'
|
||||
},
|
||||
tableGroup: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
field: 'table_group'
|
||||
},
|
||||
rowIndex: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'row_index'
|
||||
},
|
||||
sortOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'sort_order'
|
||||
}
|
||||
}, {
|
||||
tableName: 'billing_template_field',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default BillingTemplateField;
|
||||
43
backend/models/BillingUserSetting.js
Normal file
43
backend/models/BillingUserSetting.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const BillingUserSetting = sequelize.define('BillingUserSetting', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id'
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'user_id'
|
||||
},
|
||||
lastHourlyRate: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'last_hourly_rate'
|
||||
},
|
||||
lastSelfRecipientName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'last_self_recipient_name'
|
||||
},
|
||||
lastLocationText: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'last_location_text'
|
||||
}
|
||||
}, {
|
||||
tableName: 'billing_user_setting',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default BillingUserSetting;
|
||||
@@ -58,6 +58,12 @@ import TrainingGroup from './TrainingGroup.js';
|
||||
import MemberTrainingGroup from './MemberTrainingGroup.js';
|
||||
import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js';
|
||||
import TrainingTime from './TrainingTime.js';
|
||||
import BillingTemplate from './BillingTemplate.js';
|
||||
import BillingTemplateField from './BillingTemplateField.js';
|
||||
import BillingRun from './BillingRun.js';
|
||||
import BillingDocument from './BillingDocument.js';
|
||||
import BillingDocumentValue from './BillingDocumentValue.js';
|
||||
import BillingUserSetting from './BillingUserSetting.js';
|
||||
// Official tournaments relations
|
||||
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
|
||||
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
|
||||
@@ -399,6 +405,24 @@ ClubDisabledPresetGroup.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
TrainingGroup.hasMany(TrainingTime, { foreignKey: 'trainingGroupId', as: 'trainingTimes' });
|
||||
TrainingTime.belongsTo(TrainingGroup, { foreignKey: 'trainingGroupId', as: 'trainingGroup' });
|
||||
|
||||
// Billing
|
||||
Club.hasMany(BillingTemplate, { foreignKey: 'clubId', as: 'billingTemplates' });
|
||||
BillingTemplate.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
BillingTemplate.hasMany(BillingTemplateField, { foreignKey: 'templateId', as: 'fields' });
|
||||
BillingTemplateField.belongsTo(BillingTemplate, { foreignKey: 'templateId', as: 'template' });
|
||||
Club.hasMany(BillingRun, { foreignKey: 'clubId', as: 'billingRuns' });
|
||||
BillingRun.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
BillingTemplate.hasMany(BillingRun, { foreignKey: 'templateId', as: 'runs' });
|
||||
BillingRun.belongsTo(BillingTemplate, { foreignKey: 'templateId', as: 'template' });
|
||||
BillingRun.hasMany(BillingDocument, { foreignKey: 'runId', as: 'documents' });
|
||||
BillingDocument.belongsTo(BillingRun, { foreignKey: 'runId', as: 'run' });
|
||||
BillingDocument.hasMany(BillingDocumentValue, { foreignKey: 'billingDocumentId', as: 'values' });
|
||||
BillingDocumentValue.belongsTo(BillingDocument, { foreignKey: 'billingDocumentId', as: 'document' });
|
||||
Club.hasMany(BillingUserSetting, { foreignKey: 'clubId', as: 'billingUserSettings' });
|
||||
BillingUserSetting.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
User.hasMany(BillingUserSetting, { foreignKey: 'userId', as: 'billingUserSettings' });
|
||||
BillingUserSetting.belongsTo(User, { foreignKey: 'userId', as: 'user' });
|
||||
|
||||
export {
|
||||
User,
|
||||
Log,
|
||||
@@ -457,4 +481,10 @@ export {
|
||||
MemberTrainingGroup,
|
||||
ClubDisabledPresetGroup,
|
||||
TrainingTime,
|
||||
BillingTemplate,
|
||||
BillingTemplateField,
|
||||
BillingRun,
|
||||
BillingDocument,
|
||||
BillingDocumentValue,
|
||||
BillingUserSetting,
|
||||
};
|
||||
|
||||
43
backend/package-lock.json
generated
43
backend/package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"nodemailer": "^8.0.4",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"pdfjs-dist": "^5.4.394",
|
||||
"playwright": "^1.58.2",
|
||||
@@ -878,6 +879,24 @@
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/upng": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
@@ -3178,6 +3197,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -3228,6 +3253,24 @@
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.4.tgz",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"nodemailer": "^8.0.4",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"pdfjs-dist": "^5.4.394",
|
||||
"playwright": "^1.58.2",
|
||||
|
||||
38
backend/routes/billingRoutes.js
Normal file
38
backend/routes/billingRoutes.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
import {
|
||||
uploadBillingTemplateMiddleware,
|
||||
listTemplates,
|
||||
createTemplate,
|
||||
deleteTemplate,
|
||||
updateTemplateFields,
|
||||
createRun,
|
||||
listRuns,
|
||||
getRunDetails,
|
||||
deleteRun,
|
||||
getUserSettings,
|
||||
calculateHoursPreview,
|
||||
generateRun,
|
||||
downloadTemplatePdf,
|
||||
downloadRunPdf
|
||||
} from '../controllers/billingController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/templates/:clubId', authenticate, authorize('members', 'read'), listTemplates);
|
||||
router.post('/templates/:clubId', authenticate, authorize('members', 'write'), uploadBillingTemplateMiddleware, createTemplate);
|
||||
router.delete('/templates/:templateId', authenticate, deleteTemplate);
|
||||
router.put('/templates/:templateId/fields', authenticate, updateTemplateFields);
|
||||
router.get('/templates/:templateId/pdf', authenticate, downloadTemplatePdf);
|
||||
|
||||
router.get('/runs/:clubId', authenticate, authorize('members', 'read'), listRuns);
|
||||
router.get('/run/:runId', authenticate, getRunDetails);
|
||||
router.get('/settings/:clubId', authenticate, authorize('members', 'read'), getUserSettings);
|
||||
router.get('/hours-preview/:clubId', authenticate, authorize('members', 'read'), calculateHoursPreview);
|
||||
router.post('/runs/:clubId', authenticate, authorize('members', 'write'), createRun);
|
||||
router.post('/runs/:runId/generate', authenticate, generateRun);
|
||||
router.get('/runs/:runId/download', authenticate, downloadRunPdf);
|
||||
router.delete('/runs/:runId', authenticate, deleteRun);
|
||||
|
||||
export default router;
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
|
||||
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, ClubTeamMember, TeamDocument, Group,
|
||||
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
|
||||
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest
|
||||
, MemberOrder, MemberOrderHistory, MemberGroupPhoto
|
||||
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest,
|
||||
MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting
|
||||
} from './models/index.js';
|
||||
import authRoutes from './routes/authRoutes.js';
|
||||
import clubRoutes from './routes/clubRoutes.js';
|
||||
@@ -57,6 +57,7 @@ import trainingGroupRoutes from './routes/trainingGroupRoutes.js';
|
||||
import trainingTimeRoutes from './routes/trainingTimeRoutes.js';
|
||||
import memberOrderRoutes from './routes/memberOrderRoutes.js';
|
||||
import memberGroupPhotoRoutes from './routes/memberGroupPhotoRoutes.js';
|
||||
import billingRoutes from './routes/billingRoutes.js';
|
||||
import schedulerService from './services/schedulerService.js';
|
||||
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
|
||||
import HttpError from './exceptions/HttpError.js';
|
||||
@@ -166,6 +167,7 @@ const SEO_NOINDEX_PREFIXES = [
|
||||
'/member-transfer-settings',
|
||||
'/personal-settings',
|
||||
'/orders',
|
||||
'/billing',
|
||||
];
|
||||
|
||||
function normalizeSeoPath(pathname = '/') {
|
||||
@@ -302,6 +304,7 @@ app.use('/api/training-groups', trainingGroupRoutes);
|
||||
app.use('/api/training-times', trainingTimeRoutes);
|
||||
app.use('/api/member-orders', memberOrderRoutes);
|
||||
app.use('/api/member-group-photos', memberGroupPhotoRoutes);
|
||||
app.use('/api/billing', billingRoutes);
|
||||
|
||||
// Middleware für dynamischen kanonischen Tag (vor express.static)
|
||||
const setCanonicalTag = (req, res, next) => {
|
||||
@@ -549,6 +552,12 @@ app.use((err, req, res, next) => {
|
||||
await safeSync(MemberPlayInterest);
|
||||
await safeSync(MemberOrder);
|
||||
await safeSync(MemberOrderHistory);
|
||||
await safeSync(BillingTemplate);
|
||||
await safeSync(BillingTemplateField);
|
||||
await safeSync(BillingRun);
|
||||
await safeSync(BillingDocument);
|
||||
await safeSync(BillingDocumentValue);
|
||||
await safeSync(BillingUserSetting);
|
||||
await safeSync(ClubTeam);
|
||||
await safeSync(TeamDocument);
|
||||
|
||||
|
||||
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();
|
||||
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-10.pdf
Normal file
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-10.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-11.pdf
Normal file
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-11.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-2.pdf
Normal file
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-2.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-3.pdf
Normal file
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-3.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-5.pdf
Normal file
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-5.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-6.pdf
Normal file
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-6.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-7.pdf
Normal file
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-7.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-8.pdf
Normal file
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-8.pdf
Normal file
Binary file not shown.
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-9.pdf
Normal file
BIN
backend/uploads/billing-generated/Abrechnung_2026-04-9.pdf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user