From 9e55c0549b4d0ba49ac945527b14a38bca328115 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 3 Aug 2026 20:20:57 +0200 Subject: [PATCH] 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 --- myteamwallet_backend/package-lock.json | 73 +++++ myteamwallet_backend/package.json | 1 + myteamwallet_backend/src/app.module.ts | 4 + .../logging/model/logging-event.type.ts | 6 +- .../dto/create-recurring-transaction.dto.ts | 49 +++ .../dto/recurring-transaction-response.dto.ts | 12 + .../dto/update-recurring-transaction.dto.ts | 43 +++ .../entities/recurring-transaction.entity.ts | 45 +++ .../recurring-transaction-interval.enum.ts | 5 + .../recurring-transactions.controller.ts | 62 ++++ .../recurring-transactions.http.spec.ts | 150 +++++++++ .../recurring-transactions.module.ts | 22 ++ .../recurring-transactions.scheduler.spec.ts | 151 +++++++++ .../recurring-transactions.scheduler.ts | 83 +++++ .../recurring-transactions.service.spec.ts | 245 ++++++++++++++ .../recurring-transactions.service.ts | 202 ++++++++++++ .../src/app/app.routes.ts | 7 + .../team/recurring-transaction-api.spec.ts | 65 ++++ .../core/team/recurring-transaction-api.ts | 36 +++ .../src/app/features/team/more/more.html | 10 + .../recurring-transactions.html | 185 +++++++++++ .../recurring-transactions.scss | 157 +++++++++ .../recurring-transactions.spec.ts | 234 ++++++++++++++ .../recurring-transactions.ts | 306 ++++++++++++++++++ .../app/models/recurring-transaction.model.ts | 29 ++ 25 files changed, 2181 insertions(+), 1 deletion(-) create mode 100644 myteamwallet_backend/src/recurring-transactions/dto/create-recurring-transaction.dto.ts create mode 100644 myteamwallet_backend/src/recurring-transactions/dto/recurring-transaction-response.dto.ts create mode 100644 myteamwallet_backend/src/recurring-transactions/dto/update-recurring-transaction.dto.ts create mode 100644 myteamwallet_backend/src/recurring-transactions/entities/recurring-transaction.entity.ts create mode 100644 myteamwallet_backend/src/recurring-transactions/recurring-transaction-interval.enum.ts create mode 100644 myteamwallet_backend/src/recurring-transactions/recurring-transactions.controller.ts create mode 100644 myteamwallet_backend/src/recurring-transactions/recurring-transactions.http.spec.ts create mode 100644 myteamwallet_backend/src/recurring-transactions/recurring-transactions.module.ts create mode 100644 myteamwallet_backend/src/recurring-transactions/recurring-transactions.scheduler.spec.ts create mode 100644 myteamwallet_backend/src/recurring-transactions/recurring-transactions.scheduler.ts create mode 100644 myteamwallet_backend/src/recurring-transactions/recurring-transactions.service.spec.ts create mode 100644 myteamwallet_backend/src/recurring-transactions/recurring-transactions.service.ts create mode 100644 myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.spec.ts create mode 100644 myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.ts create mode 100644 myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.html create mode 100644 myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.scss create mode 100644 myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.spec.ts create mode 100644 myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.ts create mode 100644 myteamwallet_frontend_modern/src/app/models/recurring-transaction.model.ts diff --git a/myteamwallet_backend/package-lock.json b/myteamwallet_backend/package-lock.json index 49ade17..a639208 100644 --- a/myteamwallet_backend/package-lock.json +++ b/myteamwallet_backend/package-lock.json @@ -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", diff --git a/myteamwallet_backend/package.json b/myteamwallet_backend/package.json index a44d6f9..9bb37bf 100644 --- a/myteamwallet_backend/package.json +++ b/myteamwallet_backend/package.json @@ -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", diff --git a/myteamwallet_backend/src/app.module.ts b/myteamwallet_backend/src/app.module.ts index 084d3be..bd5ca45 100644 --- a/myteamwallet_backend/src/app.module.ts +++ b/myteamwallet_backend/src/app.module.ts @@ -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: [], }) diff --git a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts index 671d433..cfcf2c7 100644 --- a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts +++ b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts @@ -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'; diff --git a/myteamwallet_backend/src/recurring-transactions/dto/create-recurring-transaction.dto.ts b/myteamwallet_backend/src/recurring-transactions/dto/create-recurring-transaction.dto.ts new file mode 100644 index 0000000..2a9987b --- /dev/null +++ b/myteamwallet_backend/src/recurring-transactions/dto/create-recurring-transaction.dto.ts @@ -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; +} diff --git a/myteamwallet_backend/src/recurring-transactions/dto/recurring-transaction-response.dto.ts b/myteamwallet_backend/src/recurring-transactions/dto/recurring-transaction-response.dto.ts new file mode 100644 index 0000000..87869fd --- /dev/null +++ b/myteamwallet_backend/src/recurring-transactions/dto/recurring-transaction-response.dto.ts @@ -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; +} diff --git a/myteamwallet_backend/src/recurring-transactions/dto/update-recurring-transaction.dto.ts b/myteamwallet_backend/src/recurring-transactions/dto/update-recurring-transaction.dto.ts new file mode 100644 index 0000000..73a08f2 --- /dev/null +++ b/myteamwallet_backend/src/recurring-transactions/dto/update-recurring-transaction.dto.ts @@ -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; +} diff --git a/myteamwallet_backend/src/recurring-transactions/entities/recurring-transaction.entity.ts b/myteamwallet_backend/src/recurring-transactions/entities/recurring-transaction.entity.ts new file mode 100644 index 0000000..b106936 --- /dev/null +++ b/myteamwallet_backend/src/recurring-transactions/entities/recurring-transaction.entity.ts @@ -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; +} diff --git a/myteamwallet_backend/src/recurring-transactions/recurring-transaction-interval.enum.ts b/myteamwallet_backend/src/recurring-transactions/recurring-transaction-interval.enum.ts new file mode 100644 index 0000000..f358ca7 --- /dev/null +++ b/myteamwallet_backend/src/recurring-transactions/recurring-transaction-interval.enum.ts @@ -0,0 +1,5 @@ +export enum RecurringTransactionIntervalEnum { + 'monthly' = 'monthly', + 'quarterly' = 'quarterly', + 'yearly' = 'yearly', +} diff --git a/myteamwallet_backend/src/recurring-transactions/recurring-transactions.controller.ts b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.controller.ts new file mode 100644 index 0000000..acf54e1 --- /dev/null +++ b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.controller.ts @@ -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 { + await this.service.deleteRecurringTransaction(id, request.user.id); + } +} diff --git a/myteamwallet_backend/src/recurring-transactions/recurring-transactions.http.spec.ts b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.http.spec.ts new file mode 100644 index 0000000..4b812a5 --- /dev/null +++ b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.http.spec.ts @@ -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); + }); +}); diff --git a/myteamwallet_backend/src/recurring-transactions/recurring-transactions.module.ts b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.module.ts new file mode 100644 index 0000000..008f5a7 --- /dev/null +++ b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.module.ts @@ -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 {} diff --git a/myteamwallet_backend/src/recurring-transactions/recurring-transactions.scheduler.spec.ts b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.scheduler.spec.ts new file mode 100644 index 0000000..dba8d37 --- /dev/null +++ b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.scheduler.spec.ts @@ -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); + }); +}); diff --git a/myteamwallet_backend/src/recurring-transactions/recurring-transactions.scheduler.ts b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.scheduler.ts new file mode 100644 index 0000000..d4810eb --- /dev/null +++ b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.scheduler.ts @@ -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.monthly]: 1, + [RecurringTransactionIntervalEnum.quarterly]: 3, + [RecurringTransactionIntervalEnum.yearly]: 12, +}; + +@Injectable() +export class RecurringTransactionsScheduler { + constructor( + @InjectRepository(RecurringTransaction) + private readonly repository: Repository, + private readonly dataSource: DataSource, + private readonly logger: LoggingService, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_3AM) + async runDueRecurringTransactions(): Promise { + 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 { + 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(); + } +} diff --git a/myteamwallet_backend/src/recurring-transactions/recurring-transactions.service.spec.ts b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.service.spec.ts new file mode 100644 index 0000000..1163825 --- /dev/null +++ b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.service.spec.ts @@ -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(); + }); +}); diff --git a/myteamwallet_backend/src/recurring-transactions/recurring-transactions.service.ts b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.service.ts new file mode 100644 index 0000000..f18e5fe --- /dev/null +++ b/myteamwallet_backend/src/recurring-transactions/recurring-transactions.service.ts @@ -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, + @InjectRepository(Team) + private readonly teamRepository: Repository, + private readonly dataSource: DataSource, + private readonly access: TeamAccessService, + private readonly logger: LoggingService, + ) {} + + async getTeamRecurringTransactions( + userId: number, + teamId: number, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + id: number, + teamId: number, + ): Promise { + 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, + }; + } +} diff --git a/myteamwallet_frontend_modern/src/app/app.routes.ts b/myteamwallet_frontend_modern/src/app/app.routes.ts index 7eab3cc..6fe99d6 100644 --- a/myteamwallet_frontend_modern/src/app/app.routes.ts +++ b/myteamwallet_frontend_modern/src/app/app.routes.ts @@ -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), diff --git a/myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.spec.ts b/myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.spec.ts new file mode 100644 index 0000000..8305f5e --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.spec.ts @@ -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); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.ts b/myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.ts new file mode 100644 index 0000000..2d7f1aa --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/core/team/recurring-transaction-api.ts @@ -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 { + return this.http.get(`${this.baseUrl}/${teamId}`); + } + + createRecurringTransaction( + entry: CreateRecurringTransaction, + ): Observable { + return this.http.post(this.baseUrl, entry); + } + + updateRecurringTransaction( + id: number, + entry: UpdateRecurringTransaction, + ): Observable { + return this.http.patch(`${this.baseUrl}/${id}`, entry); + } + + deleteRecurringTransaction(id: number): Observable { + return this.http.delete(`${this.baseUrl}/${id}`); + } +} diff --git a/myteamwallet_frontend_modern/src/app/features/team/more/more.html b/myteamwallet_frontend_modern/src/app/features/team/more/more.html index ea7e4fc..bcb52ad 100644 --- a/myteamwallet_frontend_modern/src/app/features/team/more/more.html +++ b/myteamwallet_frontend_modern/src/app/features/team/more/more.html @@ -40,6 +40,16 @@ chevron_right + event_repeat +
+ Wiederkehrende BuchungenFälligkeiten automatisch buchen lassen +
+ chevron_right
person_add diff --git a/myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.html b/myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.html new file mode 100644 index 0000000..4affada --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.html @@ -0,0 +1,185 @@ +arrow_backMehr +
+

