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