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,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 [];