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

@@ -17,6 +17,7 @@
"@nestjs/jwt": "9.0.0",
"@nestjs/passport": "9.0.0",
"@nestjs/platform-express": "9.1.6",
"@nestjs/schedule": "^2.2.3",
"@nestjs/serve-static": "^3.0.0",
"@nestjs/swagger": "6.1.3",
"@nestjs/typeorm": "9.0.1",
@@ -3402,6 +3403,31 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
},
"node_modules/@nestjs/schedule": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-2.2.3.tgz",
"integrity": "sha512-PxoGdoBwZQ6SzGfFcERTk7mDxrmesNt2cfqKgtLsFpjYNpV6ZYlKw9Ku8C0ZIjdhy0tBbysj+Fsi3sYua6o6Eg==",
"license": "MIT",
"dependencies": {
"cron": "2.3.1",
"uuid": "9.0.0"
},
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0",
"reflect-metadata": "^0.1.12"
}
},
"node_modules/@nestjs/schedule/node_modules/uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@nestjs/schematics": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-9.0.3.tgz",
@@ -5944,6 +5970,15 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"devOptional": true
},
"node_modules/cron": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/cron/-/cron-2.3.1.tgz",
"integrity": "sha512-1eRRlIT0UfIqauwbG9pkg3J6CX9A6My2ytJWqAXoK0T9oJnUZTzGBNPxao0zjodIbPgf8UQWjE62BMb9eVllSQ==",
"license": "MIT",
"dependencies": {
"luxon": "^3.2.1"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -12551,6 +12586,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/macos-release": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz",
@@ -19998,6 +20042,22 @@
}
}
},
"@nestjs/schedule": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-2.2.3.tgz",
"integrity": "sha512-PxoGdoBwZQ6SzGfFcERTk7mDxrmesNt2cfqKgtLsFpjYNpV6ZYlKw9Ku8C0ZIjdhy0tBbysj+Fsi3sYua6o6Eg==",
"requires": {
"cron": "2.3.1",
"uuid": "9.0.0"
},
"dependencies": {
"uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg=="
}
}
},
"@nestjs/schematics": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-9.0.3.tgz",
@@ -21973,6 +22033,14 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"devOptional": true
},
"cron": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/cron/-/cron-2.3.1.tgz",
"integrity": "sha512-1eRRlIT0UfIqauwbG9pkg3J6CX9A6My2ytJWqAXoK0T9oJnUZTzGBNPxao0zjodIbPgf8UQWjE62BMb9eVllSQ==",
"requires": {
"luxon": "^3.2.1"
}
},
"cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -26872,6 +26940,11 @@
"yallist": "^3.0.2"
}
},
"luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="
},
"macos-release": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz",

View File

@@ -36,6 +36,7 @@
"@nestjs/jwt": "9.0.0",
"@nestjs/passport": "9.0.0",
"@nestjs/platform-express": "9.1.6",
"@nestjs/schedule": "^2.2.3",
"@nestjs/serve-static": "^3.0.0",
"@nestjs/swagger": "6.1.3",
"@nestjs/typeorm": "9.0.1",

View File

@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import databaseConfig from './config/database.config';
@@ -22,9 +23,11 @@ import { join } from 'path';
import { LoggingModule } from './database/logging/logging.module';
import { TranslateModule } from './translate/translate.module';
import { PenaltyModule } from './penalty/penalty.module';
import { RecurringTransactionsModule } from './recurring-transactions/recurring-transactions.module';
@Module({
imports: [
ScheduleModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
load: [databaseConfig, authConfig, appConfig, mailConfig],
@@ -55,6 +58,7 @@ import { PenaltyModule } from './penalty/penalty.module';
LoggingModule,
TranslateModule,
PenaltyModule,
RecurringTransactionsModule,
],
providers: [],
})

View File

