feat: add theoretical balance history to team overview stats
Reconstructs each active player's balance per month (same backward technique as the existing cash-balance history) so the overview stats endpoint can report what the team balance would be if all currently open dues had already been paid. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
127
myteamwallet_backend/src/teams/teams.service.spec.ts
Normal file
127
myteamwallet_backend/src/teams/teams.service.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { TeamsService } from './teams.service';
|
||||||
|
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
|
||||||
|
|
||||||
|
function monthKey(monthsAgo: number): string {
|
||||||
|
const now = new Date();
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - monthsAgo, 1);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(monthsAgo: number, day: number): string {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(now.getFullYear(), now.getMonth() - monthsAgo, day).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TeamsService#getOverviewStats theoretical balance', () => {
|
||||||
|
const repository = { findOneOrFail: jest.fn() };
|
||||||
|
const access = { assertMember: jest.fn() };
|
||||||
|
let service: TeamsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
access.assertMember.mockResolvedValue(undefined);
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds still-open, unpaid debt to the theoretical balance while leaving the actual cash balance untouched', async () => {
|
||||||
|
repository.findOneOrFail.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
balance: 100,
|
||||||
|
// Bewegung liegt außerhalb des 12-Monats-Fensters, damit der Ist-Kassenstand
|
||||||
|
// über das gesamte sichtbare Fenster flach bei 100 bleibt.
|
||||||
|
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
firstName: 'Alex',
|
||||||
|
lastName: 'Muster',
|
||||||
|
active: true,
|
||||||
|
balance: -30,
|
||||||
|
transactions: [
|
||||||
|
{
|
||||||
|
date: isoDate(2, 10),
|
||||||
|
amount: 30,
|
||||||
|
type: { id: 11, name: 'fine' },
|
||||||
|
note: 'Zu spät zum Training',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getOverviewStats(9, 42);
|
||||||
|
|
||||||
|
expect(access.assertMember).toHaveBeenCalledWith(42, 9);
|
||||||
|
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
|
||||||
|
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||||
|
|
||||||
|
expect(beforeFine?.balance).toBe(100);
|
||||||
|
expect(beforeFine?.theoreticalBalance).toBe(100);
|
||||||
|
expect(now?.balance).toBe(100);
|
||||||
|
// Sanity-Check: entspricht team.balance (100) + aktuelle offene Beiträge (30).
|
||||||
|
expect(now?.theoreticalBalance).toBe(130);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes deactivation-adjustment transactions from the historical reconstruction', async () => {
|
||||||
|
repository.findOneOrFail.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
balance: 50,
|
||||||
|
transactions: [{ date: isoDate(13, 5), amount: 50, type: { name: 'credit' } }],
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
firstName: 'Alex',
|
||||||
|
lastName: 'Muster',
|
||||||
|
active: true,
|
||||||
|
balance: -20,
|
||||||
|
transactions: [
|
||||||
|
{
|
||||||
|
date: isoDate(6, 5),
|
||||||
|
amount: 999,
|
||||||
|
type: { id: 1, name: 'credit' },
|
||||||
|
note: `${DEACTIVATION_ADJUSTMENT_NOTE_PREFIX} #1`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: isoDate(1, 10),
|
||||||
|
amount: 20,
|
||||||
|
type: { id: 11, name: 'fine' },
|
||||||
|
note: 'Zu spät',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getOverviewStats(9, 42);
|
||||||
|
|
||||||
|
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
|
||||||
|
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||||
|
|
||||||
|
// Wäre die Ausgleichsbuchung (999) nicht ausgeschlossen, würde sie hier bereits
|
||||||
|
// durchschlagen (beforeFine liegt chronologisch nach ihrem Datum) — tut sie aber nicht.
|
||||||
|
expect(beforeFine?.theoreticalBalance).toBe(50);
|
||||||
|
expect(now?.theoreticalBalance).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps returning an empty balance history when the team has no cash movement at all', async () => {
|
||||||
|
repository.findOneOrFail.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
balance: 0,
|
||||||
|
transactions: [],
|
||||||
|
players: [{ id: 1, firstName: 'Alex', lastName: 'Muster', active: true, balance: 0, transactions: [] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getOverviewStats(9, 42);
|
||||||
|
|
||||||
|
expect(result.balanceHistory).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import { CreateTeamDTO } from './dto/create-team.dto';
|
|||||||
import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
|
import { UpdatePlayerProfileDto } from './dto/update-player-profile.dto';
|
||||||
import { Team } from './entities/team.entity';
|
import { Team } from './entities/team.entity';
|
||||||
import { TeamAccessService } from './team-access.service';
|
import { TeamAccessService } from './team-access.service';
|
||||||
|
import { DEACTIVATION_ADJUSTMENT_NOTE_PREFIX } from './team-members.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TeamsService {
|
export class TeamsService {
|
||||||
@@ -232,7 +233,7 @@ export class TeamsService {
|
|||||||
teamId: string | number,
|
teamId: string | number,
|
||||||
actorUserId: string | number,
|
actorUserId: string | number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
balanceHistory: { month: string; balance: number }[];
|
balanceHistory: { month: string; balance: number; theoreticalBalance: number }[];
|
||||||
monthlyFlow: { month: string; income: number; expense: number }[];
|
monthlyFlow: { month: string; income: number; expense: number }[];
|
||||||
topOutstanding: { playerId: number; playerName: string; balance: number }[];
|
topOutstanding: { playerId: number; playerName: string; balance: number }[];
|
||||||
}> {
|
}> {
|
||||||
@@ -330,7 +331,13 @@ export class TeamsService {
|
|||||||
return { month, income: this.round(income), expense: this.round(expense) };
|
return { month, income: this.round(income), expense: this.round(expense) };
|
||||||
});
|
});
|
||||||
|
|
||||||
return { balanceHistory, monthlyFlow, topOutstanding };
|
const outstandingHistory = this.reconstructOutstandingHistory(months, players);
|
||||||
|
const balanceHistoryWithTheoretical = balanceHistory.map((point, index) => ({
|
||||||
|
...point,
|
||||||
|
theoreticalBalance: this.round(point.balance - outstandingHistory[index]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { balanceHistory: balanceHistoryWithTheoretical, monthlyFlow, topOutstanding };
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLast12Months(): string[] {
|
private getLast12Months(): string[] {
|
||||||
@@ -347,6 +354,56 @@ export class TeamsService {
|
|||||||
return movement.type === 'expense' ? -movement.amount : movement.amount;
|
return movement.type === 'expense' ? -movement.amount : movement.amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private reconstructOutstandingHistory(months: string[], players: Player[]): number[] {
|
||||||
|
const activePlayers = players.filter((p) => p.active);
|
||||||
|
const totals = months.map(() => 0);
|
||||||
|
|
||||||
|
for (const player of activePlayers) {
|
||||||
|
const realTransactions = (player.transactions ?? []).filter(
|
||||||
|
(t) => !t.note?.startsWith(DEACTIVATION_ADJUSTMENT_NOTE_PREFIX),
|
||||||
|
);
|
||||||
|
const playerHistory = this.reconstructPlayerBalanceHistory(
|
||||||
|
months,
|
||||||
|
Number(player.balance),
|
||||||
|
realTransactions,
|
||||||
|
);
|
||||||
|
playerHistory.forEach((balance, index) => {
|
||||||
|
totals[index] += balance;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reconstructPlayerBalanceHistory(
|
||||||
|
months: string[],
|
||||||
|
currentBalance: number,
|
||||||
|
transactions: Transaction[],
|
||||||
|
): number[] {
|
||||||
|
const descendingMovements = transactions
|
||||||
|
.map((t) => ({
|
||||||
|
date: t.date,
|
||||||
|
amount: t.type && t.type.id > 10 ? -Number(t.amount) : Number(t.amount),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (a.date > b.date ? -1 : a.date < b.date ? 1 : 0));
|
||||||
|
|
||||||
|
let futureSum = 0;
|
||||||
|
let movementIndex = 0;
|
||||||
|
return [...months]
|
||||||
|
.reverse()
|
||||||
|
.map((month) => {
|
||||||
|
while (
|
||||||
|
movementIndex < descendingMovements.length &&
|
||||||
|
descendingMovements[movementIndex].date.slice(0, 7) > month
|
||||||
|
) {
|
||||||
|
futureSum += descendingMovements[movementIndex].amount;
|
||||||
|
movementIndex++;
|
||||||
|
}
|
||||||
|
return currentBalance - futureSum;
|
||||||
|
})
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
private round(value: number): number {
|
private round(value: number): number {
|
||||||
return Math.round(value * 100) / 100;
|
return Math.round(value * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user