Browse Source

Feature/extend activities import by tags (#5287)

* Extend activities import by tags

* Update changelog
pull/5330/head^2
Attila Cseh 4 days ago
committed by GitHub
parent
commit
7613b54514
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 4
      apps/api/src/app/endpoints/tags/create-tag.dto.ts
  3. 7
      apps/api/src/app/import/import-data.dto.ts
  4. 1
      apps/api/src/app/import/import.controller.ts
  5. 2
      apps/api/src/app/import/import.module.ts
  6. 96
      apps/api/src/app/import/import.service.ts
  7. 10
      apps/api/src/app/order/create-order.dto.ts
  8. 1
      apps/api/src/app/order/interfaces/activities.interface.ts
  9. 6
      apps/api/src/app/order/order.controller.ts
  10. 12
      apps/api/src/app/order/order.service.ts
  11. 10
      apps/api/src/app/order/update-order.dto.ts
  12. 4
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  13. 4
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  14. 4
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  15. 4
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  16. 4
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  17. 9
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  18. 26
      apps/client/src/app/services/import-activities.service.ts

6
CHANGELOG.md

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Extended the import functionality by tags
## 2.189.0 - 2025-08-05
### Changed

4
apps/api/src/app/endpoints/tags/create-tag.dto.ts

@ -1,6 +1,10 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateTagDto {
@IsOptional()
@IsString()
id?: string;
@IsString()
name: string;

7
apps/api/src/app/import/import-data.dto.ts

@ -3,6 +3,7 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Type } from 'class-transformer';
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
import { CreateTagDto } from '../endpoints/tags/create-tag.dto';
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto';
@ -23,4 +24,10 @@ export class ImportDataDto {
@Type(() => CreateAssetProfileWithMarketDataDto)
@ValidateNested({ each: true })
assetProfiles?: CreateAssetProfileWithMarketDataDto[];
@IsArray()
@IsOptional()
@Type(() => CreateTagDto)
@ValidateNested({ each: true })
tags?: CreateTagDto[];
}

1
apps/api/src/app/import/import.controller.ts

@ -74,6 +74,7 @@ export class ImportController {
accountsWithBalancesDto: importData.accounts ?? [],
activitiesDto: importData.activities,
assetProfilesWithMarketDataDto: importData.assetProfiles ?? [],
tagsDto: importData.tags ?? [],
user: this.request.user
});

2
apps/api/src/app/import/import.module.ts

@ -13,6 +13,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
@ -35,6 +36,7 @@ import { ImportService } from './import.service';
PrismaModule,
RedisCacheModule,
SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],

96
apps/api/src/app/import/import.service.ts

