more club funtions, fix for team edit

This commit is contained in:
Torsten Schulz (local)
2026-06-21 17:15:04 +02:00
parent 542fae089c
commit 302ed20a9b
18 changed files with 2032 additions and 16 deletions

View File

@@ -0,0 +1,93 @@
import clubInvoiceService from '../services/clubInvoiceService.js';
class ClubInvoiceController {
async listClubInvoices(req, res) {
try {
const { clubId } = req.params;
const payload = await clubInvoiceService.listClubInvoices(Number(clubId));
res.json(payload);
} catch (error) {
console.error('[listClubInvoices] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungen konnten nicht geladen werden.' });
}
}
async createInvoiceParty(req, res) {
try {
const { clubId } = req.params;
const party = await clubInvoiceService.createInvoiceParty(Number(clubId), req.body || {});
res.status(201).json({ party });
} catch (error) {
console.error('[createInvoiceParty] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungspartei konnte nicht gespeichert werden.' });
}
}
async updateInvoiceParty(req, res) {
try {
const { clubId, partyId } = req.params;
const party = await clubInvoiceService.updateInvoiceParty(Number(clubId), Number(partyId), req.body || {});
res.json({ party });
} catch (error) {
console.error('[updateInvoiceParty] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungspartei konnte nicht gespeichert werden.' });
}
}
async deleteInvoiceParty(req, res) {
try {
const { clubId, partyId } = req.params;
await clubInvoiceService.deleteInvoiceParty(Number(clubId), Number(partyId));
res.json({ success: true });
} catch (error) {
console.error('[deleteInvoiceParty] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungspartei konnte nicht gelöscht werden.' });
}
}
async createInvoice(req, res) {
try {
const { clubId } = req.params;
const invoice = await clubInvoiceService.createInvoice(Number(clubId), req.user?.id || null, req.body || {});
res.status(201).json({ invoice });
} catch (error) {
console.error('[createInvoice] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnung konnte nicht gespeichert werden.' });
}
}
async updateInvoice(req, res) {
try {
const { clubId, invoiceId } = req.params;
const invoice = await clubInvoiceService.updateInvoice(Number(clubId), Number(invoiceId), req.body || {});
res.json({ invoice });
} catch (error) {
console.error('[updateInvoice] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnung konnte nicht gespeichert werden.' });
}
}
async updateInvoiceStatus(req, res) {
try {
const { clubId, invoiceId } = req.params;
const invoice = await clubInvoiceService.updateInvoiceStatus(Number(clubId), Number(invoiceId), String(req.body?.status || ''));
res.json({ invoice });
} catch (error) {
console.error('[updateInvoiceStatus] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungsstatus konnte nicht gespeichert werden.' });
}
}
async deleteInvoice(req, res) {
try {
const { clubId, invoiceId } = req.params;
await clubInvoiceService.deleteInvoice(Number(clubId), Number(invoiceId));
res.json({ success: true });
} catch (error) {
console.error('[deleteInvoice] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnung konnte nicht gelöscht werden.' });
}
}
}
export default new ClubInvoiceController();

View File