@@ -26,6 +26,10 @@ export type LOGEVENT =
| 'penalty_catalog_update'
| 'penalty_catalog_delete'
| 'team_create'
| 'team_permissions_update';
| 'team_permissions_update'
| 'recurring_transaction_create'
| 'recurring_transaction_update'
| 'recurring_transaction_delete'
| 'recurring_transaction_run';
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';

View File

@@ -0,0 +1,49 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsIn,
IsInt,
IsNotEmpty,
IsNumber,
IsString,
Length,
Max,
Min,
} from 'class-validator';
import { TransactionTypeEnum } from 'src/transactions/transaction-type.enum';
import { RecurringTransactionIntervalEnum } from '../recurring-transaction-interval.enum';
const ALLOWED_TYPES = [TransactionTypeEnum.fee, TransactionTypeEnum.levy];
export class CreateRecurringTransactionDTO {
@ApiProperty({ example: 2342 })
@IsInt()
@Min(1)
teamId: number;
@ApiProperty({ example: 'Monatsbeitrag' })
@Transform(({ value }) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@Length(1, 120)
description: string;
@ApiProperty({ example: 10 })
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
@Max(10000)
amount: number;
@ApiProperty({ enum: ALLOWED_TYPES })
@IsIn(ALLOWED_TYPES)
type: TransactionTypeEnum;
@ApiProperty({ enum: RecurringTransactionIntervalEnum })
@IsIn(Object.values(RecurringTransactionIntervalEnum))
interval: RecurringTransactionIntervalEnum;
@ApiProperty({ example: 'isotimestring' })
@IsNotEmpty()
startDate: string;
}

View File

@@ -0,0 +1,12 @@
import { RecurringTransactionIntervalEnum } from '../recurring-transaction-interval.enum';
export class RecurringTransactionResponseDTO {
id: number;
description: string;
amount: number;
type: number;
interval: RecurringTransactionIntervalEnum;
nextRunDate: string;
active: boolean;
createdAt: Date;
}

View File

@@ -0,0 +1,43 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsNumber,
IsString,
Length,
Max,
Min,
} from 'class-validator';
import { TransactionTypeEnum } from 'src/transactions/transaction-type.enum';
import { RecurringTransactionIntervalEnum } from '../recurring-transaction-interval.enum';
const ALLOWED_TYPES = [TransactionTypeEnum.fee, TransactionTypeEnum.levy];
export class UpdateRecurringTransactionDTO {
@ApiProperty({ example: 'Monatsbeitrag' })
@Transform(({ value }) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@Length(1, 120)
description: string;
@ApiProperty({ example: 10 })
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0.01)
@Max(10000)
amount: number;
@ApiProperty({ enum: ALLOWED_TYPES })
@IsIn(ALLOWED_TYPES)
type: TransactionTypeEnum;
@ApiProperty({ enum: RecurringTransactionIntervalEnum })
@IsIn(Object.values(RecurringTransactionIntervalEnum))
interval: RecurringTransactionIntervalEnum;
@ApiProperty({ example: true })
@IsBoolean()
active: boolean;
}

View File

@@ -0,0 +1,45 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { EntityHelper } from 'src/utils/entity-helper';
import { Team } from 'src/teams/entities/team.entity';
import { TransactionType } from 'src/transactions/entitites/transaction-type.entity';
import { RecurringTransactionIntervalEnum } from '../recurring-transaction-interval.enum';
@Entity()
export class RecurringTransaction extends EntityHelper {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => Team, {
eager: false,
})
team: Team;
@Column({ default: '' })
description: string;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
amount: number;
@ManyToOne(() => TransactionType, {
eager: true,
})
type: TransactionType;
@Column()
interval: RecurringTransactionIntervalEnum;
@Column()
nextRunDate: string;
@Column({ default: true })
active: boolean;
@CreateDateColumn()
createdAt: Date;
}

View File

@@ -0,0 +1,5 @@
export enum RecurringTransactionIntervalEnum {
'monthly' = 'monthly',
'quarterly' = 'quarterly',
'yearly' = 'yearly',
}

View File

