From 526b01ea7c30f08ed63d0b3347a141600a6110d4 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:04:38 +0200 Subject: [PATCH] Refuse account balance with date in future --- .../account-balance.service.ts | 6 +-- apps/api/src/app/account/account.service.ts | 4 +- .../src/app/activities/activities.service.ts | 6 +-- apps/api/src/helper/account.helper.spec.ts | 31 +-------------- apps/api/src/helper/account.helper.ts | 11 ------ .../lib/dtos/create-account-balance.dto.ts | 5 ++- libs/common/src/lib/helper.spec.ts | 39 +++++++++++++++++++ libs/common/src/lib/helper.ts | 10 +++++ .../is-not-in-the-future.spec.ts | 29 ++++++++++++++ .../is-not-in-the-future.ts | 21 ++++++++++ 10 files changed, 110 insertions(+), 52 deletions(-) create mode 100644 libs/common/src/lib/validator-constraints/is-not-in-the-future.spec.ts create mode 100644 libs/common/src/lib/validator-constraints/is-not-in-the-future.ts 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 c002f89c7d..56975cdf08 100644 --- a/apps/api/src/app/account-balance/account-balance.service.ts +++ b/apps/api/src/app/account-balance/account-balance.service.ts @@ -1,8 +1,5 @@ import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; -import { - isAccountBalanceInFuture, - WHERE_ACCOUNT_NOT_EXCLUDED -} from '@ghostfolio/api/helper/account.helper'; +import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; @@ -11,6 +8,7 @@ import { DATE_FORMAT, getStartOfUtcDateOfTomorrow, getSum, + isAccountBalanceInFuture, resetHours } from '@ghostfolio/common/helper'; import { diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index d1a895a5a3..ebf7a1ab70 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -2,7 +2,6 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { getWhereAccountBalanceNotInFuture, - isAccountBalanceInFuture, WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; @@ -10,7 +9,8 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { DATE_FORMAT, - getStartOfUtcDateOfTomorrow + getStartOfUtcDateOfTomorrow, + isAccountBalanceInFuture } from '@ghostfolio/common/helper'; import { Filter } from '@ghostfolio/common/interfaces'; import { AccountWithBalance } from '@ghostfolio/common/types'; diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 93a37e1a3d..c0051aefb2 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -3,10 +3,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; -import { - isAccountBalanceInFuture, - WHERE_ACCOUNT_NOT_EXCLUDED -} from '@ghostfolio/api/helper/account.helper'; +import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; import { getTagsWithDraftTag, isActivityInFuture, @@ -38,6 +35,7 @@ import { getAssetProfileIdentifier, getStartOfUtcDateOfTomorrow, hasGhostfolioPrefix, + isAccountBalanceInFuture, isDraftActivity } from '@ghostfolio/common/helper'; import { diff --git a/apps/api/src/helper/account.helper.spec.ts b/apps/api/src/helper/account.helper.spec.ts index fece959440..331c64c57c 100644 --- a/apps/api/src/helper/account.helper.spec.ts +++ b/apps/api/src/helper/account.helper.spec.ts @@ -1,7 +1,4 @@ -import { - getWhereAccountBalanceNotInFuture, - isAccountBalanceInFuture -} from './account.helper'; +import { getWhereAccountBalanceNotInFuture } 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 @@ -26,30 +23,4 @@ describe('account.helper', () => { }); }); }); - - 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 0ae1ae743f..7d829e6f29 100644 --- a/apps/api/src/helper/account.helper.ts +++ b/apps/api/src/helper/account.helper.ts @@ -2,7 +2,6 @@ import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { getStartOfUtcDateOfTomorrow } from '@ghostfolio/common/helper'; import { Prisma } from '@prisma/client'; -import { isAfter } from 'date-fns'; export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = { tags: { @@ -31,13 +30,3 @@ export function getWhereAccountBalanceNotInFuture(): Prisma.AccountBalanceWhereI date: { lte: getStartOfUtcDateOfTomorrow() } }; } - -export function isAccountBalanceInFuture({ - date, - startOfUtcDateOfTomorrow = getStartOfUtcDateOfTomorrow() -}: { - date: Date; - startOfUtcDateOfTomorrow?: Date; -}) { - return isAfter(date, startOfUtcDateOfTomorrow); -} diff --git a/libs/common/src/lib/dtos/create-account-balance.dto.ts b/libs/common/src/lib/dtos/create-account-balance.dto.ts index 28e939b82e..39d06f0205 100644 --- a/libs/common/src/lib/dtos/create-account-balance.dto.ts +++ b/libs/common/src/lib/dtos/create-account-balance.dto.ts @@ -1,4 +1,6 @@ -import { IsISO8601, IsNumber, IsUUID } from 'class-validator'; +import { IsNotInTheFutureConstraint } from '@ghostfolio/common/validator-constraints/is-not-in-the-future'; + +import { IsISO8601, IsNumber, IsUUID, Validate } from 'class-validator'; export class CreateAccountBalanceDto { @IsUUID() @@ -8,5 +10,6 @@ export class CreateAccountBalanceDto { balance: number; @IsISO8601() + @Validate(IsNotInTheFutureConstraint) date: string; } diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index d1ae262248..01c7339a36 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -12,6 +12,7 @@ import { getNumberFormatGroup, getStringOrNull, getStringOrUndefined, + isAccountBalanceInFuture, isAccountExcluded, isCurrency, isCurrencySymbol, @@ -362,6 +363,44 @@ describe('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. + describe('Is account balance in future', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-09-03T23:30:00.000Z')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + 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: new Date('2026-09-04T00:00:00.000Z') }) + ).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); + }); + }); + describe('Is account excluded', () => { it('Account with Exclude from Analysis tag', () => { expect( diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index cee6b7c19e..174a2ec167 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -610,6 +610,16 @@ export function interpolate(template: string, context: any) { }); } +export function isAccountBalanceInFuture({ + date, + startOfUtcDateOfTomorrow = getStartOfUtcDateOfTomorrow() +}: { + date: Date; + startOfUtcDateOfTomorrow?: Date; +}) { + return isAfter(date, startOfUtcDateOfTomorrow); +} + export function isAccountExcluded(account?: { tags?: { id: string }[] }) { return ( account?.tags?.some(({ id }) => { diff --git a/libs/common/src/lib/validator-constraints/is-not-in-the-future.spec.ts b/libs/common/src/lib/validator-constraints/is-not-in-the-future.spec.ts new file mode 100644 index 0000000000..4b9de73837 --- /dev/null +++ b/libs/common/src/lib/validator-constraints/is-not-in-the-future.spec.ts @@ -0,0 +1,29 @@ +import { IsNotInTheFutureConstraint } from './is-not-in-the-future'; + +describe('IsNotInTheFutureConstraint', () => { + const constraint = new IsNotInTheFutureConstraint(); + + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-09-03T23:30:00.000Z')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('Accepts a date in the past', () => { + expect(constraint.validate('2026-09-02')).toBe(true); + }); + + it('Accepts the current date', () => { + expect(constraint.validate('2026-09-03')).toBe(true); + }); + + it('Accepts the current date of a user in a time zone ahead of the instance', () => { + expect(constraint.validate('2026-09-04')).toBe(true); + }); + + it('Refuses a date in the future', () => { + expect(constraint.validate('2026-09-05')).toBe(false); + }); +}); diff --git a/libs/common/src/lib/validator-constraints/is-not-in-the-future.ts b/libs/common/src/lib/validator-constraints/is-not-in-the-future.ts new file mode 100644 index 0000000000..6de91e9e3f --- /dev/null +++ b/libs/common/src/lib/validator-constraints/is-not-in-the-future.ts @@ -0,0 +1,21 @@ +import { + isAccountBalanceInFuture, + resetHours +} from '@ghostfolio/common/helper'; + +import { + ValidatorConstraint, + ValidatorConstraintInterface +} from 'class-validator'; +import { parseISO } from 'date-fns'; + +@ValidatorConstraint({ name: 'isNotInTheFuture' }) +export class IsNotInTheFutureConstraint implements ValidatorConstraintInterface { + public defaultMessage() { + return '$property must not be a date in the future'; + } + + public validate(aDate: string) { + return !isAccountBalanceInFuture({ date: resetHours(parseISO(aDate)) }); + } +}