first commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum TransactionTypeEnum {
|
||||
'payment' = 0,
|
||||
'credit' = 1,
|
||||
'fine' = 11,
|
||||
'levy' = 12,
|
||||
'fee' = 13,
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
26
myteamwallet_backend/src/transactions/transactions.module.ts
Normal file
26
myteamwallet_backend/src/transactions/transactions.module.ts
Normal 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 {}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
188
myteamwallet_backend/src/transactions/transactions.service.ts
Normal file
188
myteamwallet_backend/src/transactions/transactions.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user