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';
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user