Browse Source

feat(portfolio): add taxes to activities

pull/8252/head
piggei 4 days ago
parent
commit
465e5f8c2f
  1. 210
      apps/api/src/app/activities/activities.service.spec.ts
  2. 21
      apps/api/src/app/activities/activities.service.ts
  3. 3
      apps/api/src/app/endpoints/ai/ai.schemas.ts
  4. 14
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  5. 14
      apps/api/src/app/endpoints/ai/ai.service.ts
  6. 1
      apps/api/src/app/endpoints/ai/ai.test-utils.ts
  7. 16
      apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts
  8. 3
      apps/api/src/app/endpoints/mcp/mcp.schemas.ts
  9. 1
      apps/api/src/app/endpoints/mcp/mcp.test-utils.ts
  10. 2
      apps/api/src/app/endpoints/public/public.service.ts
  11. 63
      apps/api/src/app/export/export.service.spec.ts
  12. 2
      apps/api/src/app/export/export.service.ts
  13. 107
      apps/api/src/app/import/import.service.spec.ts
  14. 10
      apps/api/src/app/import/import.service.ts
  15. 5
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  16. 58
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  17. 126
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-tax.spec.ts
  18. 2
      apps/api/src/app/portfolio/interfaces/holding-balance.interface.ts
  19. 2
      apps/api/src/app/portfolio/interfaces/holding-valuation-item.interface.ts
  20. 1
      apps/api/src/app/portfolio/interfaces/portfolio-calculator-activity-item.interface.ts
  21. 2
      apps/api/src/app/portfolio/interfaces/portfolio-calculator-activity.interface.ts
  22. 2
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  23. 2
      apps/api/src/app/portfolio/portfolio.service.ts
  24. 1
      apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts
  25. 13
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  26. 20
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  27. 78
      apps/client/src/app/services/import-activities.service.spec.ts
  28. 35
      apps/client/src/app/services/import-activities.service.ts
  29. 12
      apps/client/src/locales/messages.ca.xlf
  30. 12
      apps/client/src/locales/messages.de.xlf
  31. 12
      apps/client/src/locales/messages.es.xlf
  32. 12
      apps/client/src/locales/messages.fr.xlf
  33. 12
      apps/client/src/locales/messages.it.xlf
  34. 12
      apps/client/src/locales/messages.ja.xlf
  35. 12
      apps/client/src/locales/messages.ko.xlf
  36. 12
      apps/client/src/locales/messages.nl.xlf
  37. 12
      apps/client/src/locales/messages.pl.xlf
  38. 12
      apps/client/src/locales/messages.pt.xlf
  39. 12
      apps/client/src/locales/messages.tr.xlf
  40. 12
      apps/client/src/locales/messages.uk.xlf
  41. 11
      apps/client/src/locales/messages.xlf
  42. 12
      apps/client/src/locales/messages.zh.xlf
  43. 7
      libs/common/src/lib/config.ts
  44. 5
      libs/common/src/lib/dtos/create-order.dto.ts
  45. 5
      libs/common/src/lib/dtos/update-order.dto.ts
  46. 2
      libs/common/src/lib/interfaces/activities.interface.ts
  47. 1
      libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts
  48. 2
      libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts
  49. 8
      libs/common/src/lib/models/portfolio-snapshot-holding.ts
  50. 25
      libs/ui/src/lib/activities-table/activities-table.component.html
  51. 1
      libs/ui/src/lib/activities-table/activities-table.component.ts
  52. 4
      libs/ui/src/lib/activities-table/interfaces/interfaces.ts
  53. 2
      prisma/migrations/20261019000000_added_tax_to_order/migration.sql
  54. 1
      prisma/schema.prisma

210
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());

21
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

3
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'
),

14
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
});
});