@@ -0,0 +1,62 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Patch,
Post,
Request,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth } from '@nestjs/swagger';
import { CreateRecurringTransactionDTO } from './dto/create-recurring-transaction.dto';
import { UpdateRecurringTransactionDTO } from './dto/update-recurring-transaction.dto';
import { RecurringTransactionsService } from './recurring-transactions.service';
type AuthenticatedRequest = { user: { id: number } };
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
@Controller({ path: 'recurring-transactions', version: '1' })
export class RecurringTransactionsController {
constructor(private readonly service: RecurringTransactionsService) {}
@Get(':teamId')
getTeamRecurringTransactions(
@Request() request: AuthenticatedRequest,
@Param('teamId', ParseIntPipe) teamId: number,
) {
return this.service.getTeamRecurringTransactions(request.user.id, teamId);
}
@Post()
createRecurringTransaction(
@Request() request: AuthenticatedRequest,
@Body() dto: CreateRecurringTransactionDTO,
) {
return this.service.createRecurringTransaction(dto, request.user.id);
}
@Patch(':id')
updateRecurringTransaction(
@Request() request: AuthenticatedRequest,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateRecurringTransactionDTO,
) {
return this.service.updateRecurringTransaction(id, dto, request.user.id);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteRecurringTransaction(
@Request() request: AuthenticatedRequest,
@Param('id', ParseIntPipe) id: number,
): Promise<void> {
await this.service.deleteRecurringTransaction(id, request.user.id);
}
}

View File

@@ -0,0 +1,150 @@
import {
INestApplication,
UnauthorizedException,
ValidationPipe,
VersioningType,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import validationOptions from '../utils/validation-options';
import { TransactionTypeEnum } from '../transactions/transaction-type.enum';
import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
import { RecurringTransactionsController } from './recurring-transactions.controller';
import { RecurringTransactionsService } from './recurring-transactions.service';
describe('recurring transactions HTTP boundary', () => {
let app: INestApplication;
const entry = {
id: 8,
description: 'Monatsbeitrag',
amount: 10,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.monthly,
nextRunDate: '2026-09-01T00:00:00.000Z',
active: true,
createdAt: '2026-01-02T00:00:00.000Z',
};
const service = {
getTeamRecurringTransactions: jest.fn(() => [entry]),
createRecurringTransaction: jest.fn(() => entry),
updateRecurringTransaction: jest.fn(() => entry),
deleteRecurringTransaction: jest.fn(),
};
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [RecurringTransactionsController],
providers: [
{ provide: RecurringTransactionsService, useValue: service },
],
})
.overrideGuard(AuthGuard('jwt'))
.useValue({
canActivate(context) {
const httpRequest = context.switchToHttp().getRequest();
if (httpRequest.headers.authorization !== 'Bearer user') {
throw new UnauthorizedException();
}
httpRequest.user = { id: 42, role: { id: 2 } };
return true;
},
})
.compile();
app = module.createNestApplication();
app.setGlobalPrefix('api');
app.enableVersioning({ type: VersioningType.URI });
app.useGlobalPipes(new ValidationPipe(validationOptions));
await app.init();
});
afterAll(() => app.close());
beforeEach(() => jest.clearAllMocks());
it('requires authentication for team reads', async () => {
await request(app.getHttpServer())
.get('/api/v1/recurring-transactions/5')
.expect(401);
await request(app.getHttpServer())
.get('/api/v1/recurring-transactions/5')
.set('Authorization', 'Bearer user')
.expect(200)
.expect([entry]);
expect(service.getTeamRecurringTransactions).toHaveBeenCalledWith(42, 5);
});
it('rejects a create payload with a disallowed transaction type', async () => {
await request(app.getHttpServer())
.post('/api/v1/recurring-transactions')
.set('Authorization', 'Bearer user')
.send({
teamId: 5,
description: 'Monatsbeitrag',
amount: 10,
type: TransactionTypeEnum.payment,
interval: RecurringTransactionIntervalEnum.monthly,
startDate: '2026-09-01T00:00:00.000Z',
})
.expect(422);
expect(service.createRecurringTransaction).not.toHaveBeenCalled();
});
it('accepts a valid create payload and forwards the actor', async () => {
await request(app.getHttpServer())
.post('/api/v1/recurring-transactions')
.set('Authorization', 'Bearer user')
.send({
teamId: 5,
description: 'Monatsbeitrag',
amount: 10,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.monthly,
startDate: '2026-09-01T00:00:00.000Z',
})
.expect(201)
.expect(entry);
expect(service.createRecurringTransaction).toHaveBeenCalledWith(
{
teamId: 5,
description: 'Monatsbeitrag',
amount: 10,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.monthly,
startDate: '2026-09-01T00:00:00.000Z',
},
42,
);
});
it('updates and deletes by id', async () => {
await request(app.getHttpServer())
.patch('/api/v1/recurring-transactions/8')
.set('Authorization', 'Bearer user')
.send({
description: 'Monatsbeitrag',
amount: 12,
type: TransactionTypeEnum.levy,
interval: RecurringTransactionIntervalEnum.quarterly,
active: false,
})
.expect(200)
.expect(entry);
expect(service.updateRecurringTransaction).toHaveBeenCalledWith(
8,
{
description: 'Monatsbeitrag',
amount: 12,
type: TransactionTypeEnum.levy,
interval: RecurringTransactionIntervalEnum.quarterly,
active: false,
},
42,
);
await request(app.getHttpServer())
.delete('/api/v1/recurring-transactions/8')
.set('Authorization', 'Bearer user')
.expect(204);
expect(service.deleteRecurringTransaction).toHaveBeenCalledWith(8, 42);
});
});

