Browse Source

Feature/add accounts import export (#1635)

* Add accounts to activities export

* Add logic for importing accounts

* Update changelog
pull/1672/head
Yash Solanki 2 years ago
committed by GitHub
parent
commit
a79f31b006
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      CHANGELOG.md
  2. 4
      apps/api/src/app/account/create-account.dto.ts
  3. 17
      apps/api/src/app/export/export.service.ts
  4. 9
      apps/api/src/app/import/import-data.dto.ts
  5. 9
      apps/api/src/app/import/import.controller.ts
  6. 93
      apps/api/src/app/import/import.service.ts
  7. 25
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  8. 42
      apps/client/src/app/services/import-activities.service.ts
  9. 3
      libs/common/src/lib/interfaces/export.interface.ts
  10. 48
      test/import/ok-without-accounts.json
  11. 21
      test/import/ok.json

5
CHANGELOG.md

@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added support to export accounts
- Added suport to import accounts
### Changed ### Changed
- Improved the styling in the admin control panel - Improved the styling in the admin control panel

4
apps/api/src/app/account/create-account.dto.ts

@ -17,6 +17,10 @@ export class CreateAccountDto {
@IsString() @IsString()
currency: string; currency: string;
@IsOptional()
@IsString()
id?: string;
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isExcluded?: boolean; isExcluded?: boolean;

17
apps/api/src/app/export/export.service.ts

@ -14,6 +14,22 @@ export class ExportService {
activityIds?: string[]; activityIds?: string[];
userId: string; userId: string;
}): Promise<Export> { }): Promise<Export> {
const accounts = await this.prismaService.account.findMany({
orderBy: {
name: 'asc'
},
select: {
accountType: true,
balance: true,
currency: true,
id: true,
isExcluded: true,
name: true,
platformId: true
},
where: { userId }
});
let activities = await this.prismaService.order.findMany({ let activities = await this.prismaService.order.findMany({
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
select: { select: {
@ -38,6 +54,7 @@ export class ExportService {
return { return {
meta: { date: new Date().toISOString(), version: environment.version }, meta: { date: new Date().toISOString(), version: environment.version },
accounts,
activities: activities.map( activities: activities.map(
({ ({
accountId, accountId,

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

@ -1,8 +1,15 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, ValidateNested } from 'class-validator'; import { IsArray, IsOptional, ValidateNested } from 'class-validator';
export class ImportDataDto { export class ImportDataDto {
@IsOptional()
@IsArray()
@Type(() => CreateAccountDto)
@ValidateNested({ each: true })
accounts: CreateAccountDto[];
@IsArray() @IsArray()
@Type(() => CreateOrderDto) @Type(() => CreateOrderDto)
@ValidateNested({ each: true }) @ValidateNested({ each: true })

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

@ -2,6 +2,7 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ImportResponse } from '@ghostfolio/common/interfaces'; import { ImportResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -38,7 +39,10 @@ export class ImportController {
@Body() importData: ImportDataDto, @Body() importData: ImportDataDto,
@Query('dryRun') isDryRun?: boolean @Query('dryRun') isDryRun?: boolean
): Promise<ImportResponse> { ): Promise<ImportResponse> {
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) { if (
!this.configurationService.get('ENABLE_FEATURE_IMPORT') ||
!hasPermission(this.request.user.permissions, permissions.createAccount)
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -60,9 +64,10 @@ export class ImportController {
try { try {
const activities = await this.importService.import({ const activities = await this.importService.import({
maxActivitiesToImport,
isDryRun, isDryRun,
maxActivitiesToImport,
userCurrency, userCurrency,
accountsDto: importData.accounts ?? [],
activitiesDto: importData.activities, activitiesDto: importData.activities,
userId: this.request.user.id userId: this.request.user.id
}); });

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

@ -1,4 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
@ -100,18 +101,75 @@ export class ImportService {
} }
public async import({ public async import({
accountsDto,
activitiesDto, activitiesDto,
isDryRun = false, isDryRun = false,
maxActivitiesToImport, maxActivitiesToImport,
userCurrency, userCurrency,
userId userId
}: { }: {
accountsDto: Partial<CreateAccountDto>[];
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
isDryRun?: boolean; isDryRun?: boolean;
maxActivitiesToImport: number; maxActivitiesToImport: number;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<Activity[]> { }): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {};
if (!isDryRun && accountsDto?.length) {
const existingAccounts = await this.accountService.accounts({
where: {
id: {
in: accountsDto.map(({ id }) => {
return id;
})
}
}
});
for (const account of accountsDto) {
// Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find(
(existingAccount) => existingAccount.id === account.id
);
// If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== userId) {
let oldAccountId: string;
const platformId = account.platformId;
delete account.platformId;
if (accountWithSameId) {
oldAccountId = account.id;
delete account.id;
}
const newAccountObject = {
...account,
User: { connect: { id: userId } }
};
if (platformId) {
Object.assign(newAccountObject, {
Platform: { connect: { id: platformId } }
});
}
const newAccount = await this.accountService.createAccount(
newAccountObject,
userId
);
// Store the new to old account ID mappings for updating activities
if (accountWithSameId && oldAccountId) {
accountIdMapping[oldAccountId] = newAccount.id;
}
}
}
}
for (const activity of activitiesDto) { for (const activity of activitiesDto) {
if (!activity.dataSource) { if (!activity.dataSource) {
if (activity.type === 'ITEM') { if (activity.type === 'ITEM') {
@ -120,6 +178,13 @@ export class ImportService {
activity.dataSource = this.dataProviderService.getPrimaryDataSource(); activity.dataSource = this.dataProviderService.getPrimaryDataSource();
} }
} }
// If a new account is created, then update the accountId in all activities
if (!isDryRun) {
if (Object.keys(accountIdMapping).includes(activity.accountId)) {
activity.accountId = accountIdMapping[activity.accountId];
}
}
} }
const assetProfiles = await this.validateActivities({ const assetProfiles = await this.validateActivities({
@ -128,12 +193,18 @@ export class ImportService {
userId userId
}); });
const accountIds = (await this.accountService.getAccounts(userId)).map( const accounts = (await this.accountService.getAccounts(userId)).map(
(account) => { (account) => {
return account.id; return { id: account.id, name: account.name };
} }
); );
if (isDryRun) {
accountsDto.forEach(({ id, name }) => {
accounts.push({ id, name });
});
}
const activities: Activity[] = []; const activities: Activity[] = [];
for (const { for (const {
@ -149,11 +220,15 @@ export class ImportService {
unitPrice unitPrice
} of activitiesDto) { } of activitiesDto) {
const date = parseISO(<string>(<unknown>dateString)); const date = parseISO(<string>(<unknown>dateString));
const validatedAccountId = accountIds.includes(accountId) const validatedAccount = accounts.find(({ id }) => {
? accountId return id === accountId;
: undefined; });
let order: OrderWithAccount; let order:
| OrderWithAccount
| (Omit<OrderWithAccount, 'Account'> & {
Account?: { id: string; name: string };
});
if (isDryRun) { if (isDryRun) {
order = { order = {
@ -164,7 +239,7 @@ export class ImportService {
type, type,
unitPrice, unitPrice,
userId, userId,
accountId: validatedAccountId, accountId: validatedAccount?.id,
accountUserId: undefined, accountUserId: undefined,
createdAt: new Date(), createdAt: new Date(),
id: uuidv4(), id: uuidv4(),
@ -187,6 +262,7 @@ export class ImportService {
url: null, url: null,
...assetProfiles[symbol] ...assetProfiles[symbol]
}, },
Account: validatedAccount,
symbolProfileId: undefined, symbolProfileId: undefined,
updatedAt: new Date() updatedAt: new Date()
}; };
@ -199,7 +275,7 @@ export class ImportService {
type, type,
unitPrice, unitPrice,
userId, userId,
accountId: validatedAccountId, accountId: validatedAccount?.id,
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: { create: {
@ -221,6 +297,7 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();
//@ts-ignore
activities.push({ activities.push({
...order, ...order,
value, value,

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

@ -11,6 +11,7 @@ import {
MatLegacyDialogRef as MatDialogRef MatLegacyDialogRef as MatDialogRef
} from '@angular/material/legacy-dialog'; } from '@angular/material/legacy-dialog';
import { MatLegacySnackBar as MatSnackBar } from '@angular/material/legacy-snack-bar'; import { MatLegacySnackBar as MatSnackBar } from '@angular/material/legacy-snack-bar';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service'; import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
@ -28,6 +29,7 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
templateUrl: 'import-activities-dialog.html' templateUrl: 'import-activities-dialog.html'
}) })
export class ImportActivitiesDialog implements OnDestroy { export class ImportActivitiesDialog implements OnDestroy {
public accounts: CreateAccountDto[] = [];
public activities: Activity[] = []; public activities: Activity[] = [];
public details: any[] = []; public details: any[] = [];
public errorMessages: string[] = []; public errorMessages: string[] = [];
@ -91,9 +93,10 @@ export class ImportActivitiesDialog implements OnDestroy {
try { try {
this.snackBar.open('⏳ ' + $localize`Importing data...`); this.snackBar.open('⏳ ' + $localize`Importing data...`);
await this.importActivitiesService.importSelectedActivities( await this.importActivitiesService.importSelectedActivities({
this.selectedActivities accounts: this.accounts,
); activities: this.selectedActivities
});
this.snackBar.open( this.snackBar.open(
'✅ ' + $localize`Import has been completed`, '✅ ' + $localize`Import has been completed`,
@ -163,6 +166,8 @@ export class ImportActivitiesDialog implements OnDestroy {
if (file.name.endsWith('.json')) { if (file.name.endsWith('.json')) {
const content = JSON.parse(fileContent); const content = JSON.parse(fileContent);
this.accounts = content.accounts;
if (!isArray(content.activities)) { if (!isArray(content.activities)) {
if (isArray(content.orders)) { if (isArray(content.orders)) {
this.handleImportError({ this.handleImportError({
@ -180,10 +185,13 @@ export class ImportActivitiesDialog implements OnDestroy {
} }
try { try {
this.activities = await this.importActivitiesService.importJson({ const { activities } =
content: content.activities, await this.importActivitiesService.importJson({
isDryRun: true accounts: content.accounts,
}); activities: content.activities,
isDryRun: true
});
this.activities = activities;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
this.handleImportError({ error, activities: content.activities }); this.handleImportError({ error, activities: content.activities });
@ -192,11 +200,12 @@ export class ImportActivitiesDialog implements OnDestroy {
return; return;
} else if (file.name.endsWith('.csv')) { } else if (file.name.endsWith('.csv')) {
try { try {
this.activities = await this.importActivitiesService.importCsv({ const data = await this.importActivitiesService.importCsv({
fileContent, fileContent,
isDryRun: true, isDryRun: true,
userAccounts: this.data.user.accounts userAccounts: this.data.user.accounts
}); });
this.activities = data.activities;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
this.handleImportError({ this.handleImportError({

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

@ -1,5 +1,6 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { Account, DataSource, Type } from '@prisma/client'; import { Account, DataSource, Type } from '@prisma/client';
@ -33,7 +34,9 @@ export class ImportActivitiesService {
fileContent: string; fileContent: string;
isDryRun?: boolean; isDryRun?: boolean;
userAccounts: Account[]; userAccounts: Account[];
}): Promise<Activity[]> { }): Promise<{
activities: Activity[];
}> {
const content = csvToJson(fileContent, { const content = csvToJson(fileContent, {
dynamicTyping: true, dynamicTyping: true,
header: true, header: true,
@ -55,20 +58,26 @@ export class ImportActivitiesService {
}); });
} }
return await this.importJson({ isDryRun, content: activities }); return await this.importJson({ activities, isDryRun });
} }
public importJson({ public importJson({
content, accounts,
activities,
isDryRun = false isDryRun = false
}: { }: {
content: CreateOrderDto[]; activities: CreateOrderDto[];
accounts?: CreateAccountDto[];
isDryRun?: boolean; isDryRun?: boolean;
}): Promise<Activity[]> { }): Promise<{
activities: Activity[];
accounts?: CreateAccountDto[];
}> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.postImport( this.postImport(
{ {
activities: content accounts,
activities
}, },
isDryRun isDryRun
) )
@ -80,22 +89,29 @@ export class ImportActivitiesService {
) )
.subscribe({ .subscribe({
next: (data) => { next: (data) => {
resolve(data.activities); resolve(data);
} }
}); });
}); });
} }
public importSelectedActivities( public importSelectedActivities({
selectedActivities: Activity[] accounts,
): Promise<Activity[]> { activities
}: {
accounts: CreateAccountDto[];
activities: Activity[];
}): Promise<{
activities: Activity[];
accounts?: CreateAccountDto[];
}> {
const importData: CreateOrderDto[] = []; const importData: CreateOrderDto[] = [];
for (const activity of selectedActivities) { for (const activity of activities) {
importData.push(this.convertToCreateOrderDto(activity)); importData.push(this.convertToCreateOrderDto(activity));
} }
return this.importJson({ content: importData }); return this.importJson({ accounts, activities: importData });
} }
private convertToCreateOrderDto({ private convertToCreateOrderDto({
@ -347,7 +363,7 @@ export class ImportActivitiesService {
} }
private postImport( private postImport(
aImportData: { activities: CreateOrderDto[] }, aImportData: { accounts: CreateAccountDto[]; activities: CreateOrderDto[] },
aIsDryRun = false aIsDryRun = false
) { ) {
return this.http.post<{ activities: Activity[] }>( return this.http.post<{ activities: Activity[] }>(

3
libs/common/src/lib/interfaces/export.interface.ts

@ -1,10 +1,11 @@
import { Order } from '@prisma/client'; import { Account, Order } from '@prisma/client';
export interface Export { export interface Export {
meta: { meta: {
date: string; date: string;
version: string; version: string;
}; };
accounts: Omit<Account, 'createdAt' | 'isDefault' | 'updatedAt' | 'userId'>[];
activities: (Omit< activities: (Omit<
Order, Order,
| 'accountUserId' | 'accountUserId'

48
test/import/ok-without-accounts.json

@ -0,0 +1,48 @@
{
"meta": {
"date": "2022-04-01T00:00:00.000Z",
"version": "dev"
},
"activities": [
{
"fee": 0,
"quantity": 0,
"type": "BUY",
"unitPrice": 0,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2050-06-05T22:00:00.000Z",
"symbol": "MSFT"
},
{
"fee": 0,
"quantity": 1,
"type": "ITEM",
"unitPrice": 500000,
"currency": "USD",
"dataSource": "MANUAL",
"date": "2021-12-31T22:00:00.000Z",
"symbol": "Penthouse Apartment"
},
{
"fee": 0,
"quantity": 5,
"type": "DIVIDEND",
"unitPrice": 0.62,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2021-11-16T22:00:00.000Z",
"symbol": "MSFT"
},
{
"fee": 19,
"quantity": 5,
"type": "BUY",
"unitPrice": 298.58,
"currency": "USD",
"dataSource": "YAHOO",
"date": "2021-09-15T22:00:00.000Z",
"symbol": "MSFT"
}
]
}

21
test/import/ok.json

@ -1,10 +1,23 @@
{ {
"meta": { "meta": {
"date": "2022-04-01T00:00:00.000Z", "date": "2023-02-05T00:00:00.000Z",
"version": "dev" "version": "dev"
}, },
"accounts": [
{
"accountType": "SECURITIES",
"balance": 2000,
"currency": "USD",
"id": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0",
"isExcluded": false,
"name": "My Online Trading Account",
"platformId": null
}
],
"activities": [ "activities": [
{ {
"accountId": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0",
"comment": null,
"fee": 0, "fee": 0,
"quantity": 0, "quantity": 0,
"type": "BUY", "type": "BUY",
@ -15,6 +28,8 @@
"symbol": "MSFT" "symbol": "MSFT"
}, },
{ {
"accountId": null,
"comment": null,
"fee": 0, "fee": 0,
"quantity": 1, "quantity": 1,
"type": "ITEM", "type": "ITEM",
@ -25,6 +40,8 @@
"symbol": "Penthouse Apartment" "symbol": "Penthouse Apartment"
}, },
{ {
"accountId": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0",
"comment": null,
"fee": 0, "fee": 0,
"quantity": 5, "quantity": 5,
"type": "DIVIDEND", "type": "DIVIDEND",
@ -35,6 +52,8 @@
"symbol": "MSFT" "symbol": "MSFT"
}, },
{ {
"accountId": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0",
"comment": "My first order",
"fee": 19, "fee": 19,
"quantity": 5, "quantity": 5,
"type": "BUY", "type": "BUY",

Loading…
Cancel
Save