Browse Source

Feature/remove redundant currency from order table (#5481)

* Remove redundant currency

* Update changelog
pull/8352/head
Thomas Kaul 4 days ago
committed by GitHub
parent
commit
51bcfe6571
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 11
      apps/api/src/app/endpoints/activities/activities.controller.ts
  3. 182
      apps/api/src/services/activities/activities.service.spec.ts
  4. 33
      apps/api/src/services/activities/activities.service.ts
  5. 5
      prisma/migrations/20261029000000_removed_redundant_currency_from_order/migration.sql

1
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 - 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 - 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`) - 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 `@internationalized/number` from version `3.6.8` to `3.6.9`
- Upgraded `countries-and-timezones` from version `3.10.0` to `3.11.0` - Upgraded `countries-and-timezones` from version `3.10.0` to `3.11.0`

11
apps/api/src/app/endpoints/activities/activities.controller.ts

@ -18,6 +18,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
ActivitiesResponse, ActivitiesResponse,
ActivityResponse, ActivityResponse,
@ -229,17 +230,21 @@ export class ActivitiesController {
userSubscription userSubscription
}: ImpersonationContext }: ImpersonationContext
): Promise<CreateActivityResponse> { ): Promise<CreateActivityResponse> {
await this.validateActivity({ const assetProfiles = await this.validateActivity({
authenticatedUserSubscription, authenticatedUserSubscription,
userId, userId,
userSubscription, userSubscription,
activityDto: data activityDto: data
}); });
const currency = data.currency;
const customCurrency = data.customCurrency; const customCurrency = data.customCurrency;
const dataSource = data.dataSource; const dataSource = data.dataSource;
const currency =
assetProfiles[
getAssetProfileIdentifier({ dataSource, symbol: data.symbol })
]?.currency ?? data.currency;
if (customCurrency) { if (customCurrency) {
data.currency = customCurrency; data.currency = customCurrency;
@ -449,7 +454,7 @@ export class ActivitiesController {
: authenticatedUserSubscription; : authenticatedUserSubscription;
try { try {
await this.dataProviderService.validateActivities({ return await this.dataProviderService.validateActivities({
subscription, subscription,
userId, userId,
activitiesDto: [{ currency, dataSource, symbol, type }], activitiesDto: [{ currency, dataSource, symbol, type }],

182
apps/api/src/services/activities/activities.service.spec.ts

@ -109,7 +109,8 @@ describe('ActivitiesService', () => {
}, },
userId: 'user-id' userId: 'user-id'
}) })
} },
symbolProfile: { findUnique: jest.fn().mockResolvedValue(null) }
} as unknown as PrismaService, } as unknown as PrismaService,
null, null,
{ validateTagIds: jest.fn() } as unknown as TagService { 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({ async function getCreatedAssetProfile({
existingAssetProfile, existingAssetProfile,
symbol symbol
@ -688,12 +813,48 @@ describe('ActivitiesService', () => {
expect(data).not.toHaveProperty('SymbolProfile'); 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({ async function getUpdatedActivityData({
currency,
dataSource = DataSource.MANUAL, dataSource = DataSource.MANUAL,
symbol = 'GF_GOLD', symbol = 'GF_GOLD',
type = ActivityType.BUY, type = ActivityType.BUY,
userId userId
}: { }: {
currency?: string;
dataSource?: DataSource; dataSource?: DataSource;
symbol?: string; symbol?: string;
type?: ActivityType; type?: ActivityType;
@ -706,7 +867,7 @@ describe('ActivitiesService', () => {
null, null,
null, null,
null, null,
null, { gatherSymbols: jest.fn() } as unknown as DataGatheringService,
null, null,
{ emit: jest.fn() } as unknown as EventEmitter2, { emit: jest.fn() } as unknown as EventEmitter2,
null, null,
@ -714,9 +875,19 @@ describe('ActivitiesService', () => {
{ {
order: { order: {
update, update,
findUnique: jest findUnique: jest.fn().mockResolvedValue({
.fn() SymbolProfile: { userId, currency: 'USD' }
.mockResolvedValue({ SymbolProfile: { userId } }) })
},
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, } as unknown as PrismaService,
null, null,
@ -725,6 +896,7 @@ describe('ActivitiesService', () => {
await service.updateActivity({ await service.updateActivity({
data: { data: {
currency,
type, type,
assetClass: AssetClass.COMMODITY, assetClass: AssetClass.COMMODITY,
assetSubClass: AssetSubClass.PRECIOUS_METAL, assetSubClass: AssetSubClass.PRECIOUS_METAL,

33
apps/api/src/services/activities/activities.service.ts

@ -174,7 +174,6 @@ export class ActivitiesService {
accountId?: string; accountId?: string;
assetClass?: AssetClass; assetClass?: AssetClass;
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
currency?: string;
symbol?: string; symbol?: string;
tags?: { id: string }[]; tags?: { id: string }[];
updateAccountBalance?: boolean; updateAccountBalance?: boolean;
@ -286,6 +285,19 @@ export class ActivitiesService {
delete data.comment; 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.symbol;
delete data.tags; delete data.tags;
delete data.updateAccountBalance; delete data.updateAccountBalance;
@ -907,7 +919,6 @@ export class ActivitiesService {
data: Prisma.OrderUpdateInput & { data: Prisma.OrderUpdateInput & {
assetClass?: AssetClass; assetClass?: AssetClass;
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
currency?: string;
symbol?: string; symbol?: string;
tags?: { id: string }[]; tags?: { id: string }[];
type?: ActivityType; type?: ActivityType;
@ -930,6 +941,8 @@ export class ActivitiesService {
data.comment = null; data.comment = null;
} }
let assetProfileCurrency: string;
if ( if (
NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) || NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ||
(data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' && (data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' &&
@ -938,10 +951,12 @@ export class ActivitiesService {
delete data.SymbolProfile.connect; delete data.SymbolProfile.connect;
const activityToUpdate = await this.prismaService.order.findUnique({ const activityToUpdate = await this.prismaService.order.findUnique({
select: { SymbolProfile: { select: { userId: true } } }, select: { SymbolProfile: { select: { currency: true, userId: true } } },
where where
}); });
assetProfileCurrency = activityToUpdate?.SymbolProfile.currency;
if (activityToUpdate?.SymbolProfile.userId === userId) { if (activityToUpdate?.SymbolProfile.userId === userId) {
data.SymbolProfile.update = { data.SymbolProfile.update = {
data: { data: {
@ -956,6 +971,13 @@ export class ActivitiesService {
} else { } else {
delete data.SymbolProfile.update; 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 })) { if (!isActivityInFuture({ date: data.date as Date })) {
// Gather symbol data of order in the background, if the date is not in // Gather symbol data of order in the background, if the date is not in
// the future // the future
@ -975,6 +997,11 @@ export class ActivitiesService {
delete data.assetClass; delete data.assetClass;
delete data.assetSubClass; delete data.assetSubClass;
if (data.currency === assetProfileCurrency) {
data.currency = null;
}
delete data.symbol; delete data.symbol;
delete data.tags; delete data.tags;

5
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";
Loading…
Cancel
Save