Thomas Kaul 2 days ago
committed by GitHub
parent
commit
9a4215bed5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 5
      apps/api/jest.config.ts
  3. 17
      apps/api/src/app/account-balance/account-balance.service.ts
  4. 14
      apps/api/src/app/account/account.service.ts
  5. 6
      apps/api/src/app/activities/activities.service.ts
  6. 55
      apps/api/src/helper/account.helper.spec.ts
  7. 25
      apps/api/src/helper/account.helper.ts
  8. 5
      libs/common/src/lib/helper.ts
  9. 7
      nx.json

1
CHANGELOG.md

@ -44,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed the loading state of the accounts table on the accounts page
- Fixed the loading state of the holdings table on the portfolio holdings page
- Fixed the missing account balance of the current day for users in a time zone ahead of the instance
## 3.65.0 - 2026-08-31

5
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',

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

14
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 }) => {

6
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
})
) {

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

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

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

7
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

Loading…
Cancel
Save