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:
73
myteamwallet_backend/package-lock.json
generated
73
myteamwallet_backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [],
|
||||
})
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,13 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/team/more/penalties/penalties').then((m) => m.Penalties),
|
||||
},
|
||||
{
|
||||
path: 'more/recurring-transactions',
|
||||
loadComponent: () =>
|
||||
import('./features/team/more/recurring-transactions/recurring-transactions').then(
|
||||
(m) => m.RecurringTransactions,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'more/invite',
|
||||
loadComponent: () => import('./features/team/more/invite/invite').then((m) => m.Invite),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { RecurringTransactionApi } from './recurring-transaction-api';
|
||||
|
||||
describe('RecurringTransactionApi', () => {
|
||||
let api: RecurringTransactionApi;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
api = TestBed.inject(RecurringTransactionApi);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('loads the recurring transactions of a team', () => {
|
||||
api.loadRecurringTransactions(5).subscribe();
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}recurring-transactions/5`);
|
||||
expect(request.request.method).toBe('GET');
|
||||
request.flush([]);
|
||||
});
|
||||
|
||||
it('creates a recurring transaction', () => {
|
||||
const entry = {
|
||||
teamId: 5,
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 10,
|
||||
type: 13,
|
||||
interval: 'monthly' as const,
|
||||
startDate: '2026-09-01T00:00:00.000Z',
|
||||
};
|
||||
api.createRecurringTransaction(entry).subscribe();
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}recurring-transactions`);
|
||||
expect(request.request.method).toBe('POST');
|
||||
expect(request.request.body).toEqual(entry);
|
||||
request.flush({ id: 1, ...entry });
|
||||
});
|
||||
|
||||
it('updates a recurring transaction', () => {
|
||||
const update = {
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 12,
|
||||
type: 12,
|
||||
interval: 'quarterly' as const,
|
||||
active: false,
|
||||
};
|
||||
api.updateRecurringTransaction(8, update).subscribe();
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}recurring-transactions/8`);
|
||||
expect(request.request.method).toBe('PATCH');
|
||||
expect(request.request.body).toEqual(update);
|
||||
request.flush({ id: 8, ...update });
|
||||
});
|
||||
|
||||
it('deletes a recurring transaction', () => {
|
||||
api.deleteRecurringTransaction(8).subscribe();
|
||||
const request = httpMock.expectOne(`${environment.apiUrl}recurring-transactions/8`);
|
||||
expect(request.request.method).toBe('DELETE');
|
||||
request.flush(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import {
|
||||
CreateRecurringTransaction,
|
||||
RecurringTransaction,
|
||||
UpdateRecurringTransaction,
|
||||
} from '../../models/recurring-transaction.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RecurringTransactionApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = `${environment.apiUrl}recurring-transactions`;
|
||||
|
||||
loadRecurringTransactions(teamId: number): Observable<RecurringTransaction[]> {
|
||||
return this.http.get<RecurringTransaction[]>(`${this.baseUrl}/${teamId}`);
|
||||
}
|
||||
|
||||
createRecurringTransaction(
|
||||
entry: CreateRecurringTransaction,
|
||||
): Observable<RecurringTransaction> {
|
||||
return this.http.post<RecurringTransaction>(this.baseUrl, entry);
|
||||
}
|
||||
|
||||
updateRecurringTransaction(
|
||||
id: number,
|
||||
entry: UpdateRecurringTransaction,
|
||||
): Observable<RecurringTransaction> {
|
||||
return this.http.patch<RecurringTransaction>(`${this.baseUrl}/${id}`, entry);
|
||||
}
|
||||
|
||||
deleteRecurringTransaction(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,16 @@
|
||||
<mat-icon>chevron_right</mat-icon></mat-card
|
||||
></a
|
||||
>
|
||||
<a routerLink="recurring-transactions"
|
||||
><mat-card
|
||||
><mat-icon>event_repeat</mat-icon>
|
||||
<div>
|
||||
<strong>Wiederkehrende Buchungen</strong
|
||||
><span>Fälligkeiten automatisch buchen lassen</span>
|
||||
</div>
|
||||
<mat-icon>chevron_right</mat-icon></mat-card
|
||||
></a
|
||||
>
|
||||
<a routerLink="invite"
|
||||
><mat-card
|
||||
><mat-icon>person_add</mat-icon>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<a mat-button routerLink="../"><mat-icon>arrow_back</mat-icon>Mehr</a>
|
||||
<header>
|
||||
<p class="eyebrow">Kasse</p>
|
||||
<h1>Wiederkehrende Buchungen</h1>
|
||||
<p>Fälligkeiten wie den Monatsbeitrag automatisch für alle aktiven Spieler buchen lassen.</p>
|
||||
</header>
|
||||
|
||||
@if (canManage()) {
|
||||
<mat-card class="create-card">
|
||||
<form [formGroup]="form" (ngSubmit)="createEntry()" aria-label="Wiederkehrende Buchung anlegen">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Beschreibung</mat-label>
|
||||
<input matInput maxlength="120" formControlName="description" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Betrag</mat-label>
|
||||
<input matInput type="number" min="0.01" max="10000" step="0.01" formControlName="amount" />
|
||||
<span matTextSuffix>€</span>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Typ</mat-label>
|
||||
<mat-select formControlName="type">
|
||||
@for (option of typeOptions; track option.id) {
|
||||
<mat-option [value]="option.id">{{ option.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Intervall</mat-label>
|
||||
<mat-select formControlName="interval">
|
||||
@for (option of intervalOptions; track option.value) {
|
||||
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Erste Buchung am</mat-label>
|
||||
<input matInput type="date" formControlName="startDate" />
|
||||
</mat-form-field>
|
||||
<button mat-flat-button type="submit" [disabled]="form.invalid || mutationPending()">
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18" />
|
||||
} @else {
|
||||
<mat-icon>add</mat-icon>
|
||||
}
|
||||
Eintrag anlegen
|
||||
</button>
|
||||
</form>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@if (mutationError()) {
|
||||
<p class="error-message" role="alert">{{ mutationError() }}</p>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="state" aria-live="polite">
|
||||
<mat-spinner diameter="36" />
|
||||
<span>Liste wird geladen …</span>
|
||||
</div>
|
||||
} @else if (loadError()) {
|
||||
<div class="state" role="alert">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<strong>{{ loadError() }}</strong>
|
||||
<button mat-stroked-button type="button" (click)="retryLoad()">Erneut versuchen</button>
|
||||
</div>
|
||||
} @else if (entries().length === 0) {
|
||||
<div class="state">
|
||||
<mat-icon>event_repeat</mat-icon>
|
||||
<strong>Noch keine Einträge</strong>
|
||||
<span>Es gibt noch keine wiederkehrenden Buchungen für dieses Team.</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="catalog">
|
||||
@for (entry of entries(); track entry.id) {
|
||||
<mat-card class="entry-card" [class.paused]="!entry.active">
|
||||
@if (editingEntryId() === entry.id) {
|
||||
<form
|
||||
class="edit-form"
|
||||
[formGroup]="editForm"
|
||||
(ngSubmit)="saveEntry(entry)"
|
||||
[attr.aria-label]="'Eintrag ' + entry.description + ' bearbeiten'"
|
||||
>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Beschreibung</mat-label>
|
||||
<input matInput maxlength="120" formControlName="description" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Betrag</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="10000"
|
||||
step="0.01"
|
||||
formControlName="amount"
|
||||
/>
|
||||
<span matTextSuffix>€</span>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Typ</mat-label>
|
||||
<mat-select formControlName="type">
|
||||
@for (option of typeOptions; track option.id) {
|
||||
<mat-option [value]="option.id">{{ option.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Intervall</mat-label>
|
||||
<mat-select formControlName="interval">
|
||||
@for (option of intervalOptions; track option.value) {
|
||||
<mat-option [value]="option.value">{{ option.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<div class="edit-actions">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="cancelEdit()"
|
||||
[disabled]="pendingEntryId() === entry.id"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
[disabled]="editForm.invalid || pendingEntryId() === entry.id"
|
||||
>
|
||||
@if (pendingEntryId() === entry.id) {
|
||||
<mat-spinner diameter="18" />
|
||||
} @else {
|
||||
<mat-icon>save</mat-icon>
|
||||
}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
} @else {
|
||||
<div class="entry-content">
|
||||
<span>{{ entry.description }}</span>
|
||||
<strong>{{ entry.amount | currency: 'EUR' }}</strong>
|
||||
@if (!entry.active) {
|
||||
<span class="paused-badge">Pausiert</span>
|
||||
} @else {
|
||||
<span class="next-run">nächste Buchung am {{ entry.nextRunDate | date: 'dd.MM.yyyy' }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="entry-actions">
|
||||
@if (canManage()) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="toggleActive(entry)"
|
||||
[disabled]="mutationPending()"
|
||||
[attr.aria-label]="(entry.active ? 'Pausieren' : 'Fortsetzen') + ': ' + entry.description"
|
||||
>
|
||||
<mat-icon>{{ entry.active ? 'pause_circle' : 'play_circle' }}</mat-icon>
|
||||
{{ entry.active ? 'Pausieren' : 'Fortsetzen' }}
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="startEdit(entry)"
|
||||
[disabled]="mutationPending()"
|
||||
[attr.aria-label]="entry.description + ' bearbeiten'"
|
||||
>
|
||||
<mat-icon>edit</mat-icon>Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="confirmDelete(entry)"
|
||||
[disabled]="mutationPending()"
|
||||
[attr.aria-label]="entry.description + ' löschen'"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>Löschen
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
:host {
|
||||
display: block;
|
||||
padding: 24px 28px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header {
|
||||
margin: 20px 0 26px;
|
||||
}
|
||||
|
||||
h1,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
line-height: clamp(2rem, 4vw, 3rem);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--mat-sys-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.create-card {
|
||||
padding: 20px;
|
||||
border-radius: 18px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.create-card form,
|
||||
.edit-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 140px 150px 160px 160px auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.create-card button,
|
||||
.edit-actions button {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.create-card button mat-spinner,
|
||||
.edit-actions button mat-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
color: var(--mat-sys-error);
|
||||
background: var(--mat-sys-error-container);
|
||||
}
|
||||
|
||||
.catalog {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.entry-card {
|
||||
padding: 16px 18px;
|
||||
border-radius: 16px;
|
||||
|
||||
&.paused {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.entry-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.next-run,
|
||||
.paused-badge {
|
||||
font-size: 0.8rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.paused-badge {
|
||||
color: var(--mat-sys-error);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.entry-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
grid-template-columns: minmax(0, 1fr) 140px 150px 160px;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.state {
|
||||
min-height: 180px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
:host {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.create-card form,
|
||||
.edit-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.create-card form button,
|
||||
.edit-actions,
|
||||
.edit-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
grid-column: auto;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.entry-content {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.entry-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.entry-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { Subject, of, throwError } from 'rxjs';
|
||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
import { RecurringTransactionApi } from '../../../../core/team/recurring-transaction-api';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import { RecurringTransaction } from '../../../../models/recurring-transaction.model';
|
||||
import { RecurringTransactions } from './recurring-transactions';
|
||||
|
||||
const first: RecurringTransaction = {
|
||||
id: 1,
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 10,
|
||||
type: 13,
|
||||
interval: 'monthly',
|
||||
nextRunDate: '2026-09-01T00:00:00.000Z',
|
||||
active: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
const team = {
|
||||
id: 5,
|
||||
name: 'Team A',
|
||||
alias: 'a',
|
||||
balance: 0,
|
||||
players: [
|
||||
{
|
||||
id: 1,
|
||||
firstName: 'Alex',
|
||||
lastName: 'Muster',
|
||||
balance: 0,
|
||||
active: true,
|
||||
teamRole: { id: 3 },
|
||||
user: { id: 42 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('RecurringTransactions', () => {
|
||||
let fixture: ComponentFixture<RecurringTransactions>;
|
||||
let currentUser: ReturnType<typeof signal<{ id: number; role: { id: number } }>>;
|
||||
let loadRecurringTransactions: ReturnType<typeof vi.fn>;
|
||||
let createRecurringTransaction: ReturnType<typeof vi.fn>;
|
||||
let updateRecurringTransaction: ReturnType<typeof vi.fn>;
|
||||
let deleteRecurringTransaction: ReturnType<typeof vi.fn>;
|
||||
let dialogClosed: Subject<boolean>;
|
||||
let dialog: { open: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(async () => {
|
||||
currentUser = signal({ id: 42, role: { id: 2 } });
|
||||
loadRecurringTransactions = vi.fn(() => of([first]));
|
||||
createRecurringTransaction = vi.fn(() =>
|
||||
of({ ...first, id: 2, description: 'Ausrüstung' }),
|
||||
);
|
||||
updateRecurringTransaction = vi.fn(() => of({ ...first, description: 'Neu', amount: 12 }));
|
||||
deleteRecurringTransaction = vi.fn(() => of(undefined));
|
||||
dialogClosed = new Subject<boolean>();
|
||||
dialog = { open: vi.fn(() => ({ afterClosed: () => dialogClosed.asObservable() })) };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RecurringTransactions],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: TeamStore, useValue: { team: signal(team) } },
|
||||
{ provide: AuthStore, useValue: { currentUser } },
|
||||
{
|
||||
provide: RecurringTransactionApi,
|
||||
useValue: {
|
||||
loadRecurringTransactions,
|
||||
createRecurringTransaction,
|
||||
updateRecurringTransaction,
|
||||
deleteRecurringTransaction,
|
||||
},
|
||||
},
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
function create(): void {
|
||||
fixture = TestBed.createComponent(RecurringTransactions);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function text(): string {
|
||||
return (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||
}
|
||||
|
||||
function button(label: string): HTMLButtonElement {
|
||||
const match = [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find(
|
||||
(element) => element.textContent?.includes(label),
|
||||
);
|
||||
if (!match) throw new Error(`Missing button: ${label}`);
|
||||
return match as HTMLButtonElement;
|
||||
}
|
||||
|
||||
it('shows entries without mutation controls to a reader', () => {
|
||||
currentUser.set({ id: 7, role: { id: 2 } });
|
||||
create();
|
||||
|
||||
expect(text()).toContain('Monatsbeitrag');
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.create-card')).toBeNull();
|
||||
expect(text()).not.toContain('Bearbeiten');
|
||||
expect(text()).not.toContain('Löschen');
|
||||
});
|
||||
|
||||
it('lets an authorized user create an entry and reloads authoritative data', () => {
|
||||
loadRecurringTransactions
|
||||
.mockReturnValueOnce(of([first]))
|
||||
.mockReturnValueOnce(of([first, { ...first, id: 2, description: 'Ausrüstung' }]));
|
||||
create();
|
||||
fixture.componentInstance['form'].setValue({
|
||||
description: 'Ausrüstung',
|
||||
amount: 20,
|
||||
type: 12,
|
||||
interval: 'yearly',
|
||||
startDate: '2026-09-01',
|
||||
});
|
||||
|
||||
fixture.componentInstance['createEntry']();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(createRecurringTransaction).toHaveBeenCalledWith({
|
||||
teamId: 5,
|
||||
description: 'Ausrüstung',
|
||||
amount: 20,
|
||||
type: 12,
|
||||
interval: 'yearly',
|
||||
startDate: new Date('2026-09-01T12:00:00').toISOString(),
|
||||
});
|
||||
expect(loadRecurringTransactions).toHaveBeenCalledTimes(2);
|
||||
expect(text()).toContain('Ausrüstung');
|
||||
});
|
||||
|
||||
it('rejects blank descriptions and amounts with more than two decimals', () => {
|
||||
create();
|
||||
|
||||
fixture.componentInstance['form'].patchValue({ description: ' ', amount: 1.234 });
|
||||
|
||||
expect(fixture.componentInstance['form'].invalid).toBe(true);
|
||||
fixture.componentInstance['createEntry']();
|
||||
expect(createRecurringTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens one inline editor, supports cancel, and saves pessimistically', () => {
|
||||
const updateResult = new Subject<RecurringTransaction>();
|
||||
updateRecurringTransaction.mockReturnValue(updateResult);
|
||||
loadRecurringTransactions
|
||||
.mockReturnValueOnce(of([first]))
|
||||
.mockReturnValueOnce(of([{ ...first, description: 'Neu', amount: 12 }]));
|
||||
create();
|
||||
|
||||
button('Bearbeiten').click();
|
||||
fixture.detectChanges();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.edit-form')).not.toBeNull();
|
||||
fixture.componentInstance['cancelEdit']();
|
||||
fixture.detectChanges();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.edit-form')).toBeNull();
|
||||
|
||||
button('Bearbeiten').click();
|
||||
fixture.componentInstance['editForm'].patchValue({ description: 'Neu', amount: 12 });
|
||||
fixture.componentInstance['saveEntry'](first);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance['editingEntryId']()).toBe(first.id);
|
||||
expect(loadRecurringTransactions).toHaveBeenCalledTimes(1);
|
||||
|
||||
updateResult.next({ ...first, description: 'Neu', amount: 12 });
|
||||
fixture.detectChanges();
|
||||
expect(updateRecurringTransaction).toHaveBeenCalledWith(1, {
|
||||
description: 'Neu',
|
||||
amount: 12,
|
||||
type: 13,
|
||||
interval: 'monthly',
|
||||
active: true,
|
||||
});
|
||||
expect(loadRecurringTransactions).toHaveBeenCalledTimes(2);
|
||||
expect(text()).toContain('Neu');
|
||||
});
|
||||
|
||||
it('pauses and resumes an entry via the active toggle', () => {
|
||||
const paused = { ...first, active: false };
|
||||
updateRecurringTransaction.mockReturnValue(of(paused));
|
||||
loadRecurringTransactions
|
||||
.mockReturnValueOnce(of([first]))
|
||||
.mockReturnValueOnce(of([paused]));
|
||||
create();
|
||||
|
||||
button('Pausieren').click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(updateRecurringTransaction).toHaveBeenCalledWith(1, {
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 10,
|
||||
type: 13,
|
||||
interval: 'monthly',
|
||||
active: false,
|
||||
});
|
||||
expect(loadRecurringTransactions).toHaveBeenCalledTimes(2);
|
||||
expect(text()).toContain('Fortsetzen');
|
||||
});
|
||||
|
||||
it('confirms deletion and reloads only after server success', () => {
|
||||
const deletion = new Subject<void>();
|
||||
deleteRecurringTransaction.mockReturnValue(deletion);
|
||||
loadRecurringTransactions.mockReturnValueOnce(of([first])).mockReturnValueOnce(of([]));
|
||||
create();
|
||||
|
||||
button('Löschen').click();
|
||||
expect(dialog.open).toHaveBeenCalled();
|
||||
dialogClosed.next(true);
|
||||
expect(deleteRecurringTransaction).toHaveBeenCalledWith(1);
|
||||
expect(loadRecurringTransactions).toHaveBeenCalledTimes(1);
|
||||
|
||||
deletion.next();
|
||||
fixture.detectChanges();
|
||||
expect(loadRecurringTransactions).toHaveBeenCalledTimes(2);
|
||||
expect(text()).toContain('Noch keine Einträge');
|
||||
});
|
||||
|
||||
it('shows a load error and retries', () => {
|
||||
loadRecurringTransactions
|
||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 500 })))
|
||||
.mockReturnValueOnce(of([first]));
|
||||
create();
|
||||
fixture.detectChanges();
|
||||
expect(text()).toContain('konnte nicht geladen werden');
|
||||
|
||||
button('Erneut versuchen').click();
|
||||
fixture.detectChanges();
|
||||
expect(text()).toContain('Monatsbeitrag');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common';
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, DestroyRef, LOCALE_ID, computed, effect, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { EMPTY, Observable, catchError, finalize, switchMap, take, tap } from 'rxjs';
|
||||
import { RecurringTransactionApi } from '../../../../core/team/recurring-transaction-api';
|
||||
import { TeamPermissionsService } from '../../../../core/team/team-permissions';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
import {
|
||||
RecurringTransaction,
|
||||
RecurringTransactionInterval,
|
||||
} from '../../../../models/recurring-transaction.model';
|
||||
import { ConfirmDialog } from '../../../../shared/confirm-dialog/confirm-dialog';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ id: 13, label: 'Gebühr' },
|
||||
{ id: 12, label: 'Umlage' },
|
||||
];
|
||||
|
||||
const INTERVAL_OPTIONS: { value: RecurringTransactionInterval; label: string }[] = [
|
||||
{ value: 'monthly', label: 'Monatlich' },
|
||||
{ value: 'quarterly', label: 'Quartalsweise' },
|
||||
{ value: 'yearly', label: 'Jährlich' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-recurring-transactions',
|
||||
imports: [
|
||||
CurrencyPipe,
|
||||
DatePipe,
|
||||
ReactiveFormsModule,
|
||||
RouterLink,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSelectModule,
|
||||
],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||
templateUrl: './recurring-transactions.html',
|
||||
styleUrl: './recurring-transactions.scss',
|
||||
})
|
||||
export class RecurringTransactions {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
private readonly api = inject(RecurringTransactionApi);
|
||||
private readonly permissions = inject(TeamPermissionsService);
|
||||
private readonly teamStore = inject(TeamStore);
|
||||
private loadedTeamId: number | null = null;
|
||||
|
||||
protected readonly typeOptions = TYPE_OPTIONS;
|
||||
protected readonly intervalOptions = INTERVAL_OPTIONS;
|
||||
protected readonly team = this.teamStore.team;
|
||||
protected readonly entries = signal<RecurringTransaction[]>([]);
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly pendingEntryId = signal<number | null>(null);
|
||||
protected readonly editingEntryId = signal<number | null>(null);
|
||||
protected readonly loadError = signal<string | null>(null);
|
||||
protected readonly mutationError = signal<string | null>(null);
|
||||
protected readonly mutationPending = computed(
|
||||
() => this.saving() || this.pendingEntryId() !== null,
|
||||
);
|
||||
protected readonly canManage = computed(() =>
|
||||
this.permissions.canDo(this.team(), 'transactionCreate'),
|
||||
);
|
||||
protected readonly form = this.formBuilder.nonNullable.group({
|
||||
description: ['', [Validators.required, Validators.maxLength(120), Validators.pattern(/\S/)]],
|
||||
amount: [
|
||||
0,
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0.01),
|
||||
Validators.max(10000),
|
||||
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
||||
],
|
||||
],
|
||||
type: [13, Validators.required],
|
||||
interval: ['monthly' as RecurringTransactionInterval, Validators.required],
|
||||
startDate: ['', Validators.required],
|
||||
});
|
||||
protected readonly editForm = this.formBuilder.nonNullable.group({
|
||||
description: ['', [Validators.required, Validators.maxLength(120), Validators.pattern(/\S/)]],
|
||||
amount: [
|
||||
0,
|
||||
[
|
||||
Validators.required,
|
||||
Validators.min(0.01),
|
||||
Validators.max(10000),
|
||||
Validators.pattern(/^\d+(\.\d{1,2})?$/),
|
||||
],
|
||||
],
|
||||
type: [13, Validators.required],
|
||||
interval: ['monthly' as RecurringTransactionInterval, Validators.required],
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const teamId = this.team()?.id;
|
||||
if (teamId && teamId !== this.loadedTeamId) {
|
||||
this.loadedTeamId = teamId;
|
||||
this.load(teamId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected createEntry(): void {
|
||||
const team = this.team();
|
||||
if (!this.canManage() || !team || this.form.invalid || this.mutationPending()) return;
|
||||
this.saving.set(true);
|
||||
this.mutationError.set(null);
|
||||
const { description, amount, type, interval, startDate } = this.form.getRawValue();
|
||||
this.api
|
||||
.createRecurringTransaction({
|
||||
teamId: team.id,
|
||||
description,
|
||||
amount,
|
||||
type,
|
||||
interval,
|
||||
startDate: this.toIso(startDate),
|
||||
})
|
||||
.pipe(
|
||||
tap(() =>
|
||||
this.form.reset({ description: '', amount: 0, type: 13, interval: 'monthly', startDate: '' }),
|
||||
),
|
||||
switchMap(() => this.reloadAfterMutation(team.id)),
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe({
|
||||
next: (entries) => this.entries.set(entries),
|
||||
error: (error: HttpErrorResponse) =>
|
||||
this.mutationError.set(this.errorMessage(error, 'Eintrag konnte nicht angelegt werden.')),
|
||||
});
|
||||
}
|
||||
|
||||
protected startEdit(entry: RecurringTransaction): void {
|
||||
if (!this.canManage() || this.mutationPending()) return;
|
||||
this.editingEntryId.set(entry.id);
|
||||
this.editForm.setValue({
|
||||
description: entry.description,
|
||||
amount: entry.amount,
|
||||
type: entry.type,
|
||||
interval: entry.interval,
|
||||
});
|
||||
this.mutationError.set(null);
|
||||
}
|
||||
|
||||
protected cancelEdit(): void {
|
||||
if (this.pendingEntryId() !== null) return;
|
||||
this.editingEntryId.set(null);
|
||||
this.mutationError.set(null);
|
||||
}
|
||||
|
||||
protected saveEntry(entry: RecurringTransaction): void {
|
||||
const teamId = this.team()?.id;
|
||||
if (
|
||||
!this.canManage() ||
|
||||
!teamId ||
|
||||
this.editingEntryId() !== entry.id ||
|
||||
this.editForm.invalid ||
|
||||
this.mutationPending()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.pendingEntryId.set(entry.id);
|
||||
this.mutationError.set(null);
|
||||
this.api
|
||||
.updateRecurringTransaction(entry.id, { ...this.editForm.getRawValue(), active: entry.active })
|
||||
.pipe(
|
||||
tap(() => this.editingEntryId.set(null)),
|
||||
switchMap(() => this.reloadAfterMutation(teamId)),
|
||||
finalize(() => this.pendingEntryId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe({
|
||||
next: (entries) => this.entries.set(entries),
|
||||
error: (error: HttpErrorResponse) =>
|
||||
this.mutationError.set(this.errorMessage(error, 'Eintrag konnte nicht gespeichert werden.')),
|
||||
});
|
||||
}
|
||||
|
||||
protected toggleActive(entry: RecurringTransaction): void {
|
||||
const teamId = this.team()?.id;
|
||||
if (!this.canManage() || !teamId || this.mutationPending()) return;
|
||||
this.pendingEntryId.set(entry.id);
|
||||
this.mutationError.set(null);
|
||||
this.api
|
||||
.updateRecurringTransaction(entry.id, {
|
||||
description: entry.description,
|
||||
amount: entry.amount,
|
||||
type: entry.type,
|
||||
interval: entry.interval,
|
||||
active: !entry.active,
|
||||
})
|
||||
.pipe(
|
||||
switchMap(() => this.reloadAfterMutation(teamId)),
|
||||
finalize(() => this.pendingEntryId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe({
|
||||
next: (entries) => this.entries.set(entries),
|
||||
error: (error: HttpErrorResponse) =>
|
||||
this.mutationError.set(this.errorMessage(error, 'Eintrag konnte nicht geändert werden.')),
|
||||
});
|
||||
}
|
||||
|
||||
protected confirmDelete(entry: RecurringTransaction): void {
|
||||
if (!this.canManage() || this.mutationPending()) return;
|
||||
this.dialog
|
||||
.open(ConfirmDialog, {
|
||||
data: {
|
||||
title: 'Eintrag löschen?',
|
||||
message: `„${entry.description}“ wird endgültig gelöscht.`,
|
||||
confirmLabel: 'Löschen',
|
||||
},
|
||||
restoreFocus: true,
|
||||
})
|
||||
.afterClosed()
|
||||
.pipe(take(1), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((confirmed) => {
|
||||
if (confirmed) this.deleteEntry(entry);
|
||||
});
|
||||
}
|
||||
|
||||
protected retryLoad(): void {
|
||||
const teamId = this.team()?.id;
|
||||
if (teamId) this.load(teamId);
|
||||
}
|
||||
|
||||
private deleteEntry(entry: RecurringTransaction): void {
|
||||
const teamId = this.team()?.id;
|
||||
if (!teamId) return;
|
||||
this.pendingEntryId.set(entry.id);
|
||||
this.mutationError.set(null);
|
||||
this.api
|
||||
.deleteRecurringTransaction(entry.id)
|
||||
.pipe(
|
||||
switchMap(() => this.reloadAfterMutation(teamId)),
|
||||
finalize(() => this.pendingEntryId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe({
|
||||
next: (entries) => this.entries.set(entries),
|
||||
error: (error: HttpErrorResponse) =>
|
||||
this.mutationError.set(this.errorMessage(error, 'Eintrag konnte nicht gelöscht werden.')),
|
||||
});
|
||||
}
|
||||
|
||||
private reloadAfterMutation(teamId: number): Observable<RecurringTransaction[]> {
|
||||
return this.api.loadRecurringTransactions(teamId).pipe(
|
||||
catchError(() => {
|
||||
this.loadError.set(
|
||||
'Änderung wurde gespeichert, aber die Liste konnte nicht aktualisiert werden.',
|
||||
);
|
||||
return EMPTY;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private load(teamId: number): void {
|
||||
this.loading.set(true);
|
||||
this.loadError.set(null);
|
||||
this.api
|
||||
.loadRecurringTransactions(teamId)
|
||||
.pipe(
|
||||
finalize(() => this.loading.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe({
|
||||
next: (entries) => this.entries.set(entries),
|
||||
error: () => {
|
||||
this.entries.set([]);
|
||||
this.loadError.set('Liste konnte nicht geladen werden.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private toIso(date: string): string {
|
||||
return new Date(`${date}T12:00:00`).toISOString();
|
||||
}
|
||||
|
||||
private errorMessage(error: HttpErrorResponse, fallback: string): string {
|
||||
const detail = typeof error.error?.message === 'string' ? error.error.message : '';
|
||||
if (error.status === 403) return 'Keine Berechtigung für diese Änderung.';
|
||||
if (error.status === 404) return 'Der Eintrag wurde nicht gefunden. Bitte lade die Liste neu.';
|
||||
if (error.status === 422) return 'Bitte prüfe die Eingaben.';
|
||||
return detail || fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export type RecurringTransactionInterval = 'monthly' | 'quarterly' | 'yearly';
|
||||
|
||||
export interface RecurringTransaction {
|
||||
id: number;
|
||||
description: string;
|
||||
amount: number;
|
||||
type: number;
|
||||
interval: RecurringTransactionInterval;
|
||||
nextRunDate: string;
|
||||
active: boolean;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateRecurringTransaction {
|
||||
teamId: number;
|
||||
description: string;
|
||||
amount: number;
|
||||
type: number;
|
||||
interval: RecurringTransactionInterval;
|
||||
startDate: string;
|
||||
}
|
||||
|
||||
export interface UpdateRecurringTransaction {
|
||||
description: string;
|
||||
amount: number;
|
||||
type: number;
|
||||
interval: RecurringTransactionInterval;
|
||||
active: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user