mirror of https://github.com/ghostfolio/ghostfolio
54 changed files with 1022 additions and 22 deletions
@ -0,0 +1,63 @@ |
|||||
|
import { ExportService } from './export.service'; |
||||
|
|
||||
|
describe('ExportService', () => { |
||||
|
it('exports the tax amount of an activity', async () => { |
||||
|
const accountService = { |
||||
|
accounts: jest.fn().mockResolvedValue([]) |
||||
|
}; |
||||
|
|
||||
|
const activitiesService = { |
||||
|
getActivities: jest.fn().mockResolvedValue({ |
||||
|
activities: [ |
||||
|
{ |
||||
|
accountId: null, |
||||
|
assetProfile: { |
||||
|
currency: 'USD', |
||||
|
dataSource: 'YAHOO', |
||||
|
symbol: 'MSFT', |
||||
|
userId: 'another-user' |
||||
|
}, |
||||
|
comment: null, |
||||
|
currency: 'USD', |
||||
|
date: new Date('2024-05-15T00:00:00.000Z'), |
||||
|
fee: 1, |
||||
|
id: 'activity-1', |
||||
|
quantity: 2, |
||||
|
tax: 7.5, |
||||
|
tags: [], |
||||
|
type: 'BUY', |
||||
|
unitPrice: 100 |
||||
|
} |
||||
|
] |
||||
|
}) |
||||
|
}; |
||||
|
|
||||
|
const marketDataService = { |
||||
|
marketDataItems: jest.fn() |
||||
|
}; |
||||
|
|
||||
|
const tagService = { |
||||
|
getTagsForUser: jest.fn().mockResolvedValue([]) |
||||
|
}; |
||||
|
|
||||
|
const exportService = new ExportService( |
||||
|
accountService as never, |
||||
|
activitiesService as never, |
||||
|
marketDataService as never, |
||||
|
tagService as never |
||||
|
); |
||||
|
|
||||
|
const result = await exportService.export({ |
||||
|
userId: 'user-1', |
||||
|
userSettings: { |
||||
|
baseCurrency: 'USD' |
||||
|
} as never |
||||
|
}); |
||||
|
|
||||
|
expect(result.activities).toHaveLength(1); |
||||
|
expect(result.activities[0]).toMatchObject({ |
||||
|
fee: 1, |
||||
|
tax: 7.5 |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,126 @@ |
|||||
|
import { |
||||
|
getPerformanceByDateRange, |
||||
|
loadActivitiesFromExportFile, |
||||
|
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 { 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 { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
|
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('PortfolioCalculator tax', () => { |
||||
|
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
PortfolioSnapshotServiceMock.reset(); |
||||
|
RedisCacheServiceMock.reset(); |
||||
|
|
||||
|
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
||||
|
new ConfigurationService(), |
||||
|
new CurrentRateService(null, null, null), |
||||
|
new ExchangeRateDataService(null, null, null, null), |
||||
|
new PortfolioSnapshotService(null, null), |
||||
|
new RedisCacheService(null, null) |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
it('subtracts taxes from net performance while preserving gross performance and gross dividends', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); |
||||
|
|
||||
|
const { activities, userCurrency } = loadActivitiesFromExportFile( |
||||
|
'msft-buy-with-dividend.json' |
||||
|
); |
||||
|
|
||||
|
const activitiesWithTax = activities.map((activity) => { |
||||
|
const tax = |
||||
|
activity.type === 'BUY' ? 5 : activity.type === 'DIVIDEND' ? 0.2 : 0; |
||||
|
|
||||
|
return { |
||||
|
...activity, |
||||
|
tax, |
||||
|
taxInAssetProfileCurrency: tax, |
||||
|
taxInBaseCurrency: tax |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
||||
|
activities: activitiesWithTax, |
||||
|
calculationType: PerformanceCalculationType.ROAI, |
||||
|
currency: userCurrency, |
||||
|
usePortfolioSnapshotCache: false, |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
||||
|
|
||||
|
const performanceByDateRange = await getPerformanceByDateRange({ |
||||
|
portfolioCalculator, |
||||
|
dateRanges: ['max', 'ytd'] |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
positions: [ |
||||
|
expect.objectContaining({ |
||||
|
dividend: new Big('0.62'), |
||||
|
dividendInBaseCurrency: new Big('0.62'), |
||||
|
fee: new Big('19'), |
||||
|
grossPerformance: new Big('33.87'), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('33.87'), |
||||
|
netPerformance: new Big('9.67'), |
||||
|
tax: new Big('5.2'), |
||||
|
taxInBaseCurrency: new Big('5.2') |
||||
|
}) |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
expect(performanceByDateRange.max).toMatchObject({ |
||||
|
dividendInBaseCurrency: 0.62, |
||||
|
netPerformance: expect.closeTo(9.67, 10), |
||||
|
netPerformanceWithCurrencyEffect: expect.closeTo(9.67, 10) |
||||
|
}); |
||||
|
|
||||
|
// Both taxes occurred before the YTD range and must therefore be part of
|
||||
|
// the starting baseline instead of reducing the performance of this range.
|
||||
|
expect(performanceByDateRange.ytd).toMatchObject({ |
||||
|
dividendInBaseCurrency: 0, |
||||
|
netPerformance: expect.closeTo(-7.68, 10), |
||||
|
netPerformanceWithCurrencyEffect: expect.closeTo(-7.68, 10) |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,78 @@ |
|||||
|
import { ImportActivitiesService } from './import-activities.service'; |
||||
|
|
||||
|
jest.mock('uuid', () => ({ |
||||
|
v4: jest.fn(() => '00000000-0000-4000-8000-000000000000') |
||||
|
})); |
||||
|
|
||||
|
describe('ImportActivitiesService', () => { |
||||
|
let importActivitiesService: ImportActivitiesService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
importActivitiesService = Object.create( |
||||
|
ImportActivitiesService.prototype |
||||
|
) as ImportActivitiesService; |
||||
|
|
||||
|
jest |
||||
|
.spyOn(importActivitiesService, 'importJson') |
||||
|
.mockImplementation(async ({ activities }) => { |
||||
|
return { |
||||
|
activities: activities as never |
||||
|
}; |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('defaults tax to zero when the CSV has no tax column', async () => { |
||||
|
const { activities } = await importActivitiesService.importCsv({ |
||||
|
fileContent: |
||||
|
'Date,Code,DataSource,Currency,Price,Quantity,Action,Fee\n' + |
||||
|
'16-09-2021,MSFT,YAHOO,USD,298.58,5,buy,19', |
||||
|
userAccounts: [] |
||||
|
}); |
||||
|
|
||||
|
expect(activities[0]).toMatchObject({ |
||||
|
fee: 19, |
||||
|
tax: 0 |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('imports tax from the CSV', async () => { |
||||
|
const { activities } = await importActivitiesService.importCsv({ |
||||
|
fileContent: |
||||
|
'Date,Code,DataSource,Currency,Price,Quantity,Action,Fee,Tax\n' + |
||||
|
'16-09-2021,MSFT,YAHOO,USD,298.58,5,buy,19,7.5', |
||||
|
userAccounts: [] |
||||
|
}); |
||||
|
|
||||
|
expect(activities[0]).toMatchObject({ |
||||
|
fee: 19, |
||||
|
tax: 7.5 |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('imports tax from a withholding tax column', async () => { |
||||
|
const { activities } = await importActivitiesService.importCsv({ |
||||
|
fileContent: |
||||
|
'Date,Code,DataSource,Currency,Price,Quantity,Action,Fee,Withholding Tax\n' + |
||||
|
'17-11-2021,MSFT,YAHOO,USD,0.62,5,dividend,0,0.93', |
||||
|
userAccounts: [] |
||||
|
}); |
||||
|
|
||||
|
expect(activities[0]).toMatchObject({ |
||||
|
tax: 0.93, |
||||
|
type: 'DIVIDEND' |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('rejects an invalid tax value', async () => { |
||||
|
await expect( |
||||
|
importActivitiesService.importCsv({ |
||||
|
fileContent: |
||||
|
'Date,Code,DataSource,Currency,Price,Quantity,Action,Fee,Tax\n' + |
||||
|
'16-09-2021,MSFT,YAHOO,USD,298.58,5,buy,19,<invalid>', |
||||
|
userAccounts: [] |
||||
|
}) |
||||
|
).rejects.toMatchObject({ |
||||
|
message: 'activities.0.tax is not valid' |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,2 @@ |
|||||
|
ALTER TABLE "Order" |
||||
|
ADD COLUMN "tax" DOUBLE PRECISION NOT NULL DEFAULT 0; |
||||
Loading…
Reference in new issue