first commit

This commit is contained in:
Bastian Wagner
2026-07-31 21:02:47 +02:00
commit 6bea4f766a
512 changed files with 64459 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn, IsNotEmpty, IsNumber, IsPositive, Max } from 'class-validator';
import { TransactionTypeEnum } from '../transaction-type.enum';
export class CreateTransactionDto {
@ApiProperty({ example: 0 })
@IsNotEmpty()
playerId: number;
@ApiProperty({ example: 'Extra teuer wegen diskutierens' })
note?: string | null;
@ApiProperty({ example: 'isotimestring' })
@IsNotEmpty()
date: string;
@ApiProperty({ example: 10 })
@IsNumber({ maxDecimalPlaces: 2 })
@IsPositive()
@Max(10000)
amount: number;
@ApiProperty({ enum: TransactionTypeEnum })
@IsIn(Object.values(TransactionTypeEnum).filter((v) => typeof v === 'number'))
type: TransactionTypeEnum;
}

View File

@@ -0,0 +1,16 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
import { EntityHelper } from 'src/utils/entity-helper';
import { ApiProperty } from '@nestjs/swagger';
import { Allow } from 'class-validator';
@Entity()
export class TransactionType extends EntityHelper {
@ApiProperty({ example: 1 })
@PrimaryColumn()
id: number;
@Allow()
@ApiProperty({ example: 'credit' })
@Column()
name?: string;
}

View File

@@ -0,0 +1,62 @@
import {
AfterLoad,
BeforeInsert,
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { EntityHelper } from 'src/utils/entity-helper';
import { Player } from 'src/players/entities/player.entity';
import { TransactionType } from './transaction-type.entity';
@Entity()
export class Transaction extends EntityHelper {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => Player, {
eager: false,
})
player: Player;
@Column()
note: string;
@Column()
date: string;
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
amount: number;
@ManyToOne(() => TransactionType, {
eager: true,
})
type?: TransactionType | null;
@CreateDateColumn()
createdAt: Date;
@BeforeInsert()
async setBalance() {
if (!this.date) {
this.date = new Date().toISOString();
}
let amount = this.amount;
if (this.type.id > 10 && amount > 0) {
amount = amount * -1;
}
this.player.balance = Number(this.player.balance) + amount;
if (this.type.id == 0) {
this.player.team.balance = Number(this.player.team.balance) + amount;
await this.player.team.save();
}
await this.player.save();
}
@AfterLoad()
updateValue() {
this.amount = Number(this.amount);
}
}

View File

@@ -0,0 +1,7 @@
export enum TransactionTypeEnum {
'payment' = 0,
'credit' = 1,
'fine' = 11,
'levy' = 12,
'fee' = 13,
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TransactionsController } from './transactions.controller';
describe('TransactionsController', () => {
let controller: TransactionsController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [TransactionsController],
}).compile();
controller = module.get<TransactionsController>(TransactionsController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,52 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Roles } from 'src/roles/roles.decorator';
import { RoleEnum } from 'src/roles/roles.enum';
import { RolesGuard } from 'src/roles/roles.guard';
import { CreateTransactionDto } from './dto/create-transaction.dto';
import { TransactionsService } from './transactions.service';
@ApiTags('Transactions')
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Controller({
path: 'transactions',
version: '1',
})
export class TransactionsController {
constructor(private transactionsService: TransactionsService) {}
@Roles([RoleEnum.admin, RoleEnum.user])
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Req() req, @Body() createTransactionDtos: CreateTransactionDto[]) {
const userId = req.user?.id;
return this.transactionsService.createTransactions(
createTransactionDtos,
userId,
);
}
@ApiOperation({
summary: 'Buchung stornieren',
description:
'Erstellt eine Ausgleichsbuchung, die eine fehlerhafte Buchung rückgängig macht. Die Originalbuchung bleibt zur Nachvollziehbarkeit erhalten.',
})
@Roles([RoleEnum.admin, RoleEnum.user])
@Post(':id/reverse')
@HttpCode(HttpStatus.CREATED)
reverse(@Req() req, @Param('id') id: string) {
const userId = req.user?.id;
return this.transactionsService.reverse(Number(id), userId);
}
}

