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:
@@ -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';
|
import { TeamsController } from './teams.controller';
|
||||||
|
|
||||||
describe('TeamsController', () => {
|
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;
|
let controller: TeamsController;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
jest.resetAllMocks();
|
||||||
controllers: [TeamsController],
|
controller = new TeamsController(
|
||||||
}).compile();
|
service as any,
|
||||||
|
publicAccess as any,
|
||||||
controller = module.get<TeamsController>(TeamsController);
|
teamMembers as any,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
describe('GET :id/overview/stats', () => {
|
||||||
expect(controller).toBeDefined();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -86,6 +86,15 @@ export class TeamsController {
|
|||||||
return this.service.getOverview(id);
|
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({
|
@ApiOperation({
|
||||||
summary: 'Transactionen für ein Team',
|
summary: 'Transactionen für ein Team',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,18 +1,231 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import { TeamsService } from './teams.service';
|
import { TeamsService } from './teams.service';
|
||||||
|
|
||||||
describe('TeamsService', () => {
|
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;
|
let service: TeamsService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
jest.resetAllMocks();
|
||||||
providers: [TeamsService],
|
service = new TeamsService(
|
||||||
}).compile();
|
repository as any,
|
||||||
|
playerRepository as any,
|
||||||
service = module.get<TeamsService>(TeamsService);
|
transactionsRepository as any,
|
||||||
|
rolesRepository as any,
|
||||||
|
settingsRepository as any,
|
||||||
|
teamWalletTransactionRepository as any,
|
||||||
|
logger as any,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
describe('getOverviewStats', () => {
|
||||||
expect(service).toBeDefined();
|
// "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'],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -218,6 +218,95 @@ export class TeamsService {
|
|||||||
return result;
|
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) {
|
async updatePlayer(playerDTO: UpdatePlayerProfileDto) {
|
||||||
const player = await this.playerRepository.findOneOrFail({
|
const player = await this.playerRepository.findOneOrFail({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
Reference in New Issue
Block a user