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);