more club funtions, fix for team edit
This commit is contained in:
93
backend/controllers/clubInvoiceController.js
Normal file
93
backend/controllers/clubInvoiceController.js
Normal 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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
109
backend/models/ClubInvoice.js
Normal file
109
backend/models/ClubInvoice.js
Normal 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;
|
||||
49
backend/models/ClubInvoiceItem.js
Normal file
49
backend/models/ClubInvoiceItem.js
Normal 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;
|
||||
75
backend/models/ClubInvoiceParty.js
Normal file
75
backend/models/ClubInvoiceParty.js
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
21
backend/routes/clubInvoiceRoutes.js
Normal file
21
backend/routes/clubInvoiceRoutes.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
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