Kasse

+

Wiederkehrende Buchungen

+

Fälligkeiten wie den Monatsbeitrag automatisch für alle aktiven Spieler buchen lassen.

+
+ +@if (canManage()) { + +
+ + Beschreibung + + + + Betrag + + + + + Typ + + @for (option of typeOptions; track option.id) { + {{ option.label }} + } + + + + Intervall + + @for (option of intervalOptions; track option.value) { + {{ option.label }} + } + + + + Erste Buchung am + + + +
+
+} + +@if (mutationError()) { + +} + +@if (loading()) { +
+ + Liste wird geladen … +
+} @else if (loadError()) { + +} @else if (entries().length === 0) { +
+ event_repeat + Noch keine Einträge + Es gibt noch keine wiederkehrenden Buchungen für dieses Team. +
+} @else { +
+ @for (entry of entries(); track entry.id) { + + @if (editingEntryId() === entry.id) { +
+ + Beschreibung + + + + Betrag + + + + + Typ + + @for (option of typeOptions; track option.id) { + {{ option.label }} + } + + + + Intervall + + @for (option of intervalOptions; track option.value) { + {{ option.label }} + } + + +
+ + +
+
+ } @else { +
+ {{ entry.description }} + {{ entry.amount | currency: 'EUR' }} + @if (!entry.active) { + Pausiert + } @else { + nächste Buchung am {{ entry.nextRunDate | date: 'dd.MM.yyyy' }} + } +
+
+ @if (canManage()) { + + + + } +
+ } +
+ } +
+} diff --git a/myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.scss b/myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.scss new file mode 100644 index 0000000..cce366c --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.scss @@ -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; + } +} diff --git a/myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.spec.ts b/myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.spec.ts new file mode 100644 index 0000000..ae6ebb0 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.spec.ts @@ -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; + let currentUser: ReturnType>; + let loadRecurringTransactions: ReturnType; + let createRecurringTransaction: ReturnType; + let updateRecurringTransaction: ReturnType; + let deleteRecurringTransaction: ReturnType; + let dialogClosed: Subject; + let dialog: { open: ReturnType }; + + 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(); + 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(); + 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(); + 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'); + }); +}); diff --git a/myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.ts b/myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.ts new file mode 100644 index 0000000..ffe8b40 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/features/team/more/recurring-transactions/recurring-transactions.ts @@ -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([]); + protected readonly loading = signal(false); + protected readonly saving = signal(false); + protected readonly pendingEntryId = signal(null); + protected readonly editingEntryId = signal(null); + protected readonly loadError = signal(null); + protected readonly mutationError = signal(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 { + 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; + } +} diff --git a/myteamwallet_frontend_modern/src/app/models/recurring-transaction.model.ts b/myteamwallet_frontend_modern/src/app/models/recurring-transaction.model.ts new file mode 100644 index 0000000..aa9ef35 --- /dev/null +++ b/myteamwallet_frontend_modern/src/app/models/recurring-transaction.model.ts @@ -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; +}