This commit is contained in:
Bastian Wagner
2026-08-03 14:49:35 +02:00
parent 71d76725a4
commit b21641f37a
24 changed files with 1009 additions and 136 deletions

View File

@@ -9,9 +9,11 @@ import {
Put,
Patch,
ParseIntPipe,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { TransactionsQueryDto } from 'src/transactions/dto/transactions-query.dto';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Roles } from 'src/roles/roles.decorator';
@@ -142,6 +144,25 @@ export class TeamsController {
return this.service.getTeamTransactions(id, userId);
}
@ApiOperation({
summary: 'Paginiertes Kassenjournal für ein Team',
description:
'Gibt Transaktionen (Spieler und Teamwallet) seitenweise zurück, mit serverseitiger Sortierung, Typ-Filter und Freitextsuche - für das AG-Grid-Journal.',
})
@ApiBearerAuth()
@Roles([RoleEnum.user, RoleEnum.admin])
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Get(':id/transactions/journal')
@HttpCode(HttpStatus.OK)
getTransactionsJournal(
@Req() req,
@Param('id') id: string,
@Query() query: TransactionsQueryDto,
) {
const userId = req.user?.id;
return this.service.getTeamTransactionsJournal(id, userId, query);
}
@ApiOperation({
summary: 'Neuen Spieler anlegen',
description:

View File

@@ -192,6 +192,41 @@ describe('TeamsService#getOverviewStats theoretical balance', () => {
expect(now!.theoreticalBalance).toBeLessThan(now!.balance);
});
it('sums fine/levy/fee bookings per month into monthlyFlow.penalties, excluded from income/expense', async () => {
repository.findOneOrFail.mockResolvedValue({
id: 9,
balance: 100,
transactions: [{ date: isoDate(0, 1), amount: 100, type: { name: 'credit' } }],
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
active: true,
balance: -45,
transactions: [
{ date: isoDate(0, 5), amount: 10, type: { id: 11, name: 'fine' }, note: 'Zu spät' },
{ date: isoDate(0, 6), amount: 15, type: { id: 12, name: 'levy' }, note: 'Umlage' },
{ date: isoDate(0, 7), amount: 5, type: { id: 13, name: 'fee' }, note: 'Gebühr' },
{ date: isoDate(1, 8), amount: 20, type: { id: 11, name: 'fine' }, note: 'Vormonat' },
{ date: isoDate(0, 9), amount: 12, type: { id: 0, name: 'payment' }, note: 'Beitrag' },
],
},
],
});
const result = await service.getOverviewStats(9, 42);
const now = result.monthlyFlow.find((p) => p.month === monthKey(0));
const lastMonth = result.monthlyFlow.find((p) => p.month === monthKey(1));
expect(now?.penalties).toBe(30);
expect(lastMonth?.penalties).toBe(20);
// Payment still counts as income, fine/levy/fee never do.
expect(now?.income).toBe(12);
expect(now?.expense).toBe(0);
});
it('keeps returning an empty balance history when the team has no cash movement at all', async () => {
repository.findOneOrFail.mockResolvedValue({
id: 9,
@@ -205,3 +240,125 @@ describe('TeamsService#getOverviewStats theoretical balance', () => {
expect(result.balanceHistory).toEqual([]);
});
});
describe('TeamsService#getTeamTransactionsJournal', () => {
const repository = { findOneOrFail: jest.fn() };
const access = { assertMember: jest.fn() };
let service: TeamsService;
const team = {
id: 9,
transactions: [
{ id: 101, date: '2026-06-01', amount: 50, type: { name: 'credit' }, note: 'Sponsoring' },
{ id: 102, date: '2026-06-15', amount: 20, type: { name: 'expense' }, note: 'Bälle' },
],
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
transactions: [
{ id: 1, date: '2026-06-10', amount: 12, type: { name: 'payment' }, note: 'Beitrag' },
{ id: 2, date: '2026-06-20', amount: 5, type: { name: 'fine' }, note: 'Zu spät' },
],
},
{
id: 2,
firstName: 'Bea',
lastName: 'Beispiel',
transactions: [
{ id: 3, date: '2026-06-05', amount: 30, type: { name: 'levy' }, note: 'Turnier-Umlage' },
],
},
],
};
beforeEach(() => {
jest.resetAllMocks();
access.assertMember.mockResolvedValue(undefined);
repository.findOneOrFail.mockResolvedValue(team);
service = new TeamsService(
repository as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{ info: jest.fn(), debug: jest.fn(), warn: jest.fn() } as any,
access as any,
);
});
function query(overrides: Partial<Record<string, unknown>> = {}) {
return {
page: 1,
limit: 25,
sortBy: 'date',
sortDir: 'desc',
...overrides,
} as any;
}
it('checks membership before returning any data', async () => {
await service.getTeamTransactionsJournal(9, 42, query());
expect(access.assertMember).toHaveBeenCalledWith(42, 9);
});
it('returns the total count of all 5 bookings across both sources, unpaginated', async () => {
const result = await service.getTeamTransactionsJournal(9, 42, query());
expect(result.total).toBe(5);
});
it('paginates using page/limit and slices from the already-sorted result', async () => {
const page1 = await service.getTeamTransactionsJournal(9, 42, query({ page: 1, limit: 2 }));
const page2 = await service.getTeamTransactionsJournal(9, 42, query({ page: 2, limit: 2 }));
expect(page1.data).toHaveLength(2);
expect(page2.data).toHaveLength(2);
expect(page1.total).toBe(5);
expect(page2.total).toBe(5);
// Newest first by default (date desc) - no overlap between the two pages.
expect(page1.data.map((row) => row.id)).toEqual([2, 102]);
expect(page2.data.map((row) => row.id)).toEqual([1, 3]);
});
it('sorts by amount in both directions', async () => {
const asc = await service.getTeamTransactionsJournal(
9,
42,
query({ sortBy: 'amount', sortDir: 'asc', limit: 100 }),
);
const desc = await service.getTeamTransactionsJournal(
9,
42,
query({ sortBy: 'amount', sortDir: 'desc', limit: 100 }),
);
expect(asc.data.map((row) => row.amount)).toEqual([5, 12, 20, 30, 50]);
expect(desc.data.map((row) => row.amount)).toEqual([50, 30, 20, 12, 5]);
});
it('filters by exact booking type', async () => {
const result = await service.getTeamTransactionsJournal(9, 42, query({ type: 'fine' }));
expect(result.total).toBe(1);
expect(result.data[0].note).toBe('Zu spät');
});
it('filters by a case-insensitive search across player name and note', async () => {
const byName = await service.getTeamTransactionsJournal(9, 42, query({ search: 'bea' }));
const byNote = await service.getTeamTransactionsJournal(9, 42, query({ search: 'BÄLLE' }));
expect(byName.total).toBe(1);
expect(byName.data[0].playerName).toBe('Bea Beispiel');
expect(byNote.total).toBe(1);
expect(byNote.data[0].note).toBe('Bälle');
});
it('rejects when the actor is not a team member', async () => {
access.assertMember.mockRejectedValue(new Error('forbidden'));
await expect(service.getTeamTransactionsJournal(9, 42, query())).rejects.toThrow('forbidden');
expect(repository.findOneOrFail).not.toHaveBeenCalled();
});
});

