From a057f1bdc51b03d9d6aa3417dfaa9832eb40b753 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:17:53 +0200 Subject: [PATCH] Task/prepare tool to update account balance in MCP (#8177) * Preparations * Update changelog --- CHANGELOG.md | 1 + .../account-balance.service.ts | 7 +- 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 | 69 +++++++++++++++++++ libs/common/src/lib/helper.ts | 16 +++++ .../is-valid-account-balance-date.spec.ts | 11 +++ .../is-valid-account-balance-date.ts | 22 ++++++ 11 files changed, 131 insertions(+), 52 deletions(-) create mode 100644 libs/common/src/lib/validator-constraints/is-valid-account-balance-date.spec.ts create mode 100644 libs/common/src/lib/validator-constraints/is-valid-account-balance-date.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 69ab9fa669..596a34e60c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the colors of the muted texts and borders by enabling the dark mode of `bootstrap` - Reduced the size of the global stylesheet by removing the unused utilities of `bootstrap` +- Hardened the validation of the date in the endpoint to create an account balance - Upgraded `bootstrap` from version `4.6.2` to `5.3.8` ### Fixed 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 ad990c66bd..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 { @@ -70,6 +68,7 @@ export class AccountBalanceService { value: balance }, where: { + userId, accountId_date: { accountId, date: resetHours(parseISO(date)) 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..5ca4b85479 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 { IsValidAccountBalanceDateConstraint } from '@ghostfolio/common/validator-constraints/is-valid-account-balance-date'; + +import { IsISO8601, IsNumber, IsUUID, Validate } from 'class-validator'; export class CreateAccountBalanceDto { @IsUUID() @@ -8,5 +10,6 @@ export class CreateAccountBalanceDto { balance: number; @IsISO8601() + @Validate(IsValidAccountBalanceDateConstraint) date: string; } diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index d1ae262248..b4ef699e5b 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -12,11 +12,13 @@ import { getNumberFormatGroup, getStringOrNull, getStringOrUndefined, + isAccountBalanceInFuture, isAccountExcluded, isCurrency, isCurrencySymbol, isDarkColorScheme, isSplitRatio, + isValidAccountBalanceDate, isValidCurrencyCode, isValidCustomAssetProfileSymbol, isValidDateAfter1970, @@ -362,6 +364,43 @@ 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. + 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( @@ -520,6 +559,36 @@ describe('Helper', () => { }); }); + describe('Is valid account balance date', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-09-03T23:30:00.000Z')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('Accepts a date in the past', () => { + expect(isValidAccountBalanceDate('2026-09-02')).toBe(true); + }); + + it('Accepts the current date', () => { + expect(isValidAccountBalanceDate('2026-09-03')).toBe(true); + }); + + it('Accepts the current date of a user in a time zone ahead of the instance', () => { + expect(isValidAccountBalanceDate('2026-09-04')).toBe(true); + }); + + it('Refuses a date in the future', () => { + expect(isValidAccountBalanceDate('2026-09-05')).toBe(false); + }); + + it('Refuses a date which is not valid', () => { + expect(isValidAccountBalanceDate('2026-02-30')).toBe(false); + }); + }); + describe('Is valid currency code', () => { it('Currency code in lower case', () => { expect(isValidCurrencyCode('usd')).toEqual(false); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index cee6b7c19e..242cf4ef57 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 }) => { @@ -725,6 +735,12 @@ export function isUserSettingOfAuthenticatedUser(aKey: string) { ); } +export function isValidAccountBalanceDate(aDate: string) { + const date = parseISO(aDate); + + return isValid(date) && !isAccountBalanceInFuture({ date: resetHours(date) }); +} + export function isValidCurrencyCode(aCurrency: string) { if (!aCurrency) { return false; diff --git a/libs/common/src/lib/validator-constraints/is-valid-account-balance-date.spec.ts b/libs/common/src/lib/validator-constraints/is-valid-account-balance-date.spec.ts new file mode 100644 index 0000000000..39199bcb6d --- /dev/null +++ b/libs/common/src/lib/validator-constraints/is-valid-account-balance-date.spec.ts @@ -0,0 +1,11 @@ +import { IsValidAccountBalanceDateConstraint } from './is-valid-account-balance-date'; + +describe('IsValidAccountBalanceDateConstraint', () => { + const constraint = new IsValidAccountBalanceDateConstraint(); + + it('Leaves a value which is not a string to IsISO8601', () => { + expect(constraint.validate(undefined)).toBe(true); + expect(constraint.validate(null)).toBe(true); + expect(constraint.validate(123)).toBe(true); + }); +}); diff --git a/libs/common/src/lib/validator-constraints/is-valid-account-balance-date.ts b/libs/common/src/lib/validator-constraints/is-valid-account-balance-date.ts new file mode 100644 index 0000000000..2ea61570ff --- /dev/null +++ b/libs/common/src/lib/validator-constraints/is-valid-account-balance-date.ts @@ -0,0 +1,22 @@ +import { isValidAccountBalanceDate } from '@ghostfolio/common/helper'; + +import { + ValidatorConstraint, + ValidatorConstraintInterface +} from 'class-validator'; + +@ValidatorConstraint({ name: 'isValidAccountBalanceDate' }) +export class IsValidAccountBalanceDateConstraint implements ValidatorConstraintInterface { + public defaultMessage() { + return '$property must be a valid date which is not in the future'; + } + + public validate(aDate: unknown) { + // IsISO8601 refuses a value which is not a string + if (typeof aDate !== 'string') { + return true; + } + + return isValidAccountBalanceDate(aDate); + } +}