From 55939ea2e5f3612f9927d08eadeec29336471026 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:11:44 +0200 Subject: [PATCH] Feature/add quantity to accounts tab of holding detail dialog (#7595) * Add quantity to accounts tab of holding detail dialog * Update changelog --- CHANGELOG.md | 5 + .../app/portfolio/portfolio.service.spec.ts | 122 +++++++++++++++++- .../src/app/portfolio/portfolio.service.ts | 77 +++++++---- .../holding-detail-dialog.html | 1 + libs/common/src/lib/config.ts | 1 + .../interfaces/portfolio-details.interface.ts | 2 + .../src/lib/types/account-with-value.type.ts | 2 + .../accounts-table.component.html | 20 +++ .../accounts-table.component.ts | 9 +- 9 files changed, 209 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d98e93424..104ff9b3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added the quantity to the accounts tab of the holding detail dialog (experimental) + ### Fixed - Fixed the allocation in the accounts tab of the holding detail dialog caused by floating-point rounding +- Fixed the allocation in the accounts tab of the holding detail dialog by excluding the cash balance of the account - Fixed the account aggregations in impersonation mode to be based on the impersonated user - Fixed the base currency of the activities in impersonation mode to be based on the impersonated user - Fixed the base currency of the dividends in impersonation mode to be based on the impersonated user diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index c5e0a8332..b503b18e9 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -426,7 +426,10 @@ describe('PortfolioService', () => { return ( portfolioService as unknown as { getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{ - accounts: Record; + accounts: Record< + string, + { quantity?: number; valueInBaseCurrency: number } + >; platforms: Record; }>; } @@ -443,6 +446,10 @@ describe('PortfolioService', () => { }; beforeEach(() => { + jest + .spyOn(accountService, 'accounts') + .mockResolvedValue([account] as unknown as AccountWithBalance[]); + jest .spyOn(accountService, 'getAccounts') .mockResolvedValue([account] as unknown as AccountWithBalance[]); @@ -548,5 +555,118 @@ describe('PortfolioService', () => { expect(accounts[account.id].valueInBaseCurrency).toBe(100); expect(platforms[account.platformId].valueInBaseCurrency).toBe(100); }); + + it('should aggregate the quantity per account if the activities are filtered by a single holding', async () => { + const { accounts } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 0.1, + type: 'BUY' + }, + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 0.2, + type: 'BUY' + } + ], + filters: [{ id: 'AAPL', type: 'SYMBOL' }], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + expect(accounts[account.id].quantity).toBe(0.3); + }); + + it('should not expose a quantity if the activities are not filtered by a single holding', async () => { + const { accounts } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 1, + type: 'BUY' + } + ], + filters: [], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + expect(accounts[account.id].quantity).toBeUndefined(); + }); + + it('should only consider accounts of the current user if the activities are filtered by a single account', async () => { + const accountsSpy = jest.spyOn(accountService, 'accounts'); + + await getValueOfAccountsAndPlatforms({ + activities: [], + filters: [{ id: account.id, type: 'ACCOUNT' }], + portfolioItemsNow: {}, + userCurrency: 'USD', + userId: userDummyData.id + }); + + expect(accountsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: userDummyData.id, id: account.id } + }) + ); + }); + + it('should exclude the cash balance if the activities are filtered by a single holding', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 1, + type: 'BUY' + } + ], + filters: [{ id: 'AAPL', type: 'SYMBOL' }], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 1 * 10 (activity), without the balance of 100 + expect(accounts[account.id].valueInBaseCurrency).toBe(10); + expect(platforms[account.platformId].valueInBaseCurrency).toBe(10); + }); + + it('should not accumulate rounding errors of the balances of accounts sharing a platform', async () => { + const platformId = randomUUID(); + + jest.spyOn(accountService, 'getAccounts').mockResolvedValue([ + { ...account, platformId, balance: 0.1, id: randomUUID() }, + { ...account, platformId, balance: 0.2, id: randomUUID() } + ] as unknown as AccountWithBalance[]); + + const { platforms } = await getValueOfAccountsAndPlatforms({ + activities: [], + filters: [], + portfolioItemsNow: {}, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 0.1 (balance) + 0.2 (balance) + expect(platforms[platformId].valueInBaseCurrency).toBe(0.3); + }); }); }); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 2c7701784..25e79ceff 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -249,6 +249,10 @@ export class PortfolioService { } } + const quantityOfHolding = filterBySymbol + ? (details.accounts[account.id]?.quantity ?? 0) + : undefined; + const valueInBaseCurrency = details.accounts[account.id]?.valueInBaseCurrency ?? 0; @@ -264,6 +268,7 @@ export class PortfolioService { account.currency, userCurrency ), + quantity: quantityOfHolding, value: this.exchangeRateDataService.toCurrency( valueInBaseCurrency, userCurrency, @@ -2194,6 +2199,10 @@ export class PortfolioService { const accounts: PortfolioDetails['accounts'] = {}; const platforms: PortfolioDetails['platforms'] = {}; + const { SYMBOL: [filterBySymbol] = [] } = groupBy(filters, ({ type }) => { + return type; + }); + let currentAccounts: (AccountWithBalance & { Order?: Order[]; platform?: Platform; @@ -2205,7 +2214,7 @@ export class PortfolioService { } else if (filters.length === 1 && filters[0].type === 'ACCOUNT') { currentAccounts = await this.accountService.accounts({ include: { platform: true, tags: true }, - where: { id: filters[0].id } + where: { userId, id: filters[0].id } }); } else { const accountIds = Array.from( @@ -2233,39 +2242,43 @@ export class PortfolioService { // Iterate over the accounts plus a null entry to group activities without // an account into the unknown bucket for (const account of [...currentAccounts, null]) { + const currentAccountId = account?.id || UNKNOWN_KEY; + const currentPlatformId = account?.platformId || UNKNOWN_KEY; + const ordersByAccount = activities.filter(({ accountId }) => { return account ? accountId === account.id : !accountId; }); if (account) { - accounts[account.id] = { + // The cash balance is not part of a holding and would distort the value + // and thus the allocation per account and platform + const balanceInBaseCurrency = filterBySymbol + ? 0 + : this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ); + + accounts[currentAccountId] = { balance: account.balance, currency: account.currency, name: account.name, - valueInBaseCurrency: this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ) + valueInBaseCurrency: balanceInBaseCurrency }; - if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) { - platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency += - this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ); + if (platforms[currentPlatformId]) { + platforms[currentPlatformId].valueInBaseCurrency = new Big( + platforms[currentPlatformId].valueInBaseCurrency + ) + .plus(balanceInBaseCurrency) + .toNumber(); } else { - platforms[account.platformId || UNKNOWN_KEY] = { + platforms[currentPlatformId] = { balance: account.balance, currency: account.currency, name: account.platform?.name, - valueInBaseCurrency: this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ) + valueInBaseCurrency: balanceInBaseCurrency }; } } @@ -2274,23 +2287,30 @@ export class PortfolioService { continue; } + let quantityOfAccount = new Big(0); let valueOfAccountInBaseCurrency = new Big(0); for (const { assetProfile, quantity, type } of ordersByAccount) { + const currentQuantityOfSymbol = new Big(quantity).mul(getFactor(type)); + + quantityOfAccount = quantityOfAccount.plus(currentQuantityOfSymbol); + valueOfAccountInBaseCurrency = valueOfAccountInBaseCurrency.plus( - new Big(quantity) - .mul(getFactor(type)) - .mul( - portfolioItemsNow[assetProfile.symbol] - ?.marketPriceInBaseCurrency ?? 0 - ) + currentQuantityOfSymbol.mul( + portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ?? + 0 + ) ); } - const currentAccountId = account?.id || UNKNOWN_KEY; - const currentPlatformId = account?.platformId || UNKNOWN_KEY; + // The quantity is only meaningful if the activities are filtered by a + // single holding + const quantityOfHolding = filterBySymbol + ? quantityOfAccount.toNumber() + : undefined; if (accounts[currentAccountId]) { + accounts[currentAccountId].quantity = quantityOfHolding; accounts[currentAccountId].valueInBaseCurrency = new Big( accounts[currentAccountId].valueInBaseCurrency ) @@ -2301,6 +2321,7 @@ export class PortfolioService { balance: 0, currency: account?.currency, name: account?.name, + quantity: quantityOfHolding, valueInBaseCurrency: valueOfAccountInBaseCurrency.toNumber() }; } diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index 8292ff598..c9528a34d 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -418,6 +418,7 @@ [showAllocationInPercentage]="user?.settings?.isExperimentalFeatures" [showBalance]="false" [showFooter]="false" + [showQuantity]="user?.settings?.isExperimentalFeatures" [showValue]="false" [showValueInBaseCurrency]="false" /> diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 761f7c3ed..265e27690 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -113,6 +113,7 @@ export const DEFAULT_REDACTED_PATHS = [ 'accounts[*].comment', 'accounts[*].dividendInBaseCurrency', 'accounts[*].interestInBaseCurrency', + 'accounts[*].quantity', 'accounts[*].value', 'accounts[*].valueInBaseCurrency', 'activities[*].account.comment', diff --git a/libs/common/src/lib/interfaces/portfolio-details.interface.ts b/libs/common/src/lib/interfaces/portfolio-details.interface.ts index 746736f6b..15a07f671 100644 --- a/libs/common/src/lib/interfaces/portfolio-details.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-details.interface.ts @@ -10,6 +10,8 @@ export interface PortfolioDetails { balance: number; currency: string; name: string; + /** Only set if the activities are filtered by a single holding */ + quantity?: number; valueInBaseCurrency: number; valueInPercentage?: number; }; diff --git a/libs/common/src/lib/types/account-with-value.type.ts b/libs/common/src/lib/types/account-with-value.type.ts index 27b7541dc..0d43c612f 100644 --- a/libs/common/src/lib/types/account-with-value.type.ts +++ b/libs/common/src/lib/types/account-with-value.type.ts @@ -9,6 +9,8 @@ export type AccountWithValue = AccountWithBalance & { dividendInBaseCurrency: number; interestInBaseCurrency: number; platform?: Platform; + /** Only set if the accounts are filtered by a single holding */ + quantity?: number; tags?: Tag[]; value: number; valueInBaseCurrency: number; diff --git a/libs/ui/src/lib/accounts-table/accounts-table.component.html b/libs/ui/src/lib/accounts-table/accounts-table.component.html index b6e0d846c..0b4bb72ce 100644 --- a/libs/ui/src/lib/accounts-table/accounts-table.component.html +++ b/libs/ui/src/lib/accounts-table/accounts-table.component.html @@ -131,6 +131,26 @@ + + + Quantity + + + + + + + (); public readonly showBalance = input(true); public readonly showFooter = input(true); + public readonly showQuantity = input(); public readonly showValue = input(true); public readonly showValueInBaseCurrency = input(true); public readonly totalBalanceInBaseCurrency = input(); @@ -103,6 +104,10 @@ export class GfAccountsTableComponent { columns.push('activitiesCount'); } + if (this.showQuantity()) { + columns.push('quantity'); + } + if (this.showBalance()) { columns.push('balance'); } @@ -111,7 +116,9 @@ export class GfAccountsTableComponent { columns.push('value'); } - columns.push('currency'); + if (this.showBalance() || this.showValue()) { + columns.push('currency'); + } if (this.showValueInBaseCurrency()) { columns.push('valueInBaseCurrency');