View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TransactionType } from './entitites/transaction-type.entity';
import { Transaction } from './entitites/transaction.entity';
import { TransactionsService } from './transactions.service';
import { TransactionsController } from './transactions.controller';
import { Player } from 'src/players/entities/player.entity';
import { User } from 'src/users/entities/user.entity';
import { TeamSetting } from 'src/team-settings/entities/team-setting.entity';
import { LoggingModule } from 'src/database/logging/logging.module';
@Module({
imports: [
TypeOrmModule.forFeature([
TransactionType,
Transaction,
Player,
User,
TeamSetting,
]),
LoggingModule,
],
providers: [TransactionsService],
controllers: [TransactionsController],
})
export class TransactionsModule {}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TransactionsService } from './transactions.service';
describe('TransactionsService', () => {
let service: TransactionsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [TransactionsService],
}).compile();
service = module.get<TransactionsService>(TransactionsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,188 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LoggingService } from 'src/database/logging/logging.service';
import { Player } from 'src/players/entities/player.entity';
import { RoleEnum } from 'src/roles/roles.enum';
import { User } from 'src/users/entities/user.entity';
import { Repository } from 'typeorm';
import { CreateTransactionDto } from './dto/create-transaction.dto';
import { TransactionType } from './entitites/transaction-type.entity';
import { Transaction } from './entitites/transaction.entity';
import { TransactionTypeEnum } from './transaction-type.enum';
@Injectable()
export class TransactionsService {
constructor(
@InjectRepository(Transaction)
private transactionsRepository: Repository<Transaction>,
@InjectRepository(Player)
private playersRepository: Repository<Player>,
@InjectRepository(TransactionType)
private transactionTypesRepository: Repository<TransactionType>,
@InjectRepository(User)
private usersRepository: Repository<User>,
private logger: LoggingService,
) {}
async createTransactions(data: CreateTransactionDto[], userId: string) {
const res = [];
for (const d of data) {
const r = await this.create(d, userId);
res.push(r);
}
return res;
}
async create(data: CreateTransactionDto, userId: string) {
const player = await this.playersRepository.findOne({
where: { id: data.playerId },
});
const creatingUser = await this.usersRepository.findOne({
where: {
id: Number(userId),
},
relations: ['players'],
});
const transactionType = await this.transactionTypesRepository.findOne({
where: { id: TransactionTypeEnum[TransactionTypeEnum[data.type]] },
});
if (creatingUser.role.id != RoleEnum.admin) {
if (
!player ||
!transactionType ||
!creatingUser ||
!creatingUser.players ||
creatingUser.players.length == 0
) {
await this.logger.warn({
event: 'transaction_create_fail',
details: `Player: ${data.playerId}, amount: ${data.amount}, typeEnum: ${data.type}`,
userId: Number(userId),
});
return;
}
const teamPlayer = creatingUser.players.find(
(p) =>
p.team.id == player.team.id &&
p.teamRole.id >=
Number(
player.team.settings.find(
(s) => s.key == 'transaction_create_min_role',
)['value'],
),
);
if (!teamPlayer) {
return;
}
}
const transaction = this.transactionsRepository.create({
amount: data.amount,
date: data.date,
player: player,
note: data.note || '',
type: transactionType,
});
await this.logger.info({
event: 'transaction_create',
details: `Player: ${transaction.player.id}, amount: ${transaction.amount}, type: ${transaction.type?.name}`,
userId: Number(userId),
});
return this.transactionsRepository.save(transaction);
}
async reverse(transactionId: number, userId: string) {
const original = await this.transactionsRepository.findOne({
where: { id: transactionId },
relations: ['player', 'type'],
});
if (!original) {
throw new NotFoundException('Buchung nicht gefunden');
}
if (original.note?.startsWith('Stornierung von Buchung #')) {
throw new BadRequestException(
'Eine Stornobuchung kann nicht erneut storniert werden',
);
}
const alreadyReversed = await this.transactionsRepository.findOne({
where: {
player: { id: original.player.id },
note: `Stornierung von Buchung #${original.id}`,
},
});
if (alreadyReversed) {
throw new BadRequestException('Diese Buchung wurde bereits storniert');
}
const creatingUser = await this.usersRepository.findOne({
where: { id: Number(userId) },
relations: ['players'],
});
if (creatingUser.role.id != RoleEnum.admin) {
const teamPlayer = creatingUser.players?.find(
(p) =>
p.team.id == original.player.team.id &&
p.teamRole.id >=
Number(
original.player.team.settings.find(
(s) => s.key == 'transaction_create_min_role',
)?.['value'] ?? 0,
),
);
if (!teamPlayer) {
throw new ForbiddenException(
'Keine Berechtigung, diese Buchung zu stornieren',
);
}
}
const originalAmount = Math.abs(Number(original.amount));
let reversalType = original.type;
let reversalAmount = -originalAmount;
// Buchungstypen > 10 (z.B. Strafe/Umlage/Gebühr) ziehen im Entity-Hook
// immer vom Guthaben ab, egal welches Vorzeichen der Betrag hat. Um sie
// auszugleichen, wird die Gegenbuchung stattdessen als "credit" gebucht.
if (original.type.id > 10) {
reversalType = await this.transactionTypesRepository.findOne({
where: { id: TransactionTypeEnum.credit },
});
reversalAmount = originalAmount;
}
const reversal = this.transactionsRepository.create({
amount: reversalAmount,
date: new Date().toISOString(),
player: original.player,
note: `Stornierung von Buchung #${original.id}`,
type: reversalType,
});
const saved = await this.transactionsRepository.save(reversal);
await this.logger.info({
event: 'transaction_reverse',
details: `Buchung #${original.id} storniert durch Buchung #${saved.id}, Player: ${original.player.id}, amount: ${original.amount}`,
userId: Number(userId),
});
return saved;
}
}