diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fbd3700ad..3eabc008e8 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 - Localized the number formatting in the tooltip of the line chart, the investment chart and the benchmark comparator - Deprecated the `api/v1/symbol` endpoints in favor of the `api/v1/symbols` endpoints +- Removed the redundant currency from the activities in the currency of the asset profile - Changed the _Redis_ image from `redis:alpine` to `redis:8-alpine` in the `docker-compose` file (`docker-compose.yml`) - Upgraded `@internationalized/number` from version `3.6.8` to `3.6.9` - Upgraded `countries-and-timezones` from version `3.10.0` to `3.11.0` diff --git a/apps/api/src/app/endpoints/activities/activities.controller.ts b/apps/api/src/app/endpoints/activities/activities.controller.ts index 99924c0b27..bc793d0660 100644 --- a/apps/api/src/app/endpoints/activities/activities.controller.ts +++ b/apps/api/src/app/endpoints/activities/activities.controller.ts @@ -18,6 +18,7 @@ import { } from '@ghostfolio/common/config'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { SubscriptionType } from '@ghostfolio/common/enums'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { ActivitiesResponse, ActivityResponse, @@ -229,17 +230,21 @@ export class ActivitiesController { userSubscription }: ImpersonationContext ): Promise { - await this.validateActivity({ + const assetProfiles = await this.validateActivity({ authenticatedUserSubscription, userId, userSubscription, activityDto: data }); - const currency = data.currency; const customCurrency = data.customCurrency; const dataSource = data.dataSource; + const currency = + assetProfiles[ + getAssetProfileIdentifier({ dataSource, symbol: data.symbol }) + ]?.currency ?? data.currency; + if (customCurrency) { data.currency = customCurrency; @@ -449,7 +454,7 @@ export class ActivitiesController { : authenticatedUserSubscription; try { - await this.dataProviderService.validateActivities({ + return await this.dataProviderService.validateActivities({ subscription, userId, activitiesDto: [{ currency, dataSource, symbol, type }], diff --git a/apps/api/src/services/activities/activities.service.spec.ts b/apps/api/src/services/activities/activities.service.spec.ts index a91ca439da..a16fecb560 100644 --- a/apps/api/src/services/activities/activities.service.spec.ts +++ b/apps/api/src/services/activities/activities.service.spec.ts @@ -109,7 +109,8 @@ describe('ActivitiesService', () => { }, userId: 'user-id' }) - } + }, + symbolProfile: { findUnique: jest.fn().mockResolvedValue(null) } } as unknown as PrismaService, null, { validateTagIds: jest.fn() } as unknown as TagService @@ -153,6 +154,130 @@ describe('ActivitiesService', () => { ); }); + it('does not store the currency if it is the currency of the existing asset profile', async () => { + const data = await getCreatedActivityData({ + assetProfile: { currency: 'USD' }, + currency: 'USD' + }); + + expect(data).not.toHaveProperty('currency'); + }); + + it('stores the currency if it is not the currency of the existing asset profile', async () => { + const data = await getCreatedActivityData({ + assetProfile: { currency: 'USD' }, + currency: 'EUR' + }); + + expect(data.currency).toBe('EUR'); + }); + + it('does not store the currency if it is the currency of a new custom asset profile', async () => { + const data = await getCreatedActivityData({ + currency: 'USD', + dataSource: DataSource.MANUAL + }); + + expect(data).not.toHaveProperty('currency'); + }); + + it('does not store the currency if it is the currency of the existing custom asset profile (clone)', async () => { + const data = await getCreatedActivityData({ + assetProfile: { currency: 'EUR', id: 'asset-profile-id' }, + currency: 'EUR', + dataSource: DataSource.MANUAL, + symbol: 'GF_COPX' + }); + + expect(data).not.toHaveProperty('currency'); + }); + + it('does not store the currency if it is the currency of a new asset profile of a data provider', async () => { + const data = await getCreatedActivityData({ currency: 'USD' }); + + expect(data).not.toHaveProperty('currency'); + }); + + it('stores the currency if it is not the currency of a new asset profile of a data provider', async () => { + const data = await getCreatedActivityData({ currency: 'EUR' }); + + expect(data.currency).toBe('EUR'); + }); + + it('compares the currency with the asset profile which the activity connects to', async () => { + const data = await getCreatedActivityData({ + currency: 'USD', + findUnique: jest.fn(({ where }: Prisma.SymbolProfileFindUniqueArgs) => { + return where.dataSource_symbol.dataSource === DataSource.MANUAL + ? null + : { currency: 'EUR' }; + }), + type: ActivityType.FEE + }); + + expect(data).not.toHaveProperty('currency'); + }); + + async function getCreatedActivityData({ + assetProfile, + currency, + dataSource = DataSource.YAHOO, + findUnique = jest.fn().mockResolvedValue(assetProfile ?? null), + symbol = 'VT', + type = ActivityType.BUY + }: { + assetProfile?: { currency: string; id?: string }; + currency: string; + dataSource?: DataSource; + findUnique?: jest.Mock; + symbol?: string; + type?: ActivityType; + }) { + const create = jest.fn(({ data }: Prisma.OrderCreateArgs) => { + return { + ...data, + SymbolProfile: data.SymbolProfile.connectOrCreate.create + }; + }); + + const service = new ActivitiesService( + null, + null, + null, + null, + { addJobToQueue: jest.fn() } as unknown as DataGatheringService, + null, + { emit: jest.fn() } as unknown as EventEmitter2, + null, + null, + { + order: { create }, + symbolProfile: { findUnique } + } as unknown as PrismaService, + null, + { validateTagIds: jest.fn() } as unknown as TagService + ); + + await service.createActivity({ + currency, + type, + date: parseDate('2026-10-01'), + fee: 0, + quantity: 1, + SymbolProfile: { + connectOrCreate: { + create: { dataSource, symbol, currency: 'USD' }, + where: { dataSource_symbol: { dataSource, symbol } } + } + }, + unitPrice: 100, + user: { connect: { id: 'user-id' } }, + userId: 'user-id' + }); + + return create.mock.calls[0][0].data; + } + async function getCreatedAssetProfile({ existingAssetProfile, symbol @@ -688,12 +813,48 @@ describe('ActivitiesService', () => { expect(data).not.toHaveProperty('SymbolProfile'); }); + it.each([ + { currency: 'USD', dataSource: DataSource.MANUAL, symbol: 'GF_GOLD' }, + { currency: 'EUR', dataSource: DataSource.YAHOO, symbol: 'AAPL' } + ])( + 'removes the currency if it is the currency of the asset profile ($dataSource)', + async ({ currency, dataSource, symbol }) => { + const data = await getUpdatedActivityData({ + currency, + dataSource, + symbol, + userId: 'user-id' + }); + + expect(data.currency).toBeNull(); + } + ); + + it.each([ + { currency: 'EUR', dataSource: DataSource.MANUAL, symbol: 'GF_GOLD' }, + { currency: 'USD', dataSource: DataSource.YAHOO, symbol: 'AAPL' } + ])( + 'keeps the currency if it is not the currency of the asset profile ($dataSource)', + async ({ currency, dataSource, symbol }) => { + const data = await getUpdatedActivityData({ + currency, + dataSource, + symbol, + userId: 'user-id' + }); + + expect(data.currency).toBe(currency); + } + ); + async function getUpdatedActivityData({ + currency, dataSource = DataSource.MANUAL, symbol = 'GF_GOLD', type = ActivityType.BUY, userId }: { + currency?: string; dataSource?: DataSource; symbol?: string; type?: ActivityType; @@ -706,7 +867,7 @@ describe('ActivitiesService', () => { null, null, null, - null, + { gatherSymbols: jest.fn() } as unknown as DataGatheringService, null, { emit: jest.fn() } as unknown as EventEmitter2, null, @@ -714,9 +875,19 @@ describe('ActivitiesService', () => { { order: { update, - findUnique: jest - .fn() - .mockResolvedValue({ SymbolProfile: { userId } }) + findUnique: jest.fn().mockResolvedValue({ + SymbolProfile: { userId, currency: 'USD' } + }) + }, + symbolProfile: { + findUnique: jest.fn( + ({ where }: Prisma.SymbolProfileFindUniqueArgs) => { + return where.dataSource_symbol.dataSource === + DataSource.YAHOO && where.dataSource_symbol.symbol === 'AAPL' + ? { currency: 'EUR' } + : null; + } + ) } } as unknown as PrismaService, null, @@ -725,6 +896,7 @@ describe('ActivitiesService', () => { await service.updateActivity({ data: { + currency, type, assetClass: AssetClass.COMMODITY, assetSubClass: AssetSubClass.PRECIOUS_METAL, diff --git a/apps/api/src/services/activities/activities.service.ts b/apps/api/src/services/activities/activities.service.ts index a6f6f09353..95385a2cd9 100644 --- a/apps/api/src/services/activities/activities.service.ts +++ b/apps/api/src/services/activities/activities.service.ts @@ -174,7 +174,6 @@ export class ActivitiesService { accountId?: string; assetClass?: AssetClass; assetSubClass?: AssetSubClass; - currency?: string; symbol?: string; tags?: { id: string }[]; updateAccountBalance?: boolean; @@ -286,6 +285,19 @@ export class ActivitiesService { delete data.comment; } + const assetProfile = await this.prismaService.symbolProfile.findUnique({ + select: { currency: true }, + where: data.SymbolProfile.connectOrCreate.where + }); + + const assetProfileCurrency = + assetProfile?.currency ?? + data.SymbolProfile.connectOrCreate.create.currency; + + if (data.currency === assetProfileCurrency) { + delete data.currency; + } + delete data.symbol; delete data.tags; delete data.updateAccountBalance; @@ -907,7 +919,6 @@ export class ActivitiesService { data: Prisma.OrderUpdateInput & { assetClass?: AssetClass; assetSubClass?: AssetSubClass; - currency?: string; symbol?: string; tags?: { id: string }[]; type?: ActivityType; @@ -930,6 +941,8 @@ export class ActivitiesService { data.comment = null; } + let assetProfileCurrency: string; + if ( NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) || (data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' && @@ -938,10 +951,12 @@ export class ActivitiesService { delete data.SymbolProfile.connect; const activityToUpdate = await this.prismaService.order.findUnique({ - select: { SymbolProfile: { select: { userId: true } } }, + select: { SymbolProfile: { select: { currency: true, userId: true } } }, where }); + assetProfileCurrency = activityToUpdate?.SymbolProfile.currency; + if (activityToUpdate?.SymbolProfile.userId === userId) { data.SymbolProfile.update = { data: { @@ -956,6 +971,13 @@ export class ActivitiesService { } else { delete data.SymbolProfile.update; + const assetProfile = await this.prismaService.symbolProfile.findUnique({ + select: { currency: true }, + where: data.SymbolProfile.connect + }); + + assetProfileCurrency = assetProfile?.currency; + if (!isActivityInFuture({ date: data.date as Date })) { // Gather symbol data of order in the background, if the date is not in // the future @@ -975,6 +997,11 @@ export class ActivitiesService { delete data.assetClass; delete data.assetSubClass; + + if (data.currency === assetProfileCurrency) { + data.currency = null; + } + delete data.symbol; delete data.tags; diff --git a/prisma/migrations/20261029000000_removed_redundant_currency_from_order/migration.sql b/prisma/migrations/20261029000000_removed_redundant_currency_from_order/migration.sql new file mode 100644 index 0000000000..b71780e19a --- /dev/null +++ b/prisma/migrations/20261029000000_removed_redundant_currency_from_order/migration.sql @@ -0,0 +1,5 @@ +-- Remove redundant currency from the 'Order' table +UPDATE "Order" +SET "currency" = NULL +FROM "SymbolProfile" +WHERE "Order"."symbolProfileId" = "SymbolProfile"."id" AND "Order"."currency" = "SymbolProfile"."currency";