diff --git a/CHANGELOG.md b/CHANGELOG.md index 8277033f51..6cf81783c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,19 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Improved the language localization for Chinese (`zh`) + +## 3.82.0 - 2026-10-09 + ### Added - Extended the content of the _General_ section by information about the import of activities from _Interactive Brokers_ on the Frequently Asked Questions (FAQ) page ### Changed +- Extended the user account deletion flow in the user settings of the user account page to users without a _Security Token_ within 14 days after the registration - Improved the _Storybook_ stories of the account selector, accounts table, activities table and holdings table components -- Improved the language localization for Chinese (`zh`) +- Removed an unnecessary index from the market data database table +- Refreshed the cryptocurrencies list - Improved the language localization for Spanish (`es`) ### Fixed +- Fixed the missing list numbers in the top and bottom holdings on the analysis page in Safari - Fixed the missing first data point in the portfolio evolution chart on the analysis page +- Fixed the end date of calendar year date ranges in the portfolio performance calculation for instances in time zones with a negative UTC offset - Fixed the missing mapping for Korea in the country weightings of the _Financial Modeling Prep_ service ## 3.81.0 - 2026-10-07 diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 7a8bbcc741..ca440c6d2a 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -1407,7 +1407,7 @@ export abstract class PortfolioCalculator { return this.snapshot.totalLiabilitiesWithCurrencyEffect; } - public async getPerformance({ end, start }) { + public async getPerformance({ end, start }: { end: Date; start: Date }) { await this.snapshotPromise; const { historicalData } = this.snapshot; @@ -1418,14 +1418,13 @@ export abstract class PortfolioCalculator { let netPerformanceAtStartDate: number; let netPerformanceWithCurrencyEffectAtStartDate: number; - const startDate = isEqual(start, endOfDay(start)) - ? resetHours(start) - : start; + const endDate = this.toChartDate(end); + const startDate = this.toChartDate(start); for (const historicalDataItem of historicalData) { const date = resetHours(parseDate(historicalDataItem.date)); - if (!isBefore(date, startDate) && !isAfter(date, end)) { + if (!isBefore(date, startDate) && !isAfter(date, endDate)) { // Take the values at the start date from the first day of the date // range if (historicalDataItemsOfDateRange.length === 0) { @@ -1915,4 +1914,8 @@ export abstract class PortfolioCalculator { await this.initialize(attempt + 1); } } + + private toChartDate(aDate: Date) { + return isEqual(aDate, endOfDay(aDate)) ? resetHours(aDate) : aDate; + } } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts index 5f8d9cdc44..43ff160a78 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts @@ -1,3 +1,7 @@ +/** + * @jest-environment /jest-environment-tz.js + * @jest-environment-options {"timeZone": "America/New_York"} + */ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -9,6 +13,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; @@ -183,4 +188,43 @@ describe('PortfolioCalculator', () => { ).toBeCloseTo(0.23, 10); }); }); + + describe('get performance', () => { + it('with a calendar year date range', async () => { + const snapshot = await portfolioCalculator.getSnapshot(); + + snapshot.historicalData = [ + getHistoricalDataItem({ + date: '2016-12-31', + investment: 100, + netPerformance: 0 + }), + getHistoricalDataItem({ + date: '2017-12-31', + investment: 100, + netPerformance: 10 + }), + getHistoricalDataItem({ + date: '2018-01-01', + investment: 100, + netPerformance: 20 + }) + ]; + + const { endDate, startDate } = getIntervalFromDateRange({ + dateRange: '2017' + }); + + const { chart } = await portfolioCalculator.getPerformance({ + end: endDate, + start: startDate + }); + + expect( + chart.map(({ date }) => { + return date; + }) + ).toEqual(['2016-12-31', '2017-12-31']); + }); + }); }); diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 32ac353351..c4824fe7d6 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -71,14 +71,21 @@ export class UserController { @Delete() @HasPermission(permissions.deleteOwnUser) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) public async deleteOwnUser( - @Body() data: DeleteOwnUserDto + @Body() data: DeleteOwnUserDto, + @Impersonation() { isActive }: ImpersonationContext ): Promise { - const user = await this.validateAccessToken( - data.accessToken, - this.request.user.id - ); + const user = this.request.user; + + if (user.provider === 'ANONYMOUS') { + await this.validateAccessToken(data.accessToken, user.id); + } else if (isActive) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } return this.userService.deleteUser({ id: user.id @@ -260,6 +267,13 @@ export class UserController { accessToken: string, userId: string ): Promise { + if (!accessToken) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + const hashedAccessToken = this.userService.createAccessToken({ password: accessToken, salt: this.configurationService.get('ACCESS_TOKEN_SALT') diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 2e54a7ddc3..227d04da80 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -14,6 +14,7 @@ import { DEFAULT_CURRENCY, DEFAULT_DATE_RANGE, DEFAULT_LOCALE, + DELETE_OWN_USER_PERIOD, PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_MAX_DAILY_REQUESTS, @@ -45,7 +46,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler'; import { Prisma, Role, User } from '@prisma/client'; -import { differenceInDays, subDays } from 'date-fns'; +import { addMilliseconds, differenceInDays, isBefore, subDays } from 'date-fns'; import { isNil, without } from 'lodash-es'; import { createHmac } from 'node:crypto'; @@ -438,6 +439,17 @@ export class UserService { currentPermissions.push(permissions.enableSubscriptionInterstitial); } + if ( + !hasRole(user, Role.DEMO) && + user.provider !== 'ANONYMOUS' && + isBefore( + new Date(), + addMilliseconds(user.createdAt, DELETE_OWN_USER_PERIOD) + ) + ) { + currentPermissions.push(permissions.deleteOwnUser); + } + currentPermissions = without( currentPermissions, permissions.accessHoldingsChart, diff --git a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json index 7e9558bbbd..1bee37531b 100644 --- a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json +++ b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json @@ -3367,6 +3367,7 @@ "CARV": "CARV", "CAS": "Cashaa", "CASH": "Litecash", + "CASHCAT": "Cash Cat", "CASHCOIN": "CashCoin", "CASHIO": "Cashio Dollar", "CASHLY": "Cashly", @@ -10790,7 +10791,7 @@ "MARS": "Marscoin", "MARS4": "MARS4", "MARSC": "MarsCoin", - "MARSCOIN": "Marscoin", + "MARSCOIN": "MarsCoin", "MARSERC": "Mars", "MARSH": "UnMarshal", "MARSMI": "MarsMi", @@ -13919,6 +13920,7 @@ "PONKE": "Ponke", "PONKEBNB": "Ponke BNB", "PONKEI": "Chinese Ponkei the Original", + "PONS": "Pons", "PONTEM": "Pontem Liquidswap", "PONYO": "Ponyo Impact", "PONZI": "PonziCoin", @@ -14660,6 +14662,7 @@ "RECORD": "Music Protocol", "RECT": "ReflectionAI", "RED": "RED", + "RED21707": "RedStone", "REDC": "RedCab", "REDCO": "Redcoin", "REDDIT": "Reddit", @@ -16488,7 +16491,7 @@ "SQUAWK": "Squawk", "SQUEEZER": "Squeezer", "SQUIBONK": "SQUIBONK", - "SQUID": "Squid Game", + "SQUID": "SQUIDGames Token", "SQUID2": "SquidDao", "SQUIDGROW": "SquidGrow", "SQUIDGROWV1": "SquidGrow v1", @@ -20250,7 +20253,7 @@ "ZPTC": "Zeptacoin", "ZRC": "Zircuit", "ZRCOIN": "ZrCoin", - "ZRO": "Carb0n.fi", + "ZRO": "LayerZero", "ZRO26997": "LayerZero", "ZRPY": "Zerpaay", "ZRS": "Zaros", diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts index 83f7ddd9ae..3c95652e44 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts @@ -94,6 +94,7 @@ export class GfUserAccountSettingsComponent implements OnInit { protected hasImpersonationId: boolean; protected hasPermissionToDeleteOwnUser: boolean; protected hasPermissionToRequestOwnUserDeletion: boolean; + protected hasPermissionToUpdateOwnAccessToken: boolean; protected hasPermissionToUpdateViewMode: boolean; protected hasPermissionToUpdateUserSettings: boolean; protected isAccessTokenHidden = true; @@ -194,6 +195,11 @@ export class GfUserAccountSettingsComponent implements OnInit { permissions.requestOwnUserDeletion ); + this.hasPermissionToUpdateOwnAccessToken = hasPermission( + this.user.permissions, + permissions.updateOwnAccessToken + ); + this.hasPermissionToUpdateUserSettings = hasPermission( this.user.permissions, permissions.updateUserSettings @@ -271,13 +277,19 @@ export class GfUserAccountSettingsComponent implements OnInit { this.notificationService.confirm({ confirmFn: () => { this.dataService - .deleteOwnUser({ - accessToken: this.deleteOwnUserForm.controls.accessToken.value - }) + .deleteOwnUser( + this.hasPermissionToUpdateOwnAccessToken + ? { + accessToken: this.deleteOwnUserForm.controls.accessToken.value + } + : {} + ) .pipe( catchError(() => { this.notificationService.alert({ - title: $localize`Oops! Incorrect Security Token.` + title: this.hasPermissionToUpdateOwnAccessToken + ? $localize`Oops! Incorrect Security Token.` + : $localize`Oops! Something went wrong.` }); return EMPTY; diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html index 2d4c090dd2..bf18373447 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.html +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -318,7 +318,10 @@
Danger Zone
- @if (hasPermissionToDeleteOwnUser) { + @if ( + hasPermissionToDeleteOwnUser && + hasPermissionToUpdateOwnAccessToken + ) {
+ } @else if (hasPermissionToDeleteOwnUser && !hasImpersonationId) { + } @else if ( hasPermissionToRequestOwnUserDeletion && (user?.accounts?.length > 0 || user?.activitiesCount > 0) diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index 96dfe24c45..c39b774f4b 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -330,7 +330,7 @@ > -
    +
      @for (holding of top3; track holding) {
    1. -
      - {{ holding.assetProfile.name }} +
      + + {{ holding.assetProfile.name }} +
      -
        +
          @for (holding of bottom3; track holding) {
        1. -
          - {{ holding.assetProfile.name }} +
          + + {{ holding.assetProfile.name }} +