@@ -67,7 +67,11 @@ export const updateClubSettings = async (req, res) => {
autoFetchRankings,
countryCode,
stateCode,
memberDataQualityRequirements
memberDataQualityRequirements,
outgoingInvoicePrefix,
outgoingInvoiceNextNumber,
incomingInvoicePrefix,
incomingInvoiceNextNumber
} = req.body;
const updated = await ClubService.updateClubSettings(token, clubid, {
greetingText,
@@ -76,7 +80,11 @@ export const updateClubSettings = async (req, res) => {
autoFetchRankings,
countryCode,
stateCode,
memberDataQualityRequirements
memberDataQualityRequirements,
outgoingInvoicePrefix,
outgoingInvoiceNextNumber,
incomingInvoicePrefix,
incomingInvoiceNextNumber
});
res.status(200).json(updated);
} catch (error) {

View File

@@ -48,6 +48,30 @@ const Club = sequelize.define('Club', {
allowNull: true,
field: 'member_data_quality_requirements',
comment: 'Configures which member fields are required for data quality checks'
},
outgoingInvoicePrefix: {
type: DataTypes.STRING(24),
allowNull: false,
defaultValue: 'RE',
field: 'outgoing_invoice_prefix'
},
outgoingInvoiceNextNumber: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
field: 'outgoing_invoice_next_number'
},
incomingInvoicePrefix: {
type: DataTypes.STRING(24),
allowNull: false,
defaultValue: 'EI',
field: 'incoming_invoice_prefix'
},
incomingInvoiceNextNumber: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
field: 'incoming_invoice_next_number'
}
}, {
tableName: 'clubs',

View File

@@ -0,0 +1,109 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubInvoice = sequelize.define('ClubInvoice', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
},
invoiceDirection: {
type: DataTypes.STRING(16),
allowNull: false,
field: 'invoice_direction',
},
invoiceType: {
type: DataTypes.STRING(32),
allowNull: false,
field: 'invoice_type',
},
status: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'draft',
},
invoiceNumber: {
type: DataTypes.STRING(64),
allowNull: true,
field: 'invoice_number',
},
externalReference: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'external_reference',
},
partyId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'party_id',
},
accountId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'account_id',
},
issuedOn: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'issued_on',
},
dueOn: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'due_on',
},
paidOn: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'paid_on',
},
netAmountCents: {
type: DataTypes.BIGINT,
allowNull: false,
defaultValue: 0,
field: 'net_amount_cents',
},
taxAmountCents: {
type: DataTypes.BIGINT,
allowNull: false,
defaultValue: 0,
field: 'tax_amount_cents',
},
grossAmountCents: {
type: DataTypes.BIGINT,
allowNull: false,
defaultValue: 0,
field: 'gross_amount_cents',
},
currencyCode: {
type: DataTypes.STRING(3),
allowNull: false,
defaultValue: 'EUR',
field: 'currency_code',
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
documentId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'document_id',
},
createdByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'created_by_user_id',
},
archivedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'archived_at',
},
}, {
tableName: 'club_invoices',
underscored: true,
timestamps: true,
});
export default ClubInvoice;

View File

@@ -0,0 +1,49 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubInvoiceItem = sequelize.define('ClubInvoiceItem', {
invoiceId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'invoice_id',
},
lineNo: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
field: 'line_no',
},
description: {
type: DataTypes.TEXT,
allowNull: false,
},
quantity: {
type: DataTypes.DECIMAL(12, 2),
allowNull: false,
defaultValue: 1,
},
unitPriceCents: {
type: DataTypes.BIGINT,
allowNull: false,
defaultValue: 0,
field: 'unit_price_cents',
},
taxRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: false,
defaultValue: 0,
field: 'tax_rate',
},
totalCents: {
type: DataTypes.BIGINT,
allowNull: false,
defaultValue: 0,
field: 'total_cents',
},
}, {
tableName: 'club_invoice_items',
underscored: true,
timestamps: true,
});
export default ClubInvoiceItem;

View File

@@ -0,0 +1,75 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubInvoiceParty = sequelize.define('ClubInvoiceParty', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
},
partyType: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'customer',
field: 'party_type',
},
name: {
type: DataTypes.STRING(255),
allowNull: false,
},
contactName: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'contact_name',
},
email: {
type: DataTypes.STRING(255),
allowNull: true,
},
phone: {
type: DataTypes.STRING(80),
allowNull: true,
},
street: {
type: DataTypes.STRING(255),
allowNull: true,
},
postalCode: {
type: DataTypes.STRING(24),
allowNull: true,
field: 'postal_code',
},
city: {
type: DataTypes.STRING(120),
allowNull: true,
},
countryCode: {
type: DataTypes.STRING(2),
allowNull: true,
defaultValue: 'DE',
field: 'country_code',
},
iban: {
type: DataTypes.STRING(34),
allowNull: true,
},
bic: {
type: DataTypes.STRING(11),
allowNull: true,
},
taxIdentifier: {
type: DataTypes.STRING(64),
allowNull: true,
field: 'tax_identifier',
},
notes: {
type: DataTypes.TEXT,
allowNull: true,
},
}, {
tableName: 'club_invoice_parties',
underscored: true,
timestamps: true,
});
export default ClubInvoiceParty;

View File

