Browse Source

Add quantity to accounts tab of holding detail dialog

Aggregate the quantity per account if the activities are filtered by a
single holding and expose it in the accounts table behind the new
showQuantity attribute. The value is redacted in impersonation mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pull/7595/head
Thomas Kaul 3 weeks ago
parent
commit
dd017d379f
  1. 4
      CHANGELOG.md
  2. 60
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  3. 32
      apps/api/src/app/portfolio/portfolio.service.ts
  4. 1
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  5. 1
      libs/common/src/lib/config.ts
  6. 2
      libs/common/src/lib/interfaces/portfolio-details.interface.ts
  7. 2
      libs/common/src/lib/types/account-with-value.type.ts
  8. 20
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  9. 5
      libs/ui/src/lib/accounts-table/accounts-table.component.ts

4
CHANGELOG.md

@ -7,6 +7,10 @@ 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
### Fixed
- Fixed the allocation in the accounts tab of the holding detail dialog caused by floating-point rounding

60
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<string, { valueInBaseCurrency: number }>;
accounts: Record<
string,
{ quantity?: number; valueInBaseCurrency: number }
>;
platforms: Record<string, { valueInBaseCurrency: number }>;
}>;
}
@ -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,56 @@ 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();
});
});
});

32
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;
@ -2274,23 +2283,33 @@ 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 +2320,7 @@ export class PortfolioService {
balance: 0,
currency: account?.currency,
name: account?.name,
quantity: quantityOfHolding,
valueInBaseCurrency: valueOfAccountInBaseCurrency.toNumber()
};
}

1
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]="true"
[showValue]="false"
[showValueInBaseCurrency]="false"
/>

1
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',

2
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;
};

2
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;

20
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -131,6 +131,26 @@
</td>
</ng-container>
<ng-container matColumnDef="quantity">
<th
*matHeaderCellDef
class="justify-content-end px-1"
mat-header-cell
mat-sort-header
>
<ng-container i18n>Quantity</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale()"
[value]="element.quantity"
/>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="balance">
<th
*matHeaderCellDef

5
libs/ui/src/lib/accounts-table/accounts-table.component.ts

@ -66,6 +66,7 @@ export class GfAccountsTableComponent {
public readonly showAllocationInPercentage = input<boolean>();
public readonly showBalance = input(true);
public readonly showFooter = input(true);
public readonly showQuantity = input<boolean>();
public readonly showValue = input(true);
public readonly showValueInBaseCurrency = input(true);
public readonly totalBalanceInBaseCurrency = input<number>();
@ -103,6 +104,10 @@ export class GfAccountsTableComponent {
columns.push('activitiesCount');
}
if (this.showQuantity()) {
columns.push('quantity');
}
if (this.showBalance()) {
columns.push('balance');
}

Loading…
Cancel
Save