mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
193 changed files with 20253 additions and 15259 deletions
@ -0,0 +1,337 @@ |
|||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
assetProfileDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; |
||||
|
import { |
||||
|
INVESTMENT_ACTIVITY_TYPES, |
||||
|
NON_INVESTMENT_ACTIVITY_TYPES |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
import { Activity, Filter } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { AssetProfileSplit, DataSource } from '@prisma/client'; |
||||
|
import { Big } from 'big.js'; |
||||
|
|
||||
|
import { ActivitiesService } from './activities.service'; |
||||
|
|
||||
|
describe('ActivitiesService', () => { |
||||
|
let activitiesService: ActivitiesService; |
||||
|
let getSplitsByUserId: jest.Mock; |
||||
|
let accountService: { getCashDetails: jest.Mock }; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
getSplitsByUserId = jest.fn().mockResolvedValue([]); |
||||
|
accountService = { getCashDetails: jest.fn() }; |
||||
|
|
||||
|
activitiesService = new ActivitiesService( |
||||
|
null, |
||||
|
accountService as unknown as AccountService, |
||||
|
{ getSplitsByUserId } as unknown as AssetProfileSplitService, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('getActivitiesForPortfolioCalculator', () => { |
||||
|
it('leaves an activity unchanged when no splits exist', async () => { |
||||
|
const activity = createActivity({ symbol: 'AAPL' }); |
||||
|
|
||||
|
const result = await getAdjustedActivity(activity, []); |
||||
|
|
||||
|
expect(result).toEqual(activity); |
||||
|
expect(result).toBe(activity); |
||||
|
}); |
||||
|
|
||||
|
it.each([ |
||||
|
{ denominator: 1, expectedPrice: 50, expectedQuantity: 20, numerator: 2 }, |
||||
|
{ |
||||
|
denominator: 10, |
||||
|
expectedPrice: 1000, |
||||
|
expectedQuantity: 1, |
||||
|
numerator: 1 |
||||
|
} |
||||
|
])( |
||||
|
'applies a $numerator:$denominator split to quantity and price', |
||||
|
async ({ denominator, expectedPrice, expectedQuantity, numerator }) => { |
||||
|
const result = await getAdjustedActivity( |
||||
|
createActivity({ symbol: 'AAPL' }), |
||||
|
[createSplit('2021-01-01', numerator, denominator)] |
||||
|
); |
||||
|
|
||||
|
expect(result.quantity).toBe(expectedQuantity); |
||||
|
expect(result.unitPrice).toBe(expectedPrice); |
||||
|
expect(result.unitPriceInAssetProfileCurrency).toBe(expectedPrice); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it('adjusts only activities before the split calendar date', async () => { |
||||
|
const split = createSplit('2021-01-01', 2, 1); |
||||
|
const activityBeforeSplit = createActivity({ |
||||
|
date: '2020-12-31T23:00:00.000Z', |
||||
|
symbol: 'AAPL' |
||||
|
}); |
||||
|
const activityOnSplitDate = createActivity({ |
||||
|
date: '2021-01-01T23:00:00.000Z', |
||||
|
symbol: 'AAPL' |
||||
|
}); |
||||
|
const activityAfterSplit = createActivity({ |
||||
|
date: '2021-01-02T00:00:00.000Z', |
||||
|
symbol: 'AAPL' |
||||
|
}); |
||||
|
|
||||
|
expect( |
||||
|
(await getAdjustedActivity(activityBeforeSplit, [split])).quantity |
||||
|
).toBe(20); |
||||
|
expect( |
||||
|
(await getAdjustedActivity(activityOnSplitDate, [split])).quantity |
||||
|
).toBe(10); |
||||
|
expect( |
||||
|
(await getAdjustedActivity(activityAfterSplit, [split])).quantity |
||||
|
).toBe(10); |
||||
|
}); |
||||
|
|
||||
|
it('applies multiple splits cumulatively with exact ratio arithmetic', async () => { |
||||
|
const result = await getAdjustedActivity( |
||||
|
createActivity({ symbol: 'AAPL' }), |
||||
|
[createSplit('2021-01-01', 2, 1), createSplit('2022-01-01', 1, 3)] |
||||
|
); |
||||
|
|
||||
|
expect(new Big(result.quantity).toFixed(15)).toBe( |
||||
|
new Big(20).div(3).toFixed(15) |
||||
|
); |
||||
|
expect(result.unitPrice).toBe(150); |
||||
|
}); |
||||
|
|
||||
|
it('preserves fees and total activity value', async () => { |
||||
|
const activity = createActivity({ symbol: 'AAPL' }); |
||||
|
activity.feeInAssetProfileCurrency = 12; |
||||
|
activity.feeInBaseCurrency = 15; |
||||
|
|
||||
|
const result = await getAdjustedActivity(activity, [ |
||||
|
createSplit('2021-01-01', 2, 1) |
||||
|
]); |
||||
|
|
||||
|
expect(result).toMatchObject({ |
||||
|
feeInAssetProfileCurrency: 12, |
||||
|
feeInBaseCurrency: 15, |
||||
|
value: 1000, |
||||
|
valueInBaseCurrency: 1000 |
||||
|
}); |
||||
|
expect(result.quantity * result.unitPrice).toBe(1000); |
||||
|
}); |
||||
|
|
||||
|
it('does not apply splits from another symbol or data source', async () => { |
||||
|
const activity = createActivity({ symbol: 'AAPL' }); |
||||
|
const split = createSplit('2021-01-01', 2, 1); |
||||
|
|
||||
|
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ |
||||
|
activities: [activity], |
||||
|
count: 1 |
||||
|
}); |
||||
|
getSplitsByUserId.mockResolvedValue([ |
||||
|
{ ...split, symbolProfileId: 'YAHOO-MSFT-profile' }, |
||||
|
{ ...split, symbolProfileId: 'MANUAL-AAPL-profile' } |
||||
|
]); |
||||
|
|
||||
|
const result = |
||||
|
await activitiesService.getActivitiesForPortfolioCalculator({ |
||||
|
userCurrency: 'USD', |
||||
|
userId: 'user-id' |
||||
|
}); |
||||
|
|
||||
|
expect(result.activities[0].quantity).toBe(10); |
||||
|
expect(result.activities[0].unitPrice).toBe(100); |
||||
|
}); |
||||
|
|
||||
|
it.each(INVESTMENT_ACTIVITY_TYPES)( |
||||
|
'adjusts %s activities', |
||||
|
async (type) => { |
||||
|
const activity = createActivity({ symbol: 'AAPL' }); |
||||
|
activity.type = type as Activity['type']; |
||||
|
|
||||
|
const result = await getAdjustedActivity(activity, [ |
||||
|
createSplit('2021-01-01', 2, 1) |
||||
|
]); |
||||
|
|
||||
|
expect(result.quantity).toBe(20); |
||||
|
expect(result.unitPrice).toBe(50); |
||||
|
expect(result.quantity * result.unitPrice).toBe(1000); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it.each(NON_INVESTMENT_ACTIVITY_TYPES)( |
||||
|
'leaves %s activities unchanged', |
||||
|
async (type) => { |
||||
|
const activity = createActivity({ symbol: 'AAPL' }); |
||||
|
activity.type = type as Activity['type']; |
||||
|
|
||||
|
const result = await getAdjustedActivity(activity, [ |
||||
|
createSplit('2021-01-01', 2, 1) |
||||
|
]); |
||||
|
|
||||
|
expect(result).toBe(activity); |
||||
|
expect(result.quantity).toBe(10); |
||||
|
expect(result.unitPrice).toBe(100); |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
it('loads and applies splits to standard activities while preserving filters', async () => { |
||||
|
const activity = createActivity({ symbol: 'AAPL' }); |
||||
|
const filters = [{ id: 'AAPL', type: 'SYMBOL' }] as Filter[]; |
||||
|
const split = createSplit(); |
||||
|
|
||||
|
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ |
||||
|
activities: [activity], |
||||
|
count: 1 |
||||
|
}); |
||||
|
getSplitsByUserId.mockResolvedValue([split]); |
||||
|
|
||||
|
const result = |
||||
|
await activitiesService.getActivitiesForPortfolioCalculator({ |
||||
|
filters, |
||||
|
userCurrency: 'USD', |
||||
|
userId: 'user-id' |
||||
|
}); |
||||
|
|
||||
|
expect(activitiesService.getActivities).toHaveBeenCalledWith({ |
||||
|
filters, |
||||
|
userCurrency: 'USD', |
||||
|
userId: 'user-id', |
||||
|
withExcludedAccountsAndActivities: false |
||||
|
}); |
||||
|
expect(getSplitsByUserId).toHaveBeenCalledWith({ userId: 'user-id' }); |
||||
|
expect(result.activities[0]).toMatchObject({ |
||||
|
quantity: 20, |
||||
|
unitPrice: 50, |
||||
|
unitPriceInAssetProfileCurrency: 50 |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('does not adjust synthetic cash activities', async () => { |
||||
|
const activity = createActivity({ symbol: 'AAPL' }); |
||||
|
const cashActivity = createActivity({ |
||||
|
assetSubClass: 'CASH', |
||||
|
currency: 'USD', |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
quantity: 100, |
||||
|
symbol: 'USD', |
||||
|
unitPrice: 1 |
||||
|
}); |
||||
|
const split = createSplit(); |
||||
|
|
||||
|
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ |
||||
|
activities: [activity], |
||||
|
count: 1 |
||||
|
}); |
||||
|
jest.spyOn(activitiesService, 'getCashActivities').mockResolvedValue({ |
||||
|
activities: [cashActivity], |
||||
|
count: 1 |
||||
|
}); |
||||
|
accountService.getCashDetails.mockResolvedValue({ accounts: [] }); |
||||
|
getSplitsByUserId.mockResolvedValue([split]); |
||||
|
|
||||
|
const result = |
||||
|
await activitiesService.getActivitiesForPortfolioCalculator({ |
||||
|
userCurrency: 'USD', |
||||
|
userId: 'user-id', |
||||
|
withCash: true |
||||
|
}); |
||||
|
|
||||
|
expect(getSplitsByUserId).toHaveBeenCalledWith({ userId: 'user-id' }); |
||||
|
expect(result.activities).toEqual([ |
||||
|
expect.objectContaining({ |
||||
|
assetProfile: expect.objectContaining({ symbol: 'AAPL' }), |
||||
|
quantity: 20 |
||||
|
}), |
||||
|
cashActivity |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
async function getAdjustedActivity( |
||||
|
activity: Activity, |
||||
|
splits: AssetProfileSplit[] |
||||
|
) { |
||||
|
jest.spyOn(activitiesService, 'getActivities').mockResolvedValue({ |
||||
|
activities: [activity], |
||||
|
count: 1 |
||||
|
}); |
||||
|
getSplitsByUserId.mockResolvedValue( |
||||
|
splits.map((split) => { |
||||
|
return { ...split, symbolProfileId: activity.assetProfile.id }; |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
const result = |
||||
|
await activitiesService.getActivitiesForPortfolioCalculator({ |
||||
|
userCurrency: 'USD', |
||||
|
userId: 'user-id' |
||||
|
}); |
||||
|
|
||||
|
return result.activities[0]; |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
function createActivity({ |
||||
|
assetSubClass, |
||||
|
currency, |
||||
|
dataSource = DataSource.YAHOO, |
||||
|
date = '2020-01-01', |
||||
|
quantity = 10, |
||||
|
symbol, |
||||
|
unitPrice = 100 |
||||
|
}: { |
||||
|
assetSubClass?: string; |
||||
|
currency?: string; |
||||
|
dataSource?: DataSource; |
||||
|
date?: string; |
||||
|
quantity?: number; |
||||
|
symbol: string; |
||||
|
unitPrice?: number; |
||||
|
}): Activity { |
||||
|
return { |
||||
|
...activityDummyData, |
||||
|
assetProfile: { |
||||
|
...assetProfileDummyData, |
||||
|
assetSubClass, |
||||
|
currency, |
||||
|
dataSource, |
||||
|
id: `${dataSource}-${symbol}-profile`, |
||||
|
symbol |
||||
|
}, |
||||
|
date: parseDate(date), |
||||
|
quantity, |
||||
|
type: 'BUY', |
||||
|
unitPrice, |
||||
|
unitPriceInAssetProfileCurrency: unitPrice, |
||||
|
value: quantity * unitPrice, |
||||
|
valueInBaseCurrency: quantity * unitPrice |
||||
|
} as Activity; |
||||
|
} |
||||
|
|
||||
|
function createSplit( |
||||
|
dateString = '2021-01-01', |
||||
|
numerator = 2, |
||||
|
denominator = 1 |
||||
|
): AssetProfileSplit { |
||||
|
const date = parseDate(dateString); |
||||
|
|
||||
|
return { |
||||
|
createdAt: date, |
||||
|
date, |
||||
|
denominator, |
||||
|
id: `${dateString}-${numerator}-${denominator}`, |
||||
|
numerator, |
||||
|
symbolProfileId: 'YAHOO-AAPL-profile', |
||||
|
updatedAt: date |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,179 @@ |
|||||
|
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; |
||||
|
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; |
||||
|
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; |
||||
|
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; |
||||
|
|
||||
|
import { NotFoundException } from '@nestjs/common'; |
||||
|
import { EventEmitter2 } from '@nestjs/event-emitter'; |
||||
|
import { AssetProfileSplit, DataSource } from '@prisma/client'; |
||||
|
|
||||
|
import { AssetProfilesService } from './asset-profiles.service'; |
||||
|
|
||||
|
describe('AssetProfilesService', () => { |
||||
|
let assetProfilesService: AssetProfilesService; |
||||
|
let deleteById: jest.Mock; |
||||
|
let emit: jest.Mock; |
||||
|
let finished: jest.Mock; |
||||
|
let gatherSymbol: jest.Mock; |
||||
|
let getUserIdsBySymbolProfileId: jest.Mock; |
||||
|
let upsert: jest.Mock; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
deleteById = jest.fn(); |
||||
|
emit = jest.fn(); |
||||
|
finished = jest.fn().mockResolvedValue(undefined); |
||||
|
gatherSymbol = jest.fn().mockResolvedValue([{ finished }]); |
||||
|
getUserIdsBySymbolProfileId = jest.fn().mockResolvedValue([]); |
||||
|
upsert = jest.fn(); |
||||
|
|
||||
|
assetProfilesService = new AssetProfilesService( |
||||
|
{ getUserIdsBySymbolProfileId } as unknown as ActivitiesService, |
||||
|
{ |
||||
|
deleteById, |
||||
|
upsert |
||||
|
} as unknown as AssetProfileSplitService, |
||||
|
null, |
||||
|
{ gatherSymbol } as unknown as DataGatheringService, |
||||
|
null, |
||||
|
{ emit } as unknown as EventEmitter2, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('createSplit', () => { |
||||
|
it('upserts the split and refreshes the asset profile data', async () => { |
||||
|
const split = {} as AssetProfileSplit; |
||||
|
const data = { |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
date: new Date('2024-06-15T18:30:00.000Z'), |
||||
|
denominator: 1, |
||||
|
numerator: 2, |
||||
|
symbol: 'AAPL', |
||||
|
symbolProfileId: 'profile-id' |
||||
|
}; |
||||
|
upsert.mockResolvedValue(split); |
||||
|
|
||||
|
const result = await assetProfilesService.createSplit(data); |
||||
|
|
||||
|
expect(upsert).toHaveBeenCalledWith({ |
||||
|
date: data.date, |
||||
|
denominator: data.denominator, |
||||
|
numerator: data.numerator, |
||||
|
symbolProfileId: data.symbolProfileId |
||||
|
}); |
||||
|
expect(gatherSymbol).toHaveBeenCalledWith({ |
||||
|
dataSource: data.dataSource, |
||||
|
symbol: data.symbol |
||||
|
}); |
||||
|
expect(result).toBe(split); |
||||
|
}); |
||||
|
|
||||
|
it('invalidates portfolio snapshots for users holding the asset', async () => { |
||||
|
upsert.mockResolvedValue({} as AssetProfileSplit); |
||||
|
getUserIdsBySymbolProfileId.mockResolvedValue(['user-1', 'user-2']); |
||||
|
|
||||
|
await assetProfilesService.createSplit({ |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
date: new Date('2024-06-15T18:30:00.000Z'), |
||||
|
denominator: 1, |
||||
|
numerator: 2, |
||||
|
symbol: 'AAPL', |
||||
|
symbolProfileId: 'profile-id' |
||||
|
}); |
||||
|
await flushPendingPromises(); |
||||
|
|
||||
|
expect(getUserIdsBySymbolProfileId).toHaveBeenCalledWith('profile-id'); |
||||
|
expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([ |
||||
|
'user-1', |
||||
|
'user-2' |
||||
|
]); |
||||
|
expect(emit.mock.calls[0][0]).toBe(PortfolioChangedEvent.getName()); |
||||
|
}); |
||||
|
|
||||
|
it('emits the events only once the market data has been gathered', async () => { |
||||
|
let completeJob: () => void; |
||||
|
|
||||
|
finished.mockReturnValue( |
||||
|
new Promise<void>((resolve) => { |
||||
|
completeJob = resolve; |
||||
|
}) |
||||
|
); |
||||
|
upsert.mockResolvedValue({} as AssetProfileSplit); |
||||
|
getUserIdsBySymbolProfileId.mockResolvedValue(['user-1']); |
||||
|
|
||||
|
await assetProfilesService.createSplit({ |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
date: new Date('2024-06-15T18:30:00.000Z'), |
||||
|
denominator: 1, |
||||
|
numerator: 2, |
||||
|
symbol: 'AAPL', |
||||
|
symbolProfileId: 'profile-id' |
||||
|
}); |
||||
|
await flushPendingPromises(); |
||||
|
|
||||
|
expect(emit).not.toHaveBeenCalled(); |
||||
|
|
||||
|
completeJob(); |
||||
|
await flushPendingPromises(); |
||||
|
|
||||
|
expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([ |
||||
|
'user-1' |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('deleteSplit', () => { |
||||
|
it('throws NotFoundException when the scoped split does not exist', async () => { |
||||
|
deleteById.mockResolvedValue(false); |
||||
|
|
||||
|
await expect( |
||||
|
assetProfilesService.deleteSplit({ |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
id: 'split-id', |
||||
|
symbol: 'AAPL', |
||||
|
symbolProfileId: 'profile-id' |
||||
|
}) |
||||
|
).rejects.toBeInstanceOf(NotFoundException); |
||||
|
await flushPendingPromises(); |
||||
|
|
||||
|
expect(gatherSymbol).not.toHaveBeenCalled(); |
||||
|
expect(emit).not.toHaveBeenCalled(); |
||||
|
}); |
||||
|
|
||||
|
it('deletes an existing split using its profile scope', async () => { |
||||
|
deleteById.mockResolvedValue(true); |
||||
|
getUserIdsBySymbolProfileId.mockResolvedValue(['user-1']); |
||||
|
|
||||
|
await expect( |
||||
|
assetProfilesService.deleteSplit({ |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
id: 'split-id', |
||||
|
symbol: 'AAPL', |
||||
|
symbolProfileId: 'profile-id' |
||||
|
}) |
||||
|
).resolves.toBeUndefined(); |
||||
|
await flushPendingPromises(); |
||||
|
|
||||
|
expect(deleteById).toHaveBeenCalledWith({ |
||||
|
id: 'split-id', |
||||
|
symbolProfileId: 'profile-id' |
||||
|
}); |
||||
|
expect(emit.mock.calls.map(([, event]) => event.getUserId())).toEqual([ |
||||
|
'user-1' |
||||
|
]); |
||||
|
expect(gatherSymbol).toHaveBeenCalledWith({ |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
symbol: 'AAPL' |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
function flushPendingPromises() { |
||||
|
return new Promise((resolve) => { |
||||
|
setImmediate(resolve); |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,276 @@ |
|||||
|
import { |
||||
|
activityDummyData, |
||||
|
assetProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { adjustActivityBySplits } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.helper'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
||||
|
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
import { Activity } from '@ghostfolio/common/interfaces'; |
||||
|
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
|
import { AssetProfileSplit, DataSource } from '@prisma/client'; |
||||
|
import { Big } from 'big.js'; |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
|
return { |
||||
|
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
|
return CurrentRateServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
||||
|
() => { |
||||
|
return { |
||||
|
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
||||
|
return PortfolioSnapshotServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('RoaiPortfolioCalculator stock splits', () => { |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
|
let portfolioSnapshotService: PortfolioSnapshotService; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
PortfolioSnapshotServiceMock.reset(); |
||||
|
RedisCacheServiceMock.reset(); |
||||
|
|
||||
|
configurationService = new ConfigurationService(); |
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
portfolioSnapshotService = new PortfolioSnapshotService(null, null); |
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
portfolioSnapshotService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('doubles the position and halves the average price for a 2:1 split', () => { |
||||
|
const activity = adjustActivityBySplits( |
||||
|
createActivity({ unitPrice: 100 }), |
||||
|
[createSplit({ denominator: 1, numerator: 2 })] |
||||
|
); |
||||
|
|
||||
|
const position = getLastPosition(portfolioCalculatorFactory, [activity]); |
||||
|
|
||||
|
expect(position).toMatchObject({ |
||||
|
averagePrice: new Big(50), |
||||
|
investment: new Big(1000), |
||||
|
quantity: new Big(20) |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('applies the inverse quantity and price changes for a reverse split', () => { |
||||
|
const activity = adjustActivityBySplits( |
||||
|
createActivity({ unitPrice: 100 }), |
||||
|
[createSplit({ denominator: 10, numerator: 1 })] |
||||
|
); |
||||
|
|
||||
|
const position = getLastPosition(portfolioCalculatorFactory, [activity]); |
||||
|
|
||||
|
expect(position).toMatchObject({ |
||||
|
averagePrice: new Big(1000), |
||||
|
investment: new Big(1000), |
||||
|
quantity: new Big(1) |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('uses adjusted quantities when selling after a split', () => { |
||||
|
const buy = adjustActivityBySplits( |
||||
|
createActivity({ date: '2020-01-01', unitPrice: 100 }), |
||||
|
[createSplit({ denominator: 1, numerator: 2 })] |
||||
|
); |
||||
|
const sell = createActivity({ |
||||
|
date: '2021-01-01', |
||||
|
quantity: 5, |
||||
|
type: 'SELL', |
||||
|
unitPrice: 60 |
||||
|
}); |
||||
|
|
||||
|
const position = getLastPosition(portfolioCalculatorFactory, [buy, sell]); |
||||
|
|
||||
|
expect(position).toMatchObject({ |
||||
|
averagePrice: new Big(50), |
||||
|
investment: new Big(750), |
||||
|
quantity: new Big(15) |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('applies multiple splits while preserving fractional precision', () => { |
||||
|
const activity = adjustActivityBySplits( |
||||
|
createActivity({ unitPrice: 100 }), |
||||
|
[ |
||||
|
createSplit({ denominator: 1, numerator: 2 }), |
||||
|
createSplit({ denominator: 3, numerator: 1, date: '2022-01-01' }) |
||||
|
] |
||||
|
); |
||||
|
|
||||
|
const position = getLastPosition(portfolioCalculatorFactory, [activity]); |
||||
|
|
||||
|
expect(position.averagePrice).toEqual(new Big(150)); |
||||
|
expect(position.quantity.toFixed(15)).toBe(new Big(20).div(3).toFixed(15)); |
||||
|
expect(position.investment.toNumber()).toBeCloseTo(1000, 12); |
||||
|
}); |
||||
|
|
||||
|
it('resets quantity and investment when the adjusted position is closed', () => { |
||||
|
const buy = adjustActivityBySplits( |
||||
|
createActivity({ date: '2020-01-01', unitPrice: 100 }), |
||||
|
[createSplit({ denominator: 1, numerator: 2 })] |
||||
|
); |
||||
|
const sell = createActivity({ |
||||
|
date: '2021-01-01', |
||||
|
quantity: 20, |
||||
|
type: 'SELL', |
||||
|
unitPrice: 60 |
||||
|
}); |
||||
|
|
||||
|
const position = getLastPosition(portfolioCalculatorFactory, [buy, sell]); |
||||
|
|
||||
|
expect(position.quantity).toEqual(new Big(0)); |
||||
|
expect(position.investment).toEqual(new Big(0)); |
||||
|
}); |
||||
|
|
||||
|
it('preserves existing behavior when no splits exist', () => { |
||||
|
const position = getLastPosition(portfolioCalculatorFactory, [ |
||||
|
createActivity({ unitPrice: 100 }) |
||||
|
]); |
||||
|
|
||||
|
expect(position).toMatchObject({ |
||||
|
averagePrice: new Big(100), |
||||
|
investment: new Big(1000), |
||||
|
quantity: new Big(10) |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('uses provider market data without adjusting it a second time', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); |
||||
|
|
||||
|
const activity = adjustActivityBySplits( |
||||
|
createActivity({ date: '2023-07-09', unitPrice: 674.44 }), |
||||
|
[ |
||||
|
createSplit({ |
||||
|
date: '2023-07-10', |
||||
|
denominator: 1, |
||||
|
numerator: 2 |
||||
|
}) |
||||
|
] |
||||
|
); |
||||
|
const calculator = portfolioCalculatorFactory.createCalculator({ |
||||
|
activities: [activity], |
||||
|
calculationType: PerformanceCalculationType.ROAI, |
||||
|
currency: 'USD', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const snapshot = await calculator.computeSnapshot(); |
||||
|
const [position] = snapshot.positions; |
||||
|
|
||||
|
expect(position).toMatchObject({ |
||||
|
investment: new Big(6744.4), |
||||
|
marketPrice: 331.83, |
||||
|
quantity: new Big(20), |
||||
|
valueInBaseCurrency: new Big(6636.6) |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
function getLastPosition( |
||||
|
portfolioCalculatorFactory: PortfolioCalculatorFactory, |
||||
|
activities: Activity[] |
||||
|
) { |
||||
|
const calculator = portfolioCalculatorFactory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.ROAI, |
||||
|
currency: 'USD', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
return calculator.getTransactionPoints().at(-1).items[0]; |
||||
|
} |
||||
|
|
||||
|
function createActivity({ |
||||
|
date = '2020-01-01', |
||||
|
quantity = 10, |
||||
|
type = 'BUY', |
||||
|
unitPrice = 100 |
||||
|
}: { |
||||
|
date?: string; |
||||
|
quantity?: number; |
||||
|
type?: Activity['type']; |
||||
|
unitPrice?: number; |
||||
|
}): Activity { |
||||
|
return { |
||||
|
...activityDummyData, |
||||
|
assetProfile: { |
||||
|
...assetProfileDummyData, |
||||
|
currency: 'USD', |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
name: 'Microsoft Inc.', |
||||
|
symbol: 'MSFT' |
||||
|
}, |
||||
|
date: parseDate(date), |
||||
|
feeInAssetProfileCurrency: 0, |
||||
|
feeInBaseCurrency: 0, |
||||
|
quantity, |
||||
|
type, |
||||
|
unitPrice, |
||||
|
unitPriceInAssetProfileCurrency: unitPrice, |
||||
|
value: quantity * unitPrice, |
||||
|
valueInBaseCurrency: quantity * unitPrice |
||||
|
} as Activity; |
||||
|
} |
||||
|
|
||||
|
function createSplit({ |
||||
|
date = '2021-01-01', |
||||
|
denominator, |
||||
|
numerator |
||||
|
}: { |
||||
|
date?: string; |
||||
|
denominator: number; |
||||
|
numerator: number; |
||||
|
}): AssetProfileSplit { |
||||
|
const splitDate = parseDate(date); |
||||
|
|
||||
|
return { |
||||
|
denominator, |
||||
|
numerator, |
||||
|
createdAt: splitDate, |
||||
|
date: splitDate, |
||||
|
id: `${date}-${numerator}-${denominator}`, |
||||
|
symbolProfileId: 'msft-profile', |
||||
|
updatedAt: splitDate |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
import { SetMetadata } from '@nestjs/common'; |
||||
|
|
||||
|
export const ALLOW_DURING_IMPERSONATION_KEY = 'allow_during_impersonation'; |
||||
|
|
||||
|
/** |
||||
|
* Marks a controller or a route which modifies data of the authenticated user |
||||
|
* instead of data of the impersonated user, hence it stays available while an |
||||
|
* impersonation is active |
||||
|
*/ |
||||
|
export function AllowDuringImpersonation() { |
||||
|
return SetMetadata(ALLOW_DURING_IMPERSONATION_KEY, true); |
||||
|
} |
||||
@ -0,0 +1,28 @@ |
|||||
|
import { getScopesOfOwnAccess } from '@ghostfolio/common/scopes'; |
||||
|
import type { |
||||
|
ImpersonationContext, |
||||
|
RequestWithUser |
||||
|
} from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; |
||||
|
|
||||
|
/** |
||||
|
* Provides the impersonation context of the request, which requires the |
||||
|
* ImpersonationGuard to be applied to the route |
||||
|
*/ |
||||
|
export const Impersonation = createParamDecorator( |
||||
|
(_data: unknown, context: ExecutionContext): ImpersonationContext => { |
||||
|
const { impersonation, user } = context |
||||
|
.switchToHttp() |
||||
|
.getRequest<RequestWithUser>(); |
||||
|
|
||||
|
return ( |
||||
|
impersonation ?? { |
||||
|
isActive: false, |
||||
|
scopes: getScopesOfOwnAccess(), |
||||
|
userId: user?.id, |
||||
|
userSettings: user?.settings?.settings ?? {} |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
); |
||||
@ -0,0 +1,41 @@ |
|||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
||||
|
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; |
||||
|
import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard'; |
||||
|
import { scopes } from '@ghostfolio/common/scopes'; |
||||
|
|
||||
|
import { GUARDS_METADATA } from '@nestjs/common/constants'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
|
||||
|
import { REQUIRES_SCOPE_KEY, RequiresScope } from './requires-scope.decorator'; |
||||
|
|
||||
|
class TestController { |
||||
|
@RequiresScope(scopes.portfolioRead) |
||||
|
public getPortfolio() { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
describe('Requires scope', () => { |
||||
|
it('Sets the required scopes', () => { |
||||
|
expect( |
||||
|
Reflect.getMetadata( |
||||
|
REQUIRES_SCOPE_KEY, |
||||
|
TestController.prototype.getPortfolio |
||||
|
) |
||||
|
).toEqual([scopes.portfolioRead]); |
||||
|
}); |
||||
|
|
||||
|
it('Applies the guards in the required order', () => { |
||||
|
expect( |
||||
|
Reflect.getMetadata( |
||||
|
GUARDS_METADATA, |
||||
|
TestController.prototype.getPortfolio |
||||
|
) |
||||
|
).toEqual([ |
||||
|
AuthGuard('jwt'), |
||||
|
HasPermissionGuard, |
||||
|
ImpersonationGuard, |
||||
|
ScopeGuard |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,26 @@ |
|||||
|
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
||||
|
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; |
||||
|
import { ScopeGuard } from '@ghostfolio/api/guards/scope.guard'; |
||||
|
import { Scope } from '@ghostfolio/common/scopes'; |
||||
|
|
||||
|
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
|
||||
|
export const REQUIRES_SCOPE_KEY = 'requires_scope'; |
||||
|
|
||||
|
/** |
||||
|
* Marks a route which requires the given scopes and applies the guards which |
||||
|
* resolve the impersonation context and evaluate it, hence the ScopeGuard |
||||
|
* cannot be applied without the ImpersonationGuard preceding it |
||||
|
*/ |
||||
|
export function RequiresScope(...requiredScopes: Scope[]) { |
||||
|
return applyDecorators( |
||||
|
SetMetadata(REQUIRES_SCOPE_KEY, requiredScopes), |
||||
|
UseGuards( |
||||
|
AuthGuard('jwt'), |
||||
|
HasPermissionGuard, |
||||
|
ImpersonationGuard, |
||||
|
ScopeGuard |
||||
|
) |
||||
|
); |
||||
|
} |
||||
@ -0,0 +1,53 @@ |
|||||
|
import { ALLOW_DURING_IMPERSONATION_KEY } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; |
||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; |
||||
|
|
||||
|
import { |
||||
|
CanActivate, |
||||
|
ExecutionContext, |
||||
|
HttpException, |
||||
|
Injectable |
||||
|
} from '@nestjs/common'; |
||||
|
import { Reflector } from '@nestjs/core'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
/** |
||||
|
* Blocks write requests while an impersonation is active, so that data of the |
||||
|
* authenticated user cannot be changed from a view presenting data of the |
||||
|
* impersonated user. The header is evaluated instead of the resolved context to |
||||
|
* fail closed, also for an identifier which cannot be resolved. |
||||
|
*/ |
||||
|
@Injectable() |
||||
|
export class ImpersonationWriteGuard implements CanActivate { |
||||
|
public constructor(private readonly reflector: Reflector) {} |
||||
|
|
||||
|
public canActivate(context: ExecutionContext): boolean { |
||||
|
if (context.getType() !== 'http') { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
const request = context.switchToHttp().getRequest(); |
||||
|
|
||||
|
if (request.method === 'GET') { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
if (!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()]) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
const isAllowedDuringImpersonation = |
||||
|
this.reflector.getAllAndOverride<boolean>( |
||||
|
ALLOW_DURING_IMPERSONATION_KEY, |
||||
|
[context.getHandler(), context.getClass()] |
||||
|
); |
||||
|
|
||||
|
if (isAllowedDuringImpersonation) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; |
||||
|
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class ImpersonationGuard implements CanActivate { |
||||
|
public constructor( |
||||
|
private readonly impersonationService: ImpersonationService |
||||
|
) {} |
||||
|
|
||||
|
public async canActivate(context: ExecutionContext) { |
||||
|
const request = context.switchToHttp().getRequest<RequestWithUser>(); |
||||
|
|
||||
|
request.impersonation = await this.impersonationService.resolve({ |
||||
|
impersonationId: request.headers?.[ |
||||
|
HEADER_KEY_IMPERSONATION.toLowerCase() |
||||
|
] as string, |
||||
|
user: request.user |
||||
|
}); |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
import { Logger, mixin, Type } from '@nestjs/common'; |
||||
|
import { AuthGuard, IAuthGuard } from '@nestjs/passport'; |
||||
|
|
||||
|
export function OAuthCallbackGuard(strategy: string): Type<IAuthGuard> { |
||||
|
class OAuthCallbackGuardMixin extends AuthGuard(strategy) { |
||||
|
private readonly logger = new Logger(OAuthCallbackGuard.name); |
||||
|
|
||||
|
public override handleRequest(error: Error, user: any) { |
||||
|
if (error) { |
||||
|
this.logger.error( |
||||
|
`Authentication with the ${strategy} strategy has failed: ${error.message}` |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Do not throw, the callback handler redirects to the login page instead
|
||||
|
return user; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return mixin(OAuthCallbackGuardMixin); |
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; |
||||
|
import { hasScope, Scope } from '@ghostfolio/common/scopes'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { |
||||
|
CanActivate, |
||||
|
ExecutionContext, |
||||
|
HttpException, |
||||
|
Injectable |
||||
|
} from '@nestjs/common'; |
||||
|
import { Reflector } from '@nestjs/core'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
/** |
||||
|
* Denies a request whose impersonation context does not cover the scopes |
||||
|
* required by the route. It has to be applied after the ImpersonationGuard, |
||||
|
* which resolves the context, hence the RequiresScope decorator applies both. |
||||
|
*/ |
||||
|
@Injectable() |
||||
|
export class ScopeGuard implements CanActivate { |
||||
|
public constructor(private readonly reflector: Reflector) {} |
||||
|
|
||||
|
public canActivate(context: ExecutionContext): boolean { |
||||
|
const requiredScopes = this.reflector.getAllAndOverride<Scope[]>( |
||||
|
REQUIRES_SCOPE_KEY, |
||||
|
[context.getHandler(), context.getClass()] |
||||
|
); |
||||
|
|
||||
|
if (!requiredScopes?.length) { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
const { impersonation } = context |
||||
|
.switchToHttp() |
||||
|
.getRequest<RequestWithUser>(); |
||||
|
|
||||
|
const hasRequiredScopes = requiredScopes.every((scope) => { |
||||
|
return hasScope(impersonation?.scopes, scope); |
||||
|
}); |
||||
|
|
||||
|
if (!hasRequiredScopes) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,74 @@ |
|||||
|
import { |
||||
|
NON_INVESTMENT_ACTIVITY_TYPES, |
||||
|
TAG_ID_DRAFT |
||||
|
} from '@ghostfolio/common/config'; |
||||
|
|
||||
|
import { Prisma, Type as ActivityType } from '@prisma/client'; |
||||
|
import { endOfToday, isAfter } from 'date-fns'; |
||||
|
import { uniqBy } from 'lodash'; |
||||
|
|
||||
|
export const WHERE_ACTIVITY_NOT_DRAFT: Prisma.OrderWhereInput = { |
||||
|
tags: { |
||||
|
none: { |
||||
|
id: TAG_ID_DRAFT |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
export function getTagsWithDraftTag<T extends { id: string }>({ |
||||
|
date, |
||||
|
draftTag, |
||||
|
endOfTodayDate = endOfToday(), |
||||
|
originalDate, |
||||
|
tags, |
||||
|
type |
||||
|
}: { |
||||
|
date: Date; |
||||
|
draftTag: T; |
||||
|
endOfTodayDate?: Date; |
||||
|
originalDate?: Date; |
||||
|
tags: T[]; |
||||
|
type: ActivityType; |
||||
|
}) { |
||||
|
if (!isDraftTagToBeAssigned({ date, endOfTodayDate, originalDate, type })) { |
||||
|
return tags; |
||||
|
} |
||||
|
|
||||
|
return uniqBy([...tags, draftTag], 'id'); |
||||
|
} |
||||
|
|
||||
|
export function isActivityInFuture({ |
||||
|
date, |
||||
|
endOfTodayDate = endOfToday() |
||||
|
}: { |
||||
|
date: Date; |
||||
|
endOfTodayDate?: Date; |
||||
|
}) { |
||||
|
return isAfter(date, endOfTodayDate); |
||||
|
} |
||||
|
|
||||
|
export function isDraftTagToBeAssigned({ |
||||
|
date, |
||||
|
endOfTodayDate = endOfToday(), |
||||
|
originalDate, |
||||
|
type |
||||
|
}: { |
||||
|
date: Date; |
||||
|
endOfTodayDate?: Date; |
||||
|
originalDate?: Date; |
||||
|
type: ActivityType; |
||||
|
}) { |
||||
|
if (NON_INVESTMENT_ACTIVITY_TYPES.includes(type)) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if (!isActivityInFuture({ date, endOfTodayDate })) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// Assign only when the date newly moves into the future, so that a tag the
|
||||
|
// user has removed is not restored by an unrelated change
|
||||
|
return originalDate |
||||
|
? !isActivityInFuture({ endOfTodayDate, date: originalDate }) |
||||
|
: true; |
||||
|
} |
||||
@ -0,0 +1,110 @@ |
|||||
|
import { Rule } from '@ghostfolio/api/models/rule'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; |
||||
|
import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config'; |
||||
|
import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
|
||||
|
export class EmergencyFundCoverage extends Rule<Settings> { |
||||
|
public constructor( |
||||
|
exchangeRateDataService: ExchangeRateDataService, |
||||
|
private i18nService: I18nService, |
||||
|
languageCode: string, |
||||
|
private emergencyFundInBaseCurrency: number, |
||||
|
private emergencyFundHoldingsValueInBaseCurrency: number, |
||||
|
private cashBalanceInBaseCurrency: number |
||||
|
) { |
||||
|
super(exchangeRateDataService, { |
||||
|
languageCode, |
||||
|
key: EmergencyFundCoverage.name |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public evaluate(ruleSettings: Settings) { |
||||
|
if (!this.emergencyFundInBaseCurrency) { |
||||
|
return { |
||||
|
evaluation: this.i18nService.getTranslation({ |
||||
|
id: 'rule.emergencyFundCoverage.false.unset', |
||||
|
languageCode: this.getLanguageCode() |
||||
|
}), |
||||
|
value: false |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const placeholders = { |
||||
|
baseCurrency: ruleSettings.baseCurrency, |
||||
|
emergencyFund: this.emergencyFundInBaseCurrency.toLocaleString( |
||||
|
ruleSettings.locale |
||||
|
) |
||||
|
}; |
||||
|
|
||||
|
// Only the holdings tagged as emergency fund are an explicit commitment,
|
||||
|
// the cash balance covers the remainder
|
||||
|
if ( |
||||
|
new Big(this.emergencyFundHoldingsValueInBaseCurrency).gt( |
||||
|
this.emergencyFundInBaseCurrency |
||||
|
) |
||||
|
) { |
||||
|
return { |
||||
|
evaluation: this.i18nService.getTranslation({ |
||||
|
placeholders, |
||||
|
id: 'rule.emergencyFundCoverage.false.over', |
||||
|
languageCode: this.getLanguageCode() |
||||
|
}), |
||||
|
value: false |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const coverageInBaseCurrency = new Big( |
||||
|
this.emergencyFundHoldingsValueInBaseCurrency |
||||
|
).plus(this.cashBalanceInBaseCurrency); |
||||
|
|
||||
|
if (coverageInBaseCurrency.lt(this.emergencyFundInBaseCurrency)) { |
||||
|
return { |
||||
|
evaluation: this.i18nService.getTranslation({ |
||||
|
placeholders, |
||||
|
id: 'rule.emergencyFundCoverage.false.under', |
||||
|
languageCode: this.getLanguageCode() |
||||
|
}), |
||||
|
value: false |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
evaluation: this.i18nService.getTranslation({ |
||||
|
placeholders, |
||||
|
id: 'rule.emergencyFundCoverage.true', |
||||
|
languageCode: this.getLanguageCode() |
||||
|
}), |
||||
|
value: true |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public getConfiguration() { |
||||
|
return undefined; |
||||
|
} |
||||
|
|
||||
|
public getName() { |
||||
|
return this.i18nService.getTranslation({ |
||||
|
id: 'rule.emergencyFundCoverage', |
||||
|
languageCode: this.getLanguageCode() |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public getSettings({ |
||||
|
baseCurrency = DEFAULT_CURRENCY, |
||||
|
locale = DEFAULT_LOCALE, |
||||
|
xRayRules |
||||
|
}: UserSettings): Settings { |
||||
|
return { |
||||
|
baseCurrency, |
||||
|
locale, |
||||
|
isActive: xRayRules?.[this.getKey()]?.isActive ?? true |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
interface Settings extends RuleSettings { |
||||
|
baseCurrency: string; |
||||
|
} |
||||
@ -0,0 +1,114 @@ |
|||||
|
import { |
||||
|
activityDummyData, |
||||
|
assetProfileDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import * as commonHelper from '@ghostfolio/common/helper'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
import { Activity } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { AssetProfileSplit, DataSource } from '@prisma/client'; |
||||
|
import { Big } from 'big.js'; |
||||
|
|
||||
|
import { adjustActivityBySplits } from './asset-profile-split.helper'; |
||||
|
|
||||
|
describe('adjustActivityBySplits', () => { |
||||
|
it('adjusts quantity and prices using the cumulative split factor', () => { |
||||
|
const activity = createActivity('2020-01-01'); |
||||
|
const splits = [ |
||||
|
createSplit('2021-01-01', 2, 1), |
||||
|
createSplit('2022-01-01', 1, 3) |
||||
|
]; |
||||
|
|
||||
|
const adjustedActivity = adjustActivityBySplits(activity, splits); |
||||
|
|
||||
|
expect(adjustedActivity).not.toBe(activity); |
||||
|
expect(adjustedActivity).toMatchObject({ |
||||
|
quantity: 20 / 3, |
||||
|
unitPrice: 150, |
||||
|
unitPriceInAssetProfileCurrency: 150, |
||||
|
value: 1000, |
||||
|
valueInBaseCurrency: 1000 |
||||
|
}); |
||||
|
expect(new Big(adjustedActivity.quantity).toFixed(15)).toBe( |
||||
|
new Big(20).div(3).toFixed(15) |
||||
|
); |
||||
|
expect(activity).toMatchObject({ |
||||
|
quantity: 10, |
||||
|
unitPrice: 100, |
||||
|
unitPriceInAssetProfileCurrency: 100 |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('only adjusts activities before the split calendar date', () => { |
||||
|
const split = createSplit('2024-06-15T00:00:00Z', 2, 1); |
||||
|
const activityOnSplitDate = createActivity('2024-06-15T18:00:00Z'); |
||||
|
const activityAfterSplit = createActivity('2024-06-16T00:00:00Z'); |
||||
|
|
||||
|
const adjustedActivityOnSplitDate = adjustActivityBySplits( |
||||
|
activityOnSplitDate, |
||||
|
[split] |
||||
|
); |
||||
|
const adjustedActivityAfterSplit = adjustActivityBySplits( |
||||
|
activityAfterSplit, |
||||
|
[split] |
||||
|
); |
||||
|
|
||||
|
expect(adjustedActivityOnSplitDate).toEqual(activityOnSplitDate); |
||||
|
expect(adjustedActivityOnSplitDate).toBe(activityOnSplitDate); |
||||
|
expect(adjustedActivityAfterSplit).toEqual(activityAfterSplit); |
||||
|
expect(adjustedActivityAfterSplit).toBe(activityAfterSplit); |
||||
|
}); |
||||
|
|
||||
|
it('compares stored UTC split dates without normalizing them locally', () => { |
||||
|
const resetHoursSpy = jest |
||||
|
.spyOn(commonHelper, 'resetHours') |
||||
|
.mockReturnValue(new Date('2024-06-14T00:00:00Z')); |
||||
|
|
||||
|
try { |
||||
|
const activity = createActivity('2024-06-14T12:00:00Z'); |
||||
|
const split = createSplit('2024-06-15T00:00:00Z', 2, 1); |
||||
|
|
||||
|
const adjustedActivity = adjustActivityBySplits(activity, [split]); |
||||
|
|
||||
|
expect(adjustedActivity.quantity).toBe(20); |
||||
|
} finally { |
||||
|
resetHoursSpy.mockRestore(); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
function createActivity(date: string): Activity { |
||||
|
return { |
||||
|
...activityDummyData, |
||||
|
assetProfile: { |
||||
|
...assetProfileDummyData, |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
symbol: 'AAPL' |
||||
|
}, |
||||
|
date: parseDate(date), |
||||
|
quantity: 10, |
||||
|
type: 'BUY', |
||||
|
unitPrice: 100, |
||||
|
unitPriceInAssetProfileCurrency: 100, |
||||
|
value: 1000, |
||||
|
valueInBaseCurrency: 1000 |
||||
|
} as Activity; |
||||
|
} |
||||
|
|
||||
|
function createSplit( |
||||
|
date: string, |
||||
|
numerator: number, |
||||
|
denominator: number |
||||
|
): AssetProfileSplit { |
||||
|
const splitDate = new Date(date); |
||||
|
|
||||
|
return { |
||||
|
denominator, |
||||
|
numerator, |
||||
|
createdAt: splitDate, |
||||
|
date: splitDate, |
||||
|
id: `${date}-${numerator}-${denominator}`, |
||||
|
symbolProfileId: 'aapl-profile', |
||||
|
updatedAt: splitDate |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,58 @@ |
|||||
|
import { INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config'; |
||||
|
import { resetHours } from '@ghostfolio/common/helper'; |
||||
|
import { Activity } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { AssetProfileSplit } from '@prisma/client'; |
||||
|
import { Big } from 'big.js'; |
||||
|
import { isBefore } from 'date-fns'; |
||||
|
|
||||
|
export function adjustActivityBySplits( |
||||
|
activity: Activity, |
||||
|
splits: AssetProfileSplit[] |
||||
|
): Activity { |
||||
|
if (!INVESTMENT_ACTIVITY_TYPES.includes(activity.type)) { |
||||
|
return activity; |
||||
|
} |
||||
|
|
||||
|
const activityDate = resetHours(activity.date); |
||||
|
|
||||
|
// Accumulate both parts of the ratio and divide only once, so that the
|
||||
|
// cumulative split factor of consecutive splits stays exact
|
||||
|
let denominator = new Big(1); |
||||
|
let numerator = new Big(1); |
||||
|
|
||||
|
for (const split of splits) { |
||||
|
// Skip malformed splits to not break the portfolio calculation of every
|
||||
|
// user holding this asset profile
|
||||
|
if (split.denominator <= 0 || split.numerator <= 0) { |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
if (isBefore(activityDate, split.date)) { |
||||
|
denominator = denominator.mul(split.denominator); |
||||
|
numerator = numerator.mul(split.numerator); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (numerator.eq(denominator)) { |
||||
|
return activity; |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
...activity, |
||||
|
quantity: new Big(activity.quantity) |
||||
|
.mul(numerator) |
||||
|
.div(denominator) |
||||
|
.toNumber(), |
||||
|
unitPrice: new Big(activity.unitPrice) |
||||
|
.mul(denominator) |
||||
|
.div(numerator) |
||||
|
.toNumber(), |
||||
|
unitPriceInAssetProfileCurrency: new Big( |
||||
|
activity.unitPriceInAssetProfileCurrency |
||||
|
) |
||||
|
.mul(denominator) |
||||
|
.div(numerator) |
||||
|
.toNumber() |
||||
|
}; |
||||
|
} |
||||
@ -0,0 +1,161 @@ |
|||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
|
||||
|
import { AssetProfileSplit, DataSource } from '@prisma/client'; |
||||
|
|
||||
|
import { AssetProfileSplitService } from './asset-profile-split.service'; |
||||
|
|
||||
|
describe('AssetProfileSplitService', () => { |
||||
|
let assetProfileSplitService: AssetProfileSplitService; |
||||
|
let deleteMany: jest.Mock; |
||||
|
let findMany: jest.Mock; |
||||
|
let upsert: jest.Mock; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
deleteMany = jest.fn(); |
||||
|
findMany = jest.fn(); |
||||
|
upsert = jest.fn(); |
||||
|
|
||||
|
assetProfileSplitService = new AssetProfileSplitService({ |
||||
|
assetProfileSplit: { deleteMany, findMany, upsert } |
||||
|
} as unknown as PrismaService); |
||||
|
}); |
||||
|
|
||||
|
describe('deleteById', () => { |
||||
|
it('scopes deletion by split and asset profile identifiers', async () => { |
||||
|
deleteMany.mockResolvedValue({ count: 1 }); |
||||
|
|
||||
|
const result = await assetProfileSplitService.deleteById({ |
||||
|
id: 'split-id', |
||||
|
symbolProfileId: 'profile-id' |
||||
|
}); |
||||
|
|
||||
|
expect(result).toBe(true); |
||||
|
expect(deleteMany).toHaveBeenCalledWith({ |
||||
|
where: { |
||||
|
id: 'split-id', |
||||
|
symbolProfileId: 'profile-id' |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('returns false when the split belongs to another asset profile', async () => { |
||||
|
deleteMany.mockResolvedValue({ count: 0 }); |
||||
|
|
||||
|
const result = await assetProfileSplitService.deleteById({ |
||||
|
id: 'split-id', |
||||
|
symbolProfileId: 'other-profile-id' |
||||
|
}); |
||||
|
|
||||
|
expect(result).toBe(false); |
||||
|
expect(deleteMany).toHaveBeenCalledWith({ |
||||
|
where: { |
||||
|
id: 'split-id', |
||||
|
symbolProfileId: 'other-profile-id' |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('getSplitsByUserId', () => { |
||||
|
it('fetches the splits of the asset profiles held by the user with one ordered query', async () => { |
||||
|
const splits = [ |
||||
|
createStoredSplit('2020-08-31'), |
||||
|
createStoredSplit('2021-09-16') |
||||
|
]; |
||||
|
|
||||
|
findMany.mockResolvedValue(splits); |
||||
|
|
||||
|
const result = await assetProfileSplitService.getSplitsByUserId({ |
||||
|
userId: 'user-id' |
||||
|
}); |
||||
|
|
||||
|
expect(result).toBe(splits); |
||||
|
expect(findMany).toHaveBeenCalledTimes(1); |
||||
|
expect(findMany).toHaveBeenCalledWith({ |
||||
|
orderBy: [{ date: 'asc' }], |
||||
|
where: { |
||||
|
symbolProfile: { |
||||
|
activities: { |
||||
|
some: { |
||||
|
userId: 'user-id' |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('upsert', () => { |
||||
|
it('normalizes the split date before persisting it', async () => { |
||||
|
const date = new Date('2024-06-15T18:30:00.000Z'); |
||||
|
const normalizedDate = new Date('2024-06-15T00:00:00.000Z'); |
||||
|
|
||||
|
await assetProfileSplitService.upsert({ |
||||
|
date, |
||||
|
denominator: 1, |
||||
|
numerator: 2, |
||||
|
symbolProfileId: 'profile-id' |
||||
|
}); |
||||
|
|
||||
|
expect(upsert).toHaveBeenCalledWith({ |
||||
|
create: { |
||||
|
date: normalizedDate, |
||||
|
denominator: 1, |
||||
|
numerator: 2, |
||||
|
symbolProfileId: 'profile-id' |
||||
|
}, |
||||
|
update: { |
||||
|
denominator: 1, |
||||
|
numerator: 2 |
||||
|
}, |
||||
|
where: { |
||||
|
symbolProfileId_date: { |
||||
|
date: normalizedDate, |
||||
|
symbolProfileId: 'profile-id' |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('getSplits', () => { |
||||
|
it('filters by asset profile and orders splits by date ascending', async () => { |
||||
|
const splits = [ |
||||
|
createStoredSplit('2020-01-01'), |
||||
|
createStoredSplit('2021-01-01') |
||||
|
]; |
||||
|
findMany.mockResolvedValue(splits); |
||||
|
|
||||
|
const result = await assetProfileSplitService.getSplits({ |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
symbol: 'AAPL' |
||||
|
}); |
||||
|
|
||||
|
expect(result).toBe(splits); |
||||
|
expect(findMany).toHaveBeenCalledWith({ |
||||
|
orderBy: [{ date: 'asc' }], |
||||
|
where: { |
||||
|
symbolProfile: { |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
symbol: 'AAPL' |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
function createStoredSplit(date: string): AssetProfileSplit { |
||||
|
const splitDate = new Date(date); |
||||
|
|
||||
|
return { |
||||
|
createdAt: splitDate, |
||||
|
date: splitDate, |
||||
|
denominator: 1, |
||||
|
id: `${date}-split`, |
||||
|
numerator: 2, |
||||
|
symbolProfileId: 'aapl-profile', |
||||
|
updatedAt: splitDate |
||||
|
}; |
||||
|
} |
||||
@ -1,50 +1,110 @@ |
|||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; |
||||
|
import { UserSettings } from '@ghostfolio/common/interfaces'; |
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
||||
import type { RequestWithUser } from '@ghostfolio/common/types'; |
import { |
||||
|
getScopesOfAccess, |
||||
|
getScopesOfOwnAccess, |
||||
|
getScopesOfUnrestrictedImpersonation |
||||
|
} from '@ghostfolio/common/scopes'; |
||||
|
import type { |
||||
|
ImpersonationContext, |
||||
|
UserWithSettings |
||||
|
} from '@ghostfolio/common/types'; |
||||
|
|
||||
import { Inject, Injectable } from '@nestjs/common'; |
import { Injectable } from '@nestjs/common'; |
||||
import { REQUEST } from '@nestjs/core'; |
import { Access } from '@prisma/client'; |
||||
|
|
||||
@Injectable() |
@Injectable() |
||||
export class ImpersonationService { |
export class ImpersonationService { |
||||
public constructor( |
public constructor(private readonly prismaService: PrismaService) {} |
||||
private readonly prismaService: PrismaService, |
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser |
|
||||
) {} |
|
||||
|
|
||||
public async validateImpersonationId(aId = '') { |
public async resolve({ |
||||
if (this.request.user) { |
impersonationId, |
||||
|
user |
||||
|
}: { |
||||
|
impersonationId?: string; |
||||
|
user?: UserWithSettings; |
||||
|
}): Promise<ImpersonationContext> { |
||||
|
const { access, userId: impersonatedUserId } = |
||||
|
await this.validateImpersonation({ impersonationId, user }); |
||||
|
|
||||
|
if (!impersonatedUserId) { |
||||
|
return { |
||||
|
isActive: false, |
||||
|
scopes: getScopesOfOwnAccess(), |
||||
|
userId: user?.id, |
||||
|
userSettings: user?.settings?.settings ?? {} |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const settings = await this.prismaService.settings.findUnique({ |
||||
|
where: { userId: impersonatedUserId } |
||||
|
}); |
||||
|
|
||||
|
return { |
||||
|
accessId: impersonationId, |
||||
|
isActive: true, |
||||
|
// An access which has not been granted explicitly originates from the
|
||||
|
// permission to impersonate all users
|
||||
|
scopes: access |
||||
|
? getScopesOfAccess(access) |
||||
|
: getScopesOfUnrestrictedImpersonation(), |
||||
|
userId: impersonatedUserId, |
||||
|
userSettings: { |
||||
|
...((settings?.settings ?? {}) as UserSettings), |
||||
|
baseCurrency: |
||||
|
(settings?.settings as UserSettings)?.baseCurrency ?? DEFAULT_CURRENCY |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private async validateImpersonation({ |
||||
|
impersonationId, |
||||
|
user |
||||
|
}: { |
||||
|
impersonationId?: string; |
||||
|
user?: UserWithSettings; |
||||
|
}): Promise<{ access?: Access; userId: string | null }> { |
||||
|
if (!impersonationId) { |
||||
|
return { userId: null }; |
||||
|
} |
||||
|
|
||||
|
if (user) { |
||||
const accessObject = await this.prismaService.access.findFirst({ |
const accessObject = await this.prismaService.access.findFirst({ |
||||
where: { |
where: { |
||||
granteeUserId: this.request.user.id, |
granteeUserId: user.id, |
||||
id: aId |
id: impersonationId |
||||
} |
} |
||||
}); |
}); |
||||
|
|
||||
if (accessObject?.userId) { |
if (accessObject?.userId) { |
||||
return accessObject.userId; |
return { access: accessObject, userId: accessObject.userId }; |
||||
} else if ( |
} else if ( |
||||
hasPermission( |
hasPermission(user.permissions, permissions.impersonateAllUsers) |
||||
this.request.user.permissions, |
|
||||
permissions.impersonateAllUsers |
|
||||
) |
|
||||
) { |
) { |
||||
return aId; |
// The identifier is a user id in this case, hence verify its existence
|
||||
|
const impersonatedUser = await this.prismaService.user.findUnique({ |
||||
|
select: { id: true }, |
||||
|
where: { id: impersonationId } |
||||
|
}); |
||||
|
|
||||
|
return { userId: impersonatedUser?.id ?? null }; |
||||
} |
} |
||||
} else { |
} else { |
||||
// Public access
|
// Public access
|
||||
const accessObject = await this.prismaService.access.findFirst({ |
const accessObject = await this.prismaService.access.findFirst({ |
||||
where: { |
where: { |
||||
granteeUserId: null, |
granteeUserId: null, |
||||
user: { id: aId } |
user: { id: impersonationId } |
||||
} |
} |
||||
}); |
}); |
||||
|
|
||||
if (accessObject?.userId) { |
if (accessObject?.userId) { |
||||
return accessObject.userId; |
return { access: accessObject, userId: accessObject.userId }; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
return null; |
return { userId: null }; |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,9 +1,3 @@ |
|||||
:host { |
:host { |
||||
display: block; |
display: block; |
||||
|
|
||||
.mat-button-toggle-group { |
|
||||
.mat-button-toggle-appearance-standard { |
|
||||
--mat-button-toggle-height: 1.5rem; |
|
||||
} |
|
||||
} |
|
||||
} |
} |
||||
|
|||||
@ -1,5 +1,6 @@ |
|||||
import { Access } from '@ghostfolio/common/interfaces'; |
import { Access } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
export interface CreateOrUpdateAccessDialogParams { |
export interface CreateOrUpdateAccessDialogParams { |
||||
access?: Access; |
// TODO: Include the scopes once the dialog allows to configure them
|
||||
|
access?: Omit<Access, 'scopes'>; |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,296 @@ |
|||||
|
import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component'; |
||||
|
import { |
||||
|
AccountDetailDialogParams, |
||||
|
AccountDetailDialogResult |
||||
|
} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; |
||||
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; |
||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
||||
|
import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos'; |
||||
|
import { AccountResponse, User } from '@ghostfolio/common/interfaces'; |
||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
||||
|
import { internalRoutes } from '@ghostfolio/common/routes/routes'; |
||||
|
import { DataService } from '@ghostfolio/ui/services'; |
||||
|
|
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
computed, |
||||
|
DestroyRef, |
||||
|
OnDestroy, |
||||
|
OnInit, |
||||
|
inject |
||||
|
} from '@angular/core'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { MatDialog, MatDialogRef } from '@angular/material/dialog'; |
||||
|
import { ActivatedRoute, Router } from '@angular/router'; |
||||
|
import { DeviceDetectorService } from 'ngx-device-detector'; |
||||
|
import { Observable, of, Subject } from 'rxjs'; |
||||
|
import { |
||||
|
distinctUntilChanged, |
||||
|
map, |
||||
|
switchMap, |
||||
|
takeUntil, |
||||
|
tap |
||||
|
} from 'rxjs/operators'; |
||||
|
|
||||
|
import { GfCreateOrUpdateAccountDialogComponent } from '../create-or-update-account-dialog/create-or-update-account-dialog.component'; |
||||
|
import { CreateOrUpdateAccountDialogParams } from '../create-or-update-account-dialog/interfaces/interfaces'; |
||||
|
import { AccountDialogMode } from './types/account-dialog-mode.type'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
selector: 'gf-account-dialog-host', |
||||
|
template: '' |
||||
|
}) |
||||
|
export class GfAccountDialogHostComponent implements OnDestroy, OnInit { |
||||
|
private dialogRef: MatDialogRef< |
||||
|
GfAccountDetailDialogComponent | GfCreateOrUpdateAccountDialogComponent |
||||
|
>; |
||||
|
|
||||
|
private readonly deviceType = computed(() => { |
||||
|
return this.deviceDetectorService.deviceInfo().deviceType; |
||||
|
}); |
||||
|
|
||||
|
private readonly dialogClosed = new Subject<void>(); |
||||
|
|
||||
|
private readonly dataService = inject(DataService); |
||||
|
private readonly destroyRef = inject(DestroyRef); |
||||
|
private readonly deviceDetectorService = inject(DeviceDetectorService); |
||||
|
private readonly dialog = inject(MatDialog); |
||||
|
private readonly impersonationStorageService = inject( |
||||
|
ImpersonationStorageService |
||||
|
); |
||||
|
private readonly route = inject(ActivatedRoute); |
||||
|
private readonly router = inject(Router); |
||||
|
private readonly userService = inject(UserService); |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
const mode = this.route.snapshot.data.mode as AccountDialogMode; |
||||
|
|
||||
|
// The router reuses this component when only the account id changes, so
|
||||
|
// the parameters are observed instead of read from the snapshot once
|
||||
|
this.route.paramMap |
||||
|
.pipe( |
||||
|
map((paramMap) => { |
||||
|
return paramMap.get('accountId'); |
||||
|
}), |
||||
|
distinctUntilChanged(), |
||||
|
tap(() => { |
||||
|
this.closeDialog(); |
||||
|
}), |
||||
|
switchMap((accountId) => { |
||||
|
const account$: Observable<AccountResponse | undefined> = |
||||
|
mode === 'update' && accountId |
||||
|
? this.dataService.fetchAccount(accountId) |
||||
|
: of(undefined); |
||||
|
|
||||
|
return this.userService.get().pipe( |
||||
|
switchMap((user) => { |
||||
|
return account$.pipe( |
||||
|
map((account) => { |
||||
|
return { account, accountId, user }; |
||||
|
}) |
||||
|
); |
||||
|
}) |
||||
|
); |
||||
|
}), |
||||
|
takeUntilDestroyed(this.destroyRef) |
||||
|
) |
||||
|
.subscribe({ |
||||
|
error: () => { |
||||
|
this.navigateBack(); |
||||
|
}, |
||||
|
next: ({ account, accountId, user }) => { |
||||
|
if (mode === 'detail') { |
||||
|
this.openAccountDetailDialog({ accountId, user }); |
||||
|
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (mode === 'update') { |
||||
|
if ( |
||||
|
!account || |
||||
|
!hasPermission(user?.permissions, permissions.updateAccount) || |
||||
|
this.isReadOnlyMode(user) |
||||
|
) { |
||||
|
this.navigateBack(); |
||||
|
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const { balance, comment, currency, id, name, platformId, tags } = |
||||
|
account; |
||||
|
|
||||
|
this.openCreateOrUpdateAccountDialog({ |
||||
|
user, |
||||
|
account: { |
||||
|
balance, |
||||
|
comment, |
||||
|
currency, |
||||
|
id, |
||||
|
name, |
||||
|
platformId, |
||||
|
tags |
||||
|
}, |
||||
|
isUpdate: true |
||||
|
}); |
||||
|
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if ( |
||||
|
!hasPermission(user?.permissions, permissions.createAccount) || |
||||
|
this.isReadOnlyMode(user) |
||||
|
) { |
||||
|
this.navigateBack(); |
||||
|
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.openCreateOrUpdateAccountDialog({ |
||||
|
user, |
||||
|
account: { |
||||
|
balance: 0, |
||||
|
comment: null, |
||||
|
currency: user?.settings?.baseCurrency ?? null, |
||||
|
id: null, |
||||
|
name: null, |
||||
|
platformId: null, |
||||
|
tags: [] |
||||
|
}, |
||||
|
isUpdate: false |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy() { |
||||
|
// The dialog lives in an overlay outside of this component, so it needs to
|
||||
|
// be closed explicitly when leaving the route (for example via the browser
|
||||
|
// navigation)
|
||||
|
this.dialogRef?.close(); |
||||
|
|
||||
|
this.dialogClosed.complete(); |
||||
|
} |
||||
|
|
||||
|
private closeDialog() { |
||||
|
// Tear down the subscription of the dialog which is about to be replaced,
|
||||
|
// so that its result is not mistaken for the user closing it
|
||||
|
this.dialogClosed.next(); |
||||
|
|
||||
|
this.dialogRef?.close(); |
||||
|
} |
||||
|
|
||||
|
private isReadOnlyMode(user: User) { |
||||
|
return ( |
||||
|
!!this.impersonationStorageService.getId() || |
||||
|
!!user?.settings?.isRestrictedView |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
private navigateBack() { |
||||
|
void this.router.navigate(internalRoutes.accounts.routerLink); |
||||
|
} |
||||
|
|
||||
|
private openAccountDetailDialog({ |
||||
|
accountId, |
||||
|
user |
||||
|
}: { |
||||
|
accountId: string | null; |
||||
|
user: User; |
||||
|
}) { |
||||
|
if (!accountId) { |
||||
|
this.navigateBack(); |
||||
|
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const impersonationId = this.impersonationStorageService.getId(); |
||||
|
|
||||
|
const dialogRef = this.dialog.open< |
||||
|
GfAccountDetailDialogComponent, |
||||
|
AccountDetailDialogParams, |
||||
|
AccountDetailDialogResult |
||||
|
>(GfAccountDetailDialogComponent, { |
||||
|
autoFocus: false, |
||||
|
data: { |
||||
|
accountId, |
||||
|
impersonationId, |
||||
|
deviceType: this.deviceType(), |
||||
|
hasPermissionToCreateActivity: |
||||
|
!impersonationId && |
||||
|
hasPermission(user?.permissions, permissions.createActivity) && |
||||
|
!user?.settings?.isRestrictedView |
||||
|
}, |
||||
|
height: this.deviceType() === 'mobile' ? '98vh' : '80vh', |
||||
|
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' |
||||
|
}); |
||||
|
|
||||
|
this.dialogRef = dialogRef; |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((result) => { |
||||
|
if (result?.isNavigating) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.navigateBack(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private openCreateOrUpdateAccountDialog({ |
||||
|
account, |
||||
|
isUpdate, |
||||
|
user |
||||
|
}: { |
||||
|
account: CreateOrUpdateAccountDialogParams['account']; |
||||
|
isUpdate: boolean; |
||||
|
user: User; |
||||
|
}) { |
||||
|
const dialogRef = this.dialog.open< |
||||
|
GfCreateOrUpdateAccountDialogComponent, |
||||
|
CreateOrUpdateAccountDialogParams, |
||||
|
CreateAccountDto | UpdateAccountDto | null |
||||
|
>(GfCreateOrUpdateAccountDialogComponent, { |
||||
|
data: { |
||||
|
account, |
||||
|
user |
||||
|
}, |
||||
|
height: this.deviceType() === 'mobile' ? '98vh' : '80vh', |
||||
|
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' |
||||
|
}); |
||||
|
|
||||
|
this.dialogRef = dialogRef; |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((result) => { |
||||
|
if (!result) { |
||||
|
this.navigateBack(); |
||||
|
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const request$: Observable<unknown> = isUpdate |
||||
|
? this.dataService.putAccount(result as UpdateAccountDto) |
||||
|
: this.dataService.postAccount(result as CreateAccountDto); |
||||
|
|
||||
|
request$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ |
||||
|
error: () => { |
||||
|
this.navigateBack(); |
||||
|
}, |
||||
|
next: () => { |
||||
|
// Deliberately not bound to the destroy reference: navigating back
|
||||
|
// destroys this component and the refreshed user is what makes the
|
||||
|
// accounts page reload its data
|
||||
|
this.userService.get(true).subscribe(); |
||||
|
|
||||
|
this.navigateBack(); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue