diff --git a/apps/api/src/helper/markdown-table.helper.ts b/apps/api/src/helper/markdown-table.helper.ts new file mode 100644 index 0000000000..3de23d5393 --- /dev/null +++ b/apps/api/src/helper/markdown-table.helper.ts @@ -0,0 +1,44 @@ +import type { ColumnDescriptor } from 'tablemark'; + +export interface TableColumnDefinition extends ColumnDescriptor { + getValue: (item: T) => string; + name: string; +} + +// Dynamic import to load ESM module from CommonJS context +// eslint-disable-next-line @typescript-eslint/no-implied-eval +const dynamicImport = new Function('s', 'return import(s)') as ( + s: string +) => Promise; + +/** + * Renders the rows as a markdown table with one column per definition, which + * gives the title of the column and reads the value of a row + */ +export async function getMarkdownTable({ + columnDefinitions, + rows +}: { + columnDefinitions: readonly TableColumnDefinition[]; + rows: readonly T[]; +}) { + const { tablemark } = await dynamicImport('tablemark'); + + return tablemark( + rows.map((row) => { + return columnDefinitions.reduce( + (tableRow, { getValue, name }) => { + tableRow[name] = getValue(row); + + return tableRow; + }, + {} as Record + ); + }), + { + columns: columnDefinitions.map(({ align, name }) => { + return { name, align: align ?? 'left' }; + }) + } + ); +} diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts index 6364cbd5b6..ae1d2b9cae 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts @@ -6,16 +6,42 @@ import { PortfolioTableService } from './portfolio-table.service'; /** * The markdown table is rendered by a package which ships as an ECMAScript - * module only, hence the service loads it with a dynamic import which Jest - * cannot run. The tests replace the method by a simple renderer, so that they - * can read the columns and the rows which the service gives to it. + * module only, which Jest cannot run. The mock renders the columns and the + * rows in a simple form, so that the tests can read them. */ -interface PortfolioTableServiceWithMarkdownTable { - getMarkdownTable(parameters: { - columnDefinitions: readonly { name: string }[]; - rows: Record[]; - }): Promise; -} +jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => { + return { + getMarkdownTable: jest.fn( + ({ + columnDefinitions, + rows + }: { + columnDefinitions: { + getValue: (row: unknown) => string; + name: string; + }[]; + rows: unknown[]; + }) => { + return Promise.resolve( + [ + columnDefinitions + .map(({ name }) => { + return name; + }) + .join(' | '), + ...rows.map((row) => { + return columnDefinitions + .map(({ getValue }) => { + return getValue(row); + }) + .join(' | '); + }) + ].join('\n') + ); + } + ) + }; +}); function createAccount({ id = 'account-a-id', @@ -44,35 +70,7 @@ function createPortfolioTableService(accounts: AccountWithValue[]) { getAccountsWithAggregations: jest.fn().mockResolvedValue({ accounts }) } as unknown as PortfolioService; - const portfolioTableService = new PortfolioTableService( - null, - null, - portfolioService - ); - - jest - .spyOn( - portfolioTableService as unknown as PortfolioTableServiceWithMarkdownTable, - 'getMarkdownTable' - ) - .mockImplementation(async ({ columnDefinitions, rows }) => { - const columnNames = columnDefinitions.map(({ name }) => { - return name; - }); - - return [ - columnNames.join(' | '), - ...rows.map((row) => { - return columnNames - .map((columnName) => { - return row[columnName]; - }) - .join(' | '); - }) - ].join('\n'); - }); - - return portfolioTableService; + return new PortfolioTableService(null, null, portfolioService); } describe('PortfolioTableService', () => { @@ -107,6 +105,21 @@ describe('PortfolioTableService', () => { }); }); + describe('getHoldingsTableColumnNames', () => { + it('gives no column with a monetary value', () => { + expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([ + 'Name', + 'Symbol', + 'Currency', + 'Asset Class', + 'Asset Sub Class', + 'Date of First Activity', + 'Activities Count', + 'Allocation in Percentage' + ]); + }); + }); + describe('getAccountsTable', () => { it('gives no cash balance and no value of an account', async () => { const portfolioTableService = createPortfolioTableService([ diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.ts index 639545019a..914b4f9911 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.ts @@ -1,8 +1,17 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { + getMarkdownTable, + TableColumnDefinition +} from '@ghostfolio/api/helper/markdown-table.helper'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; -import { Filter } from '@ghostfolio/common/interfaces'; +import { + Activity, + Filter, + PortfolioPosition +} from '@ghostfolio/common/interfaces'; +import { AccountWithValue } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { @@ -11,7 +20,19 @@ import { Type as ActivityType } from '@prisma/client'; import { format } from 'date-fns'; -import type { ColumnDescriptor } from 'tablemark'; + +/** + * A holding with the asset class and the asset sub class in the language of + * the caller, because the column of a table cannot translate a value itself + */ +type HoldingWithTranslations = PortfolioPosition & { + assetClassLabel: string; + assetSubClassLabel: string; +}; + +function getAllocationInPercentage(allocationInPercentage: number) { + return `${(allocationInPercentage * 100).toFixed(3)}%`; +} /** * Renders the accounts, the activities and the holdings of a portfolio as a @@ -20,72 +41,156 @@ import type { ColumnDescriptor } from 'tablemark'; */ @Injectable() export class PortfolioTableService { - private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: ({ - key: - | 'ACTIVITIES_COUNT' - | 'ALLOCATION_PERCENTAGE' - | 'CURRENCY' - | 'EXCLUDED_FROM_ANALYSIS' - | 'ID' - | 'NAME' - | 'PLATFORM'; - } & ColumnDescriptor)[] = [ - { key: 'ID', name: 'Id' }, - { key: 'NAME', name: 'Name' }, - { key: 'CURRENCY', name: 'Currency' }, - { key: 'PLATFORM', name: 'Platform' }, - { align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' }, - { - align: 'right', - key: 'ALLOCATION_PERCENTAGE', - name: 'Allocation in Percentage' - }, - { key: 'EXCLUDED_FROM_ANALYSIS', name: 'Excluded from Analysis' } - ]; - - private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: ({ - key: - | 'ACCOUNT' - | 'CURRENCY' - | 'DATE' - | 'NAME' - | 'SYMBOL' - | 'TYPE' - | 'UNIT_PRICE'; - } & ColumnDescriptor)[] = [ - { key: 'DATE', name: 'Date' }, - { key: 'TYPE', name: 'Type' }, - { key: 'NAME', name: 'Name' }, - { key: 'SYMBOL', name: 'Symbol' }, - { key: 'CURRENCY', name: 'Currency' }, - { align: 'right', key: 'UNIT_PRICE', name: 'Unit Price' }, - { key: 'ACCOUNT', name: 'Account' } - ]; - - private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({ - key: - | 'ACTIVITIES_COUNT' - | 'ALLOCATION_PERCENTAGE' - | 'ASSET_CLASS' - | 'ASSET_SUB_CLASS' - | 'CURRENCY' - | 'DATE_OF_FIRST_ACTIVITY' - | 'NAME' - | 'SYMBOL'; - } & ColumnDescriptor)[] = [ - { key: 'NAME', name: 'Name' }, - { key: 'SYMBOL', name: 'Symbol' }, - { key: 'CURRENCY', name: 'Currency' }, - { key: 'ASSET_CLASS', name: 'Asset Class' }, - { key: 'ASSET_SUB_CLASS', name: 'Asset Sub Class' }, - { key: 'DATE_OF_FIRST_ACTIVITY', name: 'Date of First Activity' }, - { align: 'right', key: 'ACTIVITIES_COUNT', name: 'Activities Count' }, - { - align: 'right', - key: 'ALLOCATION_PERCENTAGE', - name: 'Allocation in Percentage' - } - ]; + private static readonly ACCOUNTS_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition[] = + [ + { + getValue: ({ id }) => { + return id; + }, + name: 'Id' + }, + { + getValue: ({ name }) => { + return name ?? ''; + }, + name: 'Name' + }, + { + getValue: ({ currency }) => { + return currency ?? ''; + }, + name: 'Currency' + }, + { + getValue: ({ platform }) => { + return platform?.name ?? ''; + }, + name: 'Platform' + }, + { + align: 'right', + getValue: ({ activitiesCount }) => { + return activitiesCount.toString(); + }, + name: 'Activities Count' + }, + { + align: 'right', + getValue: ({ allocationInPercentage }) => { + return getAllocationInPercentage(allocationInPercentage); + }, + name: 'Allocation in Percentage' + }, + { + getValue: ({ tags }) => { + return isAccountExcluded({ tags }).toString(); + }, + name: 'Excluded from Analysis' + } + ]; + + private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition[] = + [ + { + getValue: ({ date }) => { + return format(date, DATE_FORMAT); + }, + name: 'Date' + }, + { + getValue: ({ type }) => { + return type; + }, + name: 'Type' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.name ?? ''; + }, + name: 'Name' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.symbol; + }, + name: 'Symbol' + }, + { + getValue: ({ assetProfile, currency }) => { + return currency ?? assetProfile.currency; + }, + name: 'Currency' + }, + { + align: 'right', + getValue: ({ unitPrice }) => { + return unitPrice.toString(); + }, + name: 'Unit Price' + }, + { + getValue: ({ account }) => { + return account?.name ?? ''; + }, + name: 'Account' + } + ]; + + private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition[] = + [ + { + getValue: ({ assetProfile }) => { + return assetProfile.name; + }, + name: 'Name' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.symbol; + }, + name: 'Symbol' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.currency; + }, + name: 'Currency' + }, + { + getValue: ({ assetClassLabel }) => { + return assetClassLabel; + }, + name: 'Asset Class' + }, + { + getValue: ({ assetSubClassLabel }) => { + return assetSubClassLabel; + }, + name: 'Asset Sub Class' + }, + { + getValue: ({ dateOfFirstActivity }) => { + return dateOfFirstActivity + ? format(dateOfFirstActivity, DATE_FORMAT) + : ''; + }, + name: 'Date of First Activity' + }, + { + align: 'right', + getValue: ({ activitiesCount }) => { + return activitiesCount.toString(); + }, + name: 'Activities Count' + }, + { + align: 'right', + getValue: ({ allocationInPercentage }) => { + return getAllocationInPercentage(allocationInPercentage); + }, + name: 'Allocation in Percentage' + } + ]; public constructor( private readonly activitiesService: ActivitiesService, @@ -131,67 +236,14 @@ export class PortfolioTableService { withExcludedAccounts: true }); - const accountsTableRows = accounts.map( - ({ - activitiesCount, - allocationInPercentage, - currency, - id, - name: label, - platform, - tags - }) => { - return PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS.reduce( - (row, { key, name }) => { - switch (key) { - case 'ACTIVITIES_COUNT': - row[name] = activitiesCount.toString(); - break; - - case 'ALLOCATION_PERCENTAGE': - row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`; - break; - - case 'CURRENCY': - row[name] = currency ?? ''; - break; - - case 'EXCLUDED_FROM_ANALYSIS': - row[name] = isAccountExcluded({ tags }).toString(); - break; - - case 'ID': - row[name] = id; - break; - - case 'NAME': - row[name] = label ?? ''; - break; - - case 'PLATFORM': - row[name] = platform?.name ?? ''; - break; - - default: - row[name] = ''; - break; - } - - return row; - }, - {} as Record - ); - } - ); - const accountsSection = ['## Accounts', '']; - if (accountsTableRows.length > 0) { + if (accounts.length > 0) { accountsSection.push( - await this.getMarkdownTable({ + await getMarkdownTable({ columnDefinitions: PortfolioTableService.ACCOUNTS_TABLE_COLUMN_DEFINITIONS, - rows: accountsTableRows + rows: accounts }) ); } else { @@ -235,51 +287,6 @@ export class PortfolioTableService { withExcludedAccountsAndActivities: true }); - const activitiesTableRows = activities.map( - ({ account, assetProfile, currency, date, type, unitPrice }) => { - return PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS.reduce( - (row, { key, name }) => { - switch (key) { - case 'ACCOUNT': - row[name] = account?.name ?? ''; - break; - - case 'CURRENCY': - row[name] = currency ?? assetProfile.currency; - break; - - case 'DATE': - row[name] = format(date, DATE_FORMAT); - break; - - case 'NAME': - row[name] = assetProfile.name ?? ''; - break; - - case 'SYMBOL': - row[name] = assetProfile.symbol; - break; - - case 'TYPE': - row[name] = type; - break; - - case 'UNIT_PRICE': - row[name] = unitPrice.toString(); - break; - - default: - row[name] = ''; - break; - } - - return row; - }, - {} as Record - ); - } - ); - const activitiesSection = [ '## Activities', '', @@ -290,13 +297,13 @@ export class PortfolioTableService { }) ]; - if (activitiesTableRows.length > 0) { + if (activities.length > 0) { activitiesSection.push( '', - await this.getMarkdownTable({ + await getMarkdownTable({ columnDefinitions: PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, - rows: activitiesTableRows + rows: activities }) ); } @@ -330,79 +337,27 @@ export class PortfolioTableService { values: Object.values(AssetSubClass) }); - const holdingsTableRows = [...holdings] + const holdingsWithTranslations = [...holdings] .sort((a, b) => { return b.allocationInPercentage - a.allocationInPercentage; }) - .map( - ({ - activitiesCount, - allocationInPercentage, - assetProfile: { - assetClass, - assetSubClass, - currency, - name: label, - symbol - }, - dateOfFirstActivity - }) => { - return PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.reduce( - (row, { key, name }) => { - switch (key) { - case 'ACTIVITIES_COUNT': - row[name] = activitiesCount.toString(); - break; - - case 'ALLOCATION_PERCENTAGE': - row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`; - break; - - case 'ASSET_CLASS': - row[name] = assetClassTranslations[assetClass] ?? ''; - break; - - case 'ASSET_SUB_CLASS': - row[name] = assetSubClassTranslations[assetSubClass] ?? ''; - break; - - case 'CURRENCY': - row[name] = currency; - break; - - case 'DATE_OF_FIRST_ACTIVITY': - row[name] = dateOfFirstActivity - ? format(dateOfFirstActivity, DATE_FORMAT) - : ''; - break; - - case 'NAME': - row[name] = label; - break; - - case 'SYMBOL': - row[name] = symbol; - break; - - default: - row[name] = ''; - break; - } - - return row; - }, - {} as Record - ); - } - ); + .map((holding) => { + return { + ...holding, + assetClassLabel: + assetClassTranslations[holding.assetProfile.assetClass] ?? '', + assetSubClassLabel: + assetSubClassTranslations[holding.assetProfile.assetSubClass] ?? '' + }; + }); return [ '## Holdings', '', - await this.getMarkdownTable({ + await getMarkdownTable({ columnDefinitions: PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, - rows: holdingsTableRows + rows: holdingsWithTranslations }) ].join('\n'); } @@ -463,25 +418,4 @@ export class PortfolioTableService { {} as Record ); } - - private async getMarkdownTable({ - columnDefinitions, - rows - }: { - columnDefinitions: readonly ColumnDescriptor[]; - rows: Record[]; - }) { - // Dynamic import to load ESM module from CommonJS context - // eslint-disable-next-line @typescript-eslint/no-implied-eval - const dynamicImport = new Function('s', 'return import(s)') as ( - s: string - ) => Promise; - const { tablemark } = await dynamicImport('tablemark'); - - return tablemark(rows, { - columns: columnDefinitions.map(({ align, name }) => { - return { name, align: align ?? 'left' }; - }) - }); - } }