14
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.`

1
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

16
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([

3
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')

1
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,

2
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,

63
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
});
});
});

2
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,

107
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;
}

10
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: {

5
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
};

58
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,

126
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)
});
});
});

2
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;

2
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;

1
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;

2
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;
}

2
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),

2
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(),

1
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
},

13
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -226,6 +226,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
@ -265,12 +266,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();
@ -387,6 +390,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
if (type === 'VALUABLE') {
this.activityForm.get('quantity')?.setValue(1);
this.activityForm.get('tax')?.setValue(0);
}
this.activityForm
@ -411,6 +415,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();
@ -511,6 +519,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

20
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -247,6 +247,26 @@
</div>
</mat-form-field>
</div>
<div
class="mb-3"
[class.d-none]="
activityForm.get('type')?.value === 'FEE' ||
activityForm.get('type')?.value === 'LIABILITY' ||
activityForm.get('type')?.value === 'VALUABLE'
"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tax</mat-label>
<input formControlName="tax" matInput type="number" />
<div
class="ms-2"
matTextSuffix
[class.d-none]="!activityForm.get('currency')?.value"
>
{{ activityForm.get('currencyOfUnitPrice')?.value }}
</div>
</mat-form-field>
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Note</mat-label>

78
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,<invalid>',
userAccounts: []
})
).rejects.toMatchObject({
message: 'activities.0.tax is not valid'
});
});
});

35
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<string, unknown>[];
index: number;
item: Record<string, unknown>;
}) {
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,

12
apps/client/src/locales/messages.ca.xlf

@ -9615,6 +9615,18 @@
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="2617580077225620079" datatype="html">
<source>Tax</source>
<target state="new">Tax</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/activities-table/activities-table.component.html</context>
<context context-type="linenumber">315</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

12
apps/client/src/locales/messages.de.xlf

@ -9615,6 +9615,18 @@
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="2617580077225620079" datatype="html">
<source>Tax</source>
<target state="new">Tax</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/activities-table/activities-table.component.html</context>
<context context-type="linenumber">315</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

12
apps/client/src/locales/messages.es.xlf

@ -9616,6 +9616,18 @@
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="2617580077225620079" datatype="html">
<source>Tax</source>
<target state="new">Tax</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/activities-table/activities-table.component.html</context>
<context context-type="linenumber">315</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

12
apps/client/src/locales/messages.fr.xlf

@ -9615,6 +9615,18 @@
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="2617580077225620079" datatype="html">
<source>Tax</source>
<target state="new">Tax</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/activities-table/activities-table.component.html</context>
<context context-type="linenumber">315</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

12
apps/client/src/locales/messages.it.xlf

@ -9616,6 +9616,18 @@
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="2617580077225620079" datatype="html">
<source>Tax</source>
<target state="new">Tax</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/activities-table/activities-table.component.html</context>
<context context-type="linenumber">315</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

12
apps/client/src/locales/messages.ja.xlf

@ -9616,6 +9616,18 @@
<context context-type="linenumber">122</context>
</context-group>
</trans-unit>
<trans-unit id="2617580077225620079" datatype="html">
<source>Tax</source>
<target state="new">Tax</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/activities-table/activities-table.component.html</context>
<context context-type="linenumber">315</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

12
apps/client/src/locales/messages.ko.xlf

@ -9616,6 +9616,18 @@
<context context-type="linenumber">122</context>
</context-group>
</trans-unit>
<trans-unit id="2617580077225620079" datatype="html">
<source>Tax</source>
<target state="new">Tax</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/activities-table/activities-table.component.html</context>
<context context-type="linenumber">315</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

12
apps/client/src/locales/messages.nl.xlf

@ -9615,6 +9615,18 @@
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="2617580077225620079" datatype="html">
<source>Tax</source>
<target state="new">Tax</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/activities-table/activities-table.component.html</context>
<context context-type="linenumber">315</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

12
apps/client/src/locales/messages.pl.xlf

@ -9615,6 +9615,18 @@
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="2617580077225620079" datatype="html">
<source>Tax</source>
<target state="new">Tax</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/activities-table/activities-table.component.html</context>
<context context-type="linenumber">315</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

12
apps/client/src/locales/messages.pt.xlf

@ -9615,6 +9615,18 @@
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="2617580077225620079" datatype="html">
<source>Tax</source>
<target state="new">Tax</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/activities-table/activities-table.component.html</context>
<context context-type="linenumber">315</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

12
apps/client/src/locales/messages.tr.xlf

@ -9615,6 +9615,18 @@
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="2617580077225620079" datatype="html">
<source>Tax</source>
<target state="new">Tax</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/activities-table/activities-table.component.html</context>
<context context-type="linenumber">315</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

12
apps/client/src/locales/messages.uk.xlf

@ -9615,6 +9615,18 @@
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="2617580077225620079" datatype="html">
<source>Tax</source>
<target state="new">Tax</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/activities-table/activities-table.component.html</context>
<context context-type="linenumber">315</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

11
apps/client/src/locales/messages.xlf

@ -8694,6 +8694,17 @@
<context context-type="linenumber">122</context>
</context-group>
</trans-unit>
<trans-unit id="2617580077225620079" datatype="html">
<source>Tax</source>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/activities-table/activities-table.component.html</context>
<context context-type="linenumber">315</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

12
apps/client/src/locales/messages.zh.xlf

@ -9616,6 +9616,18 @@
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="2617580077225620079" datatype="html">
<source>Tax</source>
<target state="new">Tax</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html</context>
<context context-type="linenumber">259</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/activities-table/activities-table.component.html</context>
<context context-type="linenumber">315</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

7
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',

5
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;

5
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;

2
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;

1
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;

2
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,

8
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;

25
libs/ui/src/lib/activities-table/activities-table.component.html

@ -305,6 +305,31 @@
</td>
</ng-container>
<ng-container matColumnDef="tax">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end px-1"
mat-header-cell
mat-sort-header
>
<ng-container i18n>Tax</ng-container>
</th>
<td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[isLoading]="isLoading()"
[locale]="locale"
[value]="element.tax"
/>
</div>
</td>
</ng-container>
<ng-container matColumnDef="value">
<th
*matHeaderCellDef

1
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -196,6 +196,7 @@ export class GfActivitiesTableComponent<
'quantity',
'unitPrice',
'fee',
'tax',
'value',
'currency',
'valueInBaseCurrency',

4
libs/ui/src/lib/activities-table/interfaces/interfaces.ts

@ -13,7 +13,9 @@ export interface ActivitiesTableItem
| 'value'
| 'valueInBaseCurrency'
>,
Partial<Pick<Activity, 'account' | 'comment' | 'error' | 'id' | 'tags'>> {
Partial<
Pick<Activity, 'account' | 'comment' | 'error' | 'id' | 'tags' | 'tax'>
> {
assetProfile: Pick<
EnhancedAssetProfile,
'currency' | 'dataSource' | 'name' | 'symbol'

2
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;

1
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

Loading…
Cancel
Save