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

View File

@@ -16,6 +16,10 @@ ALTER TABLE `clubs`
ADD COLUMN IF NOT EXISTS `billing_email` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `iban` varchar(34) NULL,
ADD COLUMN IF NOT EXISTS `bic` varchar(11) NULL,
ADD COLUMN IF NOT EXISTS `outgoing_invoice_prefix` varchar(24) NOT NULL DEFAULT 'RE',
ADD COLUMN IF NOT EXISTS `outgoing_invoice_next_number` int NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS `incoming_invoice_prefix` varchar(24) NOT NULL DEFAULT 'EI',
ADD COLUMN IF NOT EXISTS `incoming_invoice_next_number` int NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS `is_archived` tinyint(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `archived_at` datetime NULL;
@@ -200,3 +204,67 @@ CREATE TABLE IF NOT EXISTS `club_accounts` (
KEY `idx_club_accounts_club_status` (`club_id`, `status`, `account_type`),
KEY `idx_club_accounts_default` (`club_id`, `is_default`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_invoice_parties` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`party_type` varchar(32) NOT NULL DEFAULT 'customer',
`name` varchar(255) NOT NULL,
`contact_name` varchar(255) NULL,
`email` varchar(255) NULL,
`phone` varchar(80) NULL,
`street` varchar(255) NULL,
`postal_code` varchar(24) NULL,
`city` varchar(120) NULL,
`country_code` varchar(2) NOT NULL DEFAULT 'DE',
`iban` varchar(34) NULL,
`bic` varchar(11) NULL,
`tax_identifier` varchar(64) NULL,
`notes` 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_club_invoice_parties_club_type` (`club_id`, `party_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_invoices` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`invoice_direction` varchar(16) NOT NULL,
`invoice_type` varchar(32) NOT NULL,
`status` varchar(32) NOT NULL DEFAULT 'draft',
`invoice_number` varchar(64) NULL,
`external_reference` varchar(255) NULL,
`party_id` bigint NULL,
`account_id` bigint NULL,
`issued_on` date NULL,
`due_on` date NULL,
`paid_on` date NULL,
`net_amount_cents` bigint NOT NULL DEFAULT 0,
`tax_amount_cents` bigint NOT NULL DEFAULT 0,
`gross_amount_cents` bigint NOT NULL DEFAULT 0,
`currency_code` varchar(3) NOT NULL DEFAULT 'EUR',
`description` text NULL,
`document_id` bigint NULL,
`created_by_user_id` bigint NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`archived_at` datetime NULL,
PRIMARY KEY (`id`),
KEY `idx_club_invoices_club_direction_status` (`club_id`, `invoice_direction`, `status`, `due_on`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_invoice_items` (
`id` bigint NOT NULL AUTO_INCREMENT,
`invoice_id` bigint NOT NULL,
`line_no` int NOT NULL DEFAULT 1,
`description` text NOT NULL,
`quantity` decimal(12,2) NOT NULL DEFAULT 1.00,
`unit_price_cents` bigint NOT NULL DEFAULT 0,
`tax_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
`total_cents` bigint 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`),
UNIQUE KEY `uq_club_invoice_items_line` (`invoice_id`, `line_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -21,6 +21,10 @@ ALTER TABLE IF EXISTS clubs
ADD COLUMN IF NOT EXISTS billing_email varchar(255),
ADD COLUMN IF NOT EXISTS iban varchar(34),
ADD COLUMN IF NOT EXISTS bic varchar(11),
ADD COLUMN IF NOT EXISTS outgoing_invoice_prefix varchar(24) NOT NULL DEFAULT 'RE',
ADD COLUMN IF NOT EXISTS outgoing_invoice_next_number integer NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS incoming_invoice_prefix varchar(24) NOT NULL DEFAULT 'EI',
ADD COLUMN IF NOT EXISTS incoming_invoice_next_number integer NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS archived_at timestamptz;
@@ -507,6 +511,7 @@ CREATE TABLE IF NOT EXISTS club_invoices (
invoice_number varchar(64),
external_reference varchar(255),
party_id bigint,
account_id bigint,
issued_on date,
due_on date,
paid_on date,

View File

@@ -299,6 +299,7 @@ export const CLUB_DATA_MODELS = {
'invoice_number',
'external_reference',
'party_id',
'account_id',
'issued_on',
'due_on',
'paid_on',

View File

@@ -42,6 +42,7 @@ const ClubHistoryView = () => import('./views/ClubHistoryView.vue');
const ClubStatisticsView = () => import('./views/ClubStatisticsView.vue');
const ClubArchiveView = () => import('./views/ClubArchiveView.vue');
const ClubAccountsView = () => import('./views/ClubAccountsView.vue');
const ClubInvoicesView = () => import('./views/ClubInvoicesView.vue');
const ClubConceptModuleView = () => import('./views/ClubConceptModuleView.vue');
const Impressum = () => import('./views/Impressum.vue');
const Datenschutz = () => import('./views/Datenschutz.vue');
@@ -140,6 +141,7 @@ const conceptRoutes = CLUB_CONCEPT_ROUTES
.filter((route) => route.path !== '/club-statistics')
.filter((route) => route.path !== '/club-archive')
.filter((route) => route.path !== '/club-accounts')
.filter((route) => route.path !== '/club-invoices')
.map((route) => ({
path: route.path,
name: route.name,
@@ -189,6 +191,7 @@ const routes = [
{ path: '/club-statistics', name: 'club-statistics', component: ClubStatisticsView, meta: withMeta({ products: clubOnly, permission: ['statistics', 'read'] }) },
{ path: '/club-archive', name: 'club-archive', component: ClubArchiveView, meta: withMeta({ products: clubOnly, permission: ['settings', 'read'] }) },
{ path: '/club-accounts', name: 'club-accounts', component: ClubAccountsView, meta: withMeta({ products: clubOnly, permission: ['members', 'read'] }) },
{ path: '/club-invoices', name: 'club-invoices', component: ClubInvoicesView, meta: withMeta({ products: clubOnly, permission: ['members', 'read'] }) },
...conceptRoutes,
{ path: '/impressum', name: 'impressum', component: Impressum, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/datenschutz', name: 'datenschutz', component: Datenschutz, meta: withMeta({ public: true, products: allProducts }) },

File diff suppressed because it is too large Load Diff

View File

@@ -124,6 +124,30 @@
</div>
</section>
<section v-if="currentClub && !loading" class="card">
<h2>Rechnungsnummern</h2>
<p class="hint">Automatische Nummernvergabe für Ausgangs- und Eingangsrechnungen.</p>
<div class="field-grid">
<div class="field-group">
<label>Präfix Ausgangsrechnungen</label>
<input v-model="outgoingInvoicePrefix" class="text-input" maxlength="24" placeholder="RE" />
</div>
<div class="field-group">
<label>Nächste Nummer Ausgang</label>
<input v-model.number="outgoingInvoiceNextNumber" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field-group">
<label>Präfix Eingangsrechnungen</label>
<input v-model="incomingInvoicePrefix" class="text-input" maxlength="24" placeholder="EI" />
</div>
<div class="field-group">
<label>Nächste Nummer Eingang</label>
<input v-model.number="incomingInvoiceNextNumber" class="text-input" type="number" min="1" step="1" />
</div>
</div>
<p class="hint">Beispiel heute: {{ invoiceNumberPreview('outgoing') }} / {{ invoiceNumberPreview('incoming') }}</p>
</section>
<section v-if="currentClub && !loading" class="card actions-card">
<div class="actions">
<button class="btn btn-primary" @click="save">{{ $t('clubSettings.save') }}</button>
@@ -249,6 +273,10 @@ export default {
myTischtennisFedNickname: '',
autoFetchRankings: false,
memberDataQualityRequirements: defaultMemberDataQualityRequirements(),
outgoingInvoicePrefix: 'RE',
outgoingInvoiceNextNumber: 1,
incomingInvoicePrefix: 'EI',
incomingInvoiceNextNumber: 1,
saved: false,
loading: false,
loadError: null,
@@ -315,6 +343,10 @@ export default {
this.myTischtennisFedNickname = '';
this.autoFetchRankings = false;
this.memberDataQualityRequirements = defaultMemberDataQualityRequirements();
this.outgoingInvoicePrefix = 'RE';
this.outgoingInvoiceNextNumber = 1;
this.incomingInvoicePrefix = 'EI';
this.incomingInvoiceNextNumber = 1;
this.loadError = null;
return;
}
@@ -330,6 +362,10 @@ export default {
this.myTischtennisFedNickname = club?.myTischtennisFedNickname ?? '';
this.autoFetchRankings = !!club?.autoFetchRankings;
this.memberDataQualityRequirements = this.normalizeMemberDataQualityRequirements(club?.memberDataQualityRequirements);
this.outgoingInvoicePrefix = club?.outgoingInvoicePrefix ?? 'RE';
this.outgoingInvoiceNextNumber = Number(club?.outgoingInvoiceNextNumber ?? 1) || 1;
this.incomingInvoicePrefix = club?.incomingInvoicePrefix ?? 'EI';
this.incomingInvoiceNextNumber = Number(club?.incomingInvoiceNextNumber ?? 1) || 1;
} catch (e) {
this.loadError = this.$t('clubSettings.loadFailed');
this.greeting = '';
@@ -339,6 +375,10 @@ export default {
this.myTischtennisFedNickname = '';
this.autoFetchRankings = false;
this.memberDataQualityRequirements = defaultMemberDataQualityRequirements();
this.outgoingInvoicePrefix = 'RE';
this.outgoingInvoiceNextNumber = 1;
this.incomingInvoicePrefix = 'EI';
this.incomingInvoiceNextNumber = 1;
} finally {
this.loading = false;
}
@@ -363,6 +403,26 @@ export default {
return null;
}
},
normalizeInvoicePrefix(value, fallback) {
const normalized = String(value || 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;
},
invoiceNumberPreview(direction) {
const year = new Date().getFullYear();
const isIncoming = direction === 'incoming';
const prefix = isIncoming
? this.normalizeInvoicePrefix(this.incomingInvoicePrefix, 'EI')
: this.normalizeInvoicePrefix(this.outgoingInvoicePrefix, 'RE');
const nextNumber = isIncoming
? this.normalizeInvoiceNextNumber(this.incomingInvoiceNextNumber)
: this.normalizeInvoiceNextNumber(this.outgoingInvoiceNextNumber);
const padded = String(nextNumber).padStart(4, '0');
return `${prefix}-${year}-${padded}`;
},
emptyVenueForm() {
return { id: null, name: '', address: '', zip: '', city: '' };
},
@@ -444,6 +504,10 @@ export default {
myTischtennisFedNickname: this.myTischtennisFedNickname || null,
autoFetchRankings: this.autoFetchRankings,
memberDataQualityRequirements: this.normalizeMemberDataQualityRequirements(this.memberDataQualityRequirements),
outgoingInvoicePrefix: this.normalizeInvoicePrefix(this.outgoingInvoicePrefix, 'RE'),
outgoingInvoiceNextNumber: this.normalizeInvoiceNextNumber(this.outgoingInvoiceNextNumber),
incomingInvoicePrefix: this.normalizeInvoicePrefix(this.incomingInvoicePrefix, 'EI'),
incomingInvoiceNextNumber: this.normalizeInvoiceNextNumber(this.incomingInvoiceNextNumber),
});
this.saved = true;
setTimeout(() => (this.saved = false), 1500);