diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9b8f191..85511a20f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Fixed + +- Fixed the tag filter of the holdings so that it no longer affects the quantity and the active or closed status of a holding + ## 3.68.0 - 2026-09-06 ### Added diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts index 72d195029..ca2c85a63 100644 --- a/apps/api/src/app/activities/activities.service.spec.ts +++ b/apps/api/src/app/activities/activities.service.spec.ts @@ -236,6 +236,57 @@ describe('ActivitiesService', () => { }); }); + it('resolves a tag filter to the full activity history of matching holdings', async () => { + const filters = [{ id: 'tag-1', type: 'TAG' }] as Filter[]; + const matchingBuyActivity = createActivity({ symbol: 'AAPL' }); + const matchingSellActivity = createActivity({ symbol: 'AAPL' }); + const nonMatchingActivity = createActivity({ symbol: 'MSFT' }); + const fetchedActivities = [ + matchingBuyActivity, + matchingSellActivity, + nonMatchingActivity + ]; + + jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ + activities: fetchedActivities, + count: 3 + }); + const keepActivitiesOfHoldingsMatchingTag = jest + .spyOn( + activitiesService as unknown as { + keepActivitiesOfHoldingsMatchingTag: (options: { + activities: Activity[]; + }) => Promise; + }, + 'keepActivitiesOfHoldingsMatchingTag' + ) + .mockResolvedValue([matchingBuyActivity, matchingSellActivity]); + + const result = + await activitiesService.getActivitiesForPortfolioCalculator({ + filters, + userCurrency: 'USD', + userId: 'user-id' + }); + + expect(activitiesService.getActivities).toHaveBeenCalledWith({ + filters: [], + userCurrency: 'USD', + userId: 'user-id', + withExcludedAccountsAndActivities: false + }); + expect(keepActivitiesOfHoldingsMatchingTag).toHaveBeenCalledWith({ + activities: fetchedActivities, + filters, + userId: 'user-id', + withExcludedAccountsAndActivities: false + }); + expect(result.activities).toEqual([ + matchingBuyActivity, + matchingSellActivity + ]); + }); + it('does not adjust synthetic cash activities', async () => { const activity = createActivity({ symbol: 'AAPL' }); const cashActivity = createActivity({ diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 7f6cbc8b0..d95cf006b 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -753,6 +753,11 @@ export class ActivitiesService { /** * Retrieves all activities required for the portfolio calculator, including both standard asset activities * and optional synthetic activities representing cash activities. + * + * A tag filter is resolved to holding scope: a holding qualifies if any of its activities (or its account) + * matches the tag, but all of its activities are included in the result. This ensures that the quantity and + * the status (active or closed) of a holding are always derived from its complete activity history and are + * not affected by the tag of individual activities. */ @LogPerformance public async getActivitiesForPortfolioCalculator({ @@ -773,9 +778,17 @@ export class ActivitiesService { /** Whether to include activities that are excluded from analysis. */ withExcludedAccountsAndActivities?: boolean; }) { + const hasTagFilter = filters?.some(({ type }) => { + return type === 'TAG'; + }); + + const filtersWithoutTag = filters?.filter(({ type }) => { + return type !== 'TAG'; + }); + const [activities, splits] = await Promise.all([ this.getActivities({ - filters, + filters: filtersWithoutTag, userCurrency, userId, withExcludedAccountsAndActivities @@ -783,6 +796,16 @@ export class ActivitiesService { this.assetProfileSplitService.getSplitsByUserId({ userId }) ]); + if (hasTagFilter) { + activities.activities = await this.keepActivitiesOfHoldingsMatchingTag({ + activities: activities.activities, + filters, + userId, + withExcludedAccountsAndActivities + }); + activities.count = activities.activities.length; + } + if (splits.length > 0) { const splitsBySymbolProfileId = groupBy(splits, 'symbolProfileId'); @@ -968,6 +991,73 @@ export class ActivitiesService { return activity; } + /** + * Keeps only the activities that belong to a holding matching at least one tag filter. A holding + * matches if any of its activities or its account has the tag; once it matches, every one of its + * activities is kept (not just the tagged ones). This way, a holding's quantity and status (active + * or closed) always reflect its complete activity history instead of being fragmented by the tag + * of an individual activity. + */ + private async keepActivitiesOfHoldingsMatchingTag({ + activities, + filters, + userId, + withExcludedAccountsAndActivities + }: { + activities: Activity[]; + filters: Filter[]; + userId: string; + withExcludedAccountsAndActivities: boolean; + }): Promise { + const matchingAssetProfileIdentifiers = + await this.getAssetProfileIdentifiersMatching({ + filters, + userId, + withExcludedAccountsAndActivities + }); + + return activities.filter((activity) => { + return matchingAssetProfileIdentifiers.has( + getAssetProfileIdentifier(activity.assetProfile) + ); + }); + } + + /** + * Returns the identifiers of the asset profiles that have at least one activity or account + * matching the given filters. + */ + private async getAssetProfileIdentifiersMatching({ + filters, + userId, + withExcludedAccountsAndActivities + }: { + filters: Filter[]; + userId: string; + withExcludedAccountsAndActivities: boolean; + }): Promise> { + const where = this.getWhereClause({ + filters, + userId, + withExcludedAccountsAndActivities, + includeDrafts: false + }); + + const distinctSymbolProfiles = await this.prismaService.order.findMany({ + distinct: ['symbolProfileId'], + select: { + SymbolProfile: { select: { dataSource: true, symbol: true } } + }, + where + }); + + return new Set( + distinctSymbolProfiles.map(({ SymbolProfile }) => { + return getAssetProfileIdentifier(SymbolProfile); + }) + ); + } + private getWhereClause({ endDate, filters,