Browse Source

Merge 045cb64c82 into c3596ea292

pull/8119/merge
Hrushikesh Ramilla 1 day ago
committed by GitHub
parent
commit
39fd416d22
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 216
      apps/api/src/app/import/import.service.spec.ts
  3. 37
      apps/api/src/app/import/import.service.ts

2
CHANGELOG.md

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Activity imports now detect identical rows within the same payload. Identical broker partial fills in the same second can also be flagged as duplicates.
- Moved the investment chart component to `@ghostfolio/ui` - Moved the investment chart component to `@ghostfolio/ui`
- Hardened the validation of the date in the endpoint to create an account balance - Hardened the validation of the date in the endpoint to create an account balance
- Upgraded `bootstrap` from version `4.6.2` to `5.3.8` - Upgraded `bootstrap` from version `4.6.2` to `5.3.8`
@ -20,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Scoped import duplicate detection to the same account, including unassigned activities, and aligned currency matching for saved and incoming activities
- Fixed an exception in the authentication interceptor when the time zone of the browser is not available - Fixed an exception in the authentication interceptor when the time zone of the browser is not available
## 3.86.0 - 2026-10-13 ## 3.86.0 - 2026-10-13

216
apps/api/src/app/import/import.service.spec.ts

@ -33,6 +33,8 @@ describe('ImportService', () => {
let addSymbolProfile: jest.Mock; let addSymbolProfile: jest.Mock;
let createActivity: jest.Mock; let createActivity: jest.Mock;
let gatherSymbols: jest.Mock; let gatherSymbols: jest.Mock;
let getAccounts: jest.Mock;
let getActivities: jest.Mock;
let importService: ImportService; let importService: ImportService;
let updateManyMarketData: jest.Mock; let updateManyMarketData: jest.Mock;
@ -46,6 +48,8 @@ describe('ImportService', () => {
addSymbolProfile = jest.fn(); addSymbolProfile = jest.fn();
createActivity = jest.fn(); createActivity = jest.fn();
gatherSymbols = jest.fn(); gatherSymbols = jest.fn();
getAccounts = jest.fn().mockResolvedValue([]);
getActivities = jest.fn().mockResolvedValue({ activities: [], count: 0 });
updateManyMarketData = jest.fn(); updateManyMarketData = jest.fn();
const validateActivities = jest.fn( const validateActivities = jest.fn(
@ -56,7 +60,7 @@ describe('ImportService', () => {
for (const { currency, dataSource, symbol } of activitiesDto) { for (const { currency, dataSource, symbol } of activitiesDto) {
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] ??= { assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] ??= {
currency, currency: currency ?? 'USD',
dataSource, dataSource,
symbol, symbol,
name: symbol name: symbol
@ -69,11 +73,11 @@ describe('ImportService', () => {
importService = new ImportService( importService = new ImportService(
{ {
accounts: jest.fn().mockResolvedValue([]) accounts: getAccounts
} as unknown as AccountService, } as unknown as AccountService,
{ {
createActivity, createActivity,
getActivities: jest.fn().mockResolvedValue({ activities: [], count: 0 }) getActivities
} as unknown as ActivitiesService, } as unknown as ActivitiesService,
null, null,
{ {
@ -508,6 +512,212 @@ describe('ImportService', () => {
{ dataSource: DataSource.YAHOO, symbol: 'AAPL' } { dataSource: DataSource.YAHOO, symbol: 'AAPL' }
]); ]);
}); });
it('detects duplicate activities within the same import payload', async () => {
const activities = await importActivities({
activitiesDto: [
createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL',
type: 'BUY'
}),
createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL',
type: 'BUY'
})
],
isDryRun: true
});
expect(activities[0].error).toBeUndefined();
expect(activities[1].error).toEqual({ code: 'IS_DUPLICATE' });
});
it('does not flag identical activities across different accounts as duplicates', async () => {
getAccounts.mockResolvedValue([
{ id: 'account-1', name: 'First account' },
{ id: 'account-2', name: 'Second account' }
]);
const activities = await importActivities({
activitiesDto: [
{
...createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL',
type: 'BUY'
}),
accountId: 'account-1'
},
{
...createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL',
type: 'BUY'
}),
accountId: 'account-2'
}
],
isDryRun: true
});
expect(activities[0].error).toBeUndefined();
expect(activities[1].error).toBeUndefined();
});
it.each([
{ accountId: 'account-1', isDuplicate: true },
{ accountId: 'account-2', isDuplicate: false },
{ accountId: undefined, isDuplicate: false }
])(
'checks saved activities against account $accountId',
async ({ accountId, isDuplicate }) => {
getAccounts.mockResolvedValue([
{ id: 'account-1', name: 'First account' },
{ id: 'account-2', name: 'Second account' }
]);
const dto = createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL',
type: 'BUY'
});
getActivities.mockResolvedValue({
count: 1,
activities: [
{
...dto,
accountId: 'account-1',
currency: null,
date: parseISO(dto.date),
assetProfile: {
currency: 'USD',
dataSource: dto.dataSource,
symbol: dto.symbol
}
}
]
});
const activities = await importActivities({
activitiesDto: [{ ...dto, accountId }],
isDryRun: true
});
expect(activities[0].error).toEqual(
isDuplicate ? { code: 'IS_DUPLICATE' } : undefined
);
expect(createActivity).not.toHaveBeenCalled();
}
);
it.each([
[undefined, 'account-1'],
['account-1', undefined]
])(
'keeps assigned and unassigned rows distinct in either order (%s, %s)',
async (firstAccountId, secondAccountId) => {
getAccounts.mockResolvedValue([
{ id: 'account-1', name: 'First account' }
]);
const dto = createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL',
type: 'BUY'
});
const activities = await importActivities({
activitiesDto: [
{ ...dto, accountId: firstAccountId },
{ ...dto, accountId: secondAccountId }
],
isDryRun: true
});
expect(activities.map(({ error }) => error)).toEqual([
undefined,
undefined
]);
}
);
it('treats null and omitted accounts as the same unassigned account', async () => {
const dto = createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL',
type: 'BUY'
});
const activities = await importActivities({
activitiesDto: [{ ...dto, accountId: null }, dto],
isDryRun: true
});
expect(activities[0].error).toBeUndefined();
expect(activities[1].error).toEqual({ code: 'IS_DUPLICATE' });
});
it.each([
[undefined, 'USD'],
['USD', undefined]
])(
'uses the asset currency consistently within a batch (%s, %s)',
async (firstCurrency, secondCurrency) => {
const dto = createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL',
type: 'BUY'
});
const activities = await importActivities({
activitiesDto: [
{ ...dto, currency: firstCurrency },
{ ...dto, currency: secondCurrency }
],
isDryRun: true
});
expect(activities[0].error).toBeUndefined();
expect(activities[1].error).toEqual({ code: 'IS_DUPLICATE' });
}
);
it('does not replace an explicit saved transaction currency with the asset currency', async () => {
const dto = createActivityDto({
dataSource: DataSource.YAHOO,
date: '2024-01-01T00:00:00.000Z',
symbol: 'AAPL',
type: 'BUY'
});
getActivities.mockResolvedValue({
count: 1,
activities: [
{
...dto,
accountId: null,
currency: 'EUR',
date: parseISO(dto.date),
assetProfile: {
currency: 'USD',
dataSource: dto.dataSource,
symbol: dto.symbol
}
}
]
});
const activities = await importActivities({
activitiesDto: [dto],
isDryRun: true
});
expect(activities[0].error).toBeUndefined();
});
}); });
function importActivities({ function importActivities({

37
apps/api/src/app/import/import.service.ts

@ -45,7 +45,7 @@ import {
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Account, DataSource, Prisma } from '@prisma/client'; import { Account, DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isISIN } from 'class-validator'; import { isISIN } from 'class-validator';
import { isSameSecond, parseISO, subDays } from 'date-fns'; import { isSameSecond, parseISO, subDays } from 'date-fns';
@ -766,6 +766,7 @@ export class ImportService {
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
activitiesDto, activitiesDto,
assetProfiles,
userCurrency, userCurrency,
userId: user.id userId: user.id
}); });
@ -1034,10 +1035,14 @@ export class ImportService {
private async extendActivitiesWithErrors({ private async extendActivitiesWithErrors({
activitiesDto, activitiesDto,
assetProfiles,
userCurrency, userCurrency,
userId userId
}: { }: {
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
};
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<(Partial<Activity> & Pick<Activity, 'assetProfile'>)[]> { }): Promise<(Partial<Activity> & Pick<Activity, 'assetProfile'>)[]> {
@ -1049,6 +1054,9 @@ export class ImportService {
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
const processedActivities: (Partial<Activity> &
Pick<Activity, 'assetProfile'>)[] = [];
return activitiesDto.map( return activitiesDto.map(
({ ({
accountId, accountId,
@ -1064,12 +1072,18 @@ export class ImportService {
unitPrice unitPrice
}) => { }) => {
const date = parseISO(dateString); const date = parseISO(dateString);
const assetCurrency =
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]
?.currency;
const isDuplicate = existingActivities.some((activity) => { const isMatchingActivity = (
activity: Partial<Activity> & Pick<Activity, 'assetProfile'>
) => {
return ( return (
(activity.accountId || null) === (accountId || null) &&
(activity.comment || null) === (comment || null) && (activity.comment || null) === (comment || null) &&
(activity.currency === currency || (activity.currency ?? activity.assetProfile.currency ?? null) ===
activity.assetProfile.currency === currency) && (currency ?? assetCurrency ?? null) &&
activity.assetProfile.dataSource === dataSource && activity.assetProfile.dataSource === dataSource &&
isSameSecond(activity.date, date) && isSameSecond(activity.date, date) &&
activity.fee === fee && activity.fee === fee &&
@ -1078,13 +1092,16 @@ export class ImportService {
activity.type === type && activity.type === type &&
activity.unitPrice === unitPrice activity.unitPrice === unitPrice
); );
}); };
const isDuplicate =
existingActivities.some(isMatchingActivity) ||
processedActivities.some(isMatchingActivity);
const error: ActivityError = isDuplicate const error: ActivityError = isDuplicate
? { code: 'IS_DUPLICATE' } ? { code: 'IS_DUPLICATE' }
: undefined; : undefined;
return { const activityExtended = {
accountId, accountId,
comment, comment,
currency, currency,
@ -1102,7 +1119,7 @@ export class ImportService {
assetSubClass: undefined, assetSubClass: undefined,
countries: undefined, countries: undefined,
createdAt: undefined, createdAt: undefined,
currency: undefined, currency: assetCurrency,
holdings: undefined, holdings: undefined,
id: undefined, id: undefined,
isActive: true, isActive: true,
@ -1111,6 +1128,12 @@ export class ImportService {
}, },
tagIds: tags tagIds: tags
}; };
if (!error) {
processedActivities.push(activityExtended);
}
return activityExtended;
} }
); );
} }

Loading…
Cancel
Save