View File

@@ -9,12 +9,26 @@ import { TEAM_SETTING_DEFAULTS } from 'src/team-settings/team-setting-defaults';
import { TeamWalletTransaction } from 'src/team-wallet-transactions/entities/team-wallet-transaction.entity';
import { Transaction } from 'src/transactions/entitites/transaction.entity';
import { Repository } from 'typeorm';
import {
TransactionsQueryDto,
TransactionsSortableField,
} from 'src/transactions/dto/transactions-query.dto';
import { CreateTeamDTO } from './dto/create-team.dto';
import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
import { Team } from './entities/team.entity';
import { TeamAccessService } from './team-access.service';
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
interface TeamActivityRow {
id: number;
date: string;
amount: number;
type: string;
note: string | null;
playerName?: string;
isTeamWalletTransaction: boolean;
}
@Injectable()
export class TeamsService {
constructor(
@@ -229,12 +243,98 @@ export class TeamsService {
return result;
}
async getTeamTransactionsJournal(
teamId: string | number,
userId: string | number,
query: TransactionsQueryDto,
): Promise<{ data: TeamActivityRow[]; total: number }> {
const id = Number(teamId);
await this.access.assertMember(Number(userId), id);
const team = await this.repository.findOneOrFail({
where: { id },
relations: ['players', 'players.transactions', 'transactions'],
});
const transactions: TeamActivityRow[] = [];
for (const t of team.transactions) {
transactions.push({
id: t.id,
date: t.date,
amount: Number(t.amount),
type: t.type.name,
note: t.note,
isTeamWalletTransaction: true,
});
}
for (const p of team.players) {
for (const t of p.transactions) {
transactions.push({
id: t.id,
date: t.date,
amount: Number(t.amount),
type: t.type.name,
note: t.note,
playerName: p.firstName + ' ' + p.lastName,
isTeamWalletTransaction: false,
});
}
}
const search = query.search?.trim().toLowerCase();
const filtered = transactions.filter((row) => {
if (query.type && row.type !== query.type) return false;
if (search) {
const haystack = `${row.playerName ?? 'Teamkasse'} ${row.note ?? ''}`.toLowerCase();
if (!haystack.includes(search)) return false;
}
return true;
});
const sortBy = query.sortBy ?? 'date';
const direction = query.sortDir === 'asc' ? 1 : -1;
const sorted = [...filtered].sort((a, b) => {
const aValue = this.transactionSortValue(a, sortBy);
const bValue = this.transactionSortValue(b, sortBy);
if (aValue < bValue) return -1 * direction;
if (aValue > bValue) return 1 * direction;
return 0;
});
const page = query.page ?? 1;
const limit = query.limit ?? 25;
const start = (page - 1) * limit;
const data = sorted.slice(start, start + limit);
return { data, total: filtered.length };
}
private transactionSortValue(
row: TeamActivityRow,
field: TransactionsSortableField,
): string | number {
switch (field) {
case 'amount':
return row.amount;
case 'playerName':
return (row.playerName ?? 'Teamkasse').toLowerCase();
case 'type':
return row.type;
case 'date':
default:
return row.date;
}
}
async getOverviewStats(
teamId: string | number,
actorUserId: string | number,
): Promise<{
balanceHistory: { month: string; balance: number; theoreticalBalance: number }[];
monthlyFlow: { month: string; income: number; expense: number }[];
monthlyFlow: { month: string; income: number; expense: number; penalties: number }[];
topOutstanding: { playerId: number; playerName: string; balance: number }[];
}> {
const id = Number(teamId);
@@ -256,6 +356,10 @@ export class TeamsService {
// TeamWalletTransaction/Transaction) — fine/levy/fee raise a player's
// debt but never move money, so they are excluded entirely.
const movements: { date: string; amount: number; type: string }[] = [];
// Fine/levy/fee (type.id > 10) raise a player's debt but never move real
// cash, so they're kept out of `movements` and tracked separately here
// purely to chart "how much was booked as penalties/levies this month".
const penaltyMovements: { date: string; amount: number }[] = [];
for (const t of teamTransactions) {
movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name });
@@ -265,6 +369,8 @@ export class TeamsService {
for (const t of p.transactions ?? []) {
if (t.type.name === 'payment') {
movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name });
} else if (t.type.id > 10) {
penaltyMovements.push({ date: t.date, amount: Number(t.amount) });
}
}
}
@@ -318,7 +424,15 @@ export class TeamsService {
const expense = monthMovements
.filter((m) => m.type === 'expense')
.reduce((sum, m) => sum + m.amount, 0);
return { month, income: this.round(income), expense: this.round(expense) };
const penalties = penaltyMovements
.filter((m) => m.date.slice(0, 7) === month)
.reduce((sum, m) => sum + m.amount, 0);
return {
month,
income: this.round(income),
expense: this.round(expense),
penalties: this.round(penalties),
};
});
const outstandingHistory = this.reconstructOutstandingHistory(months, players);

View File

@@ -0,0 +1,36 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export const TRANSACTIONS_SORTABLE_FIELDS = ['date', 'amount', 'playerName', 'type'] as const;
export type TransactionsSortableField = (typeof TRANSACTIONS_SORTABLE_FIELDS)[number];
export class TransactionsQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit = 25;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsString()
type?: string;
@IsOptional()
@IsIn(TRANSACTIONS_SORTABLE_FIELDS)
sortBy: TransactionsSortableField = 'date';
@IsOptional()
@IsIn(['asc', 'desc'])
sortDir: 'asc' | 'desc' = 'desc';
}