View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LoggingModule } from 'src/database/logging/logging.module';
import { Player } from 'src/players/entities/player.entity';
import { Transaction } from 'src/transactions/entitites/transaction.entity';
import { Team } from 'src/teams/entities/team.entity';
import { TeamsModule } from 'src/teams/teams.module';
import { RecurringTransactionsController } from './recurring-transactions.controller';
import { RecurringTransaction } from './entities/recurring-transaction.entity';
import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler';
import { RecurringTransactionsService } from './recurring-transactions.service';
@Module({
controllers: [RecurringTransactionsController],
providers: [RecurringTransactionsService, RecurringTransactionsScheduler],
imports: [
TypeOrmModule.forFeature([Team, RecurringTransaction, Player, Transaction]),
TeamsModule,
LoggingModule,
],
})
export class RecurringTransactionsModule {}

View File

@@ -0,0 +1,151 @@
import { TransactionTypeEnum } from '../transactions/transaction-type.enum';
import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler';
describe('RecurringTransactionsScheduler', () => {
const dueRepository = { find: jest.fn() };
const playerRepository = { find: jest.fn() };
const definitionWriteRepository = { save: jest.fn((value) => value) };
const transactionWriteRepository = {
create: jest.fn((value) => value),
save: jest.fn(async (value) => ({ id: 1, ...value })),
};
const manager = {
getRepository: jest.fn((entity) => {
if (entity.name === 'Player') return playerRepository;
if (entity.name === 'Transaction') return transactionWriteRepository;
return definitionWriteRepository;
}),
};
const dataSource = { transaction: jest.fn((work) => work(manager)) };
const logger = { info: jest.fn() };
let scheduler: RecurringTransactionsScheduler;
const player = (id: number, active: boolean) => ({ id, active, team: { id: 5 } });
beforeEach(() => {
jest.clearAllMocks();
});
function buildScheduler() {
return new RecurringTransactionsScheduler(
dueRepository as any,
dataSource as any,
logger as any,
);
}
it('does nothing when no recurring transaction is due', async () => {
dueRepository.find.mockResolvedValue([]);
scheduler = buildScheduler();
await scheduler.runDueRecurringTransactions();
expect(dataSource.transaction).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled();
});
it('books a transaction for every active player and skips inactive ones', async () => {
dueRepository.find.mockResolvedValue([
{
id: 3,
team: { id: 5 },
description: 'Monatsbeitrag',
amount: 10,
type: { id: TransactionTypeEnum.fee },
interval: RecurringTransactionIntervalEnum.monthly,
nextRunDate: '2026-08-01T00:00:00.000Z',
active: true,
},
]);
// The DB query filters by active:true itself (asserted below) — an
// inactive player would never be returned, so the mock reflects that.
playerRepository.find.mockResolvedValue([player(1, true), player(3, true)]);
scheduler = buildScheduler();
await scheduler.runDueRecurringTransactions();
expect(playerRepository.find).toHaveBeenCalledWith({
where: { team: { id: 5 }, active: true },
});
expect(transactionWriteRepository.save).toHaveBeenCalledTimes(2);
expect(transactionWriteRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
amount: 10,
note: 'Monatsbeitrag',
player: player(1, true),
type: { id: TransactionTypeEnum.fee },
}),
);
expect(logger.info).toHaveBeenCalledWith(
{
event: 'recurring_transaction_run',
details:
'recurringTransactionId=3 teamId=5 gebuchteSpieler=2',
userId: -1,
},
manager,
);
});
it.each([
[RecurringTransactionIntervalEnum.monthly, '2026-08-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z'],
[RecurringTransactionIntervalEnum.quarterly, '2026-08-01T00:00:00.000Z', '2026-11-01T00:00:00.000Z'],
[RecurringTransactionIntervalEnum.yearly, '2026-08-01T00:00:00.000Z', '2027-08-01T00:00:00.000Z'],
])(
'advances nextRunDate by %s from %s to %s',
async (interval, nextRunDate, expected) => {
dueRepository.find.mockResolvedValue([
{
id: 3,
team: { id: 5 },
description: 'Monatsbeitrag',
amount: 10,
type: { id: TransactionTypeEnum.fee },
interval,
nextRunDate,
active: true,
},
]);
playerRepository.find.mockResolvedValue([player(1, true)]);
scheduler = buildScheduler();
await scheduler.runDueRecurringTransactions();
expect(definitionWriteRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ nextRunDate: expected }),
);
},
);
it('processes each due definition inside its own transaction', async () => {
dueRepository.find.mockResolvedValue([
{
id: 3,
team: { id: 5 },
description: 'A',
amount: 10,
type: { id: TransactionTypeEnum.fee },
interval: RecurringTransactionIntervalEnum.monthly,
nextRunDate: '2026-08-01T00:00:00.000Z',
active: true,
},
{
id: 4,
team: { id: 6 },
description: 'B',
amount: 20,
type: { id: TransactionTypeEnum.levy },
interval: RecurringTransactionIntervalEnum.yearly,
nextRunDate: '2026-08-01T00:00:00.000Z',
active: true,
},
]);
playerRepository.find.mockResolvedValue([player(1, true)]);
scheduler = buildScheduler();
await scheduler.runDueRecurringTransactions();
expect(dataSource.transaction).toHaveBeenCalledTimes(2);
});
});

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();
}
}