@ -13,12 +13,14 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
AccountWithPlatform,
OrderWithAccount,
@ -46,7 +48,8 @@ export class ImportService {
private readonly orderService: OrderService,
private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService,
private readonly symbolProfileService: SymbolProfileService
private readonly symbolProfileService: SymbolProfileService,
private readonly tagService: TagService
) {}
public async getDividends({
@ -154,6 +157,7 @@ export class ImportService {
assetProfilesWithMarketDataDto,
isDryRun = false,
maxActivitiesToImport,
tagsDto,
user
}: {
accountsWithBalancesDto: ImportDataDto['accounts'];
@ -161,10 +165,12 @@ export class ImportService {
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
isDryRun?: boolean;
maxActivitiesToImport: number;
tagsDto: ImportDataDto['tags'];
user: UserWithSettings;
}): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {};
const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {};
const tagIdMapping: { [oldTagId: string]: string } = {};
const userCurrency = user.settings.settings.baseCurrency;
if (!isDryRun && accountsWithBalancesDto?.length) {
@ -293,6 +299,50 @@ export class ImportService {
}
}
if (tagsDto?.length) {
const existingTagsOfUser = await this.tagService.getTagsForUser(user.id);
const canCreateOwnTag = hasPermission(
user.permissions,
permissions.createOwnTag
);
for (const tag of tagsDto) {
const existingTagOfUser = existingTagsOfUser.find(({ id }) => {
return id === tag.id;
});
if (!existingTagOfUser || existingTagOfUser.userId !== null) {
if (!canCreateOwnTag) {
throw new Error(
`Insufficient permissions to create custom tag ("${tag.name}")`
);
}
if (!isDryRun) {
const existingTag = await this.tagService.getTag({ id: tag.id });
let oldTagId: string;
if (existingTag) {
oldTagId = tag.id;
delete tag.id;
}
const tagObject: Prisma.TagCreateInput = {
...tag,
user: { connect: { id: user.id } }
};
const newTag = await this.tagService.createTag(tagObject);
if (existingTag && oldTagId) {
tagIdMapping[oldTagId] = newTag.id;
}
}
}
}
}
for (const activity of activitiesDto) {
if (!activity.dataSource) {
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) {
@ -313,6 +363,11 @@ export class ImportService {
if (assetProfileSymbolMapping[activity.symbol]) {
activity.symbol = assetProfileSymbolMapping[activity.symbol];
}
// If a new tag is created, then update the tag ID in all activities
activity.tags = (activity.tags ?? []).map((tagId) => {
return tagIdMapping[tagId] ?? tagId;
});
}
}
@ -340,6 +395,24 @@ export class ImportService {
});
}
const tags = (await this.tagService.getTagsForUser(user.id)).map(
({ id, name }) => {
return { id, name };
}
);
if (isDryRun) {
tagsDto
.filter(({ id }) => {
return !tags.some(({ id: tagId }) => {
return tagId === id;
});
})
.forEach(({ id, name }) => {
tags.push({ id, name });
});
}
const activities: Activity[] = [];
for (const activity of activitiesExtendedWithErrors) {
@ -351,6 +424,7 @@ export class ImportService {
const fee = activity.fee;
const quantity = activity.quantity;
const SymbolProfile = activity.SymbolProfile;
const tagIds = activity.tagIds ?? [];
const type = activity.type;
const unitPrice = activity.unitPrice;
@ -388,11 +462,17 @@ export class ImportService {
const validatedAccount = accounts.find(({ id }) => {
return id === accountId;
});
const validatedTags = tags.filter(({ id: tagId }) => {
return tagIds.some((activityTagId) => {
return activityTagId === tagId;
});
});
let order:
| OrderWithAccount
| (Omit<OrderWithAccount, 'Account'> & {
Account?: { id: string; name: string };
| (Omit<OrderWithAccount, 'account' | 'tags'> & {
account?: { id: string; name: string };
tags?: { id: string; name: string }[];
});
if (isDryRun) {
@ -404,7 +484,7 @@ export class ImportService {
quantity,
type,
unitPrice,
Account: validatedAccount,
account: validatedAccount,
accountId: validatedAccount?.id,
accountUserId: undefined,
createdAt: new Date(),
@ -436,6 +516,7 @@ export class ImportService {
userId: dataSource === 'MANUAL' ? user.id : undefined
},
symbolProfileId: undefined,
tags: validatedTags,
updatedAt: new Date(),
userId: user.id
};
@ -469,6 +550,9 @@ export class ImportService {
}
}
},
tags: validatedTags.map(({ id }) => {
return { id };
}),
updateAccountBalance: false,
user: { connect: { id: user.id } },
userId: user.id
@ -546,6 +630,7 @@ export class ImportService {
fee,
quantity,
symbol,
tags,
type,
unitPrice
}) => {
@ -594,7 +679,8 @@ export class ImportService {
isActive: true,
sectors: undefined,
updatedAt: undefined
}
},
tagIds: tags
};
}
);

10
apps/api/src/app/order/create-order.dto.ts

@ -1,13 +1,7 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
@ -70,7 +64,7 @@ export class CreateOrderDto {
@IsArray()
@IsOptional()
tags?: Tag[];
tags?: string[];
@IsEnum(Type, { each: true })
type: Type;

1
apps/api/src/app/order/interfaces/activities.interface.ts

@ -14,6 +14,7 @@ export interface Activity extends Order {
feeInAssetProfileCurrency: number;
feeInBaseCurrency: number;
SymbolProfile?: EnhancedSymbolProfile;
tagIds?: string[];
tags?: Tag[];
unitPriceInAssetProfileCurrency: number;
updateAccountBalance?: boolean;

6
apps/api/src/app/order/order.controller.ts

@ -217,6 +217,9 @@ export class OrderController {
}
}
},
tags: data.tags?.map((id) => {
return { id };
}),
user: { connect: { id: this.request.user.id } },
userId: this.request.user.id
});
@ -293,6 +296,9 @@ export class OrderController {
name: data.symbol
}
},
tags: data.tags?.map((id) => {
return { id };
}),
user: { connect: { id: this.request.user.id } }
},
where: {

12
apps/api/src/app/order/order.service.ts

@ -97,7 +97,7 @@ export class OrderService {
assetSubClass?: AssetSubClass;
currency?: string;
symbol?: string;
tags?: Tag[];
tags?: { id: string }[];
updateAccountBalance?: boolean;
userId: string;
}
@ -201,9 +201,7 @@ export class OrderService {
account,
isDraft,
tags: {
connect: tags.map(({ id }) => {
return { id };
})
connect: tags
}
},
include: { SymbolProfile: true }
@ -658,7 +656,7 @@ export class OrderService {
assetSubClass?: AssetSubClass;
currency?: string;
symbol?: string;
tags?: Tag[];
tags?: { id: string }[];
type?: ActivityType;
};
where: Prisma.OrderWhereUniqueInput;
@ -720,9 +718,7 @@ export class OrderService {
...data,
isDraft,
tags: {
connect: tags.map(({ id }) => {
return { id };
})
connect: tags
}
}
});

10
apps/api/src/app/order/update-order.dto.ts

@ -1,13 +1,7 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
@ -71,7 +65,7 @@ export class UpdateOrderDto {
@IsArray()
@IsOptional()
tags?: Tag[];
tags?: string[];
@IsString()
type: Type;

4
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -18,6 +18,7 @@ import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/po
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
@ -108,6 +109,9 @@ describe('PortfolioCalculator', () => {
name: 'Bitcoin',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: 44558.42
}));

