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 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-01 19:44:49 +02:00
parent c1238929ef
commit ecb6fd4394
4 changed files with 364 additions and 18 deletions

View File

@@ -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>(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);
});
});
});

View File

@@ -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:

View File

@@ -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>(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'],
});
});
});
});

View File

@@ -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: {