Feature/import transactions (#212)

* Implement import transactions functionality

* Update changelog
pull/214/head
Thomas 3 years ago committed by GitHub
parent be5b58f49a
commit c7b7efae3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added the import functionality for transactions
### Changed ### Changed
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0` - Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`

@ -24,6 +24,7 @@ import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ExperimentalModule } from './experimental/experimental.module'; import { ExperimentalModule } from './experimental/experimental.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
import { OrderModule } from './order/order.module'; import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
@ -43,6 +44,7 @@ import { UserModule } from './user/user.module';
ConfigModule.forRoot(), ConfigModule.forRoot(),
ExperimentalModule, ExperimentalModule,
ExportModule, ExportModule,
ImportModule,
InfoModule, InfoModule,
OrderModule, OrderModule,
PortfolioModule, PortfolioModule,

@ -0,0 +1,7 @@
import { Order } from '@prisma/client';
import { IsArray } from 'class-validator';
export class ImportDataDto {
@IsArray()
orders: Partial<Order>[];
}

@ -0,0 +1,50 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
HttpException,
Inject,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ImportDataDto } from './import-data.dto';
import { ImportService } from './import.service';
@Controller('import')
export class ImportController {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Post()
@UseGuards(AuthGuard('jwt'))
public async import(@Body() importData: ImportDataDto): Promise<void> {
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
return await this.importService.import({
orders: importData.orders,
userId: this.request.user.id
});
} catch (error) {
console.error(error);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

@ -0,0 +1,34 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { ImportController } from './import.controller';
import { ImportService } from './import.service';
@Module({
imports: [RedisCacheModule],
controllers: [ImportController],
providers: [
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
ImportService,
OrderService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class ImportModule {}

@ -0,0 +1,43 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client';
import { parseISO } from 'date-fns';
@Injectable()
export class ImportService {
public constructor(private readonly orderService: OrderService) {}
public async import({
orders,
userId
}: {
orders: Partial<Order>[];
userId: string;
}): Promise<void> {
for (const {
currency,
dataSource,
date,
fee,
quantity,
symbol,
type,
unitPrice
} of orders) {
await this.orderService.createOrder(
{
currency,
dataSource,
fee,
quantity,
symbol,
type,
unitPrice,
date: parseISO(<string>(<unknown>date)),
User: { connect: { id: userId } }
},
userId
);
}
}
}

@ -27,6 +27,10 @@ export class InfoService {
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport);
}
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) { if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
globalPermissions.push(permissions.enableSocialLogin); globalPermissions.push(permissions.enableSocialLogin);
} }

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
import { environment } from '../environments/environment';
import { Environment } from './interfaces/environment.interface'; import { Environment } from './interfaces/environment.interface';
@Injectable() @Injectable()
@ -16,6 +17,7 @@ export class ConfigurationService {
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }), DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_IMPORT: bool({ default: !environment.production }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),

@ -7,6 +7,7 @@ export interface Environment extends CleanedEnvAccessors {
DATA_SOURCES: string | string[]; // string is not correct, error in envalid? DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean; ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_IMPORT: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean;

@ -212,7 +212,23 @@
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-vertical"></ion-icon>
</button> </button>
<mat-menu #transactionsMenu="matMenu" xPosition="before"> <mat-menu #transactionsMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onExport()">Export</button> <button
*ngIf="hasPermissionToImportOrders"
class="align-items-center d-flex"
mat-menu-item
(click)="onImport()"
>
<ion-icon class="mr-2" name="cloud-upload-outline"></ion-icon>
<span i18n>Import</span>
</button>
<button
class="align-items-center d-flex"
mat-menu-item
(click)="onExport()"
>
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
<span i18n>Export</span>
</button>
</mat-menu> </mat-menu>
</th> </th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell> <td *matCellDef="let element" class="px-1 text-center" mat-cell>

@ -43,11 +43,13 @@ export class TransactionsTableComponent
{ {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToImportOrders: boolean;
@Input() locale: string; @Input() locale: string;
@Input() showActions: boolean; @Input() showActions: boolean;
@Input() transactions: OrderWithAccount[]; @Input() transactions: OrderWithAccount[];
@Output() export = new EventEmitter<void>(); @Output() export = new EventEmitter<void>();
@Output() import = new EventEmitter<void>();
@Output() transactionDeleted = new EventEmitter<string>(); @Output() transactionDeleted = new EventEmitter<string>();
@Output() transactionToClone = new EventEmitter<OrderWithAccount>(); @Output() transactionToClone = new EventEmitter<OrderWithAccount>();
@Output() transactionToUpdate = new EventEmitter<OrderWithAccount>(); @Output() transactionToUpdate = new EventEmitter<OrderWithAccount>();
@ -190,6 +192,10 @@ export class TransactionsTableComponent
this.export.emit(); this.export.emit();
} }
public onImport() {
this.import.emit();
}
public onOpenPositionDialog({ public onOpenPositionDialog({
symbol, symbol,
title title

@ -1,5 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
@ -9,10 +10,11 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Order as OrderModel } from '@prisma/client'; import { Order as OrderModel } from '@prisma/client';
import { environment } from 'apps/client/src/environments/environment';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { EMPTY, Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.component'; import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.component';
@ -26,6 +28,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
public hasPermissionToDeleteOrder: boolean; public hasPermissionToDeleteOrder: boolean;
public hasPermissionToImportOrders: boolean;
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
public transactions: OrderModel[]; public transactions: OrderModel[];
public user: User; public user: User;
@ -43,6 +46,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private snackBar: MatSnackBar,
private userService: UserService private userService: UserService
) { ) {
this.routeQueryParams = route.queryParams this.routeQueryParams = route.queryParams
@ -68,6 +72,18 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
* Initializes the controller * Initializes the controller
*/ */
public ngOnInit() { public ngOnInit() {
this.dataService
.fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ globalPermissions }) => {
this.hasPermissionToImportOrders = hasPermission(
globalPermissions,
permissions.enableImport
);
this.changeDetectorRef.markForCheck();
});
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService this.impersonationStorageService
@ -145,6 +161,54 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
}); });
} }
public onImport() {
const input = document.createElement('input');
input.type = 'file';
input.onchange = (event) => {
// Getting the file reference
const file = (event.target as HTMLInputElement).files[0];
// Setting up the reader
const reader = new FileReader();
reader.readAsText(file, 'UTF-8');
reader.onload = (readerEvent) => {
try {
const content = JSON.parse(readerEvent.target.result as string);
this.snackBar.open('⏳ Importing data...');
this.dataService
.postImport({
orders: content.orders
})
.pipe(
catchError((error) => {
this.handleImportError(error);
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe({
next: () => {
this.fetchOrders();
this.snackBar.open('✅ Import has been completed', undefined, {
duration: 3000
});
}
});
} catch (error) {
this.handleImportError(error);
}
};
};
input.click();
}
public onUpdateTransaction(aTransaction: OrderModel) { public onUpdateTransaction(aTransaction: OrderModel) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { editDialog: true, transactionId: aTransaction.id } queryParams: { editDialog: true, transactionId: aTransaction.id }
@ -223,6 +287,11 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
a.click(); a.click();
} }
private handleImportError(aError: unknown) {
console.error(aError);
this.snackBar.open('❌ Oops, something went wrong...');
}
private openCreateTransactionDialog(aTransaction?: OrderModel): void { private openCreateTransactionDialog(aTransaction?: OrderModel): void {
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, { const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
data: { data: {

@ -5,10 +5,12 @@
<gf-transactions-table <gf-transactions-table
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToImportOrders]="hasPermissionToImportOrders"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteOrder" [showActions]="!hasImpersonationId && hasPermissionToDeleteOrder"
[transactions]="transactions" [transactions]="transactions"
(export)="onExport()" (export)="onExport()"
(import)="onImport()"
(transactionDeleted)="onDeleteTransaction($event)" (transactionDeleted)="onDeleteTransaction($event)"
(transactionToClone)="onCloneTransaction($event)" (transactionToClone)="onCloneTransaction($event)"
(transactionToUpdate)="onUpdateTransaction($event)" (transactionToUpdate)="onUpdateTransaction($event)"

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfTransactionsTableModule } from '@ghostfolio/client/components/transactions-table/transactions-table.module'; import { GfTransactionsTableModule } from '@ghostfolio/client/components/transactions-table/transactions-table.module';
@ -16,6 +17,7 @@ import { TransactionsPageComponent } from './transactions-page.component';
CreateOrUpdateTransactionDialogModule, CreateOrUpdateTransactionDialogModule,
GfTransactionsTableModule, GfTransactionsTableModule,
MatButtonModule, MatButtonModule,
MatSnackBarModule,
RouterModule, RouterModule,
TransactionsPageRoutingModule TransactionsPageRoutingModule
], ],

@ -2,6 +2,7 @@ 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 { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto'; import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { import {
@ -173,6 +174,10 @@ export class DataService {
return this.http.post<OrderModel>(`/api/account`, aAccount); return this.http.post<OrderModel>(`/api/account`, aAccount);
} }
public postImport(aImportData: ImportDataDto) {
return this.http.post<void>('/api/import', aImportData);
}
public postOrder(aOrder: CreateOrderDto) { public postOrder(aOrder: CreateOrderDto) {
return this.http.post<OrderModel>(`/api/order`, aOrder); return this.http.post<OrderModel>(`/api/order`, aOrder);
} }

@ -14,6 +14,7 @@ export const permissions = {
deleteAuthDevice: 'deleteAuthDevice', deleteAuthDevice: 'deleteAuthDevice',
deleteOrder: 'deleteOrder', deleteOrder: 'deleteOrder',
deleteUser: 'deleteUser', deleteUser: 'deleteUser',
enableImport: 'enableImport',
enableSocialLogin: 'enableSocialLogin', enableSocialLogin: 'enableSocialLogin',
enableStatistics: 'enableStatistics', enableStatistics: 'enableStatistics',
enableSubscription: 'enableSubscription', enableSubscription: 'enableSubscription',

Loading…
Cancel
Save