From 1058d641e78f23825ea7ce3e98f4d361828e152c Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 1 Aug 2026 21:02:32 +0200 Subject: [PATCH] fix(teams): anchor balanceHistory to team.balance instead of forward-summing Manual verification against real data (Task 3) found balanceHistory drifted from team.balance for real teams, since team.balance carries historical adjustments (e.g. from the removed legacy backend) that don't trace back to the current payment/credit/expense rows. Forward-summing those rows from zero could never be trusted to tie out. Rewrite balanceHistory to anchor on team.balance (the authoritative current value) and walk the movements backward, newest to oldest, undoing each one to reconstruct earlier month-end balances. This guarantees the most recent point equals team.balance by construction, and is mathematically identical to the old forward sum for teams whose movements fully explain their balance. monthlyFlow/topOutstanding are unaffected and left as-is. Updated teams.service.spec.ts to use a fixture where team.balance intentionally does not equal the sum of its own movements, so the tests actually exercise the drift-handling behavior instead of a case where forward-sum and backward-anchor happen to coincide. Co-Authored-By: Claude Sonnet 5 --- .../src/teams/teams.service.spec.ts | 50 +++++++++++++------ .../src/teams/teams.service.ts | 39 +++++++++++---- 2 files changed, 63 insertions(+), 26 deletions(-) diff --git a/myteamwallet_backend/src/teams/teams.service.spec.ts b/myteamwallet_backend/src/teams/teams.service.spec.ts index b8ccb21..94039a1 100644 --- a/myteamwallet_backend/src/teams/teams.service.spec.ts +++ b/myteamwallet_backend/src/teams/teams.service.spec.ts @@ -27,10 +27,21 @@ describe('TeamsService', () => { describe('getOverviewStats', () => { // "now" is fixed to 2026-08-15, so the 12-month window covers // 2025-09 .. 2026-08. + // + // team.balance is deliberately set to 490, NOT 190 (the sum of this + // fixture's own movements). This simulates real-world drift: in + // production, team.balance can include historical adjustments (e.g. + // from the legacy backend removed in commit 9664187) that don't trace + // back to the currently-visible payment/credit/expense rows. The +300 + // offset must show up on every reconstructed balanceHistory point + // (anchored backward from team.balance), proving the implementation + // walks backward from the authoritative team.balance rather than + // forward-summing the movements from zero. + const DRIFT = 300; function buildTeam() { return { id: 7, - balance: 190, + balance: 190 + DRIFT, transactions: [ // TeamWalletTransaction: credit adds, expense subtracts. { @@ -132,33 +143,42 @@ describe('TeamsService', () => { jest.useRealTimers(); }); - it('builds a 12-month balanceHistory with carry-forward and correct sign handling', async () => { + it('anchors balanceHistory to team.balance and reconstructs earlier months backward, with carry-forward and correct sign handling', async () => { repository.findOneOrFail.mockResolvedValue(buildTeam()); const result = await service.getOverviewStats('7'); + // Same relative shape as the movements alone would produce + // (100, 150, 150, 150, 150, 110, 130, 130, 130, 130, 190, 190), but + // every point is shifted by the fixture's +300 drift because the + // series is anchored backward from team.balance, not forward-summed + // from zero. 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 }, + { month: '2025-09', balance: 100 + DRIFT }, + { month: '2025-10', balance: 150 + DRIFT }, + { month: '2025-11', balance: 150 + DRIFT }, + { month: '2025-12', balance: 150 + DRIFT }, + { month: '2026-01', balance: 150 + DRIFT }, + { month: '2026-02', balance: 110 + DRIFT }, + { month: '2026-03', balance: 130 + DRIFT }, + { month: '2026-04', balance: 130 + DRIFT }, + { month: '2026-05', balance: 130 + DRIFT }, + { month: '2026-06', balance: 130 + DRIFT }, + { month: '2026-07', balance: 190 + DRIFT }, + { month: '2026-08', balance: 190 + DRIFT }, ]); }); - it('sanity check: the last balanceHistory entry equals team.balance', async () => { + it('sanity check: the last balanceHistory entry equals team.balance, even when team.balance does not equal the sum of the movements', async () => { const team = buildTeam(); repository.findOneOrFail.mockResolvedValue(team); const result = await service.getOverviewStats('7'); + // Sum of this fixture's movements is 190, but team.balance is 490 — + // if the sanity check passes, the implementation is anchored to + // team.balance rather than forward-summing the movements. + expect(team.balance).not.toBe(190); expect(result.balanceHistory.at(-1).balance).toBe(team.balance); }); diff --git a/myteamwallet_backend/src/teams/teams.service.ts b/myteamwallet_backend/src/teams/teams.service.ts index b883c7d..e18fca5 100644 --- a/myteamwallet_backend/src/teams/teams.service.ts +++ b/myteamwallet_backend/src/teams/teams.service.ts @@ -252,18 +252,35 @@ export class TeamsService { const months = this.getLast12Months(); - let cumulativeBalance = 0; + // team.balance is the one authoritative, current value — it can include + // historical adjustments (e.g. from the removed legacy backend) that + // don't trace back to the visible payment/credit/expense rows. Forward- + // summing the movements from zero would silently drift away from + // team.balance for such teams. Instead we anchor to team.balance and + // walk the movements backward (newest first), "undoing" each one to + // reconstruct earlier month-end balances — this guarantees the most + // recent point always equals team.balance by construction, regardless + // of undocumented history. + const descendingMovements = [...movements].sort((a, b) => + a.date > b.date ? -1 : a.date < b.date ? 1 : 0, + ); + + const currentBalance = Number(team.balance); + let futureSum = 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 balanceHistory = [...months] + .reverse() + .map((month) => { + while ( + movementIndex < descendingMovements.length && + descendingMovements[movementIndex].date.slice(0, 7) > month + ) { + futureSum += this.signedFlowAmount(descendingMovements[movementIndex]); + movementIndex++; + } + return { month, balance: this.round(currentBalance - futureSum) }; + }) + .reverse(); const monthlyFlow = months.map((month) => { const monthMovements = movements.filter((m) => m.date.slice(0, 7) === month);