feat: Enhance club payment claims and task automation
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 1m0s

- Added `paid_amount_cents` column to `club_payment_claims` for better tracking of payments.
- Implemented compatibility checks for the new column in `clubPaymentClaimService` and `clubTaskAutomationService`.
- Updated various views to handle read-only states when editing is not allowed.
- Refactored forms in `ClubAccountsView`, `ClubInvoicesView`, `ClubTasksView`, and `ClubCommunicationView` to use factory functions for cleaner code.
- Introduced a new migration script to add the `paid_amount_cents` column if it doesn't exist and initialize it for existing paid claims.
- Created a detailed plan for enhancing club features and ensuring stability in existing modules.
This commit is contained in:
Torsten Schulz (local)
2026-07-13 17:01:13 +02:00
parent 90e6c2f9f6
commit ac97332e6f
17 changed files with 628 additions and 307 deletions

View File

@@ -13,6 +13,7 @@ import {
TrainingGroup,
} from '../models/index.js';
import clubArchiveService from '../services/clubArchiveService.js';
import { hasClubPaymentClaimPaidAmountCentsColumn } from '../services/clubPaymentClaimCompatibility.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
function formatRequestWorkflowStage(stage) {
@@ -249,6 +250,7 @@ export const getClubDashboard = async (req, res) => {
today.setHours(0, 0, 0, 0);
const todayIso = today.toISOString().slice(0, 10);
const availableTables = await loadAvailableTables();
const hasPaidAmountCentsColumn = await hasClubPaymentClaimPaidAmountCentsColumn();
const [
requests,
@@ -293,14 +295,16 @@ export const getClubDashboard = async (req, res) => {
},
attributes: ['memberId'],
})),
loadOptionalTableData(availableTables, 'club_payment_claims', () => ClubPaymentClaim.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'partially_paid'] },
archivedAt: null,
},
order: [['dueOn', 'ASC']],
})),
hasPaidAmountCentsColumn
? loadOptionalTableData(availableTables, 'club_payment_claims', () => ClubPaymentClaim.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'partially_paid'] },
archivedAt: null,
},
order: [['dueOn', 'ASC']],
}))
: Promise.resolve([]),
loadOptionalTableData(availableTables, 'calendar_events', () => CalendarEvent.findAll({
where: {
clubId,

View File

@@ -10,6 +10,7 @@ import Season from '../models/Season.js';
import User from '../models/User.js';
import HttpError from '../exceptions/HttpError.js';
import { devLog } from '../utils/logger.js';
import { hasUserClubAccess } from '../utils/userUtils.js';
import { randomUUID } from 'crypto';
const teamDataFetchJobs = new Map();
@@ -635,15 +636,34 @@ class MyTischtennisUrlController {
/**
* Configure league from myTischtennis table URL
* POST /api/mytischtennis/configure-league
* Body: { url: string, createSeason?: boolean }
* Body: { url: string, clubId: number, createSeason?: boolean }
*/
async configureLeague(req, res, next) {
try {
const { url, createSeason } = req.body;
const { url, createSeason, clubId } = req.body;
const userIdOrEmail = req.headers.userid;
if (!url) {
throw new HttpError('URL is required', 400);
if (!url || !clubId) {
throw new HttpError('URL and clubId are required', 400);
}
let userId = userIdOrEmail;
if (isNaN(userIdOrEmail)) {
const user = await User.findOne({ where: { email: userIdOrEmail } });
if (!user) {
throw new HttpError('User not found', 404);
}
userId = user.id;
}
const normalizedClubId = Number.parseInt(clubId, 10);
if (!Number.isInteger(normalizedClubId) || normalizedClubId <= 0) {
throw new HttpError('clubId must be a valid number', 400);
}
const hasAccess = await hasUserClubAccess(userId, normalizedClubId);
if (!hasAccess) {
throw new HttpError('Keine Berechtigung für diesen Verein', 403);
}
// Parse URL
@@ -669,6 +689,7 @@ class MyTischtennisUrlController {
// Find or create league
let league = await League.findOne({
where: {
clubId: normalizedClubId,
myTischtennisGroupId: parsedData.groupId,
association: parsedData.association
}
@@ -677,6 +698,7 @@ class MyTischtennisUrlController {
if (!league) {
league = await League.create({
name: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version
clubId: normalizedClubId,
myTischtennisGroupId: parsedData.groupId,
association: parsedData.association,
groupname: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version

View File

@@ -0,0 +1,36 @@
-- club_payment_claims: Feld wie backend/models/ClubPaymentClaim.js (paidAmountCents)
-- Fehlt in der DB -> SequelizeDatabaseError ER_BAD_FIELD_ERROR in Club-Dashboard,
-- Aufgaben-Automation, Konten und Zahlungsforderungen.
--
-- Diese Migration ist idempotent:
-- - fuegt `paid_amount_cents` nur hinzu, wenn die Spalte noch fehlt
-- - initialisiert bestehende Datensaetze mit Status `paid` auf `amount_cents`
-- Hinweis:
-- Bereits teilweise bezahlte Altfaelle koennen ohne historische Buchungsdaten
-- nicht exakt rekonstruiert werden und bleiben daher initial bei 0.
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'club_payment_claims'
AND COLUMN_NAME = 'paid_amount_cents'
);
SET @add_column_sql := IF(
@column_exists = 0,
'ALTER TABLE `club_payment_claims`
ADD COLUMN `paid_amount_cents` BIGINT NOT NULL DEFAULT 0
COMMENT ''Bereits bezahlter Anteil in Cent''
AFTER `amount_cents`',
'SELECT ''Column paid_amount_cents already exists'' AS message'
);
PREPARE add_column_stmt FROM @add_column_sql;
EXECUTE add_column_stmt;
DEALLOCATE PREPARE add_column_stmt;
UPDATE `club_payment_claims`
SET `paid_amount_cents` = `amount_cents`
WHERE `status` = 'paid'
AND COALESCE(`paid_amount_cents`, 0) = 0;

View File

@@ -4,6 +4,7 @@ import ClubAccount from '../models/ClubAccount.js';
import ClubAccountTransaction from '../models/ClubAccountTransaction.js';
import { ClubPaymentClaim, Member } from '../models/index.js';
import clubPaymentClaimService from './clubPaymentClaimService.js';
import { hasClubPaymentClaimPaidAmountCentsColumn } from './clubPaymentClaimCompatibility.js';
const TRANSACTION_DIRECTIONS = new Set(['credit', 'debit']);
const TRANSACTION_BOOKING_TYPES = new Set(['manual', 'invoice', 'adjustment', 'payment_claim']);
@@ -143,6 +144,10 @@ function scorePaymentClaimMatch(transaction, claim) {
}
async function findBestPaymentClaimMatch(clubId, transaction, dbTransaction = null) {
if (!(await hasClubPaymentClaimPaidAmountCentsColumn())) {
return null;
}
if (!transaction || transaction.direction !== 'credit' || transaction.status !== 'booked' || Number(transaction.amountCents || 0) <= 0) {
return null;
}
@@ -204,6 +209,17 @@ async function findBestPaymentClaimMatch(clubId, transaction, dbTransaction = nu
return bestMatch;
}
function buildTransactionIncludes(includePaymentClaims) {
return includePaymentClaims
? [
{ model: ClubAccount, as: 'account', required: false },
{ model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] },
]
: [
{ model: ClubAccount, as: 'account', required: false },
];
}
function validatePayload(payload) {
if (!payload.name) {
const error = new Error('Kontobezeichnung ist erforderlich.');
@@ -292,6 +308,7 @@ async function ensureFallbackDefault(clubId, transaction) {
class ClubAccountService {
async listClubAccounts(clubId) {
const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn();
const [accounts, transactions] = await Promise.all([
ClubAccount.findAll({
where: { clubId },
@@ -304,10 +321,7 @@ class ClubAccountService {
}),
ClubAccountTransaction.findAll({
where: { clubId },
include: [
{ model: ClubAccount, as: 'account', required: false },
{ model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] },
],
include: buildTransactionIncludes(includePaymentClaims),
order: [['bookingDate', 'DESC'], ['createdAt', 'DESC']],
limit: 250,
}),
@@ -320,7 +334,11 @@ class ClubAccountService {
}
async createTransaction(clubId, userId, payload) {
const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn();
const normalized = normalizeTransactionPayload(payload);
if (!includePaymentClaims) {
normalized.paymentClaimId = null;
}
validateTransactionPayload(normalized);
const account = await ClubAccount.findOne({
@@ -334,7 +352,7 @@ class ClubAccountService {
return sequelize.transaction(async (dbTransaction) => {
let matchedClaim = null;
if (normalized.paymentClaimId) {
if (includePaymentClaims && normalized.paymentClaimId) {
matchedClaim = await ClubPaymentClaim.findOne({
where: {
id: normalized.paymentClaimId,
@@ -351,7 +369,7 @@ class ClubAccountService {
error.status = 404;
throw error;
}
} else {
} else if (includePaymentClaims) {
matchedClaim = await findBestPaymentClaimMatch(clubId, normalized, dbTransaction);
}
@@ -359,13 +377,13 @@ class ClubAccountService {
clubId,
createdByUserId: userId || null,
...normalized,
paymentClaimId: matchedClaim ? matchedClaim.id : normalized.paymentClaimId,
paymentClaimId: includePaymentClaims ? (matchedClaim ? matchedClaim.id : normalized.paymentClaimId) : null,
bookingType: matchedClaim ? 'payment_claim' : normalized.bookingType,
};
const transaction = await ClubAccountTransaction.create(transactionPayload, { transaction: dbTransaction });
if (matchedClaim) {
if (includePaymentClaims && matchedClaim) {
await clubPaymentClaimService.applyPaymentToClaim(
clubId,
matchedClaim,
@@ -378,16 +396,14 @@ class ClubAccountService {
}
return transaction.reload({
include: [
{ model: ClubAccount, as: 'account', required: false },
{ model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] },
],
include: buildTransactionIncludes(includePaymentClaims),
transaction: dbTransaction,
});
});
}
async updateTransaction(clubId, transactionId, payload) {
const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn();
const transactionRow = await ClubAccountTransaction.findOne({
where: { id: transactionId, clubId },
});
@@ -403,6 +419,9 @@ class ClubAccountService {
}
const normalized = normalizeTransactionPayload(payload);
if (!includePaymentClaims) {
normalized.paymentClaimId = null;
}
validateTransactionPayload(normalized);
const previousPaymentClaimId = Number(transactionRow.paymentClaimId || 0) || null;
@@ -417,20 +436,20 @@ class ClubAccountService {
await transactionRow.update(normalized);
const claimIdsToReconcile = new Set([previousPaymentClaimId, normalized.paymentClaimId || null].filter(Boolean));
for (const claimId of claimIdsToReconcile) {
await clubPaymentClaimService.reconcileFromTransactions(clubId, claimId);
if (includePaymentClaims) {
const claimIdsToReconcile = new Set([previousPaymentClaimId, normalized.paymentClaimId || null].filter(Boolean));
for (const claimId of claimIdsToReconcile) {
await clubPaymentClaimService.reconcileFromTransactions(clubId, claimId);
}
}
return transactionRow.reload({
include: [
{ model: ClubAccount, as: 'account', required: false },
{ model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] },
],
include: buildTransactionIncludes(includePaymentClaims),
});
}
async deleteTransaction(clubId, transactionId) {
const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn();
const transactionRow = await ClubAccountTransaction.findOne({
where: { id: transactionId, clubId },
});
@@ -447,13 +466,14 @@ class ClubAccountService {
const claimId = Number(transactionRow.paymentClaimId || 0) || null;
await transactionRow.destroy();
if (claimId) {
if (includePaymentClaims && claimId) {
await clubPaymentClaimService.reconcileFromTransactions(clubId, claimId);
}
return { success: true };
}
async listAccountTransactions(clubId, accountId = null) {
const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn();
const where = { clubId };
if (accountId) {
where.accountId = accountId;
@@ -461,10 +481,7 @@ class ClubAccountService {
return ClubAccountTransaction.findAll({
where,
include: [
{ model: ClubAccount, as: 'account', required: false },
{ model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] },
],
include: buildTransactionIncludes(includePaymentClaims),
order: [
['bookingDate', 'DESC'],
['createdAt', 'DESC'],

View File

@@ -1,6 +1,7 @@
import { Op } from 'sequelize';
import sequelize from '../database.js';
import { ClubPaymentClaim, ClubRequest, ClubTask, Member } from '../models/index.js';
import { hasClubPaymentClaimPaidAmountCentsColumn } from './clubPaymentClaimCompatibility.js';
const DEFAULT_LIMIT = 50;
@@ -36,6 +37,7 @@ class ClubArchiveService {
}
const availableTables = await loadAvailableTables();
const hasPaidAmountCentsColumn = await hasClubPaymentClaimPaidAmountCentsColumn();
const members = await Member.findAll({
where: { clubId },
@@ -70,21 +72,23 @@ class ClubArchiveService {
})
);
const archivedClaims = await loadOptionalTableData(
availableTables,
'club_payment_claims',
() => ClubPaymentClaim.findAll({
where: {
clubId,
[Op.or]: [
{ archivedAt: { [Op.not]: null } },
{ status: { [Op.in]: ['written_off', 'cancelled'] } },
],
},
order: [['archivedAt', 'DESC'], ['updatedAt', 'DESC']],
limit: DEFAULT_LIMIT,
})
);
const archivedClaims = hasPaidAmountCentsColumn
? await loadOptionalTableData(
availableTables,
'club_payment_claims',
() => ClubPaymentClaim.findAll({
where: {
clubId,
[Op.or]: [
{ archivedAt: { [Op.not]: null } },
{ status: { [Op.in]: ['written_off', 'cancelled'] } },
],
},
order: [['archivedAt', 'DESC'], ['updatedAt', 'DESC']],
limit: DEFAULT_LIMIT,
})
)
: [];
const inactiveMembers = members
.filter((member) => !member.active)

View File

@@ -0,0 +1,22 @@
import sequelize from '../database.js';
let hasPaidAmountCentsColumnPromise = null;
function isMissingTableError(error) {
return error?.original?.code === 'ER_NO_SUCH_TABLE';
}
export async function hasClubPaymentClaimPaidAmountCentsColumn() {
if (!hasPaidAmountCentsColumnPromise) {
hasPaidAmountCentsColumnPromise = sequelize.getQueryInterface().describeTable('club_payment_claims')
.then((description) => Boolean(description?.paid_amount_cents))
.catch((error) => {
if (isMissingTableError(error)) {
return false;
}
throw error;
});
}
return hasPaidAmountCentsColumnPromise;
}

View File

@@ -1,6 +1,7 @@
import { Op, Transaction } from 'sequelize';
import sequelize from '../database.js';
import { ClubAccount, ClubAccountTransaction, ClubPaymentClaim, Member } from '../models/index.js';
import { hasClubPaymentClaimPaidAmountCentsColumn } from './clubPaymentClaimCompatibility.js';
const CLAIM_TYPES = new Set(['membership_fee', 'additional_fee', 'course_fee', 'penalty_fee', 'other']);
const CLAIM_STATUSES = new Set(['open', 'partially_paid', 'paid', 'written_off', 'cancelled']);
@@ -82,6 +83,10 @@ async function ensureMemberBelongsToClub(clubId, memberId) {
class ClubPaymentClaimService {
async listClaims(clubId) {
if (!(await hasClubPaymentClaimPaidAmountCentsColumn())) {
return { claims: [] };
}
const claims = await ClubPaymentClaim.findAll({
where: {
clubId,

View File

@@ -1,4 +1,5 @@
import { Op } from 'sequelize';
import sequelize from '../database.js';
import {
CalendarEvent,
ClubCommunicationRecipient,
@@ -14,9 +15,24 @@ import {
Member,
} from '../models/index.js';
import { CLUB_TASK_DEFINITIONS, CLUB_WORKFLOW_SOURCES, getClubTaskDefinitionMap } from './clubTaskDefinitions.js';
import { hasClubPaymentClaimPaidAmountCentsColumn } from './clubPaymentClaimCompatibility.js';
const definitionMap = getClubTaskDefinitionMap();
async function loadAvailableTables() {
const tables = await sequelize.getQueryInterface().showAllTables();
return new Set(
tables
.map((table) => (typeof table === 'string' ? table : Object.values(table || {})[0]))
.filter(Boolean)
.map((table) => String(table).toLowerCase())
);
}
function hasTable(availableTables, tableName) {
return availableTables.has(String(tableName).toLowerCase());
}
function activeTask(task) {
return !['done', 'cancelled', 'archived'].includes(task.status);
}
@@ -184,6 +200,8 @@ function sponsorPartySuggestionFor(party, today) {
class ClubTaskAutomationService {
async buildAutomationOverview(clubId) {
const today = todayStart();
const availableTables = await loadAvailableTables();
const hasPaidAmountCentsColumn = await hasClubPaymentClaimPaidAmountCentsColumn();
const [currentTasks, requests, members, mandates, paymentClaims, invoices, parties, documents, communicationRecipients, events, suppressions] = await Promise.all([
ClubTask.findAll({
where: {
@@ -191,13 +209,15 @@ class ClubTaskAutomationService {
automationKey: { [Op.ne]: null },
},
}),
ClubRequest.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'in_progress', 'waiting'] },
},
order: [['receivedAt', 'ASC']],
}),
hasTable(availableTables, 'club_requests')
? ClubRequest.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'in_progress', 'waiting'] },
},
order: [['receivedAt', 'ASC']],
})
: Promise.resolve([]),
Member.findAll({
where: {
clubId,
@@ -205,76 +225,86 @@ class ClubTaskAutomationService {
},
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
}),
ClubSepaMandate.findAll({
where: {
clubId,
status: 'active',
revokedAt: null,
memberId: { [Op.ne]: null },
},
attributes: ['memberId'],
}),
ClubPaymentClaim.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'partially_paid'] },
archivedAt: null,
},
order: [['dueOn', 'ASC']],
}),
ClubInvoice.findAll({
where: {
clubId,
status: { [Op.in]: ['issued', 'partially_paid'] },
archivedAt: null,
dueOn: { [Op.ne]: null },
},
order: [['dueOn', 'ASC']],
}),
ClubInvoiceParty.findAll({
where: {
clubId,
partyType: 'sponsor',
},
order: [['status', 'ASC'], ['validTo', 'ASC'], ['name', 'ASC']],
}),
ClubDocument.findAll({
where: {
clubId,
documentType: { [Op.in]: ['satzung', 'protokoll', 'nachweis'] },
status: { [Op.in]: ['active', 'draft'] },
},
order: [['updatedAt', 'DESC']],
}),
ClubCommunicationRecipient.findAll({
where: {
clubId,
deliveryStatus: 'failed',
retryable: true,
},
include: [
{ model: ClubCommunicationThread, as: 'thread', required: false },
],
order: [['updatedAt', 'DESC']],
}),
CalendarEvent.findAll({
where: {
clubId,
endDate: { [Op.gte]: today.toISOString().slice(0, 10) },
},
order: [['startDate', 'ASC']],
limit: 20,
}),
ClubTaskSuppression.findAll({
where: { clubId },
attributes: ['automationKey', 'suppressionToken'],
}).catch((error) => {
if (error?.original?.code === 'ER_NO_SUCH_TABLE'
&& /club_task_suppressions/.test(String(error?.original?.sqlMessage || ''))) {
return [];
}
throw error;
}),
hasTable(availableTables, 'club_sepa_mandates')
? ClubSepaMandate.findAll({
where: {
clubId,
status: 'active',
revokedAt: null,
memberId: { [Op.ne]: null },
},
attributes: ['memberId'],
})
: Promise.resolve([]),
hasPaidAmountCentsColumn && hasTable(availableTables, 'club_payment_claims')
? ClubPaymentClaim.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'partially_paid'] },
archivedAt: null,
},
order: [['dueOn', 'ASC']],
})
: Promise.resolve([]),
hasTable(availableTables, 'club_invoices')
? ClubInvoice.findAll({
where: {
clubId,
status: { [Op.in]: ['issued', 'partially_paid'] },
archivedAt: null,
dueOn: { [Op.ne]: null },
},
order: [['dueOn', 'ASC']],
})
: Promise.resolve([]),
hasTable(availableTables, 'club_invoice_parties')
? ClubInvoiceParty.findAll({
where: {
clubId,
partyType: 'sponsor',
},
order: [['status', 'ASC'], ['validTo', 'ASC'], ['name', 'ASC']],
})
: Promise.resolve([]),
hasTable(availableTables, 'club_documents')
? ClubDocument.findAll({
where: {
clubId,
documentType: { [Op.in]: ['satzung', 'protokoll', 'nachweis'] },
status: { [Op.in]: ['active', 'draft'] },
},
order: [['updatedAt', 'DESC']],
})
: Promise.resolve([]),
hasTable(availableTables, 'club_communication_recipients') && hasTable(availableTables, 'club_communication_threads')
? ClubCommunicationRecipient.findAll({
where: {
clubId,
deliveryStatus: 'failed',
retryable: true,
},
include: [
{ model: ClubCommunicationThread, as: 'thread', required: false },
],
order: [['updatedAt', 'DESC']],
})
: Promise.resolve([]),
hasTable(availableTables, 'calendar_events')
? CalendarEvent.findAll({
where: {
clubId,
endDate: { [Op.gte]: today.toISOString().slice(0, 10) },
},
order: [['startDate', 'ASC']],
limit: 20,
})
: Promise.resolve([]),
hasTable(availableTables, 'club_task_suppressions')
? ClubTaskSuppression.findAll({
where: { clubId },
attributes: ['automationKey', 'suppressionToken'],
})
: Promise.resolve([]),
]);
const existingKeys = new Set(currentTasks.filter(activeTask).map((task) => task.automationKey).filter(Boolean));