View File

@@ -0,0 +1,245 @@
import { NotFoundException } from '@nestjs/common';
import { TeamRolesEnum } from '../team-roles/team-roles.enum';
import { TransactionTypeEnum } from '../transactions/transaction-type.enum';
import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
import { RecurringTransactionsService } from './recurring-transactions.service';
describe('RecurringTransactionsService catalog management', () => {
const readRepository = { find: jest.fn(), findOne: jest.fn() };
const teamReadRepository = { findOne: jest.fn() };
const teamQuery = {
where: jest.fn().mockReturnThis(),
setLock: jest.fn().mockReturnThis(),
getOne: jest.fn(),
};
const teamRepository = { createQueryBuilder: jest.fn(() => teamQuery) };
const typeRepository = { findOne: jest.fn() };
const writeRepository = {
create: jest.fn((value) => value),
save: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
};
const manager = {
getRepository: jest.fn((entity) => {
if (entity.name === 'Team') return teamRepository;
if (entity.name === 'TransactionType') return typeRepository;
return writeRepository;
}),
};
const dataSource = { transaction: jest.fn((work) => work(manager)) };
const access = { assertMember: jest.fn(), assertAtLeast: jest.fn() };
const logger = { info: jest.fn() };
let service: RecurringTransactionsService;
beforeEach(() => {
jest.clearAllMocks();
teamQuery.getOne.mockResolvedValue({ id: 5 });
teamReadRepository.findOne.mockResolvedValue({ id: 5 });
typeRepository.findOne.mockResolvedValue({
id: TransactionTypeEnum.fee,
name: 'fee',
});
service = new RecurringTransactionsService(
readRepository as any,
teamReadRepository as any,
dataSource as any,
access as any,
logger as any,
);
});
it('returns not found instead of leaking an unknown team as an empty list', async () => {
teamReadRepository.findOne.mockResolvedValue(null);
await expect(
service.getTeamRecurringTransactions(42, 999),
).rejects.toBeInstanceOf(NotFoundException);
expect(access.assertMember).not.toHaveBeenCalled();
});
it('authorizes team reads, sorts them, and maps only safe fields', async () => {
readRepository.find.mockResolvedValue([
{
id: 8,
description: 'Monatsbeitrag',
amount: '10.00',
type: { id: TransactionTypeEnum.fee, name: 'fee' },
interval: RecurringTransactionIntervalEnum.monthly,
nextRunDate: '2026-09-01T00:00:00.000Z',
active: true,
createdAt: new Date('2026-01-02T00:00:00.000Z'),
team: { id: 5, secret: 'hidden' },
},
]);
await expect(
service.getTeamRecurringTransactions(42, 5),
).resolves.toEqual([
{
id: 8,
description: 'Monatsbeitrag',
amount: 10,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.monthly,
nextRunDate: '2026-09-01T00:00:00.000Z',
active: true,
createdAt: new Date('2026-01-02T00:00:00.000Z'),
},
]);
expect(access.assertMember).toHaveBeenCalledWith(42, 5);
expect(readRepository.find).toHaveBeenCalledWith({
where: { team: { id: 5 } },
order: { description: 'ASC' },
});
});
it('creates a normalized entry under a team lock, resolves the type, and audits in the transaction', async () => {
writeRepository.save.mockImplementation(async (value) => ({
id: 9,
createdAt: new Date('2026-01-03T00:00:00.000Z'),
...value,
}));
await expect(
service.createRecurringTransaction(
{
teamId: 5,
description: ' Monatsbeitrag ',
amount: 10,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.monthly,
startDate: '2026-09-01T00:00:00.000Z',
},
42,
),
).resolves.toMatchObject({
id: 9,
description: 'Monatsbeitrag',
amount: 10,
type: TransactionTypeEnum.fee,
nextRunDate: '2026-09-01T00:00:00.000Z',
active: true,
});
expect(teamQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
expect(access.assertAtLeast).toHaveBeenCalledWith(
42,
5,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
manager,
);
expect(typeRepository.findOne).toHaveBeenCalledWith({
where: { id: TransactionTypeEnum.fee },
});
expect(logger.info).toHaveBeenCalledWith(
{
event: 'recurring_transaction_create',
details: 'teamId=5 recurringTransactionId=9 action=create',
userId: 42,
},
manager,
);
});
it('rejects an unknown transaction type before writing or auditing', async () => {
typeRepository.findOne.mockResolvedValue(null);
await expect(
service.createRecurringTransaction(
{
teamId: 5,
description: 'Monatsbeitrag',
amount: 10,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.monthly,
startDate: '2026-09-01T00:00:00.000Z',
},
42,
),
).rejects.toBeInstanceOf(NotFoundException);
expect(writeRepository.save).not.toHaveBeenCalled();
expect(logger.info).not.toHaveBeenCalled();
});
it('updates fields in the owning team without touching nextRunDate, and audits it', async () => {
readRepository.findOne.mockResolvedValue({ id: 8, team: { id: 5 } });
writeRepository.findOne.mockResolvedValue({
id: 8,
team: { id: 5 },
description: 'Alt',
amount: 5,
type: { id: TransactionTypeEnum.levy },
interval: RecurringTransactionIntervalEnum.monthly,
nextRunDate: '2026-09-01T00:00:00.000Z',
active: true,
createdAt: new Date('2026-01-02T00:00:00.000Z'),
});
writeRepository.save.mockImplementation(async (value) => value);
await expect(
service.updateRecurringTransaction(
8,
{
description: ' Neu ',
amount: 12,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.quarterly,
active: false,
},
42,
),
).resolves.toMatchObject({
id: 8,
description: 'Neu',
amount: 12,
type: TransactionTypeEnum.fee,
interval: RecurringTransactionIntervalEnum.quarterly,
active: false,
nextRunDate: '2026-09-01T00:00:00.000Z',
});
expect(access.assertAtLeast).toHaveBeenCalledWith(
42,
5,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
manager,
);
expect(logger.info).toHaveBeenCalledWith(
{
event: 'recurring_transaction_update',
details: 'teamId=5 recurringTransactionId=8 action=update',
userId: 42,
},
manager,
);
});
it('deletes an entry permanently after manager authorization and audits it', async () => {
readRepository.findOne.mockResolvedValue({ id: 8, team: { id: 5 } });
const entry = { id: 8, team: { id: 5 } };
writeRepository.findOne.mockResolvedValue(entry);
await service.deleteRecurringTransaction(8, 42);
expect(writeRepository.remove).toHaveBeenCalledWith(entry);
expect(logger.info).toHaveBeenCalledWith(
{
event: 'recurring_transaction_delete',
details: 'teamId=5 recurringTransactionId=8 action=delete',
userId: 42,
},
manager,
);
});
it('returns not found when a mutation target does not exist', async () => {
readRepository.findOne.mockResolvedValue(null);
await expect(
service.deleteRecurringTransaction(999, 42),
).rejects.toBeInstanceOf(NotFoundException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,202 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { TeamRolesEnum } from 'src/team-roles/team-roles.enum';
import { Team } from 'src/teams/entities/team.entity';
import { TeamAccessService } from 'src/teams/team-access.service';
import { TransactionType } from 'src/transactions/entitites/transaction-type.entity';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { CreateRecurringTransactionDTO } from './dto/create-recurring-transaction.dto';
import { RecurringTransactionResponseDTO } from './dto/recurring-transaction-response.dto';
import { UpdateRecurringTransactionDTO } from './dto/update-recurring-transaction.dto';
import { RecurringTransaction } from './entities/recurring-transaction.entity';
@Injectable()
export class RecurringTransactionsService {
constructor(
@InjectRepository(RecurringTransaction)
private readonly repository: Repository<RecurringTransaction>,
@InjectRepository(Team)
private readonly teamRepository: Repository<Team>,
private readonly dataSource: DataSource,
private readonly access: TeamAccessService,
private readonly logger: LoggingService,
) {}
async getTeamRecurringTransactions(
userId: number,
teamId: number,
): Promise<RecurringTransactionResponseDTO[]> {
const team = await this.teamRepository.findOne({ where: { id: teamId } });
if (!team) throw new NotFoundException('Team nicht gefunden.');
await this.access.assertMember(userId, teamId);
const entries = await this.repository.find({
where: { team: { id: teamId } },
order: { description: 'ASC' },
});
return entries.map((entry) => this.toResponse(entry));
}
createRecurringTransaction(
dto: CreateRecurringTransactionDTO,
userId: number,
): Promise<RecurringTransactionResponseDTO> {
return this.dataSource.transaction(async (manager) => {
const team = await this.lockTeam(manager, dto.teamId);
await this.access.assertAtLeast(
userId,
team.id,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
manager,
);
const type = await this.findType(manager, dto.type);
const repository = manager.getRepository(RecurringTransaction);
const saved = await repository.save(
repository.create({
team,
description: dto.description.trim(),
amount: dto.amount,
type,
interval: dto.interval,
nextRunDate: dto.startDate,
active: true,
}),
);
await this.logger.info(
{
event: 'recurring_transaction_create',
details: `teamId=${team.id} recurringTransactionId=${saved.id} action=create`,
userId,
},
manager,
);
return this.toResponse(saved);
});
}
async updateRecurringTransaction(
id: number,
dto: UpdateRecurringTransactionDTO,
userId: number,
): Promise<RecurringTransactionResponseDTO> {
const owner = await this.findOwner(id);
return this.dataSource.transaction(async (manager) => {
const team = await this.lockTeam(manager, owner.team.id);
await this.access.assertAtLeast(
userId,
team.id,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
manager,
);
const type = await this.findType(manager, dto.type);
const repository = manager.getRepository(RecurringTransaction);
const entry = await this.findTransactionalEntry(repository, id, team.id);
entry.description = dto.description.trim();
entry.amount = dto.amount;
entry.type = type;
entry.interval = dto.interval;
entry.active = dto.active;
const saved = await repository.save(entry);
await this.logger.info(
{
event: 'recurring_transaction_update',
details: `teamId=${team.id} recurringTransactionId=${id} action=update`,
userId,
},
manager,
);
return this.toResponse(saved);
});
}
async deleteRecurringTransaction(id: number, userId: number): Promise<void> {
const owner = await this.findOwner(id);
await this.dataSource.transaction(async (manager) => {
const team = await this.lockTeam(manager, owner.team.id);
await this.access.assertAtLeast(
userId,
team.id,
'transaction_create_min_role',
TeamRolesEnum.scnd_treasurer,
manager,
);
const repository = manager.getRepository(RecurringTransaction);
const entry = await this.findTransactionalEntry(repository, id, team.id);
await repository.remove(entry);
await this.logger.info(
{
event: 'recurring_transaction_delete',
details: `teamId=${team.id} recurringTransactionId=${id} action=delete`,
userId,
},
manager,
);
});
}
private async findOwner(id: number): Promise<RecurringTransaction> {
const entry = await this.repository.findOne({
where: { id },
relations: ['team'],
});
if (!entry?.team)
throw new NotFoundException('Wiederkehrende Buchung nicht gefunden.');
return entry;
}
private async lockTeam(
manager: EntityManager,
teamId: number,
): Promise<Team> {
const team = await manager
.getRepository(Team)
.createQueryBuilder('team')
.where('team.id = :teamId', { teamId })
.setLock('pessimistic_write')
.getOne();
if (!team) throw new NotFoundException('Team nicht gefunden.');
return team;
}
private async findType(
manager: EntityManager,
typeId: number,
): Promise<TransactionType> {
const type = await manager
.getRepository(TransactionType)
.findOne({ where: { id: typeId } });
if (!type) throw new NotFoundException('Buchungstyp nicht gefunden.');
return type;
}
private async findTransactionalEntry(
repository: Repository<RecurringTransaction>,
id: number,
teamId: number,
): Promise<RecurringTransaction> {
const entry = await repository.findOne({
where: { id, team: { id: teamId } },
relations: ['team'],
});
if (!entry)
throw new NotFoundException('Wiederkehrende Buchung nicht gefunden.');
return entry;
}
private toResponse(
entry: RecurringTransaction,
): RecurringTransactionResponseDTO {
return {
id: entry.id,
description: entry.description,
amount: Number(entry.amount),
type: entry.type.id,
interval: entry.interval,
nextRunDate: entry.nextRunDate,
active: entry.active,
createdAt: entry.createdAt,
};
}
}