fix: strengthen deactivation-exclusion test and align sign rule with canonical logic
- teams.service.spec.ts: pick a checkpoint older than the adjustment's own month so the exclusion test actually fails without the exclusion filter - teams.service.ts: only negate fine/levy/fee amounts when positive, matching TeamMembersService.recomputeBalance and Transaction.setBalance() exactly, instead of negating unconditionally - teams.service.ts: outstanding-history helper now returns a positive value when players owe money, matching the house convention already established by getOverview()'s team.outstanding - overview.spec.ts: assert the balance chart's legend becomes visible - teams.service.spec.ts: add coverage for inactive-player exclusion and a positive-balance (prepaid credit) case Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -103,15 +103,95 @@ describe('TeamsService#getOverviewStats theoretical balance', () => {
|
|||||||
|
|
||||||
const result = await service.getOverviewStats(9, 42);
|
const result = await service.getOverviewStats(9, 42);
|
||||||
|
|
||||||
const beforeFine = result.balanceHistory.find((p) => p.month === monthKey(4));
|
// Older than the adjustment's own month (6 months ago) — the only kind of checkpoint
|
||||||
|
// where wrongly including the €999 adjustment would still show up as "already happened".
|
||||||
|
const beforeAdjustment = result.balanceHistory.find((p) => p.month === monthKey(9));
|
||||||
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||||
|
|
||||||
// Wäre die Ausgleichsbuchung (999) nicht ausgeschlossen, würde sie hier bereits
|
// Without exclusion this would read 1049 (50 + the wrongly-included €999 adjustment);
|
||||||
// durchschlagen (beforeFine liegt chronologisch nach ihrem Datum) — tut sie aber nicht.
|
// with correct exclusion only the (not-yet-existing-at-month-9) fine is irrelevant here too,
|
||||||
expect(beforeFine?.theoreticalBalance).toBe(50);
|
// so it stays at the team's flat 50.
|
||||||
|
expect(beforeAdjustment?.theoreticalBalance).toBe(50);
|
||||||
expect(now?.theoreticalBalance).toBe(70);
|
expect(now?.theoreticalBalance).toBe(70);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("excludes an inactive player's outstanding debt from the theoretical balance", async () => {
|
||||||
|
repository.findOneOrFail.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
balance: 100,
|
||||||
|
transactions: [{ date: isoDate(13, 5), amount: 100, type: { name: 'credit' } }],
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
firstName: 'Alex',
|
||||||
|
lastName: 'Muster',
|
||||||
|
active: true,
|
||||||
|
balance: 0,
|
||||||
|
transactions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
firstName: 'Bea',
|
||||||
|
lastName: 'Beispiel',
|
||||||
|
active: false,
|
||||||
|
balance: -40,
|
||||||
|
transactions: [
|
||||||
|
{
|
||||||
|
date: isoDate(2, 10),
|
||||||
|
amount: 40,
|
||||||
|
type: { id: 11, name: 'fine' },
|
||||||
|
note: 'Zu spät',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getOverviewStats(9, 42);
|
||||||
|
|
||||||
|
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||||
|
|
||||||
|
// The inactive player's unpaid fine is real debt, but no longer counted at all —
|
||||||
|
// the theoretical line must match the actual cash balance exactly.
|
||||||
|
expect(now?.balance).toBe(100);
|
||||||
|
expect(now?.theoreticalBalance).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lowers the theoretical balance when a player has prepaid credit not yet reflected as spent', async () => {
|
||||||
|
repository.findOneOrFail.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
balance: 100,
|
||||||
|
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: 1, name: 'credit' },
|
||||||
|
note: 'Vorauszahlung',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getOverviewStats(9, 42);
|
||||||
|
|
||||||
|
const now = result.balanceHistory.find((p) => p.month === monthKey(0));
|
||||||
|
|
||||||
|
// Prepaid credit is not yet "spent" in the reconstruction, so the theoretical
|
||||||
|
// line dips below the actual cash balance at this checkpoint.
|
||||||
|
expect(now?.balance).toBe(100);
|
||||||
|
expect(now?.theoreticalBalance).toBe(70);
|
||||||
|
expect(now!.theoreticalBalance).toBeLessThan(now!.balance);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps returning an empty balance history when the team has no cash movement at all', async () => {
|
it('keeps returning an empty balance history when the team has no cash movement at all', async () => {
|
||||||
repository.findOneOrFail.mockResolvedValue({
|
repository.findOneOrFail.mockResolvedValue({
|
||||||
id: 9,
|
id: 9,
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ export class TeamsService {
|
|||||||
const outstandingHistory = this.reconstructOutstandingHistory(months, players);
|
const outstandingHistory = this.reconstructOutstandingHistory(months, players);
|
||||||
const balanceHistoryWithTheoretical = balanceHistory.map((point, index) => ({
|
const balanceHistoryWithTheoretical = balanceHistory.map((point, index) => ({
|
||||||
...point,
|
...point,
|
||||||
theoreticalBalance: this.round(point.balance - outstandingHistory[index]),
|
theoreticalBalance: this.round(point.balance + outstandingHistory[index]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { balanceHistory: balanceHistoryWithTheoretical, monthlyFlow, topOutstanding };
|
return { balanceHistory: balanceHistoryWithTheoretical, monthlyFlow, topOutstanding };
|
||||||
@@ -384,7 +384,9 @@ export class TeamsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return totals;
|
// House convention (see getOverview()'s team.outstanding = out * -1): "outstanding"
|
||||||
|
// is a positive number when players owe money, negative when they're in credit.
|
||||||
|
return totals.map((total) => total * -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private reconstructPlayerBalanceHistory(
|
private reconstructPlayerBalanceHistory(
|
||||||
@@ -392,10 +394,11 @@ export class TeamsService {
|
|||||||
currentBalance: number,
|
currentBalance: number,
|
||||||
transactions: Transaction[],
|
transactions: Transaction[],
|
||||||
): number[] {
|
): number[] {
|
||||||
const signedMovements = transactions.map((t) => ({
|
const signedMovements = transactions.map((t) => {
|
||||||
date: t.date,
|
const rawAmount = Number(t.amount);
|
||||||
amount: t.type && t.type.id > 10 ? -Number(t.amount) : Number(t.amount),
|
const amount = t.type && t.type.id > 10 && rawAmount > 0 ? -rawAmount : rawAmount;
|
||||||
}));
|
return { date: t.date, amount };
|
||||||
|
});
|
||||||
|
|
||||||
return this.reconstructBackward(months, currentBalance, signedMovements);
|
return this.reconstructBackward(months, currentBalance, signedMovements);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ describe('Overview', () => {
|
|||||||
expect(balanceChart.data.datasets[0].data).toEqual([100, 125]);
|
expect(balanceChart.data.datasets[0].data).toEqual([100, 125]);
|
||||||
expect(balanceChart.data.datasets[1].label).toBe('Theoretisch (inkl. offene Beiträge)');
|
expect(balanceChart.data.datasets[1].label).toBe('Theoretisch (inkl. offene Beiträge)');
|
||||||
expect(balanceChart.data.datasets[1].data).toEqual([100, 150]);
|
expect(balanceChart.data.datasets[1].data).toEqual([100, 150]);
|
||||||
|
expect(balanceChart.options?.plugins?.legend?.position).toBe('bottom');
|
||||||
expect(flowChart.type).toBe('bar');
|
expect(flowChart.type).toBe('bar');
|
||||||
expect(flowChart.data.datasets).toHaveLength(2);
|
expect(flowChart.data.datasets).toHaveLength(2);
|
||||||
expect(outstandingChart.type).toBe('bar');
|
expect(outstandingChart.type).toBe('bar');
|
||||||
|
|||||||
Reference in New Issue
Block a user