feat: Enhance club payment claims and task automation
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 1m0s
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:
@@ -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'],
|
||||
|
||||
@@ -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)
|
||||
|
||||
22
backend/services/clubPaymentClaimCompatibility.js
Normal file
22
backend/services/clubPaymentClaimCompatibility.js
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user