@@ -75,6 +75,9 @@ import ClubRequestNote from './ClubRequestNote.js';
import ClubSepaMandate from './ClubSepaMandate.js';
import ClubPaymentClaim from './ClubPaymentClaim.js';
import ClubAccount from './ClubAccount.js';
import ClubInvoiceParty from './ClubInvoiceParty.js';
import ClubInvoice from './ClubInvoice.js';
import ClubInvoiceItem from './ClubInvoiceItem.js';
import ClubTask from './ClubTask.js';
import ClubTaskSuppression from './ClubTaskSuppression.js';
import ClubRole from './ClubRole.js';
@@ -484,6 +487,21 @@ ClubPaymentClaim.belongsTo(Member, { foreignKey: 'memberId', as: 'member', const
Club.hasMany(ClubAccount, { foreignKey: 'clubId', as: 'accounts' });
ClubAccount.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Club.hasMany(ClubInvoiceParty, { foreignKey: 'clubId', as: 'invoiceParties' });
ClubInvoiceParty.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Club.hasMany(ClubInvoice, { foreignKey: 'clubId', as: 'invoices' });
ClubInvoice.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
ClubInvoice.belongsTo(ClubInvoiceParty, { foreignKey: 'partyId', as: 'party', constraints: false });
ClubInvoiceParty.hasMany(ClubInvoice, { foreignKey: 'partyId', as: 'invoices' });
ClubInvoice.belongsTo(ClubAccount, { foreignKey: 'accountId', as: 'account', constraints: false });
ClubAccount.hasMany(ClubInvoice, { foreignKey: 'accountId', as: 'invoices' });
User.hasMany(ClubInvoice, { foreignKey: 'createdByUserId', as: 'createdInvoices' });
ClubInvoice.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
ClubInvoice.hasMany(ClubInvoiceItem, { foreignKey: 'invoiceId', as: 'items' });
ClubInvoiceItem.belongsTo(ClubInvoice, { foreignKey: 'invoiceId', as: 'invoice' });
Club.hasMany(ClubTask, { foreignKey: 'clubId', as: 'clubTasks' });
ClubTask.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
User.hasMany(ClubTask, { foreignKey: 'createdByUserId', as: 'createdClubTasks' });
@@ -579,6 +597,9 @@ export {
ClubSepaMandate,
ClubPaymentClaim,
ClubAccount,
ClubInvoiceParty,
ClubInvoice,
ClubInvoiceItem,
ClubTask,
ClubTaskSuppression,
ClubRole,

View File

@@ -0,0 +1,21 @@
import express from 'express';
import clubInvoiceController from '../controllers/clubInvoiceController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', authorize('members', 'read'), clubInvoiceController.listClubInvoices);
router.post('/:clubId/parties', authorize('members', 'write'), clubInvoiceController.createInvoiceParty);
router.put('/:clubId/parties/:partyId', authorize('members', 'write'), clubInvoiceController.updateInvoiceParty);
router.delete('/:clubId/parties/:partyId', authorize('members', 'write'), clubInvoiceController.deleteInvoiceParty);
router.post('/:clubId', authorize('members', 'write'), clubInvoiceController.createInvoice);
router.put('/:clubId/:invoiceId', authorize('members', 'write'), clubInvoiceController.updateInvoice);
router.patch('/:clubId/:invoiceId/status', authorize('members', 'write'), clubInvoiceController.updateInvoiceStatus);
router.delete('/:clubId/:invoiceId', authorize('members', 'write'), clubInvoiceController.deleteInvoice);
export default router;

View File

@@ -16,7 +16,7 @@ import {
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest,
MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, FriendlyMatch, TrainingCancellation
, FriendlyMatchShared, FriendlyMatchInvitation
, CalendarEvent, ClubVenue, ClubRequest, ClubRequestNote, ClubSepaMandate, ClubPaymentClaim, ClubAccount, ClubRole, ClubUserRole
, CalendarEvent, ClubVenue, ClubRequest, ClubRequestNote, ClubSepaMandate, ClubPaymentClaim, ClubAccount, ClubInvoiceParty, ClubInvoice, ClubInvoiceItem, ClubRole, ClubUserRole
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -74,6 +74,7 @@ import clubTaskRoutes from './routes/clubTaskRoutes.js';
import clubStatisticsRoutes from './routes/clubStatisticsRoutes.js';
import clubArchiveRoutes from './routes/clubArchiveRoutes.js';
import clubAccountRoutes from './routes/clubAccountRoutes.js';
import clubInvoiceRoutes from './routes/clubInvoiceRoutes.js';
import schedulerService from './services/schedulerService.js';
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
import HttpError from './exceptions/HttpError.js';
@@ -380,6 +381,7 @@ app.use('/api/club-tasks', clubTaskRoutes);
app.use('/api/club-statistics', clubStatisticsRoutes);
app.use('/api/club-archive', clubArchiveRoutes);
app.use('/api/club-accounts', clubAccountRoutes);
app.use('/api/club-invoices', clubInvoiceRoutes);
// Middleware für dynamischen kanonischen Tag (vor express.static)
const setCanonicalTag = (req, res, next) => {
@@ -586,6 +588,9 @@ app.use((err, req, res, next) => {
await safeSync(ClubRole);
await safeSync(ClubUserRole);
await safeSync(ClubAccount);
await safeSync(ClubInvoiceParty);
await safeSync(ClubInvoice);
await safeSync(ClubInvoiceItem);
await safeSync(Log);
await safeSync(Member);
await safeSync(DiaryDate);

View File

@@ -0,0 +1,367 @@
import sequelize from '../database.js';
import Club from '../models/Club.js';
import ClubInvoice from '../models/ClubInvoice.js';
import ClubInvoiceItem from '../models/ClubInvoiceItem.js';
import ClubInvoiceParty from '../models/ClubInvoiceParty.js';
import ClubAccount from '../models/ClubAccount.js';
const INVOICE_DIRECTIONS = new Set(['incoming', 'outgoing']);
const INVOICE_STATUSES = new Set(['draft', 'issued', 'partially_paid', 'paid', 'cancelled', 'archived']);
const PARTY_TYPES = new Set(['customer', 'supplier', 'sponsor', 'other']);
const INVOICE_TYPES = new Set(['membership_fee', 'course_fee', 'sponsoring', 'material', 'service', 'expense', 'other']);
function trimText(value, maxLength = null) {
const normalized = String(value || '').trim();
if (!normalized) return null;
return maxLength ? normalized.slice(0, maxLength) : normalized;
}
function normalizeDate(value) {
const normalized = String(value || '').trim();
return normalized || null;
}
function parseNumber(value, fallback = 0) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function roundToInt(value) {
return Math.round(Number(value) || 0);
}
function normalizePartyPayload(payload = {}) {
return {
name: trimText(payload.name, 255),
partyType: PARTY_TYPES.has(payload.partyType) ? payload.partyType : 'customer',
contactName: trimText(payload.contactName, 255),
email: trimText(payload.email, 255),
phone: trimText(payload.phone, 80),
street: trimText(payload.street, 255),
postalCode: trimText(payload.postalCode, 24),
city: trimText(payload.city, 120),
countryCode: trimText(payload.countryCode, 2)?.toUpperCase() || 'DE',
iban: trimText(payload.iban, 34)?.replace(/\s+/g, '').toUpperCase() || null,
bic: trimText(payload.bic, 11)?.replace(/\s+/g, '').toUpperCase() || null,
taxIdentifier: trimText(payload.taxIdentifier, 64),
notes: trimText(payload.notes),
};
}
function normalizeInvoiceItems(items = []) {
return (Array.isArray(items) ? items : [])
.map((item, index) => {
const quantity = parseNumber(item.quantity, 1);
const unitPriceCents = roundToInt(item.unitPriceCents);
const taxRate = parseNumber(item.taxRate, 0);
const netLineCents = roundToInt(quantity * unitPriceCents);
const taxLineCents = roundToInt(netLineCents * (taxRate / 100));
const totalCents = netLineCents + taxLineCents;
return {
lineNo: index + 1,
description: trimText(item.description) || '',
quantity,
unitPriceCents,
taxRate,
netLineCents,
taxLineCents,
totalCents,
};
})
.filter((item) => item.description);
}
function normalizeInvoicePayload(payload = {}) {
return {
invoiceDirection: INVOICE_DIRECTIONS.has(payload.invoiceDirection) ? payload.invoiceDirection : 'outgoing',
invoiceType: INVOICE_TYPES.has(payload.invoiceType) ? payload.invoiceType : 'other',
status: INVOICE_STATUSES.has(payload.status) ? payload.status : 'draft',
invoiceNumber: trimText(payload.invoiceNumber, 64),
externalReference: trimText(payload.externalReference, 255),
partyId: payload.partyId ? Number(payload.partyId) : null,
accountId: payload.accountId ? Number(payload.accountId) : null,
issuedOn: normalizeDate(payload.issuedOn),
dueOn: normalizeDate(payload.dueOn),
paidOn: normalizeDate(payload.paidOn),
currencyCode: trimText(payload.currencyCode, 3)?.toUpperCase() || 'EUR',
description: trimText(payload.description),
items: normalizeInvoiceItems(payload.items),
};
}
function validateInvoicePayload(payload) {
if (!payload.partyId) {
const error = new Error('Bitte eine Rechnungspartei auswählen.');
error.status = 400;
throw error;
}
if (payload.items.length === 0) {
const error = new Error('Mindestens eine Rechnungsposition ist erforderlich.');
error.status = 400;
throw error;
}
}
function summarizeItems(items) {
return items.reduce((acc, item) => {
acc.netAmountCents += item.netLineCents;
acc.taxAmountCents += item.taxLineCents;
acc.grossAmountCents += item.totalCents;
return acc;
}, {
netAmountCents: 0,
taxAmountCents: 0,
grossAmountCents: 0,
});
}
function buildInvoiceNumber(prefix, nextNumber, referenceDate = new Date()) {
const year = referenceDate.getFullYear();
const normalizedPrefix = String(prefix || '').trim().toUpperCase();
const paddedCounter = String(Math.max(1, Number.parseInt(nextNumber, 10) || 1)).padStart(4, '0');
return normalizedPrefix ? `${normalizedPrefix}-${year}-${paddedCounter}` : `${year}-${paddedCounter}`;
}
async function generateNextInvoiceNumber(clubId, invoiceDirection, transaction) {
const club = await Club.findByPk(clubId, {
transaction,
lock: transaction.LOCK.UPDATE,
});
if (!club) {
const error = new Error('Verein wurde nicht gefunden.');
error.status = 404;
throw error;
}
const isIncoming = invoiceDirection === 'incoming';
const prefixField = isIncoming ? 'incomingInvoicePrefix' : 'outgoingInvoicePrefix';
const nextNumberField = isIncoming ? 'incomingInvoiceNextNumber' : 'outgoingInvoiceNextNumber';
const invoiceNumber = buildInvoiceNumber(club[prefixField], club[nextNumberField]);
await club.update({
[nextNumberField]: Math.max(1, Number.parseInt(club[nextNumberField], 10) || 1) + 1,
}, { transaction });
return invoiceNumber;
}
class ClubInvoiceService {
async listClubInvoices(clubId) {
const [club, parties, accounts, invoices] = await Promise.all([
Club.findByPk(clubId, {
attributes: [
'id',
'outgoingInvoicePrefix',
'outgoingInvoiceNextNumber',
'incomingInvoicePrefix',
'incomingInvoiceNextNumber',
],
}),
ClubInvoiceParty.findAll({
where: { clubId },
order: [['name', 'ASC']],
}),
ClubAccount.findAll({
where: { clubId },
order: [['isDefault', 'DESC'], ['name', 'ASC']],
}),
ClubInvoice.findAll({
where: { clubId },
include: [
{ model: ClubInvoiceParty, as: 'party', required: false },
{ model: ClubInvoiceItem, as: 'items', required: false },
{ model: ClubAccount, as: 'account', required: false },
],
order: [['updatedAt', 'DESC'], [{ model: ClubInvoiceItem, as: 'items' }, 'lineNo', 'ASC']],
}),
]);
return {
parties,
accounts,
invoices,
settings: club ? {
outgoingInvoicePrefix: club.outgoingInvoicePrefix || 'RE',
outgoingInvoiceNextNumber: club.outgoingInvoiceNextNumber || 1,
incomingInvoicePrefix: club.incomingInvoicePrefix || 'EI',
incomingInvoiceNextNumber: club.incomingInvoiceNextNumber || 1,
} : null,
};
}
async createInvoiceParty(clubId, payload) {
const normalized = normalizePartyPayload(payload);
if (!normalized.name) {
const error = new Error('Name der Rechnungspartei ist erforderlich.');
error.status = 400;
throw error;
}
return ClubInvoiceParty.create({ clubId, ...normalized });
}
async updateInvoiceParty(clubId, partyId, payload) {
const party = await ClubInvoiceParty.findOne({ where: { id: partyId, clubId } });
if (!party) {
const error = new Error('Rechnungspartei wurde nicht gefunden.');
error.status = 404;
throw error;
}
const normalized = normalizePartyPayload(payload);
if (!normalized.name) {
const error = new Error('Name der Rechnungspartei ist erforderlich.');
error.status = 400;
throw error;
}
await party.update(normalized);
return party;
}
async deleteInvoiceParty(clubId, partyId) {
const party = await ClubInvoiceParty.findOne({ where: { id: partyId, clubId } });
if (!party) {
const error = new Error('Rechnungspartei wurde nicht gefunden.');
error.status = 404;
throw error;
}
const linkedCount = await ClubInvoice.count({ where: { clubId, partyId } });
if (linkedCount > 0) {
const error = new Error('Rechnungspartei kann nicht gelöscht werden, weil bereits Rechnungen darauf verweisen.');
error.status = 400;
throw error;
}
await party.destroy();
}
async createInvoice(clubId, userId, payload) {
const normalized = normalizeInvoicePayload(payload);
validateInvoicePayload(normalized);
const totals = summarizeItems(normalized.items);
return sequelize.transaction(async (transaction) => {
const invoiceNumber = normalized.invoiceNumber
|| await generateNextInvoiceNumber(clubId, normalized.invoiceDirection, transaction);
const invoice = await ClubInvoice.create({
clubId,
createdByUserId: userId || null,
...normalized,
invoiceNumber,
...totals,
archivedAt: normalized.status === 'archived' ? new Date() : null,
}, { transaction });
await ClubInvoiceItem.bulkCreate(
normalized.items.map((item) => ({
invoiceId: invoice.id,
lineNo: item.lineNo,
description: item.description,
quantity: item.quantity,
unitPriceCents: item.unitPriceCents,
taxRate: item.taxRate,
totalCents: item.totalCents,
})),
{ transaction }
);
return ClubInvoice.findOne({
where: { id: invoice.id, clubId },
include: [
{ model: ClubInvoiceParty, as: 'party', required: false },
{ model: ClubInvoiceItem, as: 'items', required: false },
{ model: ClubAccount, as: 'account', required: false },
],
transaction,
});
});
}
async updateInvoice(clubId, invoiceId, payload) {
const invoice = await ClubInvoice.findOne({ where: { id: invoiceId, clubId } });
if (!invoice) {
const error = new Error('Rechnung wurde nicht gefunden.');
error.status = 404;
throw error;
}
const normalized = normalizeInvoicePayload(payload);
validateInvoicePayload(normalized);
const totals = summarizeItems(normalized.items);
return sequelize.transaction(async (transaction) => {
await invoice.update({
...normalized,
...totals,
archivedAt: normalized.status === 'archived' ? (invoice.archivedAt || new Date()) : null,
}, { transaction });
await ClubInvoiceItem.destroy({
where: { invoiceId: invoice.id },
transaction,
});
await ClubInvoiceItem.bulkCreate(
normalized.items.map((item) => ({
invoiceId: invoice.id,
lineNo: item.lineNo,
description: item.description,
quantity: item.quantity,
unitPriceCents: item.unitPriceCents,
taxRate: item.taxRate,
totalCents: item.totalCents,
})),
{ transaction }
);
return ClubInvoice.findOne({
where: { id: invoice.id, clubId },
include: [
{ model: ClubInvoiceParty, as: 'party', required: false },
{ model: ClubInvoiceItem, as: 'items', required: false },
{ model: ClubAccount, as: 'account', required: false },
],
transaction,
});
});
}
async updateInvoiceStatus(clubId, invoiceId, status) {
if (!INVOICE_STATUSES.has(status)) {
const error = new Error('Ungültiger Rechnungsstatus.');
error.status = 400;
throw error;
}
const invoice = await ClubInvoice.findOne({ where: { id: invoiceId, clubId } });
if (!invoice) {
const error = new Error('Rechnung wurde nicht gefunden.');
error.status = 404;
throw error;
}
await invoice.update({
status,
archivedAt: status === 'archived' ? (invoice.archivedAt || new Date()) : null,
paidOn: status === 'paid' ? (invoice.paidOn || new Date().toISOString().slice(0, 10)) : invoice.paidOn,
});
return invoice;
}
async deleteInvoice(clubId, invoiceId) {
const invoice = await ClubInvoice.findOne({ where: { id: invoiceId, clubId } });
if (!invoice) {
const error = new Error('Rechnung wurde nicht gefunden.');
error.status = 404;
throw error;
}
await sequelize.transaction(async (transaction) => {
await ClubInvoiceItem.destroy({ where: { invoiceId }, transaction });
await invoice.destroy({ transaction });
});
}
}
export default new ClubInvoiceService();

View File

@@ -74,7 +74,11 @@ class ClubService {
autoFetchRankings,
countryCode,
stateCode,
memberDataQualityRequirements
memberDataQualityRequirements,
outgoingInvoicePrefix,
outgoingInvoiceNextNumber,
incomingInvoicePrefix,
incomingInvoiceNextNumber
}) {
await checkAccess(userToken, clubId);
const club = await Club.findByPk(clubId);
@@ -89,6 +93,10 @@ class ClubService {
if (memberDataQualityRequirements !== undefined) {
updates.memberDataQualityRequirements = this.normalizeMemberDataQualityRequirements(memberDataQualityRequirements);
}
if (outgoingInvoicePrefix !== undefined) updates.outgoingInvoicePrefix = this.normalizeInvoicePrefix(outgoingInvoicePrefix, 'RE');
if (incomingInvoicePrefix !== undefined) updates.incomingInvoicePrefix = this.normalizeInvoicePrefix(incomingInvoicePrefix, 'EI');
if (outgoingInvoiceNextNumber !== undefined) updates.outgoingInvoiceNextNumber = this.normalizeInvoiceNextNumber(outgoingInvoiceNextNumber);
if (incomingInvoiceNextNumber !== undefined) updates.incomingInvoiceNextNumber = this.normalizeInvoiceNextNumber(incomingInvoiceNextNumber);
return await club.update(updates);
}
@@ -123,6 +131,16 @@ class ClubService {
);
}
normalizeInvoicePrefix(prefix, fallback) {
const normalized = String(prefix || fallback || '').trim().toUpperCase().slice(0, 24);
return normalized || fallback;
}
normalizeInvoiceNextNumber(value) {
const numeric = Number.parseInt(value, 10);
return Number.isInteger(numeric) && numeric > 0 ? numeric : 1;
}
async approveUserClubAccess(userToken, clubId, toApproveUserId) {
await checkAccess(userToken, clubId);
const toApproveUserClub = await UserClub.findOne({

View File

@@ -1,8 +1,10 @@
import ClubTeam from '../models/ClubTeam.js';
import ClubTeamMember from '../models/ClubTeamMember.js';
import League from '../models/League.js';
import Member from '../models/Member.js';
import Season from '../models/Season.js';
import {
ClubTeam,
ClubTeamMember,
League,
Member,
Season
} from '../models/index.js';
import SeasonService from './seasonService.js';
import { devLog } from '../utils/logger.js';
@@ -190,16 +192,38 @@ class ClubTeamService {
static async getTeamLineup(clubTeamId, lineupHalf = 'first_half') {
try {
return await ClubTeamMember.findAll({
const lineupEntries = await ClubTeamMember.findAll({
where: { clubTeamId, lineupHalf },
include: [
{
model: Member,
as: 'member'
}
],
order: [['position', 'ASC']]
});
if (lineupEntries.length === 0) {
return [];
}
const memberIds = [...new Set(
lineupEntries
.map((entry) => Number(entry.memberId))
.filter((memberId) => Number.isInteger(memberId) && memberId > 0)
)];
const members = memberIds.length > 0
? await Member.findAll({
where: { id: memberIds }
})
: [];
const memberById = new Map(
members.map((member) => [Number(member.id), member])
);
return lineupEntries.map((entry) => {
const plainEntry = entry.get({ plain: true });
return {
...plainEntry,
member: memberById.get(Number(entry.memberId)) || null
};
});
} catch (error) {
if (this.isMissingTeamLineupTable(error)) {
return [];