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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum RecurringTransactionIntervalEnum {
|
||||
'monthly' = 'monthly',
|
||||
'quarterly' = 'quarterly',
|
||||
'yearly' = 'yearly',
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user