feat: add recurring transactions (Wiederkehrende Buchungen)

Lets treasurers/captains/coaches define recurring fee/levy dues that
are automatically booked for all active players on a monthly,
quarterly, or yearly schedule via a daily cron job, instead of having
to book them manually every cycle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-03 20:20:57 +02:00
parent 6531f2553f
commit 9e55c0549b
25 changed files with 2181 additions and 1 deletions

View File

@@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { Player } from 'src/players/entities/player.entity';
import { Transaction } from 'src/transactions/entitites/transaction.entity';
import { DataSource, LessThanOrEqual, Repository } from 'typeorm';
import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
import { RecurringTransaction } from './entities/recurring-transaction.entity';
const INTERVAL_MONTHS: Record<RecurringTransactionIntervalEnum, number> = {
[RecurringTransactionIntervalEnum.monthly]: 1,
[RecurringTransactionIntervalEnum.quarterly]: 3,
[RecurringTransactionIntervalEnum.yearly]: 12,
};
@Injectable()
export class RecurringTransactionsScheduler {
constructor(
@InjectRepository(RecurringTransaction)
private readonly repository: Repository<RecurringTransaction>,
private readonly dataSource: DataSource,
private readonly logger: LoggingService,
) {}
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async runDueRecurringTransactions(): Promise<void> {
const today = new Date().toISOString();
const due = await this.repository.find({
where: { active: true, nextRunDate: LessThanOrEqual(today) },
relations: ['team', 'type'],
});
for (const definition of due) {
await this.runOne(definition);
}
}
private async runOne(definition: RecurringTransaction): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const players = await manager
.getRepository(Player)
.find({ where: { team: { id: definition.team.id }, active: true } });
const transactionRepository = manager.getRepository(Transaction);
for (const player of players) {
const transaction = transactionRepository.create({
amount: definition.amount,
date: new Date().toISOString(),
note: definition.description,
player,
type: definition.type,
});
await transactionRepository.save(transaction);
}
const definitionRepository = manager.getRepository(RecurringTransaction);
definition.nextRunDate = this.advance(
definition.nextRunDate,
definition.interval,
);
await definitionRepository.save(definition);
await this.logger.info(
{
event: 'recurring_transaction_run',
details: `recurringTransactionId=${definition.id} teamId=${definition.team.id} gebuchteSpieler=${players.length}`,
userId: -1,
},
manager,
);
});
}
private advance(
nextRunDate: string,
interval: RecurringTransactionIntervalEnum,
): string {
const date = new Date(nextRunDate);
date.setUTCMonth(date.getUTCMonth() + INTERVAL_MONTHS[interval]);
return date.toISOString();
}
}