diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts index b32d209ad0..abaedb2539 100644 --- a/apps/api/src/app/activities/activities.service.spec.ts +++ b/apps/api/src/app/activities/activities.service.spec.ts @@ -153,6 +153,120 @@ describe('ActivitiesService', () => { ); }); + it.each([ + { + expectedAmount: -210, + fee: 3, + quantity: 2, + tax: 7, + type: 'BUY' as ActivityType, + unitPrice: 100 + }, + { + expectedAmount: 190, + fee: 3, + quantity: 2, + tax: 7, + type: 'SELL' as ActivityType, + unitPrice: 100 + }, + { + expectedAmount: 40, + fee: 0, + quantity: 10, + tax: 10, + type: 'DIVIDEND' as ActivityType, + unitPrice: 5 + }, + { + expectedAmount: 40, + fee: 0, + quantity: 1, + tax: 10, + type: 'INTEREST' as ActivityType, + unitPrice: 50 + } + ])( + 'updates the account balance including tax for $type activities', + async ({ expectedAmount, fee, quantity, tax, type, unitPrice }) => { + const updateAccountBalance = jest.fn(); + const create = jest.fn(({ data }: Prisma.OrderCreateArgs) => { + return { + ...data, + SymbolProfile: data.SymbolProfile.connectOrCreate.create, + userId: 'user-id' + }; + }); + + const service = new ActivitiesService( + null, + { + updateAccountBalance + } as unknown as AccountService, + null, + null, + { + addJobToQueue: jest.fn() + } as unknown as DataGatheringService, + null, + { + emit: jest.fn() + } as unknown as EventEmitter2, + null, + null, + { + order: { create }, + symbolProfile: { + findUnique: jest.fn().mockResolvedValue(null) + } + } as unknown as PrismaService, + null, + { + validateTagIds: jest.fn() + } as unknown as TagService + ); + + await service.createActivity({ + accountId: 'account-id', + currency: 'USD', + date: parseDate('2026-10-19'), + fee, + quantity, + tax, + type, + unitPrice, + SymbolProfile: { + connectOrCreate: { + create: { + currency: 'USD', + dataSource: DataSource.YAHOO, + name: 'Tax Test', + symbol: 'TAX_TEST' + }, + where: { + dataSource_symbol: { + dataSource: DataSource.YAHOO, + symbol: 'TAX_TEST' + } + } + } + }, + updateAccountBalance: true, + user: { connect: { id: 'user-id' } }, + userId: 'user-id' + }); + + expect(updateAccountBalance).toHaveBeenCalledTimes(1); + expect(updateAccountBalance).toHaveBeenCalledWith({ + accountId: 'account-id', + amount: expectedAmount, + currency: 'USD', + date: parseDate('2026-10-19'), + userId: 'user-id' + }); + } + ); + async function getCreatedAssetProfile({ existingAssetProfile, symbol @@ -302,6 +416,102 @@ describe('ActivitiesService', () => { expect(activities[0].assetProfile).toBe(assetProfile); }); + it('converts tax to the asset profile and base currencies at the activity date', async () => { + const date = parseDate('2024-05-15'); + const assetProfile = { + ...assetProfileDummyData, + currency: 'EUR', + dataSource: DataSource.YAHOO, + name: 'Tax Test', + symbol: 'TAX' + }; + + const findMany = jest.fn().mockResolvedValue([ + { + ...activityDummyData, + account: null, + currency: 'CHF', + date, + fee: 1, + id: 'activity-id', + quantity: 2, + tax: 4, + SymbolProfile: { + currency: 'EUR', + dataSource: DataSource.YAHOO, + symbol: 'TAX' + }, + tags: [], + type: 'BUY', + unitPrice: 100 + } + ]); + + const toCurrencyAtDate = jest + .fn() + .mockResolvedValueOnce(11) + .mockResolvedValueOnce(12) + .mockResolvedValueOnce(21) + .mockResolvedValueOnce(22) + .mockResolvedValueOnce(31) + .mockResolvedValueOnce(32); + + const service = new ActivitiesService( + null, + null, + null, + null, + null, + null, + null, + { + toCurrencyAtDate + } as unknown as ExchangeRateDataService, + null, + { + order: { + findMany, + count: jest.fn().mockResolvedValue(1) + } + } as unknown as PrismaService, + { + getSymbolProfiles: jest.fn().mockResolvedValue([assetProfile]) + } as unknown as SymbolProfileService, + null + ); + + const { activities } = await service.getActivities({ + userCurrency: 'USD', + userId: 'user-id' + }); + + expect(activities[0]).toMatchObject({ + fee: 1, + feeInAssetProfileCurrency: 11, + feeInBaseCurrency: 12, + tax: 4, + taxInAssetProfileCurrency: 21, + taxInBaseCurrency: 22, + unitPriceInAssetProfileCurrency: 31, + valueInBaseCurrency: 32 + }); + + expect(toCurrencyAtDate).toHaveBeenNthCalledWith( + 3, + 4, + 'CHF', + 'EUR', + date + ); + expect(toCurrencyAtDate).toHaveBeenNthCalledWith( + 4, + 4, + 'CHF', + 'USD', + date + ); + }); + it('excludes the activities of the start date of a date range', async () => { jest.useFakeTimers().setSystemTime(parseDate('2024-06-15').getTime()); diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 4a20658f97..b6946d2324 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -318,7 +318,7 @@ export class ActivitiesService { amount = amount.mul(-1); } - amount = amount.minus(data.fee); + amount = amount.minus(data.fee).minus(data.tax ?? 0); await this.accountService.updateAccountBalance({ accountId, @@ -548,6 +548,9 @@ export class ActivitiesService { feeInAssetProfileCurrency: 0, feeInBaseCurrency: 0, id: balanceItem.id, + tax: 0, + taxInAssetProfileCurrency: 0, + taxInBaseCurrency: 0, quantity: 1, symbolProfileId: account.currency, type: ActivityType.BUY, @@ -726,6 +729,8 @@ export class ActivitiesService { const [ feeInAssetProfileCurrency = 0, feeInBaseCurrency = 0, + taxInAssetProfileCurrency = 0, + taxInBaseCurrency = 0, unitPriceInAssetProfileCurrency = 0, valueInBaseCurrency = 0 ] = await Promise.all([ @@ -741,6 +746,18 @@ export class ActivitiesService { userCurrency, order.date ), + this.exchangeRateDataService.toCurrencyAtDate( + order.tax, + order.currency ?? order.SymbolProfile.currency, + order.SymbolProfile.currency, + order.date + ), + this.exchangeRateDataService.toCurrencyAtDate( + order.tax, + order.currency ?? order.SymbolProfile.currency, + userCurrency, + order.date + ), this.exchangeRateDataService.toCurrencyAtDate( order.unitPrice, order.currency ?? order.SymbolProfile.currency, @@ -760,6 +777,8 @@ export class ActivitiesService { assetProfile, feeInAssetProfileCurrency, feeInBaseCurrency, + taxInAssetProfileCurrency, + taxInBaseCurrency, unitPriceInAssetProfileCurrency, value, valueInBaseCurrency diff --git a/apps/api/src/app/endpoints/ai/ai.schemas.ts b/apps/api/src/app/endpoints/ai/ai.schemas.ts index 1691035dda..0454fc35e5 100644 --- a/apps/api/src/app/endpoints/ai/ai.schemas.ts +++ b/apps/api/src/app/endpoints/ai/ai.schemas.ts @@ -24,13 +24,14 @@ export const ACTIVITY_ROWS_OUTPUT = z.object({ .string() .nullable() .describe( - 'The currency of the fee and of the unit price, as an ISO 4217 code in upper case' + 'The currency of the fee, tax and unit price, as an ISO 4217 code in upper case' ), date: z .string() .describe('The date of the activity, as an ISO 8601 date'), fee: z.number().describe('The fee of the activity'), isin: PRINTED_TEXT.describe('The ISIN of the asset, as printed'), + tax: z.number().describe('The tax of the activity'), name: PRINTED_TEXT.describe( 'The name of the asset, or the description of a fee, an interest or a liability, as printed' ), diff --git a/apps/api/src/app/endpoints/ai/ai.service.spec.ts b/apps/api/src/app/endpoints/ai/ai.service.spec.ts index 69636a19fb..5fea5fadc1 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.spec.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.spec.ts @@ -268,7 +268,7 @@ describe('AiService', () => { ); }); - it('Tells the vision model to add the fees of a buy or a sell to its row', async () => { + it('Tells the vision model to keep fees and taxes separate', async () => { await aiService.getActivityRowsFromFile({ file: { buffer: Buffer.from('%PDF-1.7'), @@ -280,7 +280,10 @@ describe('AiService', () => { const { instructions } = jest.mocked(generateText).mock.calls[0][0]; expect(instructions).toContain( - 'Add every fee, commission and tax of a buy or a sell, for example a commission, a stamp duty or an exchange fee, to the fee of its row.' + 'Add every fee and commission of a buy or a sell, for example a commission or an exchange fee, to the fee of its row.' + ); + expect(instructions).toContain( + 'Give every tax or withholding tax of an activity, for example a stamp duty or a dividend withholding tax, separately as tax. Do not include it in the fee and do not give it as a separate row.' ); }); @@ -369,6 +372,7 @@ describe('AiService', () => { date: '2026-01-15', fee: 1, quantity: 10, + tax: 0, symbol: 'AAPL', type: ActivityType.BUY, unitPrice: 230 @@ -414,6 +418,7 @@ describe('AiService', () => { date: '2026-01-15', fee: 5, quantity: 0, + tax: 0, symbol: 'Custody fee', type: ActivityType.FEE, unitPrice: 0 @@ -589,6 +594,7 @@ describe('AiService', () => { fee: row.fee, quantity: row.quantity, symbol: 'AAPL', + tax: row.tax, type: ActivityType.BUY, unitPrice: row.unitPrice } @@ -620,7 +626,7 @@ describe('AiService', () => { expect(activities[0].currency).toBe('CHF'); }); - it('Gives the absolute values of a negative fee, quantity and unit price', async () => { + it('Gives the absolute values of a negative fee, tax, quantity and unit price', async () => { mockRowMatches({ assets: [ { currency: 'USD', dataSource: 'YAHOO', id: 0, symbol: 'AAPL' } @@ -636,6 +642,7 @@ describe('AiService', () => { accountName: null, fee: -1, quantity: -10, + tax: -2, type: ActivityType.SELL, unitPrice: -230 }) @@ -645,6 +652,7 @@ describe('AiService', () => { expect(activities[0]).toMatchObject({ fee: 1, quantity: 10, + tax: 2, unitPrice: 230 }); }); diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index 516e93ad81..ce63aeed4e 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -143,9 +143,9 @@ export class AiService { * shows the broker only, for example as a logo. A row which does not give a * valid activity, for example because its asset has no match, is a row * without a match. An account id which is not the id of an account of the - * user is ignored. The fee, the quantity and the unit price are taken as - * absolute values, as a broker can give a sell or a fee as a negative - * number. + * user is ignored. The fee, the tax, the quantity and the unit price are + * taken as absolute values, as a broker can give a sell, a fee or a tax as + * a negative number. */ public async getActivitiesFromRows({ abortSignal, @@ -232,6 +232,7 @@ export class AiService { const fee = Math.abs(row.fee); const quantity = Math.abs(row.quantity); + const tax = Math.abs(row.tax); const unitPrice = Math.abs(row.unitPrice); const account = accounts.find(({ id }) => { @@ -251,6 +252,7 @@ export class AiService { date, fee, quantity, + tax, type, unitPrice, accountId: account?.id, @@ -261,6 +263,7 @@ export class AiService { date, fee, quantity, + tax, type, unitPrice, accountId: account?.id, @@ -329,10 +332,11 @@ export class AiService { 'Give the name of the broker or of the bank which issued the file as broker, for example from the logo, the letterhead, the text or the name of the file. Give null if the file does not show it.', 'For a fee, an interest or a liability, give its description as name.', `Give the date as an ISO 8601 date. Today is ${format(new Date(), DATE_FORMAT)}.`, - 'Give the quantity, the unit price and the fee as positive numbers. Give 0 as fee if the file does not show a fee.', + 'Give the quantity, the unit price, the fee and the tax as positive numbers. Give 0 as fee or tax if the file does not show it.', 'For a dividend with a total amount only, give 1 as quantity and the total amount as unit price.', 'For an interest or a liability, give 1 as quantity and the amount as unit price.', - 'Add every fee, commission and tax of a buy or a sell, for example a commission, a stamp duty or an exchange fee, to the fee of its row. Do not give them as separate rows.', + 'Add every fee and commission of a buy or a sell, for example a commission or an exchange fee, to the fee of its row. Do not give them as separate rows.', + 'Give every tax or withholding tax of an activity, for example a stamp duty or a dividend withholding tax, separately as tax. Do not include it in the fee and do not give it as a separate row.', 'For a fee which is not part of a buy or a sell, for example a custody fee, give 0 as quantity, 0 as unit price and the amount as fee.', `If the format of a date or of a number is ambiguous and the file gives no other hint, read it as in the locale ${locale}.`, `Give at most ${AI_MAX_ACTIVITIES} rows.` diff --git a/apps/api/src/app/endpoints/ai/ai.test-utils.ts b/apps/api/src/app/endpoints/ai/ai.test-utils.ts index 681177a963..db65d5df02 100644 --- a/apps/api/src/app/endpoints/ai/ai.test-utils.ts +++ b/apps/api/src/app/endpoints/ai/ai.test-utils.ts @@ -14,6 +14,7 @@ export function createActivityRow( name: 'Apple Inc.', quantity: 10, symbol: 'AAPL', + tax: 0, type: ActivityType.BUY, unitPrice: 230, ...overrides diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts index 31f4569a86..ab30d53d4b 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts @@ -201,6 +201,22 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => { expect(parse([createActivity({ currency: 'USD' })])).toBe(true); }); + it('Defaults tax to zero', () => { + expect( + IMPORT_ACTIVITIES_PARAMETERS.parse({ + activities: [createActivity()] + }).activities[0].tax + ).toBe(0); + }); + + it('Accepts a non-negative tax', () => { + expect(parse([createActivity({ tax: 7.5 })])).toBe(true); + }); + + it('Refuses a negative tax', () => { + expect(parse([createActivity({ tax: -1 })])).toBe(false); + }); + it('Accepts an encoded data source', () => { expect( parse([ diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts index 1802305ae6..63700294a1 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts @@ -149,7 +149,7 @@ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ .string() .refine(isValidCurrencyCode) .describe( - 'The currency of the fee and of the unit price, as an ISO 4217 code in upper case' + 'The currency of the fee, tax and unit price, as an ISO 4217 code in upper case' ), dataSource: DATA_SOURCE_PARAMETER.optional(), date: z @@ -160,6 +160,7 @@ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ ), fee: z.number().min(0).describe('The fee of the activity'), quantity: z.number().min(0).describe('The quantity of the activity'), + tax: z.number().min(0).default(0).describe('The tax of the activity'), symbol: SYMBOL_PARAMETER, type: z.enum(ActivityType).describe('The type of the activity'), unitPrice: z.number().min(0).describe('The unit price of the activity') diff --git a/apps/api/src/app/endpoints/mcp/mcp.test-utils.ts b/apps/api/src/app/endpoints/mcp/mcp.test-utils.ts index 84895a64ff..8af228bda5 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.test-utils.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.test-utils.ts @@ -10,6 +10,7 @@ export function createActivity( date: '2024-01-01', fee: 0, quantity: 1, + tax: 0, symbol: 'AAPL', type: ActivityType.BUY, unitPrice: 100, diff --git a/apps/api/src/app/endpoints/public/public.service.ts b/apps/api/src/app/endpoints/public/public.service.ts index c32df6d82d..096edf82c9 100644 --- a/apps/api/src/app/endpoints/public/public.service.ts +++ b/apps/api/src/app/endpoints/public/public.service.ts @@ -99,6 +99,7 @@ export class PublicService { date, fee, quantity, + tax, type, unitPrice, value, @@ -109,6 +110,7 @@ export class PublicService { date, fee, quantity, + tax, type, unitPrice, value, diff --git a/apps/api/src/app/export/export.service.spec.ts b/apps/api/src/app/export/export.service.spec.ts new file mode 100644 index 0000000000..42807c9f15 --- /dev/null +++ b/apps/api/src/app/export/export.service.spec.ts @@ -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 + }); + }); +}); diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index db08f62052..80cac2455c 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -240,6 +240,7 @@ export class ExportService { fee, id, quantity, + tax, tags: currentTags, type, unitPrice @@ -250,6 +251,7 @@ export class ExportService { fee, id, quantity, + tax, type, unitPrice, currency: currency ?? assetProfile.currency, diff --git a/apps/api/src/app/import/import.service.spec.ts b/apps/api/src/app/import/import.service.spec.ts index d6d41134a5..f6be3ed512 100644 --- a/apps/api/src/app/import/import.service.spec.ts +++ b/apps/api/src/app/import/import.service.spec.ts @@ -33,6 +33,7 @@ describe('ImportService', () => { let addSymbolProfile: jest.Mock; let createActivity: jest.Mock; let gatherSymbols: jest.Mock; + let getActivities: jest.Mock; let importService: ImportService; let updateManyMarketData: jest.Mock; @@ -46,6 +47,7 @@ describe('ImportService', () => { addSymbolProfile = jest.fn(); createActivity = jest.fn(); gatherSymbols = jest.fn(); + getActivities = jest.fn().mockResolvedValue({ activities: [], count: 0 }); updateManyMarketData = jest.fn(); const validateActivities = jest.fn( @@ -73,7 +75,7 @@ describe('ImportService', () => { } as unknown as AccountService, { createActivity, - getActivities: jest.fn().mockResolvedValue({ activities: [], count: 0 }) + getActivities } as unknown as ActivitiesService, null, { @@ -481,6 +483,108 @@ describe('ImportService', () => { ]); }); + it('propagates tax when importing an activity', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + ]); + + await importActivities({ + activitiesDto: [ + { + ...createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-01T00:00:00.000Z', + symbol: 'AAPL', + type: 'BUY' + }), + tax: 7 + } + ] + }); + + expect(createActivity).toHaveBeenCalledTimes(1); + expect(createActivity.mock.calls[0][0]).toMatchObject({ + fee: 0, + quantity: 1, + tax: 7, + type: 'BUY', + unitPrice: 100 + }); + }); + + it('includes tax when detecting duplicate activities', async () => { + getActivities.mockResolvedValue({ + activities: [ + { + assetProfile: { + currency: 'USD', + dataSource: DataSource.YAHOO, + symbol: 'AAPL' + }, + comment: undefined, + currency: 'USD', + date: parseISO('2024-01-01T00:00:00.000Z'), + fee: 0, + quantity: 1, + tax: 7, + type: 'BUY', + unitPrice: 100 + } as unknown as Activity, + { + assetProfile: { + currency: 'USD', + dataSource: DataSource.YAHOO, + symbol: 'AAPL' + }, + comment: undefined, + currency: 'USD', + date: parseISO('2024-01-02T00:00:00.000Z'), + fee: 0, + quantity: 1, + type: 'BUY', + unitPrice: 100 + } as unknown as Activity + ], + count: 2 + }); + + const activities = await importActivities({ + activitiesDto: [ + { + ...createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-01T00:00:00.000Z', + symbol: 'AAPL', + type: 'BUY' + }), + tax: 8 + }, + { + ...createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-01T00:00:00.000Z', + symbol: 'AAPL', + type: 'BUY' + }), + tax: 7 + }, + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-02T00:00:00.000Z', + symbol: 'AAPL', + type: 'BUY' + }) + ], + isDryRun: true + }); + + expect(activities[0].error).toBeUndefined(); + expect(activities[1].error).toEqual({ code: 'IS_DUPLICATE' }); + expect(activities[2].error).toEqual({ code: 'IS_DUPLICATE' }); + + expect(activities.map(({ tax }) => tax)).toEqual([8, 7, 0]); + }); + it('keeps the asset profiles of the activities in a dry run', async () => { const activities = await importActivities({ activitiesDto: [ @@ -562,6 +666,7 @@ function createActivityDto({ currency: 'USD', fee: 0, quantity: 1, + tax: 0, unitPrice: 100 } as CreateOrderDto; } diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 2d09cb78df..005784f977 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -175,6 +175,9 @@ export class ImportService { feeInAssetProfileCurrency: 0, feeInBaseCurrency: 0, id: assetProfile.id, + tax: 0, + taxInAssetProfileCurrency: 0, + taxInBaseCurrency: 0, symbolProfileId: assetProfile.id, type: 'DIVIDEND', unitPrice: marketPrice, @@ -867,6 +870,7 @@ export class ImportService { const error = activity.error; const fee = activity.fee; const quantity = activity.quantity; + const tax = activity.tax ?? 0; const tagIds = activity.tagIds ?? []; const type = activity.type; const unitPrice = activity.unitPrice; @@ -910,6 +914,7 @@ export class ImportService { date, fee, quantity, + tax, type, unitPrice, account: validatedAccount, @@ -942,6 +947,7 @@ export class ImportService { date, fee, quantity, + tax, type, unitPrice, accountId: validatedAccount?.id, @@ -1060,10 +1066,12 @@ export class ImportService { quantity, symbol, tags, + tax: taxInput, type, unitPrice }) => { const date = parseISO(dateString); + const tax = taxInput ?? 0; const isDuplicate = existingActivities.some((activity) => { return ( @@ -1073,6 +1081,7 @@ export class ImportService { activity.assetProfile.dataSource === dataSource && isSameSecond(activity.date, date) && activity.fee === fee && + (activity.tax ?? 0) === tax && activity.quantity === quantity && activity.assetProfile.symbol === symbol && activity.type === type && @@ -1092,6 +1101,7 @@ export class ImportService { error, fee, quantity, + tax, type, unitPrice, assetProfile: { diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts index fb88c8a7f1..4f849a7aba 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts @@ -21,6 +21,9 @@ export const activityDummyData = { feeInAssetProfileCurrency: undefined, feeInBaseCurrency: undefined, id: undefined, + tax: undefined, + taxInAssetProfileCurrency: undefined, + taxInBaseCurrency: undefined, symbolProfileId: undefined, unitPrice: undefined, unitPriceInAssetProfileCurrency: undefined, @@ -103,6 +106,8 @@ export function loadActivitiesFromExportFile(fileName: string): { date: parseDate(activity.date), feeInAssetProfileCurrency: activity.fee, feeInBaseCurrency: activity.fee, + taxInAssetProfileCurrency: activity.tax ?? 0, + taxInBaseCurrency: activity.tax ?? 0, tags: [], unitPriceInAssetProfileCurrency: activity.unitPrice }; diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 7d0c2ddda9..41f8f8fed3 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -160,6 +160,8 @@ export abstract class PortfolioCalculator { feeInAssetProfileCurrency, feeInBaseCurrency, quantity, + taxInAssetProfileCurrency, + taxInBaseCurrency, tags = [], type, unitPriceInAssetProfileCurrency @@ -182,6 +184,8 @@ export abstract class PortfolioCalculator { fee: new Big(feeInAssetProfileCurrency), feeInBaseCurrency: new Big(feeInBaseCurrency), quantity: new Big(quantity), + tax: new Big(taxInAssetProfileCurrency ?? 0), + taxInBaseCurrency: new Big(taxInBaseCurrency ?? 0), unitPrice: new Big(unitPriceInAssetProfileCurrency) }; } @@ -521,6 +525,8 @@ export abstract class PortfolioCalculator { dividendInBaseCurrency: totalDividendInBaseCurrency, fee: item.fee, feeInBaseCurrency: item.feeInBaseCurrency, + tax: item.tax, + taxInBaseCurrency: item.taxInBaseCurrency, grossPerformance: !hasErrors ? (grossPerformance ?? null) : null, grossPerformancePercentage: !hasErrors ? (grossPerformancePercentage ?? null) @@ -795,6 +801,8 @@ export abstract class PortfolioCalculator { date: startDateString, fee: new Big(0), feeInBaseCurrency: new Big(0), + tax: new Big(0), + taxInBaseCurrency: new Big(0), itemType: 'start', quantity: new Big(0), type: 'BUY', @@ -806,6 +814,8 @@ export abstract class PortfolioCalculator { date: endDateString, fee: new Big(0), feeInBaseCurrency: new Big(0), + tax: new Big(0), + taxInBaseCurrency: new Big(0), itemType: 'end', quantity: new Big(0), type: 'BUY', @@ -880,6 +890,8 @@ export abstract class PortfolioCalculator { fee: new Big(0), feeInBaseCurrency: new Big(0), quantity: new Big(0), + tax: new Big(0), + taxInBaseCurrency: new Big(0), type: 'BUY', unitPriceFromMarketData: unitPrice }); @@ -1073,6 +1085,10 @@ export abstract class PortfolioCalculator { let feesAtStartDate = new Big(0); let feesAtStartDateWithCurrencyEffect = new Big(0); let feesWithCurrencyEffect = new Big(0); + let taxes = new Big(0); + let taxesAtStartDate = new Big(0); + let taxesAtStartDateWithCurrencyEffect = new Big(0); + let taxesWithCurrencyEffect = new Big(0); let grossPerformance = new Big(0); let grossPerformanceWithCurrencyEffect = new Big(0); let grossPerformanceAtStartDate = new Big(0); @@ -1139,6 +1155,13 @@ export abstract class PortfolioCalculator { ); } + if (activity.tax) { + activity.taxInBaseCurrency = activity.tax.mul(currentExchangeRate ?? 1); + activity.taxInBaseCurrencyWithCurrencyEffect = activity.tax.mul( + exchangeRateAtActivityDate ?? 1 + ); + } + const unitPrice = ['BUY', 'SELL'].includes(activity.type) ? activity.unitPrice : activity.unitPriceFromMarketData; @@ -1246,6 +1269,12 @@ export abstract class PortfolioCalculator { activity.feeInBaseCurrencyWithCurrencyEffect ?? 0 ); + taxes = taxes.plus(activity.taxInBaseCurrency ?? 0); + + taxesWithCurrencyEffect = taxesWithCurrencyEffect.plus( + activity.taxInBaseCurrencyWithCurrencyEffect ?? 0 + ); + totalQuantity = totalQuantity.plus( activity.quantity.mul(getFactor(activity.type)) ); @@ -1341,6 +1370,8 @@ export abstract class PortfolioCalculator { dividendsAtStartDateWithCurrencyEffect = dividendsWithCurrencyEffect; feesAtStartDate = fees; feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect; + taxesAtStartDate = taxes; + taxesAtStartDateWithCurrencyEffect = taxesWithCurrencyEffect; grossPerformanceAtStartDate = grossPerformance; grossPerformanceAtStartDateWithCurrencyEffect = @@ -1360,13 +1391,17 @@ export abstract class PortfolioCalculator { netPerformanceValues[activity.date] = grossPerformance .minus(grossPerformanceAtStartDate) - .minus(fees.minus(feesAtStartDate)); + .minus(fees.minus(feesAtStartDate)) + .minus(taxes.minus(taxesAtStartDate)); netPerformanceValuesWithCurrencyEffect[activity.date] = grossPerformanceWithCurrencyEffect .minus(grossPerformanceAtStartDateWithCurrencyEffect) .minus( feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect) + ) + .minus( + taxesWithCurrencyEffect.minus(taxesAtStartDateWithCurrencyEffect) ); investmentValuesAccumulated[activity.date] = totalInvestment; @@ -1404,6 +1439,8 @@ export abstract class PortfolioCalculator { fees, feesWithCurrencyEffect, grossPerformance, + taxes, + taxesWithCurrencyEffect, grossPerformanceWithCurrencyEffect, transactionInvestment, transactionInvestmentWithCurrencyEffect, @@ -1626,7 +1663,8 @@ export abstract class PortfolioCalculator { fees: feesAtStartDate, grossPerformance: grossPerformanceAtStartDate, grossPerformanceWithCurrencyEffect: - grossPerformanceAtStartDateWithCurrencyEffect + grossPerformanceAtStartDateWithCurrencyEffect, + taxes: taxesAtStartDate } = items[indexOfStartActivity]; const { @@ -1635,7 +1673,8 @@ export abstract class PortfolioCalculator { grossPerformanceWithCurrencyEffect, investment: totalInvestment, investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, - quantity: totalQuantity + quantity: totalQuantity, + taxes } = items[indexOfEndActivity]; const totalGrossPerformance = grossPerformance.minus( @@ -1647,9 +1686,9 @@ export abstract class PortfolioCalculator { grossPerformanceAtStartDateWithCurrencyEffect ); - const totalNetPerformance = totalGrossPerformance.minus( - fees.minus(feesAtStartDate) - ); + const totalNetPerformance = totalGrossPerformance + .minus(fees.minus(feesAtStartDate)) + .minus(taxes.minus(taxesAtStartDate)); const dateRanges = ( [ @@ -2104,6 +2143,8 @@ export abstract class PortfolioCalculator { feeInBaseCurrency, quantity, tags, + tax, + taxInBaseCurrency, type, unitPrice } of this.activities) { @@ -2173,6 +2214,9 @@ export abstract class PortfolioCalculator { previousHoldingBalance.feeInBaseCurrency.plus(feeInBaseCurrency), includeInHoldings: previousHoldingBalance.includeInHoldings, quantity: newQuantity, + tax: previousHoldingBalance.tax.plus(tax), + taxInBaseCurrency: + previousHoldingBalance.taxInBaseCurrency.plus(taxInBaseCurrency), tags: previousHoldingBalance.tags.concat(tags) }; } else { @@ -2185,6 +2229,8 @@ export abstract class PortfolioCalculator { skipErrors, symbol, tags, + tax, + taxInBaseCurrency, activitiesCount: 1, averagePrice: unitPrice, dateOfFirstActivity: date, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-tax.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-tax.spec.ts new file mode 100644 index 0000000000..6b719f7535 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-tax.spec.ts @@ -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) + }); + }); +}); diff --git a/apps/api/src/app/portfolio/interfaces/holding-balance.interface.ts b/apps/api/src/app/portfolio/interfaces/holding-balance.interface.ts index 2eace0ccbd..53876d98bc 100644 --- a/apps/api/src/app/portfolio/interfaces/holding-balance.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/holding-balance.interface.ts @@ -11,6 +11,8 @@ export interface HoldingBalance { fee: Big; feeInBaseCurrency: Big; includeInHoldings: boolean; + tax: Big; + taxInBaseCurrency: Big; investment: Big; quantity: Big; skipErrors: boolean; diff --git a/apps/api/src/app/portfolio/interfaces/holding-valuation-item.interface.ts b/apps/api/src/app/portfolio/interfaces/holding-valuation-item.interface.ts index 9a5ff4fcd2..83288b2c21 100644 --- a/apps/api/src/app/portfolio/interfaces/holding-valuation-item.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/holding-valuation-item.interface.ts @@ -9,6 +9,8 @@ export interface HoldingValuationItem extends Pick< fees: Big; feesWithCurrencyEffect: Big; grossPerformance: Big; + taxes: Big; + taxesWithCurrencyEffect: Big; grossPerformanceWithCurrencyEffect: Big; investment: Big; investmentBeforeTransaction: Big; diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-calculator-activity-item.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-calculator-activity-item.interface.ts index 7ed82ea0fb..0604835033 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-calculator-activity-item.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-calculator-activity-item.interface.ts @@ -5,6 +5,7 @@ import { PortfolioCalculatorActivity } from './portfolio-calculator-activity.int export interface PortfolioCalculatorActivityItem extends PortfolioCalculatorActivity { feeInBaseCurrencyWithCurrencyEffect?: Big; itemType?: 'end' | 'start'; + taxInBaseCurrencyWithCurrencyEffect?: Big; unitPriceFromMarketData?: Big; unitPriceInBaseCurrency?: Big; unitPriceInBaseCurrencyWithCurrencyEffect?: Big; diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-calculator-activity.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-calculator-activity.interface.ts index 811e774175..17e794645e 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-calculator-activity.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-calculator-activity.interface.ts @@ -12,5 +12,7 @@ export interface PortfolioCalculatorActivity extends Pick< fee: Big; feeInBaseCurrency: Big; quantity: Big; + tax: Big; + taxInBaseCurrency: Big; unitPrice: Big; } diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 12d2b3147e..f89eddc8d3 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -494,6 +494,8 @@ describe('PortfolioService', () => { dividendInBaseCurrency: new Big(0), fee: new Big(0), feeInBaseCurrency: new Big(0), + tax: new Big(0), + taxInBaseCurrency: new Big(0), grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0), diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index a8a3fc6740..df73c7bb37 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1004,6 +1004,7 @@ export class PortfolioService { dividendYieldPercentWithCurrencyEffect, feeInBaseCurrency, grossPerformance, + taxInBaseCurrency, grossPerformancePercentage, grossPerformancePercentageWithCurrencyEffect, grossPerformanceWithCurrencyEffect, @@ -1136,6 +1137,7 @@ export class PortfolioService { dividendYieldPercentWithCurrencyEffect.toNumber(), feeInBaseCurrency: feeInBaseCurrency.toNumber(), grossPerformance: grossPerformance?.toNumber(), + taxInBaseCurrency: taxInBaseCurrency.toNumber(), grossPerformancePercent: grossPerformancePercentage?.toNumber(), grossPerformancePercentWithCurrencyEffect: grossPerformancePercentageWithCurrencyEffect?.toNumber(), diff --git a/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts b/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts index 0d4ea8a14d..2c681fbebc 100644 --- a/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts @@ -128,6 +128,7 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit { date: new Date(), fee: 0, id: null, + tax: 0, type: activity?.type ?? 'BUY', unitPrice: null }, diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index d3bc34acee..9e198158d0 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts @@ -215,6 +215,7 @@ export class GfCreateOrUpdateActivityDialogComponent { date: [this.data.activity?.date, Validators.required], fee: [this.data.activity?.fee, Validators.required], name: [this.data.activity?.assetProfile?.name, Validators.required], + tax: [this.data.activity?.tax ?? 0, Validators.required], quantity: [this.data.activity?.quantity, Validators.required], searchSymbol: [ this.data.activity?.assetProfile @@ -254,12 +255,14 @@ export class GfCreateOrUpdateActivityDialogComponent { this.total = this.activityForm.get('quantity')?.value * this.activityForm.get('unitPrice')?.value + - (this.activityForm.get('fee')?.value ?? 0); + (this.activityForm.get('fee')?.value ?? 0) + + (this.activityForm.get('tax')?.value ?? 0); } else { this.total = this.activityForm.get('quantity')?.value * this.activityForm.get('unitPrice')?.value - - (this.activityForm.get('fee')?.value ?? 0); + (this.activityForm.get('fee')?.value ?? 0) - + (this.activityForm.get('tax')?.value ?? 0); } this.changeDetectorRef.markForCheck(); @@ -376,6 +379,7 @@ export class GfCreateOrUpdateActivityDialogComponent { if (type === 'VALUABLE') { this.activityForm.get('quantity')?.setValue(1); + this.activityForm.get('tax')?.setValue(0); } this.activityForm @@ -400,6 +404,10 @@ export class GfCreateOrUpdateActivityDialogComponent { this.activityForm.get('fee')?.setValue(0); } + if (['FEE', 'LIABILITY'].includes(type)) { + this.activityForm.get('tax')?.setValue(0); + } + this.activityForm.get('name')?.setValidators(Validators.required); this.activityForm.get('name')?.updateValueAndValidity(); @@ -500,6 +508,7 @@ export class GfCreateOrUpdateActivityDialogComponent { date: this.activityForm.get('date')?.value, fee: this.activityForm.get('fee')?.value, quantity: this.activityForm.get('quantity')?.value, + tax: this.activityForm.get('tax')?.value, symbol: (['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes( this.activityForm.get('type')?.value diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html index 608b1d9084..9744ff6df8 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html @@ -247,6 +247,26 @@ +
+ + Tax + +
+ {{ activityForm.get('currencyOfUnitPrice')?.value }} +
+
+
Note diff --git a/apps/client/src/app/services/import-activities.service.spec.ts b/apps/client/src/app/services/import-activities.service.spec.ts new file mode 100644 index 0000000000..6b1402bc09 --- /dev/null +++ b/apps/client/src/app/services/import-activities.service.spec.ts @@ -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,', + userAccounts: [] + }) + ).rejects.toMatchObject({ + message: 'activities.0.tax is not valid' + }); + }); +}); diff --git a/apps/client/src/app/services/import-activities.service.ts b/apps/client/src/app/services/import-activities.service.ts index 06ca922c1f..817bf201e2 100644 --- a/apps/client/src/app/services/import-activities.service.ts +++ b/apps/client/src/app/services/import-activities.service.ts @@ -29,6 +29,7 @@ export class ImportActivitiesService { private static DATA_SOURCE_KEYS = ['datasource']; private static DATE_KEYS = ['date', 'tradedate']; private static FEE_KEYS = ['commission', 'fee', 'ibcommission']; + private static TAX_KEYS = ['tax', 'withholding tax', 'withholdingtax']; private static QUANTITY_KEYS = ['qty', 'quantity', 'shares', 'units']; private static SYMBOL_KEYS = ['code', 'symbol', 'ticker']; private static TYPE_KEYS = ['action', 'buy/sell', 'type']; @@ -135,6 +136,7 @@ export class ImportActivitiesService { date: this.parseDate({ content, index, item }), fee: this.parseFee({ content, index, item }), quantity: this.parseQuantity({ content, index, item }), + tax: this.parseTax({ content, index, item }), unitPrice: this.parseUnitPrice({ content, index, item }), updateAccountBalance: false }); @@ -215,6 +217,7 @@ export class ImportActivitiesService { date, fee, quantity, + tax, tags, type, unitPrice, @@ -223,6 +226,7 @@ export class ImportActivitiesService { return { fee, quantity, + tax, type, unitPrice, updateAccountBalance, @@ -381,6 +385,37 @@ export class ImportActivitiesService { }; } + private parseTax({ + content, + index, + item + }: { + content: Record[]; + index: number; + item: Record; + }) { + item = this.lowercaseKeys(item); + + for (const key of ImportActivitiesService.TAX_KEYS) { + if (!(key in item)) { + continue; + } + + const value = item[key]; + + if (isNumber(value) && isFinite(value)) { + return Math.abs(value); + } + + throw { + activities: content, + message: `activities.${index}.tax is not valid` + }; + } + + return 0; + } + private parseQuantity({ content, index, diff --git a/apps/client/src/locales/messages.ca.xlf b/apps/client/src/locales/messages.ca.xlf index 4903891149..0dba3074e7 100644 --- a/apps/client/src/locales/messages.ca.xlf +++ b/apps/client/src/locales/messages.ca.xlf @@ -9647,6 +9647,18 @@ 170 + + Tax + Tax + + apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html + 259 + + + libs/ui/src/lib/activities-table/activities-table.component.html + 315 + + diff --git a/apps/client/src/locales/messages.de.xlf b/apps/client/src/locales/messages.de.xlf index 8db3cba122..fe76489df3 100644 --- a/apps/client/src/locales/messages.de.xlf +++ b/apps/client/src/locales/messages.de.xlf @@ -9647,6 +9647,18 @@ 170 + + Tax + Tax + + apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html + 259 + + + libs/ui/src/lib/activities-table/activities-table.component.html + 315 + + diff --git a/apps/client/src/locales/messages.es.xlf b/apps/client/src/locales/messages.es.xlf index 75c2d46677..060272dab6 100644 --- a/apps/client/src/locales/messages.es.xlf +++ b/apps/client/src/locales/messages.es.xlf @@ -9648,6 +9648,18 @@ 170 + + Tax + Tax + + apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html + 259 + + + libs/ui/src/lib/activities-table/activities-table.component.html + 315 + + diff --git a/apps/client/src/locales/messages.fr.xlf b/apps/client/src/locales/messages.fr.xlf index fb31b2f5e7..3540f418a2 100644 --- a/apps/client/src/locales/messages.fr.xlf +++ b/apps/client/src/locales/messages.fr.xlf @@ -9647,6 +9647,18 @@ 170 + + Tax + Tax + + apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html + 259 + + + libs/ui/src/lib/activities-table/activities-table.component.html + 315 + + diff --git a/apps/client/src/locales/messages.it.xlf b/apps/client/src/locales/messages.it.xlf index 4799d8eeb3..de969a6fcb 100644 --- a/apps/client/src/locales/messages.it.xlf +++ b/apps/client/src/locales/messages.it.xlf @@ -9648,6 +9648,18 @@ 170 + + Tax + Tax + + apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html + 259 + + + libs/ui/src/lib/activities-table/activities-table.component.html + 315 + + diff --git a/apps/client/src/locales/messages.ja.xlf b/apps/client/src/locales/messages.ja.xlf index 46df6c5f56..61b8761a70 100644 --- a/apps/client/src/locales/messages.ja.xlf +++ b/apps/client/src/locales/messages.ja.xlf @@ -9648,6 +9648,18 @@ 122 + + Tax + Tax + + apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html + 259 + + + libs/ui/src/lib/activities-table/activities-table.component.html + 315 + + diff --git a/apps/client/src/locales/messages.ko.xlf b/apps/client/src/locales/messages.ko.xlf index db568c6fee..6ca48a81c8 100644 --- a/apps/client/src/locales/messages.ko.xlf +++ b/apps/client/src/locales/messages.ko.xlf @@ -9648,6 +9648,18 @@ 122 + + Tax + Tax + + apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html + 259 + + + libs/ui/src/lib/activities-table/activities-table.component.html + 315 + + diff --git a/apps/client/src/locales/messages.nl.xlf b/apps/client/src/locales/messages.nl.xlf index e7f1434e12..24747abcde 100644 --- a/apps/client/src/locales/messages.nl.xlf +++ b/apps/client/src/locales/messages.nl.xlf @@ -9647,6 +9647,18 @@ 170 + + Tax + Tax + + apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html + 259 + + + libs/ui/src/lib/activities-table/activities-table.component.html + 315 + + diff --git a/apps/client/src/locales/messages.pl.xlf b/apps/client/src/locales/messages.pl.xlf index d35c349d51..7aa6c92daf 100644 --- a/apps/client/src/locales/messages.pl.xlf +++ b/apps/client/src/locales/messages.pl.xlf @@ -9647,6 +9647,18 @@ 170 + + Tax + Tax + + apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html + 259 + + + libs/ui/src/lib/activities-table/activities-table.component.html + 315 + + diff --git a/apps/client/src/locales/messages.pt.xlf b/apps/client/src/locales/messages.pt.xlf index 47aa899385..79096b0b15 100644 --- a/apps/client/src/locales/messages.pt.xlf +++ b/apps/client/src/locales/messages.pt.xlf @@ -9647,6 +9647,18 @@ 170 + + Tax + Tax + + apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html + 259 + + + libs/ui/src/lib/activities-table/activities-table.component.html + 315 + + diff --git a/apps/client/src/locales/messages.tr.xlf b/apps/client/src/locales/messages.tr.xlf index 4644006f44..6474409c74 100644 --- a/apps/client/src/locales/messages.tr.xlf +++ b/apps/client/src/locales/messages.tr.xlf @@ -9647,6 +9647,18 @@ 170 + + Tax + Tax + + apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html + 259 + + + libs/ui/src/lib/activities-table/activities-table.component.html + 315 + + diff --git a/apps/client/src/locales/messages.uk.xlf b/apps/client/src/locales/messages.uk.xlf index 342eec459c..6bd013bb5c 100644 --- a/apps/client/src/locales/messages.uk.xlf +++ b/apps/client/src/locales/messages.uk.xlf @@ -9647,6 +9647,18 @@ 170 + + Tax + Tax + + apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html + 259 + + + libs/ui/src/lib/activities-table/activities-table.component.html + 315 + + diff --git a/apps/client/src/locales/messages.xlf b/apps/client/src/locales/messages.xlf index b26141b552..bd34cb9c84 100644 --- a/apps/client/src/locales/messages.xlf +++ b/apps/client/src/locales/messages.xlf @@ -8723,6 +8723,17 @@ 122 + + Tax + + apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html + 259 + + + libs/ui/src/lib/activities-table/activities-table.component.html + 315 + + diff --git a/apps/client/src/locales/messages.zh.xlf b/apps/client/src/locales/messages.zh.xlf index 025f95e680..60ca41785f 100644 --- a/apps/client/src/locales/messages.zh.xlf +++ b/apps/client/src/locales/messages.zh.xlf @@ -9648,6 +9648,18 @@ 170 + + Tax + Tax + + apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html + 259 + + + libs/ui/src/lib/activities-table/activities-table.component.html + 315 + + diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 31caf6d3a1..79d85e8012 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -134,6 +134,9 @@ export const DEFAULT_REDACTED_PATHS = [ 'activities[*].fee', 'activities[*].feeInAssetProfileCurrency', 'activities[*].feeInBaseCurrency', + 'activities[*].tax', + 'activities[*].taxInAssetProfileCurrency', + 'activities[*].taxInBaseCurrency', 'activities[*].quantity', 'activities[*].value', 'activities[*].valueInBaseCurrency', @@ -151,6 +154,9 @@ export const DEFAULT_REDACTED_PATHS = [ 'fee', 'feeInAssetProfileCurrency', 'feeInBaseCurrency', + 'tax', + 'taxInAssetProfileCurrency', + 'taxInBaseCurrency', 'grossPerformance', 'grossPerformanceWithCurrencyEffect', 'historicalData[*].quantity', @@ -166,6 +172,7 @@ export const DEFAULT_REDACTED_PATHS = [ 'interestInBaseCurrency', 'investmentInBaseCurrencyWithCurrencyEffect', 'latestActivities[*].fee', + 'latestActivities[*].tax', 'latestActivities[*].quantity', 'latestActivities[*].value', 'latestActivities[*].valueInBaseCurrency', diff --git a/libs/common/src/lib/dtos/create-order.dto.ts b/libs/common/src/lib/dtos/create-order.dto.ts index d2374e9b1a..598e92db67 100644 --- a/libs/common/src/lib/dtos/create-order.dto.ts +++ b/libs/common/src/lib/dtos/create-order.dto.ts @@ -86,6 +86,11 @@ export class CreateOrderDto { @IsString({ each: true }) tags?: string[]; + @IsNumber() + @IsOptional() + @Min(0) + tax?: number; + @IsEnum(Type) type: Type; diff --git a/libs/common/src/lib/dtos/update-order.dto.ts b/libs/common/src/lib/dtos/update-order.dto.ts index 77e4a42286..baaf2d17db 100644 --- a/libs/common/src/lib/dtos/update-order.dto.ts +++ b/libs/common/src/lib/dtos/update-order.dto.ts @@ -87,6 +87,11 @@ export class UpdateOrderDto { @IsString({ each: true }) tags?: string[]; + @IsNumber() + @IsOptional() + @Min(0) + tax?: number; + @IsString() type: Type; diff --git a/libs/common/src/lib/interfaces/activities.interface.ts b/libs/common/src/lib/interfaces/activities.interface.ts index 2e9be921e3..b42d1cc9dd 100644 --- a/libs/common/src/lib/interfaces/activities.interface.ts +++ b/libs/common/src/lib/interfaces/activities.interface.ts @@ -10,6 +10,8 @@ export interface Activity extends Order { feeInBaseCurrency: number; tagIds?: string[]; tags?: Tag[]; + taxInAssetProfileCurrency: number; + taxInBaseCurrency: number; unitPriceInAssetProfileCurrency: number; updateAccountBalance?: boolean; value: number; diff --git a/libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts b/libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts index a3ab21705c..094c1579a6 100644 --- a/libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts +++ b/libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts @@ -29,6 +29,7 @@ export interface PortfolioHoldingResponse { dividendYieldPercentWithCurrencyEffect: number; feeInBaseCurrency: number; grossPerformance: number; + taxInBaseCurrency: number; grossPerformancePercent: number; grossPerformancePercentWithCurrencyEffect: number; grossPerformanceWithCurrencyEffect: number; diff --git a/libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts b/libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts index bf6e91bd9a..f2fa5c74f5 100644 --- a/libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts +++ b/libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts @@ -20,7 +20,7 @@ export interface PublicPortfolioResponse extends PublicPortfolioResponseV1 { >[]; latestActivities: (Pick< Order, - 'currency' | 'date' | 'fee' | 'quantity' | 'type' | 'unitPrice' + 'currency' | 'date' | 'fee' | 'quantity' | 'tax' | 'type' | 'unitPrice' > & { assetProfile: Pick< EnhancedAssetProfile, diff --git a/libs/common/src/lib/models/portfolio-snapshot-holding.ts b/libs/common/src/lib/models/portfolio-snapshot-holding.ts index e248cd997d..5a4c9a220d 100644 --- a/libs/common/src/lib/models/portfolio-snapshot-holding.ts +++ b/libs/common/src/lib/models/portfolio-snapshot-holding.ts @@ -52,6 +52,14 @@ export class PortfolioSnapshotHolding { @Type(() => Big) feeInBaseCurrency: Big; + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + tax: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + taxInBaseCurrency: Big; + @Transform(transformToBig, { toClassOnly: true }) @Type(() => Big) grossPerformance: Big; diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index c0082f65ab..5edd153762 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -305,6 +305,31 @@ + + + Tax + + +
+ +
+ +
+ , - Partial> { + Partial< + Pick + > { assetProfile: Pick< EnhancedAssetProfile, 'currency' | 'dataSource' | 'name' | 'symbol' diff --git a/prisma/migrations/20261019000000_added_tax_to_order/migration.sql b/prisma/migrations/20261019000000_added_tax_to_order/migration.sql new file mode 100644 index 0000000000..00400278c6 --- /dev/null +++ b/prisma/migrations/20261019000000_added_tax_to_order/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Order" +ADD COLUMN "tax" DOUBLE PRECISION NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 17a2d4e39d..f7e0be42fe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -193,6 +193,7 @@ model Order { quantity Float symbolProfileId String tags Tag[] + tax Float @default(0) type Type unitPrice Float updatedAt DateTime @updatedAt