import { HttpClient } from '@angular/common/http'; 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 { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Account, DataSource, Type } from '@prisma/client'; import { isMatch, parse, parseISO } from 'date-fns'; import { isFinite } from 'lodash'; import { parse as csvToJson } from 'papaparse'; import { EMPTY } from 'rxjs'; import { catchError } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class ImportActivitiesService { private static ACCOUNT_KEYS = ['account', 'accountid']; private static CURRENCY_KEYS = ['ccy', 'currency', 'currencyprimary']; private static DATA_SOURCE_KEYS = ['datasource']; private static DATE_KEYS = ['date', 'tradedate']; private static FEE_KEYS = ['commission', 'fee', 'ibcommission']; private static QUANTITY_KEYS = ['qty', 'quantity', 'shares', 'units']; private static SYMBOL_KEYS = ['code', 'symbol', 'ticker']; private static TYPE_KEYS = ['action', 'buy/sell', 'type']; private static UNIT_PRICE_KEYS = [ 'price', 'tradeprice', 'unitprice', 'value' ]; public constructor(private http: HttpClient) {} public async importCsv({ fileContent, isDryRun = false, userAccounts }: { fileContent: string; isDryRun?: boolean; userAccounts: Account[]; }): Promise<{ activities: Activity[]; }> { const content = csvToJson(fileContent, { dynamicTyping: true, header: true, skipEmptyLines: true }).data; const activities: CreateOrderDto[] = []; for (const [index, item] of content.entries()) { activities.push({ accountId: this.parseAccount({ item, userAccounts }), currency: this.parseCurrency({ content, index, item }), dataSource: this.parseDataSource({ item }), date: this.parseDate({ content, index, item }), fee: this.parseFee({ content, index, item }), quantity: this.parseQuantity({ content, index, item }), symbol: this.parseSymbol({ content, index, item }), type: this.parseType({ content, index, item }), unitPrice: this.parseUnitPrice({ content, index, item }), updateAccountBalance: false }); } return await this.importJson({ activities, isDryRun }); } public importJson({ accounts, activities, isDryRun = false }: { activities: CreateOrderDto[]; accounts?: CreateAccountDto[]; isDryRun?: boolean; }): Promise<{ activities: Activity[]; accounts?: CreateAccountDto[]; }> { return new Promise((resolve, reject) => { this.postImport( { accounts, activities }, isDryRun ) .pipe( catchError((error) => { reject(error); return EMPTY; }) ) .subscribe({ next: (data) => { resolve(data); } }); }); } public importSelectedActivities({ accounts, activities }: { accounts: CreateAccountDto[]; activities: Activity[]; }): Promise<{ activities: Activity[]; accounts?: CreateAccountDto[]; }> { const importData: CreateOrderDto[] = []; for (const activity of activities) { importData.push(this.convertToCreateOrderDto(activity)); } return this.importJson({ accounts, activities: importData }); } private convertToCreateOrderDto({ accountId, date, fee, quantity, SymbolProfile, type, unitPrice, updateAccountBalance }: Activity): CreateOrderDto { return { accountId, fee, quantity, type, unitPrice, updateAccountBalance, currency: SymbolProfile.currency, dataSource: SymbolProfile.dataSource, date: date.toString(), symbol: SymbolProfile.symbol }; } private lowercaseKeys(aObject: any) { return Object.keys(aObject).reduce((acc, key) => { acc[key.toLowerCase()] = aObject[key]; return acc; }, {}); } private parseAccount({ item, userAccounts }: { item: any; userAccounts: Account[]; }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.ACCOUNT_KEYS) { if (item[key]) { return userAccounts.find((account) => { return ( account.id === item[key] || account.name.toLowerCase() === item[key].toLowerCase() ); })?.id; } } return undefined; } private parseCurrency({ content, index, item }: { content: any[]; index: number; item: any; }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.CURRENCY_KEYS) { if (item[key]) { return item[key]; } } throw { activities: content, message: `activities.${index}.currency is not valid` }; } private parseDataSource({ item }: { item: any }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.DATA_SOURCE_KEYS) { if (item[key]) { return DataSource[item[key].toUpperCase()]; } } return undefined; } private parseDate({ content, index, item }: { content: any[]; index: number; item: any; }) { item = this.lowercaseKeys(item); let date: string; for (const key of ImportActivitiesService.DATE_KEYS) { if (item[key]) { if (isMatch(item[key], 'dd-MM-yyyy') && item[key].length === 10) { // Check length to only match yyyy (and not yy) date = parse(item[key], 'dd-MM-yyyy', new Date()).toISOString(); } else if ( isMatch(item[key], 'dd/MM/yyyy') && item[key].length === 10 ) { // Check length to only match yyyy (and not yy) date = parse(item[key], 'dd/MM/yyyy', new Date()).toISOString(); } else if (isMatch(item[key], 'yyyyMMdd') && item[key].length === 8) { // Check length to only match yyyy (and not yy) date = parse(item[key], 'yyyyMMdd', new Date()).toISOString(); } else { try { date = parseISO(item[key]).toISOString(); } catch {} } if (date) { return date; } } } throw { activities: content, message: `activities.${index}.date is not valid` }; } private parseFee({ content, index, item }: { content: any[]; index: number; item: any; }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.FEE_KEYS) { if (isFinite(item[key])) { return Math.abs(item[key]); } } throw { activities: content, message: `activities.${index}.fee is not valid` }; } private parseQuantity({ content, index, item }: { content: any[]; index: number; item: any; }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.QUANTITY_KEYS) { if (isFinite(item[key])) { return item[key]; } } throw { activities: content, message: `activities.${index}.quantity is not valid` }; } private parseSymbol({ content, index, item }: { content: any[]; index: number; item: any; }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.SYMBOL_KEYS) { if (item[key]) { return item[key]; } } throw { activities: content, message: `activities.${index}.symbol is not valid` }; } private parseType({ content, index, item }: { content: any[]; index: number; item: any; }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.TYPE_KEYS) { if (item[key]) { switch (item[key].toLowerCase()) { case 'buy': return Type.BUY; case 'dividend': return Type.DIVIDEND; case 'item': return Type.ITEM; case 'sell': return Type.SELL; default: break; } } } throw { activities: content, message: `activities.${index}.type is not valid` }; } private parseUnitPrice({ content, index, item }: { content: any[]; index: number; item: any; }) { item = this.lowercaseKeys(item); for (const key of ImportActivitiesService.UNIT_PRICE_KEYS) { if (isFinite(item[key])) { return item[key]; } } throw { activities: content, message: `activities.${index}.unitPrice is not valid` }; } private postImport( aImportData: { accounts: CreateAccountDto[]; activities: CreateOrderDto[] }, aIsDryRun = false ) { return this.http.post<{ activities: Activity[] }>( `/api/v1/import?dryRun=${aIsDryRun}`, aImportData ); } }