From 64cbb1afdde8a4a452ebdfde631290f8eb6af83d Mon Sep 17 00:00:00 2001 From: Wai Cheng Date: Sun, 4 Oct 2026 18:08:33 +0800 Subject: [PATCH 1/2] Feature/add manual exchange rate to activity * Add optional exchangeRate to Order (asset profile currency to base currency) * Apply it to the cost basis, fees, dividends, interest and liabilities at the activity date * Add exchange rate input to the create or update activity dialog * Pass it through import and export * Add toCurrencyAtDateWithExchangeRate to ExchangeRateDataService --- .../app/activities/activities.service.spec.ts | 3 +- .../src/app/activities/activities.service.ts | 29 +++++---- apps/api/src/app/export/export.service.ts | 2 + apps/api/src/app/import/import.service.ts | 18 ++++-- .../portfolio-calculator-test-utils.ts | 1 + .../calculator/portfolio-calculator.ts | 16 ++++- ...portfolio-calculator-activity.interface.ts | 1 + .../app/portfolio/portfolio.service.spec.ts | 63 +++++++++++++++++++ .../src/app/portfolio/portfolio.service.ts | 29 ++++++--- .../exchange-rate-data.service.ts | 36 +++++++++++ ...ate-or-update-activity-dialog.component.ts | 9 +++ .../create-or-update-activity-dialog.html | 19 ++++++ libs/common/src/lib/dtos/create-order.dto.ts | 6 ++ libs/common/src/lib/dtos/update-order.dto.ts | 6 ++ .../activities-table.component.stories.ts | 5 ++ .../migration.sql | 2 + prisma/schema.prisma | 1 + 17 files changed, 215 insertions(+), 31 deletions(-) create mode 100644 prisma/migrations/20261004000000_added_exchange_rate_to_order/migration.sql diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts index d1eb1b4873..986973771e 100644 --- a/apps/api/src/app/activities/activities.service.spec.ts +++ b/apps/api/src/app/activities/activities.service.spec.ts @@ -83,7 +83,8 @@ describe('ActivitiesService', () => { null, null, { - toCurrencyAtDate: jest.fn().mockResolvedValue(0) + toCurrencyAtDate: jest.fn().mockResolvedValue(0), + toCurrencyAtDateWithExchangeRate: jest.fn().mockResolvedValue(0) } as unknown as ExchangeRateDataService, null, { diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 9e1a1b8d4c..5002f2ded1 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -532,6 +532,7 @@ export class ActivitiesService { createdAt: new Date(balanceItem.date), currency: account.currency, date: new Date(balanceItem.date), + exchangeRate: null, fee: 0, feeInAssetProfileCurrency: 0, feeInBaseCurrency: 0, @@ -721,24 +722,28 @@ export class ActivitiesService { order.SymbolProfile.currency, order.date ), - this.exchangeRateDataService.toCurrencyAtDate( - order.fee, - order.currency ?? order.SymbolProfile.currency, - userCurrency, - order.date - ), + this.exchangeRateDataService.toCurrencyAtDateWithExchangeRate({ + assetProfileCurrency: order.SymbolProfile.currency, + currency: order.currency ?? order.SymbolProfile.currency, + date: order.date, + exchangeRate: order.exchangeRate, + targetCurrency: userCurrency, + value: order.fee + }), this.exchangeRateDataService.toCurrencyAtDate( order.unitPrice, order.currency ?? order.SymbolProfile.currency, order.SymbolProfile.currency, order.date ), - this.exchangeRateDataService.toCurrencyAtDate( - value, - order.currency ?? order.SymbolProfile.currency, - userCurrency, - order.date - ) + this.exchangeRateDataService.toCurrencyAtDateWithExchangeRate({ + assetProfileCurrency: order.SymbolProfile.currency, + currency: order.currency ?? order.SymbolProfile.currency, + date: order.date, + exchangeRate: order.exchangeRate, + targetCurrency: userCurrency, + value + }) ]); return { diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index db08f62052..d815424cd1 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -237,6 +237,7 @@ export class ExportService { comment, currency, date, + exchangeRate, fee, id, quantity, @@ -247,6 +248,7 @@ export class ExportService { return { accountId, comment, + exchangeRate, fee, id, quantity, diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 8a2c80e2c3..675d4ece98 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -169,6 +169,7 @@ export class ImportService { comment: undefined, currency: undefined, createdAt: undefined, + exchangeRate: null, fee: 0, feeInAssetProfileCurrency: 0, feeInBaseCurrency: 0, @@ -842,6 +843,7 @@ export class ImportService { const currency = activity.currency; const date = activity.date; const error = activity.error; + const exchangeRate = activity.exchangeRate; const fee = activity.fee; const quantity = activity.quantity; const tagIds = activity.tagIds ?? []; @@ -885,6 +887,7 @@ export class ImportService { comment, currency, date, + exchangeRate, fee, quantity, type, @@ -910,6 +913,7 @@ export class ImportService { comment, currency, date, + exchangeRate, fee, quantity, type, @@ -951,12 +955,14 @@ export class ImportService { const value = new Big(quantity).mul(unitPrice).toNumber(); const valueInBaseCurrency = - (await this.exchangeRateDataService.toCurrencyAtDate( + (await this.exchangeRateDataService.toCurrencyAtDateWithExchangeRate({ + date, + exchangeRate, value, - currency ?? assetProfile.currency, - userCurrency, - date - )) ?? 0; + assetProfileCurrency: assetProfile.currency, + currency: currency ?? assetProfile.currency, + targetCurrency: userCurrency + })) ?? 0; activities.push({ ...omit(order, ['SymbolProfile']), @@ -1020,6 +1026,7 @@ export class ImportService { currency, dataSource, date: dateString, + exchangeRate, fee, quantity, symbol, @@ -1054,6 +1061,7 @@ export class ImportService { currency, date, error, + exchangeRate, fee, quantity, type, 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..ecce463b37 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 @@ -17,6 +17,7 @@ export const activityDummyData = { comment: undefined, createdAt: new Date(), currency: undefined, + exchangeRate: null, fee: undefined, feeInAssetProfileCurrency: undefined, feeInBaseCurrency: undefined, diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index eb1e6f9fac..2e36f17dcb 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -150,6 +150,7 @@ export abstract class PortfolioCalculator { ({ assetProfile, date, + exchangeRate, feeInAssetProfileCurrency, feeInBaseCurrency, quantity, @@ -172,6 +173,7 @@ export abstract class PortfolioCalculator { tags, type, date: format(date, DATE_FORMAT), + exchangeRate, fee: new Big(feeInAssetProfileCurrency), feeInBaseCurrency: new Big(feeInBaseCurrency), quantity: new Big(quantity), @@ -1013,6 +1015,13 @@ export abstract class PortfolioCalculator { const exchangeRateAtActivityDate = exchangeRates[activity.date]; + // Manual override of the asset to base currency rate at the activity + // date. It only applies to the cost-basis path (with currency effect of + // the activity itself). Current valuations and historical market prices + // stay on market rates. + const exchangeRateAtActivityDateForCostBasis = + activity.exchangeRate ?? exchangeRateAtActivityDate; + if (activity.itemType === 'start') { // Take the unit price of the activity as the market price if there are no // activities of this symbol before the start date @@ -1025,7 +1034,7 @@ export abstract class PortfolioCalculator { if (activity.fee) { activity.feeInBaseCurrency = activity.fee.mul(currentExchangeRate ?? 1); activity.feeInBaseCurrencyWithCurrencyEffect = activity.fee.mul( - exchangeRateAtActivityDate ?? 1 + exchangeRateAtActivityDateForCostBasis ?? 1 ); } @@ -1039,7 +1048,7 @@ export abstract class PortfolioCalculator { ); activity.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul( - exchangeRateAtActivityDate ?? 1 + exchangeRateAtActivityDateForCostBasis ?? 1 ); } @@ -1486,7 +1495,8 @@ export abstract class PortfolioCalculator { let totalLiabilitiesInBaseCurrency = new Big(0); for (const activity of activities) { - const exchangeRateAtActivityDate = exchangeRates[activity.date]; + const exchangeRateAtActivityDate = + activity.exchangeRate ?? exchangeRates[activity.date]; if (activity.type === 'DIVIDEND') { const dividend = activity.quantity.mul(activity.unitPrice); 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..2e9cf38cd9 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 @@ -9,6 +9,7 @@ export interface PortfolioCalculatorActivity extends Pick< 'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId' >; date: string; + exchangeRate?: number; fee: Big; feeInBaseCurrency: Big; quantity: Big; diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 19af3615bc..6826efa753 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -320,6 +320,69 @@ describe('PortfolioService', () => { interestInBaseCurrency: 15 }); }); + + it('should apply the manual exchange rate to the dividend and the interest in the currency of the asset profile', async () => { + jest.spyOn(accountService, 'accounts').mockResolvedValue([ + { + activities: [ + { + currency: 'USD', + date: new Date('2024-01-02'), + exchangeRate: 1.35, + quantity: 2, + SymbolProfile: { currency: 'USD' }, + tags: [], + type: 'DIVIDEND', + unitPrice: 10 + }, + { + currency: 'USD', + date: new Date('2024-01-03'), + exchangeRate: 1.3, + quantity: 3, + SymbolProfile: { currency: 'USD' }, + tags: [], + type: 'INTEREST', + unitPrice: 5 + } + ], + balance: 0, + currency: 'SGD', + id: 'account-id', + name: 'Account' + } + ] as unknown as Awaited>); + + jest.spyOn(portfolioService, 'getDetails').mockResolvedValue({ + accounts: {} + } as unknown as Awaited>); + + jest.spyOn(userService, 'user').mockResolvedValue({ + settings: { settings: { baseCurrency: 'SGD' } } + } as unknown as Awaited>); + + // Market rate USD to SGD of 2, which the manual rate must replace + jest + .spyOn(exchangeRateDataService, 'toCurrencyAtDate') + .mockImplementation(async (value, from, to) => { + return from === to ? value : value * 2; + }); + + jest + .spyOn(exchangeRateDataService, 'toCurrency') + .mockImplementation((value) => { + return value; + }); + + const [account] = await portfolioService.getAccounts({ + userId: userDummyData.id + }); + + expect(account).toMatchObject({ + dividendInBaseCurrency: 27, + interestInBaseCurrency: 19.5 + }); + }); }); describe('getAggregatedMarkets', () => { diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index d906fd6aef..ba7e2ebea1 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -309,6 +309,7 @@ export class PortfolioService { for (const { currency, date, + exchangeRate, quantity, SymbolProfile, tags, @@ -324,20 +325,28 @@ export class PortfolioService { switch (type) { case ActivityType.DIVIDEND: dividendInBaseCurrency += - (await this.exchangeRateDataService.toCurrencyAtDate( - new Big(quantity).mul(unitPrice).toNumber(), - currency ?? SymbolProfile.currency, - userCurrency, - date + (await this.exchangeRateDataService.toCurrencyAtDateWithExchangeRate( + { + date, + exchangeRate, + assetProfileCurrency: SymbolProfile.currency, + currency: currency ?? SymbolProfile.currency, + targetCurrency: userCurrency, + value: new Big(quantity).mul(unitPrice).toNumber() + } )) ?? 0; break; case ActivityType.INTEREST: interestInBaseCurrency += - (await this.exchangeRateDataService.toCurrencyAtDate( - new Big(quantity).mul(unitPrice).toNumber(), - currency ?? SymbolProfile.currency, - userCurrency, - date + (await this.exchangeRateDataService.toCurrencyAtDateWithExchangeRate( + { + date, + exchangeRate, + assetProfileCurrency: SymbolProfile.currency, + currency: currency ?? SymbolProfile.currency, + targetCurrency: userCurrency, + value: new Big(quantity).mul(unitPrice).toNumber() + } )) ?? 0; break; } diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 824149e74f..143ba98f05 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -377,6 +377,42 @@ export class ExchangeRateDataService { return undefined; } + /** + * Converts the value of an activity at its date. A manual exchange rate of + * the activity replaces the market rate from the currency of the asset + * profile to the target currency. + */ + public async toCurrencyAtDateWithExchangeRate({ + assetProfileCurrency, + currency, + date, + exchangeRate, + targetCurrency, + value + }: { + assetProfileCurrency: string; + currency: string; + date: Date; + exchangeRate?: number | null; + targetCurrency: string; + value: number; + }) { + if (exchangeRate == null) { + return this.toCurrencyAtDate(value, currency, targetCurrency, date); + } + + const valueInAssetProfileCurrency = await this.toCurrencyAtDate( + value, + currency, + assetProfileCurrency, + date + ); + + return valueInAssetProfileCurrency === undefined + ? undefined + : valueInAssetProfileCurrency * exchangeRate; + } + private async getExchangeRates({ currencyFrom, currencyTo, 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 f24297dbd0..3fda89a3d0 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 @@ -224,6 +224,7 @@ export class GfCreateOrUpdateActivityDialogComponent { Validators.required ], date: [this.data.activity?.date, Validators.required], + exchangeRate: [this.data.activity?.exchangeRate ?? null], fee: [this.data.activity?.fee, Validators.required], name: [this.data.activity?.assetProfile?.name, Validators.required], quantity: [this.data.activity?.quantity, Validators.required], @@ -488,6 +489,10 @@ export class GfCreateOrUpdateActivityDialogComponent { } protected async onSubmit() { + const assetCurrency = this.activityForm.get('currency')?.value; + const baseCurrency = this.data.user?.settings?.baseCurrency; + const exchangeRateValue = this.activityForm.get('exchangeRate')?.value; + const activity: CreateOrderDto | UpdateOrderDto = { accountId: this.activityForm.get('accountId')?.value, assetClass: this.activityForm.get('assetClass')?.value, @@ -501,6 +506,10 @@ export class GfCreateOrUpdateActivityDialogComponent { ? 'MANUAL' : this.activityForm.get('dataSource')?.value, date: this.activityForm.get('date')?.value, + exchangeRate: + assetCurrency && baseCurrency && assetCurrency !== baseCurrency + ? (exchangeRateValue ?? null) + : null, fee: this.activityForm.get('fee')?.value, quantity: this.activityForm.get('quantity')?.value, symbol: 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 f9494efdc2..5185397bad 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 @@ -227,6 +227,25 @@ } + @if ( + activityForm.get('currency')?.value && + data.user?.settings?.baseCurrency && + activityForm.get('currency')?.value !== data.user?.settings?.baseCurrency + ) { +
+ + Exchange rate ({{ activityForm.get('currency')?.value }} + → + {{ data.user?.settings?.baseCurrency }}) + + Optional. Leave empty to use the market rate. + +
+ }
Date: Sun, 4 Oct 2026 18:11:49 +0800 Subject: [PATCH 2/2] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa48dcce6..a79f119857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added support for a manual exchange rate in the create or update activity dialog + ### Changed - Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.3` to `14.0`