Browse Source

Fixed the tag filter of the holdings so that it no longer affects the quantity and the active or closed status of a holding

pull/7838/head
Lyndros 18 hours ago
parent
commit
dd84b97458
  1. 6
      CHANGELOG.md
  2. 51
      apps/api/src/app/activities/activities.service.spec.ts
  3. 92
      apps/api/src/app/activities/activities.service.ts

6
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

51
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<Activity[]>;
},
'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({

92
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<Activity[]> {
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<Set<string>> {
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,

Loading…
Cancel
Save