more club funtions, fix for team edit
This commit is contained in:
367
backend/services/clubInvoiceService.js
Normal file
367
backend/services/clubInvoiceService.js
Normal 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();
|
||||
@@ -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({
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user