From f6856678228e5356f7ee3eb9be73c867adf38539 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:51:32 +0200 Subject: [PATCH] Fix missing account balance of current day for users in time zone ahead of instance --- apps/api/jest.config.ts | 5 +- .../account-balance.service.ts | 17 ++++-- apps/api/src/app/account/account.service.ts | 14 +++-- .../src/app/activities/activities.service.ts | 6 +- apps/api/src/helper/account.helper.spec.ts | 55 +++++++++++++++++++ apps/api/src/helper/account.helper.ts | 25 +++++++-- libs/common/src/lib/helper.ts | 5 ++ nx.json | 7 ++- 8 files changed, 115 insertions(+), 19 deletions(-) create mode 100644 apps/api/src/helper/account.helper.spec.ts diff --git a/apps/api/jest.config.ts b/apps/api/jest.config.ts index 805710396..942f08672 100644 --- a/apps/api/jest.config.ts +++ b/apps/api/jest.config.ts @@ -1,7 +1,8 @@ /* eslint-disable */ -// Run tests in UTC for deterministic date-based calculations -process.env.TZ = 'UTC'; +// Run tests in UTC for deterministic date-based calculations. Set TEST_TZ to +// run them with the instance in another time zone. +process.env.TZ = process.env.TEST_TZ ?? 'UTC'; export default { displayName: 'api', diff --git a/apps/api/src/app/account-balance/account-balance.service.ts b/apps/api/src/app/account-balance/account-balance.service.ts index 29c7f2887..3823498df 100644 --- a/apps/api/src/app/account-balance/account-balance.service.ts +++ b/apps/api/src/app/account-balance/account-balance.service.ts @@ -7,7 +7,12 @@ import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos'; -import { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + getStartOfUtcDateOfTomorrow, + getSum, + resetHours +} from '@ghostfolio/common/helper'; import { AccountBalancesResponse, Filter, @@ -18,7 +23,7 @@ import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { AccountBalance, Prisma } from '@prisma/client'; import { Big } from 'big.js'; -import { endOfToday, format, parseISO } from 'date-fns'; +import { endOfToday, format, min, parseISO } from 'date-fns'; import { groupBy } from 'lodash'; @Injectable() @@ -118,13 +123,17 @@ export class AccountBalanceService { {}; const lastBalancesByAccount: { [accountId: string]: Big } = {}; const endOfTodayDate = endOfToday(); + const startOfUtcDateOfTomorrow = getStartOfUtcDateOfTomorrow(); for (const { accountId, date, valueInBaseCurrency } of balances) { - if (isAccountBalanceInFuture({ date, endOfTodayDate })) { + if (isAccountBalanceInFuture({ date, startOfUtcDateOfTomorrow })) { continue; } - const formattedDate = format(date, DATE_FORMAT); + // The date of a user in a time zone ahead of the instance can be after + // the end date of the chart. Set the date back to today, so that the item + // stays in the period of the calculation. + const formattedDate = format(min([date, endOfTodayDate]), DATE_FORMAT); lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency); diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index ccd7d24bf..59315602a 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -8,7 +8,10 @@ import { import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; -import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + getStartOfUtcDateOfTomorrow +} from '@ghostfolio/common/helper'; import { Filter } from '@ghostfolio/common/interfaces'; import { AccountWithBalance } from '@ghostfolio/common/types'; @@ -24,7 +27,7 @@ import { Tag } from '@prisma/client'; import { Big } from 'big.js'; -import { endOfToday, format } from 'date-fns'; +import { format } from 'date-fns'; import { groupBy, isNil } from 'lodash'; import { CashDetails } from './interfaces/cash-details.interface'; @@ -134,7 +137,7 @@ export class AccountService { where }); - const endOfTodayDate = endOfToday(); + const startOfUtcDateOfTomorrow = getStartOfUtcDateOfTomorrow(); return accounts.map((account) => { const result = { @@ -143,7 +146,10 @@ export class AccountService { // The balances are ordered by date descending, hence the first account // balance which is not in the future reflects the current balance account.balances.find(({ date }) => { - return !isAccountBalanceInFuture({ date, endOfTodayDate }); + return !isAccountBalanceInFuture({ + date, + startOfUtcDateOfTomorrow + }); })?.value ?? 0, tags: isTagsIncluded ? (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => { diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index a0d9ffef5..4d706feb5 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -35,6 +35,7 @@ import { import { canDeleteAssetProfile, getAssetProfileIdentifier, + getStartOfUtcDateOfTomorrow, isDraftActivity, isValidCustomAssetProfileSymbol } from '@ghostfolio/common/helper'; @@ -59,7 +60,6 @@ import { Type as ActivityType } from '@prisma/client'; import { Big } from 'big.js'; -import { endOfToday } from 'date-fns'; import { groupBy, uniqBy } from 'lodash'; import { randomUUID } from 'node:crypto'; @@ -485,7 +485,7 @@ export class ActivitiesService { } const activities: Activity[] = []; - const endOfTodayDate = endOfToday(); + const startOfUtcDateOfTomorrow = getStartOfUtcDateOfTomorrow(); for (const account of cashDetails.accounts) { const { balances } = await this.accountBalanceService.getAccountBalances({ @@ -500,7 +500,7 @@ export class ActivitiesService { for (const balanceItem of balances) { if ( isAccountBalanceInFuture({ - endOfTodayDate, + startOfUtcDateOfTomorrow, date: balanceItem.date }) ) { diff --git a/apps/api/src/helper/account.helper.spec.ts b/apps/api/src/helper/account.helper.spec.ts new file mode 100644 index 000000000..fece95944 --- /dev/null +++ b/apps/api/src/helper/account.helper.spec.ts @@ -0,0 +1,55 @@ +import { + getWhereAccountBalanceNotInFuture, + isAccountBalanceInFuture +} from './account.helper'; + +// A user in a time zone ahead of the instance is already on the next day at +// this moment. The expected limit is a moment in UTC, hence this suite must +// give the same result with the instance in any time zone. Set TEST_TZ to run +// it with the instance in another time zone. +const SYSTEM_TIME = new Date('2026-09-03T23:30:00.000Z'); +const START_OF_UTC_DATE_OF_TOMORROW = new Date('2026-09-04T00:00:00.000Z'); + +describe('account.helper', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(SYSTEM_TIME); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('getWhereAccountBalanceNotInFuture', () => { + it('should limit the date to the start of the UTC date of tomorrow', () => { + expect(getWhereAccountBalanceNotInFuture()).toEqual({ + date: { lte: START_OF_UTC_DATE_OF_TOMORROW } + }); + }); + }); + + describe('isAccountBalanceInFuture', () => { + it('should accept the account balance of the current day', () => { + expect( + isAccountBalanceInFuture({ date: new Date('2026-09-03T00:00:00.000Z') }) + ).toBe(false); + }); + + it('should accept the account balance of a user in a time zone ahead of the instance', () => { + expect( + isAccountBalanceInFuture({ date: START_OF_UTC_DATE_OF_TOMORROW }) + ).toBe(false); + }); + + it('should reject an account balance after the start of the UTC date of tomorrow', () => { + expect( + isAccountBalanceInFuture({ date: new Date('2026-09-04T00:00:00.001Z') }) + ).toBe(true); + }); + + it('should reject an account balance of the day after tomorrow', () => { + expect( + isAccountBalanceInFuture({ date: new Date('2026-09-05T00:00:00.000Z') }) + ).toBe(true); + }); + }); +}); diff --git a/apps/api/src/helper/account.helper.ts b/apps/api/src/helper/account.helper.ts index da0762ba5..0ae1ae743 100644 --- a/apps/api/src/helper/account.helper.ts +++ b/apps/api/src/helper/account.helper.ts @@ -1,7 +1,8 @@ import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; +import { getStartOfUtcDateOfTomorrow } from '@ghostfolio/common/helper'; import { Prisma } from '@prisma/client'; -import { endOfToday, isAfter } from 'date-fns'; +import { isAfter } from 'date-fns'; export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = { tags: { @@ -11,18 +12,32 @@ export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = { } }; +/** + * An account balance is stored with the time set to midnight in UTC. If the + * user records it with the account balance dialog, the date is the local date + * of the user, which can be one day ahead of the date in UTC. Therefore the + * start of tomorrow in UTC is used as the limit, which covers the maximum + * offset of a time zone (UTC+14:00). + * + * The instance does not know the time zone of the user, hence the limit applies + * to all users. A balance which a user in a time zone behind UTC records for + * tomorrow can show as the current balance for up to 24 hours. + * + * TODO: Use the time zone of the request (see HEADER_KEY_TIMEZONE) to calculate + * the limit for each user + */ export function getWhereAccountBalanceNotInFuture(): Prisma.AccountBalanceWhereInput { return { - date: { lte: endOfToday() } + date: { lte: getStartOfUtcDateOfTomorrow() } }; } export function isAccountBalanceInFuture({ date, - endOfTodayDate = endOfToday() + startOfUtcDateOfTomorrow = getStartOfUtcDateOfTomorrow() }: { date: Date; - endOfTodayDate?: Date; + startOfUtcDateOfTomorrow?: Date; }) { - return isAfter(date, endOfTodayDate); + return isAfter(date, startOfUtcDateOfTomorrow); } diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index f0f9e979c..44f595036 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -12,6 +12,7 @@ import { import { Big } from 'big.js'; import { isISO4217CurrencyCode, isUUID } from 'class-validator'; import { + addDays, getDate, getMonth, getYear, @@ -488,6 +489,10 @@ export function getStartOfUtcDate(aDate: Date) { return date; } +export function getStartOfUtcDateOfTomorrow() { + return addDays(getStartOfUtcDate(new Date()), 1, { in: utc }); +} + export function getStartOfUtcDateOfYesterday() { return subDays(getStartOfUtcDate(new Date()), 1, { in: utc }); } diff --git a/nx.json b/nx.json index 1deb022df..3b8c3846a 100644 --- a/nx.json +++ b/nx.json @@ -101,7 +101,12 @@ "cache": true }, "@nx/jest:jest": { - "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"], + "inputs": [ + "default", + "^production", + "{workspaceRoot}/jest.preset.js", + { "env": "TEST_TZ" } + ], "cache": true, "options": { "passWithNoTests": true