From 302ed20a9bbead6b7ac5a16f13d18e0e7c731422 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Sun, 21 Jun 2026 17:15:04 +0200 Subject: [PATCH] more club funtions, fix for team edit --- backend/controllers/clubInvoiceController.js | 93 ++ backend/controllers/clubsController.js | 12 +- backend/models/Club.js | 24 + backend/models/ClubInvoice.js | 109 ++ backend/models/ClubInvoiceItem.js | 49 + backend/models/ClubInvoiceParty.js | 75 ++ backend/models/index.js | 21 + backend/routes/clubInvoiceRoutes.js | 21 + backend/server.js | 7 +- backend/services/clubInvoiceService.js | 367 ++++++ backend/services/clubService.js | 20 +- backend/services/clubTeamService.js | 48 +- frontend/sql/tt-verein-v1-schema.mysql.sql | 68 ++ frontend/sql/tt-verein-v1-schema.sql | 5 + frontend/src/config/clubDataModels.js | 1 + frontend/src/router.js | 3 + frontend/src/views/ClubInvoicesView.vue | 1061 ++++++++++++++++++ frontend/src/views/ClubSettings.vue | 64 ++ 18 files changed, 2032 insertions(+), 16 deletions(-) create mode 100644 backend/controllers/clubInvoiceController.js create mode 100644 backend/models/ClubInvoice.js create mode 100644 backend/models/ClubInvoiceItem.js create mode 100644 backend/models/ClubInvoiceParty.js create mode 100644 backend/routes/clubInvoiceRoutes.js create mode 100644 backend/services/clubInvoiceService.js create mode 100644 frontend/src/views/ClubInvoicesView.vue diff --git a/backend/controllers/clubInvoiceController.js b/backend/controllers/clubInvoiceController.js new file mode 100644 index 00000000..765080cb --- /dev/null +++ b/backend/controllers/clubInvoiceController.js @@ -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(); diff --git a/backend/controllers/clubsController.js b/backend/controllers/clubsController.js index 6009cf05..b91cdf3d 100644 --- a/backend/controllers/clubsController.js +++ b/backend/controllers/clubsController.js @@ -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) { diff --git a/backend/models/Club.js b/backend/models/Club.js index 3b182ae7..6a5a8327 100644 --- a/backend/models/Club.js +++ b/backend/models/Club.js @@ -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', diff --git a/backend/models/ClubInvoice.js b/backend/models/ClubInvoice.js new file mode 100644 index 00000000..30c5a263 --- /dev/null +++ b/backend/models/ClubInvoice.js @@ -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; diff --git a/backend/models/ClubInvoiceItem.js b/backend/models/ClubInvoiceItem.js new file mode 100644 index 00000000..066251c4 --- /dev/null +++ b/backend/models/ClubInvoiceItem.js @@ -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; diff --git a/backend/models/ClubInvoiceParty.js b/backend/models/ClubInvoiceParty.js new file mode 100644 index 00000000..7839e0e9 --- /dev/null +++ b/backend/models/ClubInvoiceParty.js @@ -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; diff --git a/backend/models/index.js b/backend/models/index.js index 91c0266c..2d14177a 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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, diff --git a/backend/routes/clubInvoiceRoutes.js b/backend/routes/clubInvoiceRoutes.js new file mode 100644 index 00000000..00ecbd63 --- /dev/null +++ b/backend/routes/clubInvoiceRoutes.js @@ -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; diff --git a/backend/server.js b/backend/server.js index e00790ce..535e8f46 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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); diff --git a/backend/services/clubInvoiceService.js b/backend/services/clubInvoiceService.js new file mode 100644 index 00000000..07039523 --- /dev/null +++ b/backend/services/clubInvoiceService.js @@ -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(); diff --git a/backend/services/clubService.js b/backend/services/clubService.js index bd0bf553..80b23f55 100644 --- a/backend/services/clubService.js +++ b/backend/services/clubService.js @@ -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({ diff --git a/backend/services/clubTeamService.js b/backend/services/clubTeamService.js index 4bcdeb08..0c1f3c6f 100644 --- a/backend/services/clubTeamService.js +++ b/backend/services/clubTeamService.js @@ -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 []; diff --git a/frontend/sql/tt-verein-v1-schema.mysql.sql b/frontend/sql/tt-verein-v1-schema.mysql.sql index 22311a6a..7823bacb 100644 --- a/frontend/sql/tt-verein-v1-schema.mysql.sql +++ b/frontend/sql/tt-verein-v1-schema.mysql.sql @@ -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; diff --git a/frontend/sql/tt-verein-v1-schema.sql b/frontend/sql/tt-verein-v1-schema.sql index 3f77c8af..0a91578a 100644 --- a/frontend/sql/tt-verein-v1-schema.sql +++ b/frontend/sql/tt-verein-v1-schema.sql @@ -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, diff --git a/frontend/src/config/clubDataModels.js b/frontend/src/config/clubDataModels.js index 4d2faab8..88ec7488 100644 --- a/frontend/src/config/clubDataModels.js +++ b/frontend/src/config/clubDataModels.js @@ -299,6 +299,7 @@ export const CLUB_DATA_MODELS = { 'invoice_number', 'external_reference', 'party_id', + 'account_id', 'issued_on', 'due_on', 'paid_on', diff --git a/frontend/src/router.js b/frontend/src/router.js index 2e3846f6..7e1ccb4d 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -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 }) }, diff --git a/frontend/src/views/ClubInvoicesView.vue b/frontend/src/views/ClubInvoicesView.vue new file mode 100644 index 00000000..60fd586c --- /dev/null +++ b/frontend/src/views/ClubInvoicesView.vue @@ -0,0 +1,1061 @@ + + + + + diff --git a/frontend/src/views/ClubSettings.vue b/frontend/src/views/ClubSettings.vue index d1d83b0f..e8692d80 100644 --- a/frontend/src/views/ClubSettings.vue +++ b/frontend/src/views/ClubSettings.vue @@ -124,6 +124,30 @@ +
+

Rechnungsnummern

+

Automatische Nummernvergabe für Ausgangs- und Eingangsrechnungen.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

Beispiel heute: {{ invoiceNumberPreview('outgoing') }} / {{ invoiceNumberPreview('incoming') }}

+
+
@@ -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);