4
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -18,6 +18,7 @@ import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/po
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
@ -108,6 +109,9 @@ describe('PortfolioCalculator', () => {
name: 'Bitcoin',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: 44558.42
}));

4
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -18,6 +18,7 @@ import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/po
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
@ -111,6 +112,9 @@ describe('PortfolioCalculator', () => {
name: 'Novartis AG',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice
}));

4
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -18,6 +18,7 @@ import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/po
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
@ -111,6 +112,9 @@ describe('PortfolioCalculator', () => {
name: 'Novartis AG',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice
}));

4
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -495,7 +495,9 @@ export class GfCreateOrUpdateActivityDialog implements OnDestroy {
? undefined
: this.activityForm.get('searchSymbol')?.value?.symbol) ??
this.activityForm.get('name')?.value,
tags: this.activityForm.get('tags').value,
tags: this.activityForm.get('tags').value?.map(({ id }) => {
return id;
}),
type: this.activityForm.get('type').value,
unitPrice: this.activityForm.get('unitPrice').value
};

9
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -1,3 +1,4 @@
import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto';
import { CreateAccountWithBalancesDto } from '@ghostfolio/api/app/import/create-account-with-balances.dto';
import { CreateAssetProfileWithMarketDataDto } from '@ghostfolio/api/app/import/create-asset-profile-with-market-data.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
@ -94,6 +95,7 @@ export class GfImportActivitiesDialog implements OnDestroy {
public sortColumn = 'date';
public sortDirection: SortDirection = 'desc';
public stepperOrientation: StepperOrientation;
public tags: CreateTagDto[] = [];
public totalItems: number;
private unsubscribeSubject = new Subject<void>();
@ -169,7 +171,8 @@ export class GfImportActivitiesDialog implements OnDestroy {
await this.importActivitiesService.importSelectedActivities({
accounts: this.accounts,
activities: this.selectedActivities,
assetProfiles: this.assetProfiles
assetProfiles: this.assetProfiles,
tags: this.tags
});
this.snackBar.open(
@ -297,6 +300,7 @@ export class GfImportActivitiesDialog implements OnDestroy {
this.accounts = content.accounts;
this.assetProfiles = content.assetProfiles;
this.tags = content.tags;
if (!isArray(content.activities)) {
if (isArray(content.orders)) {
@ -328,7 +332,8 @@ export class GfImportActivitiesDialog implements OnDestroy {
accounts: content.accounts,
activities: content.activities,
assetProfiles: content.assetProfiles,
isDryRun: true
isDryRun: true,
tags: content.tags
});
this.activities = activities;
this.dataSource = new MatTableDataSource(activities.reverse());

26
apps/client/src/app/services/import-activities.service.ts

@ -1,3 +1,4 @@
import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto';
import { CreateAccountWithBalancesDto } from '@ghostfolio/api/app/import/create-account-with-balances.dto';
import { CreateAssetProfileWithMarketDataDto } from '@ghostfolio/api/app/import/create-asset-profile-with-market-data.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
@ -75,12 +76,14 @@ export class ImportActivitiesService {
accounts,
activities,
assetProfiles,
isDryRun = false
isDryRun = false,
tags
}: {
activities: CreateOrderDto[];
accounts?: CreateAccountWithBalancesDto[];
assetProfiles?: CreateAssetProfileWithMarketDataDto[];
isDryRun?: boolean;
tags?: CreateTagDto[];
}): Promise<{
activities: Activity[];
}> {
@ -89,7 +92,8 @@ export class ImportActivitiesService {
{
accounts,
activities,
assetProfiles
assetProfiles,
tags
},
isDryRun
)
@ -110,11 +114,13 @@ export class ImportActivitiesService {
public importSelectedActivities({
accounts,
activities,
assetProfiles
assetProfiles,
tags
}: {
accounts?: CreateAccountWithBalancesDto[];
activities: Activity[];
assetProfiles?: CreateAssetProfileWithMarketDataDto[];
tags?: CreateTagDto[];
}): Promise<{
activities: Activity[];
}> {
@ -124,7 +130,12 @@ export class ImportActivitiesService {
importData.push(this.convertToCreateOrderDto(activity));
}
return this.importJson({ accounts, assetProfiles, activities: importData });
return this.importJson({
accounts,
assetProfiles,
tags,
activities: importData
});
}
private convertToCreateOrderDto({
@ -135,6 +146,7 @@ export class ImportActivitiesService {
fee,
quantity,
SymbolProfile,
tags,
type,
unitPrice,
updateAccountBalance
@ -150,7 +162,10 @@ export class ImportActivitiesService {
currency: currency ?? SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
date: date.toString(),
symbol: SymbolProfile.symbol
symbol: SymbolProfile.symbol,
tags: tags?.map(({ id }) => {
return id;
})
};
}
@ -391,6 +406,7 @@ export class ImportActivitiesService {
accounts?: CreateAccountWithBalancesDto[];
activities: CreateOrderDto[];
assetProfiles?: CreateAssetProfileWithMarketDataDto[];
tags?: CreateTagDto[];
},
aIsDryRun = false
) {

Loading…
Cancel
Save