From ecb6fd439484fc4b3171b330ae510dedc3c2fd2c Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 1 Aug 2026 19:44:49 +0200 Subject: [PATCH] feat(teams): add overview stats endpoint for KPI charts Adds GET :id/overview/stats + TeamsService#getOverviewStats, aggregating team-wallet and player payment transactions into a 12-month balanceHistory (cumulative, carry-forward), monthlyFlow (income/expense), and topOutstanding (top 10 active debtors) for the upcoming overview KPI charts. fine/levy/fee and player-level credit are excluded, matching the "Ist-Kasse" cash-flow rule. Replaces the unmodified NestJS-boilerplate placeholder specs for TeamsService/TeamsController (which already failed at baseline) with real tests using the team-access.service.spec.ts direct-construction convention. Co-Authored-By: Claude Sonnet 5 --- .../src/teams/teams.controller.spec.ts | 53 +++- .../src/teams/teams.controller.ts | 9 + .../src/teams/teams.service.spec.ts | 231 +++++++++++++++++- .../src/teams/teams.service.ts | 89 +++++++ 4 files changed, 364 insertions(+), 18 deletions(-) diff --git a/myteamwallet_backend/src/teams/teams.controller.spec.ts b/myteamwallet_backend/src/teams/teams.controller.spec.ts index 4cdb9f9..fb0f7e5 100644 --- a/myteamwallet_backend/src/teams/teams.controller.spec.ts +++ b/myteamwallet_backend/src/teams/teams.controller.spec.ts @@ -1,18 +1,53 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { RoleEnum } from '../roles/roles.enum'; import { TeamsController } from './teams.controller'; describe('TeamsController', () => { + const service = { + getOverview: jest.fn(), + getOverviewStats: jest.fn(), + getTeamTransactions: jest.fn(), + createNewPlayer: jest.fn(), + updatePlayer: jest.fn(), + createNewTeam: jest.fn(), + }; + const publicAccess = {}; + const teamMembers = {}; + let controller: TeamsController; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [TeamsController], - }).compile(); - - controller = module.get(TeamsController); + beforeEach(() => { + jest.resetAllMocks(); + controller = new TeamsController( + service as any, + publicAccess as any, + teamMembers as any, + ); }); - it('should be defined', () => { - expect(controller).toBeDefined(); + describe('GET :id/overview/stats', () => { + it('carries the same guards and roles as GET :id/overview', () => { + expect( + Reflect.getMetadata(GUARDS_METADATA, TeamsController.prototype.getOverviewStats), + ).toEqual(Reflect.getMetadata(GUARDS_METADATA, TeamsController.prototype.findOne)); + + expect( + Reflect.getMetadata('roles', TeamsController.prototype.getOverviewStats), + ).toEqual([RoleEnum.user, RoleEnum.admin]); + + expect( + Reflect.getMetadata('roles', TeamsController.prototype.getOverviewStats), + ).toEqual(Reflect.getMetadata('roles', TeamsController.prototype.findOne)); + }); + + it('delegates to service.getOverviewStats with the route id', () => { + const stats = { balanceHistory: [], monthlyFlow: [], topOutstanding: [] }; + service.getOverviewStats.mockReturnValue(stats); + + const result = controller.getOverviewStats('7'); + + expect(service.getOverviewStats).toHaveBeenCalledWith('7'); + expect(result).toBe(stats); + }); }); }); diff --git a/myteamwallet_backend/src/teams/teams.controller.ts b/myteamwallet_backend/src/teams/teams.controller.ts index d39a5e0..7ffdf10 100644 --- a/myteamwallet_backend/src/teams/teams.controller.ts +++ b/myteamwallet_backend/src/teams/teams.controller.ts @@ -86,6 +86,15 @@ export class TeamsController { return this.service.getOverview(id); } + @ApiBearerAuth() + @Roles([RoleEnum.user, RoleEnum.admin]) + @UseGuards(AuthGuard('jwt'), RolesGuard) + @Get(':id/overview/stats') + @HttpCode(HttpStatus.OK) + getOverviewStats(@Param('id') id: string) { + return this.service.getOverviewStats(id); + } + @ApiOperation({ summary: 'Transactionen für ein Team', description: diff --git a/myteamwallet_backend/src/teams/teams.service.spec.ts b/myteamwallet_backend/src/teams/teams.service.spec.ts index 88b4987..b8ccb21 100644 --- a/myteamwallet_backend/src/teams/teams.service.spec.ts +++ b/myteamwallet_backend/src/teams/teams.service.spec.ts @@ -1,18 +1,231 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { TeamsService } from './teams.service'; describe('TeamsService', () => { + const repository = { findOneOrFail: jest.fn(), findOneBy: jest.fn() }; + const playerRepository = {}; + const transactionsRepository = {}; + const rolesRepository = {}; + const settingsRepository = {}; + const teamWalletTransactionRepository = {}; + const logger = { info: jest.fn(), debug: jest.fn() }; + let service: TeamsService; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [TeamsService], - }).compile(); - - service = module.get(TeamsService); + beforeEach(() => { + jest.resetAllMocks(); + service = new TeamsService( + repository as any, + playerRepository as any, + transactionsRepository as any, + rolesRepository as any, + settingsRepository as any, + teamWalletTransactionRepository as any, + logger as any, + ); }); - it('should be defined', () => { - expect(service).toBeDefined(); + describe('getOverviewStats', () => { + // "now" is fixed to 2026-08-15, so the 12-month window covers + // 2025-09 .. 2026-08. + function buildTeam() { + return { + id: 7, + balance: 190, + transactions: [ + // TeamWalletTransaction: credit adds, expense subtracts. + { + id: 1, + date: '2025-09-15T10:00:00.000Z', + amount: '100.00', + type: { name: 'credit' }, + }, + { + id: 2, + date: '2026-02-20T10:00:00.000Z', + amount: '40.00', + type: { name: 'expense' }, + }, + { + id: 3, + date: '2026-07-01T10:00:00.000Z', + amount: '60.00', + type: { name: 'credit' }, + }, + ], + players: [ + { + id: 101, + firstName: 'Anna', + lastName: 'Aktive', + active: true, + balance: -40, + transactions: [ + { + id: 11, + date: '2025-10-05T09:00:00.000Z', + amount: '50.00', + type: { name: 'payment' }, + }, + // fine: raised debt, not yet paid -> must be excluded entirely. + { + id: 12, + date: '2026-01-10T09:00:00.000Z', + amount: '30.00', + type: { name: 'fine' }, + }, + // player-level credit does not touch team.balance -> excluded. + { + id: 13, + date: '2026-05-01T09:00:00.000Z', + amount: '15.00', + type: { name: 'credit' }, + }, + ], + }, + { + id: 102, + firstName: 'Bea', + lastName: 'Berg', + active: true, + balance: -75, + transactions: [ + { + id: 21, + date: '2026-03-01T09:00:00.000Z', + amount: '20.00', + type: { name: 'payment' }, + }, + // levy: raised debt, not yet paid -> must be excluded entirely. + { + id: 22, + date: '2026-04-01T09:00:00.000Z', + amount: '10.00', + type: { name: 'levy' }, + }, + ], + }, + { + id: 103, + firstName: 'Carla', + lastName: 'Inaktiv', + active: false, + balance: -1000, + transactions: [], + }, + { + id: 104, + firstName: 'Dana', + lastName: 'Doe', + active: true, + balance: 0, + transactions: [], + }, + ], + }; + } + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-08-15T00:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('builds a 12-month balanceHistory with carry-forward and correct sign handling', async () => { + repository.findOneOrFail.mockResolvedValue(buildTeam()); + + const result = await service.getOverviewStats('7'); + + expect(result.balanceHistory).toEqual([ + { month: '2025-09', balance: 100 }, + { month: '2025-10', balance: 150 }, + { month: '2025-11', balance: 150 }, + { month: '2025-12', balance: 150 }, + { month: '2026-01', balance: 150 }, + { month: '2026-02', balance: 110 }, + { month: '2026-03', balance: 130 }, + { month: '2026-04', balance: 130 }, + { month: '2026-05', balance: 130 }, + { month: '2026-06', balance: 130 }, + { month: '2026-07', balance: 190 }, + { month: '2026-08', balance: 190 }, + ]); + }); + + it('sanity check: the last balanceHistory entry equals team.balance', async () => { + const team = buildTeam(); + repository.findOneOrFail.mockResolvedValue(team); + + const result = await service.getOverviewStats('7'); + + expect(result.balanceHistory.at(-1).balance).toBe(team.balance); + }); + + it('groups monthlyFlow into income (payment+credit) and expense, excluding fine/levy/fee', async () => { + repository.findOneOrFail.mockResolvedValue(buildTeam()); + + const result = await service.getOverviewStats('7'); + + expect(result.monthlyFlow).toEqual([ + { month: '2025-09', income: 100, expense: 0 }, + { month: '2025-10', income: 50, expense: 0 }, + { month: '2025-11', income: 0, expense: 0 }, + { month: '2025-12', income: 0, expense: 0 }, + { month: '2026-01', income: 0, expense: 0 }, + { month: '2026-02', income: 0, expense: 40 }, + { month: '2026-03', income: 20, expense: 0 }, + { month: '2026-04', income: 0, expense: 0 }, + { month: '2026-05', income: 0, expense: 0 }, + { month: '2026-06', income: 0, expense: 0 }, + { month: '2026-07', income: 60, expense: 0 }, + { month: '2026-08', income: 0, expense: 0 }, + ]); + }); + + it('limits topOutstanding to active players with negative balance, sorted by debt descending', async () => { + repository.findOneOrFail.mockResolvedValue(buildTeam()); + + const result = await service.getOverviewStats('7'); + + expect(result.topOutstanding).toEqual([ + { playerId: 102, playerName: 'Bea Berg', balance: 75 }, + { playerId: 101, playerName: 'Anna Aktive', balance: 40 }, + ]); + }); + + it('limits topOutstanding to at most 10 entries', async () => { + const team = buildTeam(); + team.players = Array.from({ length: 15 }, (_, i) => ({ + id: 200 + i, + firstName: 'Player', + lastName: `${i}`, + active: true, + balance: -(i + 1), + transactions: [], + })); + repository.findOneOrFail.mockResolvedValue(team); + + const result = await service.getOverviewStats('7'); + + expect(result.topOutstanding).toHaveLength(10); + // highest debt first + expect(result.topOutstanding[0]).toEqual({ + playerId: 214, + playerName: 'Player 14', + balance: 15, + }); + }); + + it('loads the team with the same relations used by getTeamTransactions', async () => { + repository.findOneOrFail.mockResolvedValue(buildTeam()); + + await service.getOverviewStats('7'); + + expect(repository.findOneOrFail).toHaveBeenCalledWith({ + where: { id: 7 }, + relations: ['players', 'players.transactions', 'transactions'], + }); + }); }); }); diff --git a/myteamwallet_backend/src/teams/teams.service.ts b/myteamwallet_backend/src/teams/teams.service.ts index 3bac329..b883c7d 100644 --- a/myteamwallet_backend/src/teams/teams.service.ts +++ b/myteamwallet_backend/src/teams/teams.service.ts @@ -218,6 +218,95 @@ export class TeamsService { return result; } + async getOverviewStats(teamId: string | number): Promise<{ + balanceHistory: { month: string; balance: number }[]; + monthlyFlow: { month: string; income: number; expense: number }[]; + topOutstanding: { playerId: number; playerName: string; balance: number }[]; + }> { + const id = Number(teamId); + + const team = await this.repository.findOneOrFail({ + where: { id }, + relations: ['players', 'players.transactions', 'transactions'], + }); + + // Only payment/credit/expense movements represent actual cash flow and + // are the only types that touch team.balance (see setBalance() on + // 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 }[] = []; + + for (const t of team.transactions) { + movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name }); + } + + for (const p of team.players) { + for (const t of p.transactions) { + if (t.type.name === 'payment') { + movements.push({ date: t.date, amount: Number(t.amount), type: t.type.name }); + } + } + } + + movements.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0)); + + const months = this.getLast12Months(); + + let cumulativeBalance = 0; + let movementIndex = 0; + const balanceHistory = months.map((month) => { + while ( + movementIndex < movements.length && + movements[movementIndex].date.slice(0, 7) <= month + ) { + cumulativeBalance += this.signedFlowAmount(movements[movementIndex]); + movementIndex++; + } + return { month, balance: this.round(cumulativeBalance) }; + }); + + const monthlyFlow = months.map((month) => { + const monthMovements = movements.filter((m) => m.date.slice(0, 7) === month); + const income = monthMovements + .filter((m) => m.type === 'payment' || m.type === 'credit') + .reduce((sum, m) => sum + m.amount, 0); + 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 topOutstanding = team.players + .filter((p) => p.active && Number(p.balance) < 0) + .sort((a, b) => Number(a.balance) - Number(b.balance)) + .slice(0, 10) + .map((p) => ({ + playerId: p.id, + playerName: p.firstName + ' ' + p.lastName, + balance: this.round(Math.abs(Number(p.balance))), + })); + + return { balanceHistory, monthlyFlow, topOutstanding }; + } + + private getLast12Months(): string[] { + const now = new Date(); + const months: string[] = []; + for (let i = 11; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`); + } + return months; + } + + private signedFlowAmount(movement: { amount: number; type: string }): number { + return movement.type === 'expense' ? -movement.amount : movement.amount; + } + + private round(value: number): number { + return Math.round(value * 100) / 100; + } + async updatePlayer(playerDTO: UpdatePlayerProfileDto) { const player = await this.playerRepository.findOneOrFail({ where: {