Browse Source

Merge branch 'main' into task/destructure-query-parameters-in-controllers-part-2

pull/7505/head
Thomas Kaul 4 weeks ago
parent
commit
cf7dd129cd
  1. 35
      CHANGELOG.md
  2. 3
      apps/api/src/app/asset/asset.controller.ts
  3. 96
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  4. 2
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts
  5. 45
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  6. 11
      apps/api/src/app/export/export.service.ts
  7. 21
      apps/api/src/app/import/import.service.ts
  8. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  9. 89
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  10. 2
      apps/api/src/app/portfolio/portfolio.service.ts
  11. 2
      apps/api/src/app/user/user.service.ts
  12. 1
      apps/api/src/helper/account.helper.ts
  13. 12
      apps/api/src/services/asset-profile-split/asset-profile-split.module.ts
  14. 87
      apps/api/src/services/asset-profile-split/asset-profile-split.service.ts
  15. 101
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  16. 127
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  17. 4
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  18. 3
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  19. 7
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  20. 5
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  21. 6
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  22. 7
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  23. 2
      apps/client/src/index.html
  24. 296
      apps/client/src/locales/messages.ca.xlf
  25. 296
      apps/client/src/locales/messages.de.xlf
  26. 296
      apps/client/src/locales/messages.es.xlf
  27. 296
      apps/client/src/locales/messages.fr.xlf
  28. 296
      apps/client/src/locales/messages.it.xlf
  29. 296
      apps/client/src/locales/messages.ja.xlf
  30. 296
      apps/client/src/locales/messages.ko.xlf
  31. 296
      apps/client/src/locales/messages.nl.xlf
  32. 296
      apps/client/src/locales/messages.pl.xlf
  33. 296
      apps/client/src/locales/messages.pt.xlf
  34. 296
      apps/client/src/locales/messages.tr.xlf
  35. 296
      apps/client/src/locales/messages.uk.xlf
  36. 292
      apps/client/src/locales/messages.xlf
  37. 448
      apps/client/src/locales/messages.zh.xlf
  38. 6
      apps/client/src/styles/theme.scss
  39. 10
      libs/common/src/lib/dtos/create-account-with-balances.dto.ts
  40. 10
      libs/common/src/lib/dtos/create-account.dto.ts
  41. 39
      libs/common/src/lib/dtos/create-asset-profile-split.dto.ts
  42. 2
      libs/common/src/lib/dtos/create-order.dto.ts
  43. 2
      libs/common/src/lib/dtos/index.ts
  44. 10
      libs/common/src/lib/dtos/update-account.dto.ts
  45. 4
      libs/common/src/lib/dtos/update-asset-profile.dto.ts
  46. 2
      libs/common/src/lib/dtos/update-order.dto.ts
  47. 133
      libs/common/src/lib/helper.spec.ts
  48. 50
      libs/common/src/lib/helper.ts
  49. 3
      libs/common/src/lib/interfaces/admin-market-data-details.interface.ts
  50. 3
      libs/common/src/lib/interfaces/responses/asset-profile-response.interface.ts
  51. 6
      libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts
  52. 12
      libs/common/src/lib/permissions.ts
  53. 18
      libs/common/src/lib/validator-constraints/is-split-ratio.ts
  54. 3
      libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts
  55. 5
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  56. 2
      libs/ui/src/lib/activities-table/activities-table.component.ts
  57. 24
      libs/ui/src/lib/services/admin.service.ts
  58. 27
      libs/ui/src/lib/services/data.service.ts
  59. 10
      libs/ui/src/lib/tags-selector/tags-selector.component.html
  60. 8
      libs/ui/src/lib/tags-selector/tags-selector.component.stories.ts
  61. 636
      package-lock.json
  62. 18
      package.json
  63. 21
      prisma/migrations/20260801103609_removed_is_excluded_from_account/migration.sql
  64. 21
      prisma/migrations/20260802000000_added_asset_profile_split/migration.sql
  65. 31
      prisma/schema.prisma

35
CHANGELOG.md

@ -5,6 +5,41 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added the database model and endpoints to manage the stock splits of an asset profile (experimental)
## 3.40.0 - 2026-08-02
### Changed
- Improved the style of the read-only tags in the tags selector component
- Improved the language localization for Chinese (`zh`)
- Upgraded `nestjs` from version `11.1.27` to `11.1.28`
### Fixed
- Fixed the handling of the _Exclude from Analysis_ tag in the activities table
- Fixed the persistence of an empty comment in the create or update account dialog
- Resolved a validation error caused by empty strings in the asset profile details dialog of the admin control panel
## 3.39.0 - 2026-08-01
### Changed
- Harmonized the data format of the export functionality
- Removed the deprecated `firstOrderDate` attribute from the `GET api/v2/portfolio/performance` endpoint response
- Removed the deprecated `isExcluded` attribute of the account in favor of the _Exclude from Analysis_ tag including a data migration
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `7.8.0` to `7.9.1`
### Fixed
- Fixed the scroll behavior of the page content behind an open dialog
- Fixed the export functionality to only include the accounts of the exported activities if a filter is applied
## 3.38.0 - 2026-07-31
### Added

3
apps/api/src/app/asset/asset.controller.ts

@ -20,7 +20,7 @@ export class AssetController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AssetResponse> {
const { assetProfile, marketData } =
const { assetProfile, marketData, splits } =
await this.assetProfilesService.getAssetProfile({
dataSource,
symbol
@ -28,6 +28,7 @@ export class AssetController {
return {
marketData,
splits,
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol'])
};
}

96
apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts

@ -4,7 +4,10 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos';
import {
CreateAssetProfileSplitDto,
UpdateAssetProfileDataDto
} from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { AssetProfileResponse } from '@ghostfolio/common/interfaces';
import {
@ -18,19 +21,22 @@ import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource, Prisma } from '@prisma/client';
import { AssetProfileSplit, DataSource, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AssetProfilesService } from './asset-profiles.service';
@ -119,6 +125,49 @@ export class AssetProfilesController {
});
}
@Post(':dataSource/:symbol/splits')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createSplit(
@Body() data: CreateAssetProfileSplitDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AssetProfileSplit> {
const { id: symbolProfileId } = await this.validateAccessToSplits({
dataSource,
symbol,
permission: permissions.createAssetProfileSplit,
permissionOfOwnAssetProfile:
permissions.createAssetProfileSplitOfOwnAssetProfile
});
return this.assetProfilesService.createSplit({
symbolProfileId,
date: parseISO(data.date),
denominator: data.denominator,
numerator: data.numerator
});
}
@Delete(':dataSource/:symbol/splits/:id')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteSplit(
@Param('dataSource') dataSource: DataSource,
@Param('id') id: string,
@Param('symbol') symbol: string
): Promise<void> {
const { id: symbolProfileId } = await this.validateAccessToSplits({
dataSource,
symbol,
permission: permissions.deleteAssetProfileSplit,
permissionOfOwnAssetProfile:
permissions.deleteAssetProfileSplitOfOwnAssetProfile
});
return this.assetProfilesService.deleteSplit({ id, symbolProfileId });
}
@HasPermission(permissions.accessAdminControl)
@Patch(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -139,4 +188,47 @@ export class AssetProfilesController {
assetProfileData
);
}
private async validateAccessToSplits({
dataSource,
permission,
permissionOfOwnAssetProfile,
symbol
}: {
dataSource: DataSource;
permission: string;
permissionOfOwnAssetProfile: string;
symbol: string;
}) {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
if (!assetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const canAccessAllAssetProfiles = hasPermission(
this.request.user.permissions,
permission
);
const canAccessOwnAssetProfile =
assetProfile.userId === this.request.user.id &&
hasPermission(this.request.user.permissions, permissionOfOwnAssetProfile);
if (!canAccessAllAssetProfiles && !canAccessOwnAssetProfile) {
throw new HttpException(
assetProfile.userId
? getReasonPhrase(StatusCodes.NOT_FOUND)
: getReasonPhrase(StatusCodes.FORBIDDEN),
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
);
}
return assetProfile;
}
}

2
apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts

@ -2,6 +2,7 @@ import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.modu
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -20,6 +21,7 @@ import { AssetProfilesService } from './asset-profiles.service';
imports: [
ActivitiesModule,
ApiModule,
AssetProfileSplitModule,
BenchmarkModule,
DataProviderModule,
ExchangeRateDataModule,

45
apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts

@ -1,4 +1,5 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -30,6 +31,7 @@ import { groupBy } from 'lodash';
export class AssetProfilesService {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly assetProfileSplitService: AssetProfileSplitService,
private readonly benchmarkService: BenchmarkService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
@ -38,6 +40,42 @@ export class AssetProfilesService {
private readonly symbolProfileService: SymbolProfileService
) {}
public async createSplit({
date,
denominator,
numerator,
symbolProfileId
}: {
date: Date;
denominator: number;
numerator: number;
symbolProfileId: string;
}) {
return this.assetProfileSplitService.upsert({
date,
denominator,
numerator,
symbolProfileId
});
}
public async deleteSplit({
id,
symbolProfileId
}: {
id: string;
symbolProfileId: string;
}) {
const isDeleted = await this.assetProfileSplitService.deleteById({
id,
symbolProfileId
});
if (!isDeleted) {
throw new NotFoundException();
}
}
public async getAssetProfile({
dataSource,
symbol
@ -54,7 +92,7 @@ export class AssetProfilesService {
await this.activitiesService.getStatisticsByCurrency(currency));
}
const [[assetProfile], marketData] = await Promise.all([
const [[assetProfile], marketData, splits] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
{
dataSource,
@ -69,7 +107,8 @@ export class AssetProfilesService {
dataSource,
symbol
}
})
}),
this.assetProfileSplitService.getSplits({ dataSource, symbol })
]);
if (assetProfile) {
@ -80,6 +119,7 @@ export class AssetProfilesService {
return {
marketData,
splits,
assetProfile: assetProfile ?? {
activitiesCount,
currency,
@ -92,6 +132,7 @@ export class AssetProfilesService {
}
};
}
public async getAssetProfiles({
filters = [],
presetId,

11
apps/api/src/app/export/export.service.ts

@ -73,6 +73,13 @@ export class ExportService {
};
}
const isFilteredExport =
activityIds?.length > 0 ||
activityTypes?.length > 0 ||
filters?.length > 0 ||
!!endDate ||
!!startDate;
const accounts = (
await this.accountService.accounts({
where,
@ -87,7 +94,7 @@ export class ExportService {
})
)
.filter(({ id }) => {
return activityIds?.length > 0
return isFilteredExport
? activities.some(({ accountId }) => {
return accountId === id;
})
@ -100,7 +107,6 @@ export class ExportService {
comment,
currency,
id,
isExcluded,
name,
platform,
platformId,
@ -118,7 +124,6 @@ export class ExportService {
comment,
currency,
id,
isExcluded,
name,
platformId,
tags: tags.map(({ id: tagId }) => {

21
apps/api/src/app/import/import.service.ts

@ -9,7 +9,10 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos';
import {
getAssetProfileIdentifier,
@ -272,7 +275,11 @@ export class ImportService {
// If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== user.id) {
const account = omit(accountWithBalances, ['balances', 'tags']);
const account = omit(accountWithBalances, [
'balances',
'isExcluded',
'tags'
]);
let oldAccountId: string;
const platformId = account.platformId;
@ -292,6 +299,16 @@ export class ImportService {
return existingTagIds.has(tagId);
});
// Map the legacy isExcluded attribute of old export files to
// the "Exclude from Analysis" tag
if (
accountWithBalances.isExcluded &&
existingTagIds.has(TAG_ID_EXCLUDE_FROM_ANALYSIS) &&
!tagIds.includes(TAG_ID_EXCLUDE_FROM_ANALYSIS)
) {
tagIds.push(TAG_ID_EXCLUDE_FROM_ANALYSIS);
}
let accountObject: Prisma.AccountCreateInput = {
...account,
balances: {

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -186,7 +186,6 @@ describe('PortfolioCalculator', () => {
createdAt: parseDate('2023-12-31'),
currency: 'USD',
id: accountId,
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2023-12-31'),
@ -362,7 +361,6 @@ describe('PortfolioCalculator', () => {
createdAt: parseDate('2023-12-31'),
currency: 'CHF',
id: accountId,
isExcluded: false,
name: 'CHF',
platformId: null,
updatedAt: parseDate('2023-12-31'),
@ -494,7 +492,6 @@ describe('PortfolioCalculator', () => {
createdAt: parseDate('2023-12-31'),
currency: 'CHF',
id: accountId,
isExcluded: false,
name: 'CHF',
platformId: null,
updatedAt: parseDate('2023-12-31'),

89
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -1,6 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { UserService } from '@ghostfolio/api/app/user/user.service';
@ -11,7 +12,10 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation/imp
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import {
AssetProfileIdentifier,
PortfolioSummary
} from '@ghostfolio/common/interfaces';
import { Account, DataSource } from '@prisma/client';
import { Big } from 'big.js';
@ -188,7 +192,6 @@ describe('PortfolioService', () => {
createdAt: parseDate('2024-01-01'),
currency: 'USD',
id: randomUUID(),
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2024-01-01'),
@ -222,7 +225,6 @@ describe('PortfolioService', () => {
createdAt: parseDate('2024-01-01'),
currency: 'USD',
id: accountId,
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2024-01-01'),
@ -338,6 +340,86 @@ describe('PortfolioService', () => {
});
});
describe('getSummary', () => {
const getSummary = (args: object) => {
return (
portfolioService as unknown as {
getSummary: (aArgs: object) => Promise<PortfolioSummary>;
}
).getSummary(args);
};
function createPortfolioCalculator() {
return {
getDividendInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getFeesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getInterestInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getLiabilitiesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getSnapshot: jest.fn().mockResolvedValue({
currentValueInBaseCurrency: new Big(3000),
totalCashInBaseCurrency: new Big(1000),
totalInvestment: new Big(2000),
totalInvestmentWithCurrencyEffect: new Big(2000)
}),
getStartDate: jest.fn().mockReturnValue(parseDate('2024-01-01'))
} as unknown as PortfolioCalculator;
}
beforeEach(() => {
jest
.spyOn(activitiesService, 'getActivities')
.mockResolvedValue({ activities: [], count: 0 });
jest
.spyOn(impersonationService, 'validateImpersonationId')
.mockResolvedValue(null);
jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({
performance: {
currentValueInBaseCurrency: 3000,
netPerformance: 500,
netPerformancePercentage: 0.2,
netPerformancePercentageWithCurrencyEffect: 0.2,
netPerformanceWithCurrencyEffect: 500
}
} as Awaited<ReturnType<typeof portfolioService.getPerformance>>);
jest.spyOn(userService, 'user').mockResolvedValue({
id: userDummyData.id,
settings: {
settings: {
baseCurrency: 'CHF'
}
}
} as unknown as Awaited<ReturnType<typeof userService.user>>);
});
it('should derive the cash and net worth from the account balance when there are no excluded accounts, no emergency fund and no liabilities', async () => {
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [],
balanceInBaseCurrency: 1000
});
const portfolioCalculator = createPortfolioCalculator();
const summary = await getSummary({
portfolioCalculator,
balanceInBaseCurrency: 1000,
emergencyFundHoldingsValueInBaseCurrency: 0,
filteredValueInBaseCurrency: new Big(3000),
impersonationId: undefined,
userCurrency: 'CHF',
userId: userDummyData.id
});
expect(summary.cash).toBe(1000);
expect(summary.emergencyFund.total).toBe(0);
expect(summary.excludedAccountsAndActivities).toBe(0);
expect(summary.totalAssetsInBaseCurrency).toBe(3000);
expect(summary.totalValueInBaseCurrency).toBe(3000);
});
});
describe('getValueOfAccountsAndPlatforms', () => {
const getValueOfAccountsAndPlatforms = (args: object) => {
return (
@ -354,7 +436,6 @@ describe('PortfolioService', () => {
balance: 100,
currency: 'USD',
id: randomUUID(),
isExcluded: false,
name: 'Account 1',
platform: { name: 'Platform 1' },
platformId: randomUUID()

2
apps/api/src/app/portfolio/portfolio.service.ts

@ -1035,7 +1035,6 @@ export class PortfolioService {
return {
chart: [],
dateOfFirstActivity: undefined,
firstOrderDate: undefined,
hasErrors: false,
performance: {
currentNetWorth: 0,
@ -1093,7 +1092,6 @@ export class PortfolioService {
errors,
hasErrors,
dateOfFirstActivity: parseDate(historicalData[0]?.date),
firstOrderDate: parseDate(historicalData[0]?.date),
performance: {
netPerformance,
netPerformanceWithCurrencyEffect,

2
apps/api/src/app/user/user.service.ts

@ -549,9 +549,11 @@ export class UserService {
currentPermissions,
permissions.accessHoldingsChart,
permissions.createAccess,
permissions.createAssetProfileSplitOfOwnAssetProfile,
permissions.createMarketDataOfOwnAssetProfile,
permissions.createOwnTag,
permissions.createWatchlistItem,
permissions.deleteAssetProfileSplitOfOwnAssetProfile,
permissions.readAiPrompt,
permissions.readMarketDataOfOwnAssetProfile,
permissions.updateMarketDataOfOwnAssetProfile

1
apps/api/src/helper/account.helper.ts

@ -4,7 +4,6 @@ import { Prisma } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = {
isExcluded: false,
tags: {
none: {
tagId: TAG_ID_EXCLUDE_FROM_ANALYSIS

12
apps/api/src/services/asset-profile-split/asset-profile-split.module.ts

@ -0,0 +1,12 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { AssetProfileSplitService } from './asset-profile-split.service';
@Module({
exports: [AssetProfileSplitService],
imports: [PrismaModule],
providers: [AssetProfileSplitService]
})
export class AssetProfileSplitModule {}

87
apps/api/src/services/asset-profile-split/asset-profile-split.service.ts

@ -0,0 +1,87 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { AssetProfileSplit } from '@prisma/client';
@Injectable()
export class AssetProfileSplitService {
public constructor(private readonly prismaService: PrismaService) {}
/**
* Deletes the split with the given id of an asset profile and returns
* whether it existed
*/
public async deleteById({
id,
symbolProfileId
}: {
id: string;
symbolProfileId: string;
}) {
const { count } = await this.prismaService.assetProfileSplit.deleteMany({
where: {
id,
symbolProfileId
}
});
return count > 0;
}
/**
* Returns the splits of the given asset profile in ascending order by date
*/
public async getSplits({
dataSource,
symbol
}: AssetProfileIdentifier): Promise<AssetProfileSplit[]> {
return this.prismaService.assetProfileSplit.findMany({
orderBy: [
{
date: 'asc'
}
],
where: {
symbolProfile: {
dataSource,
symbol
}
}
});
}
public async upsert({
date,
denominator,
numerator,
symbolProfileId
}: {
date: Date;
denominator: number;
numerator: number;
symbolProfileId: string;
}): Promise<AssetProfileSplit> {
const dateOfSplit = resetHours(date);
return this.prismaService.assetProfileSplit.upsert({
create: {
denominator,
numerator,
symbolProfileId,
date: dateOfSplit
},
update: {
denominator,
numerator
},
where: {
symbolProfileId_date: {
symbolProfileId,
date: dateOfSplit
}
}
});
}
}

101
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -11,7 +11,11 @@ import {
DATE_FORMAT,
getCountryName,
getCurrencyFromSymbol,
isCurrency
getDateFormatString,
getStringOrNull,
getStringOrUndefined,
isCurrency,
isSplitRatio
} from '@ghostfolio/common/helper';
import {
AdminMarketDataDetails,
@ -35,6 +39,7 @@ import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplet
import { GfValueComponent } from '@ghostfolio/ui/value';
import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
@ -61,6 +66,7 @@ import {
MatCheckboxChange,
MatCheckboxModule
} from '@angular/material/checkbox';
import { MatDatepickerModule } from '@angular/material/datepicker';
import {
MAT_DIALOG_DATA,
MatDialogModule,
@ -74,6 +80,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { IonIcon } from '@ionic/angular/standalone';
import {
AssetClass,
AssetProfileSplit,
AssetSubClass,
DataGatheringFrequency,
DataSource,
@ -86,11 +93,14 @@ import { format } from 'date-fns';
import { StatusCodes } from 'http-status-codes';
import { addIcons } from 'ionicons';
import {
calendarClearOutline,
codeSlashOutline,
createOutline,
ellipsisVertical,
gitCompareOutline,
readerOutline,
serverOutline
serverOutline,
trashOutline
} from 'ionicons/icons';
import { isBoolean } from 'lodash';
import ms from 'ms';
@ -103,6 +113,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column h-100' },
imports: [
CommonModule,
FormsModule,
GfCurrencySelectorComponent,
GfEntityLogoComponent,
@ -114,6 +125,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
IonIcon,
MatButtonModule,
MatCheckboxModule,
MatDatepickerModule,
MatDialogModule,
MatInputModule,
MatMenuModule,
@ -194,6 +206,26 @@ export class GfAssetProfileDialogComponent implements OnInit {
}
);
protected readonly assetProfileSplitForm = this.formBuilder.group(
{
date: new FormControl<Date | null>(null, Validators.required),
denominator: new FormControl<number | null>(null, Validators.required),
numerator: new FormControl<number | null>(null, Validators.required)
},
{
validators: (control: AbstractControl): ValidationErrors | null => {
const { denominator, numerator } = control.value as {
denominator: number;
numerator: number;
};
return isSplitRatio({ denominator, numerator })
? null
: { invalidSplitRatio: true };
}
}
);
protected readonly canDeleteAssetProfile = canDeleteAssetProfile;
protected canEditAssetProfile = true;
@ -245,6 +277,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
value: 'max'
}
];
protected defaultDateFormat: string;
protected readonly getCountryName = getCountryName;
protected historicalDataItems: LineChartItem[];
protected isBenchmark = false;
@ -268,6 +301,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
[name: string]: { name: string; value: number };
};
protected splits: AssetProfileSplit[] = [];
protected readonly translate = translate;
protected user: User;
@ -291,11 +326,14 @@ export class GfAssetProfileDialogComponent implements OnInit {
private userService: UserService
) {
addIcons({
calendarClearOutline,
codeSlashOutline,
createOutline,
ellipsisVertical,
gitCompareOutline,
readerOutline,
serverOutline
serverOutline,
trashOutline
});
}
@ -308,6 +346,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.benchmarks = benchmarks;
this.currencies = currencies;
this.defaultDateFormat = getDateFormatString(this.data.locale);
this.initialize();
}
@ -362,8 +401,9 @@ export class GfAssetProfileDialogComponent implements OnInit {
symbol: this.data.symbol
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ assetProfile, marketData }) => {
.subscribe(({ assetProfile, marketData, splits }) => {
this.assetProfile = assetProfile;
this.splits = splits ?? [];
this.assetClassLabel = translate(this.assetProfile?.assetClass ?? '');
this.assetSubClassLabel = translate(
@ -519,6 +559,45 @@ export class GfAssetProfileDialogComponent implements OnInit {
.subscribe();
}
protected onAddSplit() {
const { date, denominator, numerator } =
this.assetProfileSplitForm.getRawValue();
if (!date || !denominator || !numerator) {
return;
}
this.adminService
.postAssetProfileSplit({
dataSource: this.data.dataSource,
split: {
denominator,
numerator,
date: format(date, DATE_FORMAT)
},
symbol: this.data.symbol
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.assetProfileSplitForm.reset();
this.initialize();
});
}
protected onDeleteSplit(aId: string) {
this.adminService
.deleteAssetProfileSplit({
dataSource: this.data.dataSource,
id: aId,
symbol: this.data.symbol
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.initialize();
});
}
protected onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) {
this.initialize();
@ -568,9 +647,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileForm.controls.scraperConfiguration.controls.headers
.value ?? '{}'
) as Record<string, string>,
locale:
locale: getStringOrUndefined(
this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value ?? undefined,
?.value
),
mode:
this.assetProfileForm.controls.scraperConfiguration.controls.mode
?.value ?? undefined,
@ -619,7 +699,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
assetClass: this.assetProfileForm.controls.assetClass.value ?? undefined,
assetSubClass:
this.assetProfileForm.controls.assetSubClass.value ?? undefined,
comment: this.assetProfileForm.controls.comment.value ?? undefined,
comment: getStringOrNull(this.assetProfileForm.controls.comment.value),
currency: this.assetProfileForm.controls.currency.value ?? undefined,
dataGatheringFrequency:
this.assetProfileForm.controls.dataGatheringFrequency.value ??
@ -628,7 +708,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
? this.assetProfileForm.controls.isActive.value
: undefined,
name: this.assetProfileForm.controls.name.value ?? undefined,
url: this.assetProfileForm.controls.url.value ?? undefined
url: getStringOrNull(this.assetProfileForm.controls.url.value)
};
try {
@ -737,9 +817,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileForm.controls.scraperConfiguration.controls.headers
.value ?? '{}'
) as Record<string, string>,
locale:
locale: getStringOrUndefined(
this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value ?? undefined,
?.value
),
mode: this.assetProfileForm.controls.scraperConfiguration.controls
.mode?.value,
selector:

127
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -503,6 +503,133 @@
</div>
</div>
</mat-tab>
<!-- Splits are not applied to the portfolio calculation yet, hence the
tab to manage them is disabled -->
@if (false && user?.settings?.isExperimentalFeatures) {
<mat-tab>
<ng-template mat-tab-label>
<ion-icon name="git-compare-outline" />
<div class="d-none d-sm-block ml-2" i18n>Splits</div>
</ng-template>
<div class="container mt-3 p-0">
<div class="no-gutters row w-100">
<div class="col-12">
<p class="text-muted" i18n>
Splits are stored for this asset profile, but are not applied
to the portfolio calculation yet.
</p>
@if (splits.length > 0) {
<table class="gf-table mb-3 w-100">
<thead>
<tr class="mat-mdc-header-row">
<th class="mat-mdc-header-cell px-1 py-2" i18n>Date</th>
<th
class="mat-mdc-header-cell px-1 py-2 text-right"
i18n
>
Split Ratio
</th>
<th class="mat-mdc-header-cell px-1 py-2"></th>
</tr>
</thead>
<tbody>
@for (split of splits; track split.id) {
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-1 py-2">
{{ split.date | date: defaultDateFormat }}
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
{{ split.numerator }}:{{ split.denominator }}
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
<button
mat-icon-button
type="button"
[disabled]="!canEditAssetProfile"
(click)="onDeleteSplit(split.id)"
>
<ion-icon name="trash-outline" />
</button>
</td>
</tr>
}
</tbody>
</table>
}
<form
[formGroup]="assetProfileSplitForm"
(ngSubmit)="onAddSplit()"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Date</mat-label>
<input
formControlName="date"
matInput
[matDatepicker]="dateOfSplit"
/>
<mat-datepicker-toggle
class="mr-2"
matSuffix
[for]="dateOfSplit"
>
<ion-icon
class="text-muted"
matDatepickerToggleIcon
name="calendar-clear-outline"
/>
</mat-datepicker-toggle>
<mat-datepicker #dateOfSplit />
</mat-form-field>
<mat-form-field appearance="outline" class="mb-3 w-100">
<mat-label i18n>Shares After</mat-label>
<input
formControlName="numerator"
matInput
step="1"
type="number"
/>
<mat-hint
><ng-container i18n>Numerator</ng-container>,
<ng-container i18n
>e.g. 4 for a 4:1 split or 1 for a 1:10 reverse
split</ng-container
></mat-hint
>
</mat-form-field>
<mat-form-field appearance="outline" class="mb-3 w-100">
<mat-label i18n>Shares Before</mat-label>
<input
formControlName="denominator"
matInput
step="1"
type="number"
/>
<mat-hint
><ng-container i18n>Denominator</ng-container>,
<ng-container i18n
>e.g. 1 for a 4:1 split or 10 for a 1:10 reverse
split</ng-container
></mat-hint
>
</mat-form-field>
<div class="text-right">
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="
!assetProfileSplitForm.valid || !canEditAssetProfile
"
>
<ng-container i18n>Add</ng-container>
</button>
</div>
</form>
</div>
</div>
</div>
</mat-tab>
}
@if (assetProfile?.dataSource === 'MANUAL') {
<mat-tab>
<ng-template mat-tab-label>

4
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -266,10 +266,6 @@ export class GfUserAccountSettingsComponent implements OnInit {
.fetchExport()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => {
for (const activity of data.activities) {
delete (activity as Omit<typeof activity, 'id'> & { id?: string }).id;
}
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${format(

3
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -191,7 +191,6 @@ export class GfAccountsPageComponent implements OnInit {
comment,
currency,
id,
isExcluded,
name,
platformId,
tags
@ -206,7 +205,6 @@ export class GfAccountsPageComponent implements OnInit {
comment,
currency,
id,
isExcluded,
name,
platformId,
tags
@ -288,7 +286,6 @@ export class GfAccountsPageComponent implements OnInit {
comment: null,
currency: this.user?.settings?.baseCurrency ?? null,
id: null,
isExcluded: false,
name: null,
platformId: null,
tags: []

7
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -1,6 +1,7 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config';
import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos';
import { getStringOrNull } from '@ghostfolio/common/helper';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { validateObjectForForm } from '@ghostfolio/common/utils';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
@ -27,7 +28,6 @@ import {
} from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import {
MAT_DIALOG_DATA,
MatDialogModule,
@ -51,7 +51,6 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
GfTagsSelectorComponent,
MatAutocompleteModule,
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
@ -106,7 +105,6 @@ export class GfCreateOrUpdateAccountDialogComponent {
balance: [this.data.account.balance, Validators.required],
comment: [this.data.account.comment],
currency: [this.data.account.currency, Validators.required],
isExcluded: [this.data.account.isExcluded],
name: [this.data.account.name, Validators.required],
platformId: [null, this.autocompleteObjectValidator()],
tags: [
@ -201,10 +199,9 @@ export class GfCreateOrUpdateAccountDialogComponent {
protected async onSubmit() {
const account: CreateAccountDto | UpdateAccountDto = {
balance: this.accountForm.get('balance')?.value,
comment: this.accountForm.get('comment')?.value ?? null,
comment: getStringOrNull(this.accountForm.get('comment')?.value),
currency: this.accountForm.get('currency')?.value,
id: this.accountForm.get('accountId')?.value,
isExcluded: this.accountForm.get('isExcluded')?.value,
name: this.accountForm.get('name')?.value,
platformId: this.accountForm.get('platformId')?.value?.id ?? null,
tags: this.accountForm

5
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html

@ -92,11 +92,6 @@
[tagsAvailable]="tagsAvailable"
/>
</div>
<div class="mb-3 px-2">
<mat-checkbox color="primary" formControlName="isExcluded" i18n
>Exclude from Analysis</mat-checkbox
>
</div>
@if (data.account.id) {
<div>
<mat-form-field appearance="outline" class="w-100">

6
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -177,10 +177,6 @@ export class GfActivitiesPageComponent implements OnInit {
.fetchExport(fetchExportParams)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => {
for (const activity of data.activities) {
delete (activity as Omit<typeof activity, 'id'> & { id?: string }).id;
}
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${format(
@ -194,7 +190,7 @@ export class GfActivitiesPageComponent implements OnInit {
protected onExportDrafts(activityIds?: string[]) {
this.dataService
.fetchExport({ activityIds })
.fetchExport({ activityIds, withActivityIds: true })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => {
downloadAsFile({

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

@ -1,7 +1,10 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ASSET_CLASS_MAPPING, DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { getDateFormatString } from '@ghostfolio/common/helper';
import {
getDateFormatString,
getStringOrNull
} from '@ghostfolio/common/helper';
import {
AssetClassSelectorOption,
LookupItem
@ -471,7 +474,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
accountId: this.activityForm.get('accountId')?.value,
assetClass: this.activityForm.get('assetClass')?.value,
assetSubClass: this.activityForm.get('assetSubClass')?.value,
comment: this.activityForm.get('comment')?.value ?? null,
comment: getStringOrNull(this.activityForm.get('comment')?.value),
currency: this.activityForm.get('currency')?.value,
customCurrency: this.activityForm.get('currencyOfUnitPrice')?.value,
dataSource: ['FEE', 'INTEREST', 'LIABILITY', 'VALUABLE'].includes(

2
apps/client/src/index.html

@ -1,5 +1,5 @@
<!doctype html>
<html class="h-100 position-relative" lang="${languageCode}">
<html class="h-100" lang="${languageCode}">
<head>
<title>Ghostfolio</title>
<base href="/" />

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

6
apps/client/src/styles/theme.scss

@ -364,6 +364,12 @@ $gf-typography: (
)
);
@include mat.pseudo-checkbox-overrides(
(
full-selected-icon-color: var(--gf-theme-primary-500)
)
);
@include mat.slide-toggle-overrides(
(
selected-focus-handle-color: var(--gf-theme-primary-500),

10
libs/common/src/lib/dtos/create-account-with-balances.dto.ts

@ -1,6 +1,6 @@
import { AccountBalance } from '@ghostfolio/common/interfaces';
import { IsArray, IsOptional } from 'class-validator';
import { IsArray, IsBoolean, IsOptional } from 'class-validator';
import { CreateAccountDto } from './create-account.dto';
@ -8,4 +8,12 @@ export class CreateAccountWithBalancesDto extends CreateAccountDto {
@IsArray()
@IsOptional()
balances?: AccountBalance[];
/**
* @deprecated Accepted for backward compatibility with old export files
* and mapped to the "Exclude from Analysis" tag (`TAG_ID_EXCLUDE_FROM_ANALYSIS`)
*/
@IsBoolean()
@IsOptional()
isExcluded?: boolean;
}

10
libs/common/src/lib/dtos/create-account.dto.ts

@ -4,7 +4,6 @@ import { Transform, TransformFnParams } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsNumber,
IsOptional,
IsString,
@ -21,7 +20,7 @@ export class CreateAccountDto {
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
comment?: string | null;
@IsCurrencyCode()
currency: string;
@ -30,13 +29,6 @@ export class CreateAccountDto {
@IsString()
id?: string;
/**
* @deprecated Use the "Exclude from Analysis" tag (`TAG_ID_EXCLUDE_FROM_ANALYSIS`) instead
*/
@IsBoolean()
@IsOptional()
isExcluded?: boolean;
@IsString()
name: string;

39
libs/common/src/lib/dtos/create-asset-profile-split.dto.ts

@ -0,0 +1,39 @@
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import { IsSplitRatioConstraint } from '@ghostfolio/common/validator-constraints/is-split-ratio';
import { IsInt, IsISO8601, Validate } from 'class-validator';
export class CreateAssetProfileSplitDto {
/**
* The date the split becomes effective. Activities before this date are
* adjusted by the ratio.
*/
@IsISO8601()
@Validate(IsAfter1970Constraint)
date: string;
/**
* The number of shares held before the split, for example 1 for a 4:1 split
* or 10 for a 1:10 reverse split.
*/
@IsInt()
denominator: number;
/**
* The number of shares held after the split, for example 4 for a 4:1 split
* or 1 for a 1:10 reverse split.
*
* The resulting split factor is numerator / denominator. Both parts are kept
* so that the ratio stays exact, for example 1/3 for a 1:3 reverse split.
*
* Activities are adjusted by this ratio: the quantity is multiplied by it and
* the unit price divided by it, so that the total value of an activity stays
* the same.
*
* Market data is already split-adjusted by the data providers and must not be
* adjusted again.
*/
@IsInt()
@Validate(IsSplitRatioConstraint)
numerator: number;
}

2
libs/common/src/lib/dtos/create-order.dto.ts

@ -35,7 +35,7 @@ export class CreateOrderDto {
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
comment?: string | null;
@IsCurrencyCode()
currency: string;

2
libs/common/src/lib/dtos/index.ts

@ -4,6 +4,7 @@ import { CreateAccessDto } from './create-access.dto';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
import { CreateAccountDto } from './create-account.dto';
import { CreateAssetProfileSplitDto } from './create-asset-profile-split.dto';
import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto';
import { CreateAssetProfileDto } from './create-asset-profile.dto';
import { CreateOrderDto } from './create-order.dto';
@ -36,6 +37,7 @@ export {
CreateAccountDto,
CreateAccountWithBalancesDto,
CreateAssetProfileDto,
CreateAssetProfileSplitDto,
CreateAssetProfileWithMarketDataDto,
CreateOrderDto,
CreatePlatformDto,

10
libs/common/src/lib/dtos/update-account.dto.ts

@ -4,7 +4,6 @@ import { Transform, TransformFnParams } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsNumber,
IsOptional,
IsString,
@ -21,7 +20,7 @@ export class UpdateAccountDto {
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
comment?: string | null;
@IsCurrencyCode()
currency: string;
@ -29,13 +28,6 @@ export class UpdateAccountDto {
@IsString()
id: string;
/**
* @deprecated Use the "Exclude from Analysis" tag (`TAG_ID_EXCLUDE_FROM_ANALYSIS`) instead
*/
@IsBoolean()
@IsOptional()
isExcluded?: boolean;
@IsString()
name: string;

4
libs/common/src/lib/dtos/update-asset-profile.dto.ts

@ -35,7 +35,7 @@ export class UpdateAssetProfileDto {
@IsOptional()
@IsString()
comment?: string;
comment?: string | null;
@IsArray()
@IsOptional()
@ -96,5 +96,5 @@ export class UpdateAssetProfileDto {
protocols: ['http', 'https'],
require_protocol: true
})
url?: string;
url?: string | null;
}

2
libs/common/src/lib/dtos/update-order.dto.ts

@ -34,7 +34,7 @@ export class UpdateOrderDto {
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
comment?: string | null;
@IsCurrencyCode()
currency: string;

133
libs/common/src/lib/helper.spec.ts

@ -1,8 +1,16 @@
import {
TAG_ID_EMERGENCY_FUND,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import {
extractNumberFromString,
getNumberFormatGroup,
getStringOrNull,
getStringOrUndefined,
isAccountExcluded,
isCurrency,
isCurrencySymbol
isCurrencySymbol,
isSplitRatio
} from '@ghostfolio/common/helper';
describe('Helper', () => {
@ -139,6 +147,84 @@ describe('Helper', () => {
});
});
describe('Get string or null', () => {
it('String', () => {
expect(getStringOrNull('https://ghostfol.io')).toEqual(
'https://ghostfol.io'
);
});
it('String (with spaces)', () => {
expect(getStringOrNull(' https://ghostfol.io ')).toEqual(
'https://ghostfol.io'
);
});
it('Empty string', () => {
expect(getStringOrNull('')).toEqual(null);
});
it('Blank string', () => {
expect(getStringOrNull(' ')).toEqual(null);
});
it('Null', () => {
expect(getStringOrNull(null)).toEqual(null);
});
it('Undefined', () => {
expect(getStringOrNull(undefined)).toEqual(null);
});
});
describe('Get string or undefined', () => {
it('String', () => {
expect(getStringOrUndefined('de-DE')).toEqual('de-DE');
});
it('String (with spaces)', () => {
expect(getStringOrUndefined(' de-DE ')).toEqual('de-DE');
});
it('Empty string', () => {
expect(getStringOrUndefined('')).toEqual(undefined);
});
it('Blank string', () => {
expect(getStringOrUndefined(' ')).toEqual(undefined);
});
it('Null', () => {
expect(getStringOrUndefined(null)).toEqual(undefined);
});
it('Undefined', () => {
expect(getStringOrUndefined(undefined)).toEqual(undefined);
});
});
describe('Is account excluded', () => {
it('Account with Exclude from Analysis tag', () => {
expect(
isAccountExcluded({ tags: [{ id: TAG_ID_EXCLUDE_FROM_ANALYSIS }] })
).toEqual(true);
});
it('Account with another tag', () => {
expect(
isAccountExcluded({ tags: [{ id: TAG_ID_EMERGENCY_FUND }] })
).toEqual(false);
});
it('Account without tags', () => {
expect(isAccountExcluded({ tags: [] })).toEqual(false);
});
it('Undefined account', () => {
expect(isAccountExcluded(undefined)).toEqual(false);
});
});
describe('Is currency', () => {
it('ISO 4217 currency code', () => {
expect(isCurrency('USD')).toEqual(true);
@ -195,4 +281,49 @@ describe('Helper', () => {
expect(isCurrencySymbol('')).toEqual(false);
});
});
describe('Is split ratio', () => {
it('Forward split', () => {
expect(isSplitRatio({ denominator: 1, numerator: 2 })).toEqual(true);
expect(isSplitRatio({ denominator: 1, numerator: 4 })).toEqual(true);
expect(isSplitRatio({ denominator: 2, numerator: 3 })).toEqual(true);
});
it('Reverse split', () => {
expect(isSplitRatio({ denominator: 10, numerator: 1 })).toEqual(true);
expect(isSplitRatio({ denominator: 3, numerator: 1 })).toEqual(true);
});
it('Ratio without effect', () => {
expect(isSplitRatio({ denominator: 1, numerator: 1 })).toEqual(false);
expect(isSplitRatio({ denominator: 3, numerator: 3 })).toEqual(false);
});
it('Zero or negative ratio', () => {
expect(isSplitRatio({ denominator: 1, numerator: 0 })).toEqual(false);
expect(isSplitRatio({ denominator: 0, numerator: 1 })).toEqual(false);
expect(isSplitRatio({ denominator: 1, numerator: -2 })).toEqual(false);
expect(isSplitRatio({ denominator: -2, numerator: 1 })).toEqual(false);
});
it('Non-integer ratio', () => {
expect(isSplitRatio({ denominator: 1, numerator: 1.5 })).toEqual(false);
expect(isSplitRatio({ denominator: 2.5, numerator: 1 })).toEqual(false);
expect(isSplitRatio({ denominator: 1, numerator: Number.NaN })).toEqual(
false
);
expect(
isSplitRatio({ denominator: 1, numerator: Number.POSITIVE_INFINITY })
).toEqual(false);
});
it('Missing ratio', () => {
expect(
isSplitRatio({ denominator: undefined, numerator: undefined })
).toEqual(false);
expect(isSplitRatio({ denominator: null, numerator: null })).toEqual(
false
);
});
});
});

50
libs/common/src/lib/helper.ts

@ -397,6 +397,26 @@ export function getStartOfUtcDate(aDate: Date) {
return date;
}
export function getStringOrNull(aString: string | null | undefined) {
const trimmedString = aString?.trim();
if (trimmedString) {
return trimmedString;
}
return null;
}
export function getStringOrUndefined(aString: string | null | undefined) {
const trimmedString = aString?.trim();
if (trimmedString) {
return trimmedString;
}
return undefined;
}
export function getSum(aArray: Big[]) {
if (aArray?.length > 0) {
return aArray.reduce((a, b) => a.plus(b), new Big(0));
@ -456,13 +476,9 @@ export function interpolate(template: string, context: any) {
});
}
export function isAccountExcluded(account: {
isExcluded: boolean;
tags?: { id: string }[];
}) {
export function isAccountExcluded(account?: { tags?: { id: string }[] }) {
return (
account.isExcluded ||
account.tags?.some(({ id }) => {
account?.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
}) === true
);
@ -510,6 +526,28 @@ export function isRootCurrency(aCurrency: string) {
});
}
/**
* Validates the ratio of a stock split, expressed as the number of shares held
* after the split (numerator) per number of shares held before (denominator),
* for example 4 and 1 for a 4:1 split or 1 and 10 for a 1:10 reverse split. An
* equal numerator and denominator would be a no-op.
*/
export function isSplitRatio({
denominator,
numerator
}: {
denominator: number;
numerator: number;
}) {
return (
Number.isSafeInteger(numerator) &&
Number.isSafeInteger(denominator) &&
numerator > 0 &&
denominator > 0 &&
numerator !== denominator
);
}
export function isValidSearchQuery(aQuery: string) {
return aQuery?.trim().length >= SEARCH_QUERY_MINIMUM_LENGTH;
}

3
libs/common/src/lib/interfaces/admin-market-data-details.interface.ts

@ -1,8 +1,9 @@
import { MarketData } from '@prisma/client';
import { AssetProfileSplit, MarketData } from '@prisma/client';
import { EnhancedAssetProfile } from './enhanced-asset-profile.interface';
export interface AdminMarketDataDetails {
assetProfile: Partial<EnhancedAssetProfile>;
marketData: MarketData[];
splits: AssetProfileSplit[];
}

3
libs/common/src/lib/interfaces/responses/asset-profile-response.interface.ts

@ -1,8 +1,9 @@
import { MarketData } from '@prisma/client';
import { AssetProfileSplit, MarketData } from '@prisma/client';
import { EnhancedAssetProfile } from '../enhanced-asset-profile.interface';
export interface AssetProfileResponse {
assetProfile: Partial<EnhancedAssetProfile>;
marketData: MarketData[];
splits: AssetProfileSplit[];
}

6
libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts

@ -5,11 +5,5 @@ import { ResponseError } from './errors.interface';
export interface PortfolioPerformanceResponse extends ResponseError {
chart?: HistoricalDataItem[];
dateOfFirstActivity: Date;
/**
* @deprecated Use `dateOfFirstActivity` instead
*/
firstOrderDate: Date;
performance: PortfolioPerformance;
}

12
libs/common/src/lib/permissions.ts

@ -12,6 +12,9 @@ export const permissions = {
createAccountBalance: 'createAccountBalance',
createActivity: 'createActivity',
createApiKey: 'createApiKey',
createAssetProfileSplit: 'createAssetProfileSplit',
createAssetProfileSplitOfOwnAssetProfile:
'createAssetProfileSplitOfOwnAssetProfile',
createMarketData: 'createMarketData',
createMarketDataOfOwnAssetProfile: 'createMarketDataOfOwnAssetProfile',
createOwnTag: 'createOwnTag',
@ -23,6 +26,9 @@ export const permissions = {
deleteAccount: 'deleteAccount',
deleteAccountBalance: 'deleteAccountBalance',
deleteActivity: 'deleteActivity',
deleteAssetProfileSplit: 'deleteAssetProfileSplit',
deleteAssetProfileSplitOfOwnAssetProfile:
'deleteAssetProfileSplitOfOwnAssetProfile',
deleteAuthDevice: 'deleteAuthDevice',
deleteOwnUser: 'deleteOwnUser',
deletePlatform: 'deletePlatform',
@ -80,6 +86,8 @@ export function getPermissions(aRole: Role): string[] {
permissions.createWatchlistItem,
permissions.deleteAccountBalance,
permissions.deleteWatchlistItem,
permissions.createAssetProfileSplit,
permissions.createAssetProfileSplitOfOwnAssetProfile,
permissions.createMarketData,
permissions.createMarketDataOfOwnAssetProfile,
permissions.createOwnTag,
@ -88,6 +96,8 @@ export function getPermissions(aRole: Role): string[] {
permissions.deleteAccess,
permissions.deleteAccount,
permissions.deleteActivity,
permissions.deleteAssetProfileSplit,
permissions.deleteAssetProfileSplitOfOwnAssetProfile,
permissions.deleteAuthDevice,
permissions.deletePlatform,
permissions.deleteTag,
@ -128,6 +138,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.createAccount,
permissions.createAccountBalance,
permissions.createActivity,
permissions.createAssetProfileSplitOfOwnAssetProfile,
permissions.createMarketDataOfOwnAssetProfile,
permissions.createOwnTag,
permissions.createWatchlistItem,
@ -135,6 +146,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.deleteAccount,
permissions.deleteAccountBalance,
permissions.deleteActivity,
permissions.deleteAssetProfileSplitOfOwnAssetProfile,
permissions.deleteAuthDevice,
permissions.deleteWatchlistItem,
permissions.readAiPrompt,

18
libs/common/src/lib/validator-constraints/is-split-ratio.ts

@ -0,0 +1,18 @@
import { isSplitRatio } from '@ghostfolio/common/helper';
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface
} from 'class-validator';
@ValidatorConstraint({ name: 'isSplitRatio' })
export class IsSplitRatioConstraint implements ValidatorConstraintInterface {
public defaultMessage() {
return 'numerator and denominator must be different positive integers';
}
public validate(_: unknown, { object }: ValidationArguments) {
return isSplitRatio(object as { denominator: number; numerator: number });
}
}

3
libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts

@ -24,7 +24,6 @@ const accounts = [
createdAt: new Date('2025-06-01T06:52:49.063Z'),
currency: 'USD',
id: '460d7401-ca43-4ed4-b08e-349f1822e9db',
isExcluded: false,
name: 'Coinbase Account',
platform: {
id: '8dc24b88-bb92-4152-af25-fe6a31643e26',
@ -46,7 +45,6 @@ const accounts = [
createdAt: new Date('2025-06-01T06:48:53.055Z'),
currency: 'USD',
id: '6d773e31-0583-4c85-a247-e69870b4f1ee',
isExcluded: false,
name: 'Private Banking Account',
platform: {
id: '43e8fcd1-5b79-4100-b678-d2229bd1660d',
@ -68,7 +66,6 @@ const accounts = [
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platform: {
id: '9da3a8a7-4795-43e3-a6db-ccb914189737',

5
libs/ui/src/lib/activities-table/activities-table.component.stories.ts

@ -44,7 +44,6 @@ const activities: Activity[] = [
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
@ -111,7 +110,6 @@ const activities: Activity[] = [
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
@ -178,7 +176,6 @@ const activities: Activity[] = [
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
@ -245,7 +242,6 @@ const activities: Activity[] = [
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),
@ -312,7 +308,6 @@ const activities: Activity[] = [
createdAt: new Date('2025-05-31T13:00:13.940Z'),
currency: 'USD',
id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625',
isExcluded: false,
name: 'Trading Account',
platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737',
updatedAt: new Date('2025-06-01T06:53:10.569Z'),

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

@ -298,7 +298,7 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
public isExcludedFromAnalysis(activity: Activity) {
return (
(activity.account && isAccountExcluded(activity.account)) ??
isAccountExcluded(activity.account) ||
activity.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
}) === true

24
libs/ui/src/lib/services/admin.service.ts

@ -4,6 +4,7 @@ import {
HEADER_KEY_TOKEN
} from '@ghostfolio/common/config';
import {
CreateAssetProfileSplitDto,
CreatePlatformDto,
UpdateAssetProfileDto,
UpdatePlatformDto
@ -23,7 +24,7 @@ import { GF_ENVIRONMENT } from '@ghostfolio/ui/environment';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { MarketData, Platform } from '@prisma/client';
import { AssetProfileSplit, MarketData, Platform } from '@prisma/client';
import { JobStatus } from 'bull';
import { isNumber } from 'lodash';
@ -61,6 +62,16 @@ export class AdminService {
return this.http.delete<void>(`/api/v1/platform/${aId}`);
}
public deleteAssetProfileSplit({
dataSource,
id,
symbol
}: AssetProfileIdentifier & { id: string }) {
return this.http.delete<void>(
`/api/v1/asset-profiles/${dataSource}/${encodeURIComponent(symbol)}/splits/${id}`
);
}
public deleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.delete<void>(
`/api/v1/admin/profile-data/${dataSource}/${encodeURIComponent(symbol)}`
@ -217,6 +228,17 @@ export class AdminService {
);
}
public postAssetProfileSplit({
dataSource,
split,
symbol
}: AssetProfileIdentifier & { split: CreateAssetProfileSplitDto }) {
return this.http.post<AssetProfileSplit>(
`/api/v1/asset-profiles/${dataSource}/${encodeURIComponent(symbol)}/splits`,
split
);
}
public postPlatform(aPlatform: CreatePlatformDto) {
return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
}

27
libs/ui/src/lib/services/data.service.ts

@ -476,12 +476,14 @@ export class DataService {
activityIds,
activityTypes,
filters,
range
range,
withActivityIds = false
}: {
activityIds?: string[];
activityTypes?: string[];
filters?: Filter[];
range?: DateRange;
withActivityIds?: boolean;
} = {}) {
let params = this.buildFiltersAsQueryParams({ filters });
@ -497,9 +499,22 @@ export class DataService {
params = params.append('range', range);
}
return this.http.get<ExportResponse>('/api/v1/export', {
params
});
return this.http
.get<ExportResponse>('/api/v1/export', {
params
})
.pipe(
map((exportResponse) => {
if (!withActivityIds) {
for (const activity of exportResponse.activities) {
delete (activity as Omit<typeof activity, 'id'> & { id?: string })
.id;
}
}
return exportResponse;
})
);
}
public fetchHoldingDetail({
@ -574,6 +589,10 @@ export class DataService {
item.date = parseISO(item.date);
}
for (const item of data.splits ?? []) {
item.date = parseISO(item.date);
}
return data;
})
);

10
libs/ui/src/lib/tags-selector/tags-selector.component.html

@ -1,20 +1,20 @@
<div class="row">
<div class="col">
@if (readonly) {
<div class="h5" i18n>Tags</div>
<div class="h5 mb-0" i18n>Tags</div>
@if (
tagsSelected().length > 0 || (tagsReadOnly && tagsReadOnly.length > 0)
) {
<mat-chip-listbox>
<mat-chip-set>
@if (tagsReadOnly) {
@for (tag of tagsReadOnly; track tag.id) {
<mat-chip-option disabled>{{ tag.name }}</mat-chip-option>
<mat-chip>{{ tag.name }}</mat-chip>
}
}
@for (tag of tagsSelected(); track tag.id) {
<mat-chip-option disabled>{{ tag.name }}</mat-chip-option>
<mat-chip>{{ tag.name }}</mat-chip>
}
</mat-chip-listbox>
</mat-chip-set>
} @else {
<div></div>
}

8
libs/ui/src/lib/tags-selector/tags-selector.component.stories.ts

@ -87,6 +87,14 @@ export const Readonly: Story = {
}
};
export const ReadonlyWithoutValue: Story = {
args: {
readonly: true,
tags: [],
tagsAvailable: OPTIONS
}
};
export const WithoutValue: Story = {
args: {
tags: [],

636
package-lock.json

File diff suppressed because it is too large

18
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "3.38.0",
"version": "3.40.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -76,19 +76,19 @@
"@nest-lab/throttler-storage-redis": "1.2.0",
"@nestjs/bull": "11.0.4",
"@nestjs/cache-manager": "3.1.3",
"@nestjs/common": "11.1.27",
"@nestjs/common": "11.1.28",
"@nestjs/config": "4.0.4",
"@nestjs/core": "11.1.27",
"@nestjs/core": "11.1.28",
"@nestjs/event-emitter": "3.1.0",
"@nestjs/jwt": "11.0.2",
"@nestjs/passport": "11.0.5",
"@nestjs/platform-express": "11.1.27",
"@nestjs/platform-express": "11.1.28",
"@nestjs/schedule": "6.1.3",
"@nestjs/serve-static": "5.0.5",
"@nestjs/throttler": "6.5.0",
"@openrouter/ai-sdk-provider": "3.0.0",
"@prisma/adapter-pg": "7.8.0",
"@prisma/client": "7.8.0",
"@prisma/adapter-pg": "7.9.1",
"@prisma/client": "7.9.1",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1",
"ai": "7.0.37",
@ -160,7 +160,7 @@
"@eslint/eslintrc": "3.3.1",
"@eslint/js": "9.35.0",
"@nestjs/schematics": "11.1.0",
"@nestjs/testing": "11.1.27",
"@nestjs/testing": "11.1.28",
"@nx/angular": "23.0.2",
"@nx/eslint-plugin": "23.0.2",
"@nx/jest": "23.0.2",
@ -171,7 +171,7 @@
"@nx/storybook": "23.0.2",
"@nx/web": "23.0.2",
"@nx/workspace": "23.0.2",
"@prisma/config": "7.8.0",
"@prisma/config": "7.9.1",
"@schematics/angular": "21.2.6",
"@storybook/addon-docs": "10.1.10",
"@storybook/addon-themes": "10.1.10",
@ -201,7 +201,7 @@
"nx": "23.0.2",
"prettier": "3.9.6",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "7.8.0",
"prisma": "7.9.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "8.4.0",

21
prisma/migrations/20260801103609_removed_is_excluded_from_account/migration.sql

@ -0,0 +1,21 @@
-- Create the "EXCLUDE_FROM_ANALYSIS" tag if it does not exist yet
INSERT INTO "Tag" ("id", "name")
VALUES ('f2e868af-8333-459f-b161-cbc6544c24bd', 'EXCLUDE_FROM_ANALYSIS')
ON CONFLICT DO NOTHING;
-- Migrate accounts with "isExcluded" to the "EXCLUDE_FROM_ANALYSIS" tag
INSERT INTO "TagsOnAccounts" ("accountId", "tagId", "updatedAt", "userId")
SELECT
"id",
'f2e868af-8333-459f-b161-cbc6544c24bd',
CURRENT_TIMESTAMP,
"userId"
FROM "Account"
WHERE "isExcluded" = true
ON CONFLICT DO NOTHING;
-- DropIndex
DROP INDEX "Account_isExcluded_idx";
-- AlterTable
ALTER TABLE "Account" DROP COLUMN "isExcluded";

21
prisma/migrations/20260802000000_added_asset_profile_split/migration.sql

@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "AssetProfileSplit" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"date" TIMESTAMP(3) NOT NULL,
"denominator" INTEGER NOT NULL,
"id" TEXT NOT NULL,
"numerator" INTEGER NOT NULL,
"symbolProfileId" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AssetProfileSplit_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AssetProfileSplit_date_idx" ON "AssetProfileSplit"("date");
-- CreateIndex
CREATE UNIQUE INDEX "AssetProfileSplit_symbolProfileId_date_key" ON "AssetProfileSplit"("symbolProfileId", "date");
-- AddForeignKey
ALTER TABLE "AssetProfileSplit" ADD CONSTRAINT "AssetProfileSplit_symbolProfileId_fkey" FOREIGN KEY ("symbolProfileId") REFERENCES "SymbolProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;

31
prisma/schema.prisma

@ -33,8 +33,6 @@ model Account {
createdAt DateTime @default(now())
currency String?
id String @default(uuid())
/// @deprecated Use the "Exclude from Analysis" tag (`TAG_ID_EXCLUDE_FROM_ANALYSIS`) instead
isExcluded Boolean @default(false)
name String?
platform Platform? @relation(fields: [platformId], references: [id])
platformId String?
@ -46,7 +44,6 @@ model Account {
@@id([id, userId])
@@index([currency])
@@index([id])
@@index([isExcluded])
@@index([name])
@@index([userId])
}
@ -119,6 +116,33 @@ model AssetProfileResolution {
@@unique([dataSourceOrigin, symbolOrigin])
}
/// A stock split of an asset profile. The numerator is the number of shares
/// held after the split, the denominator the number of shares held before, for
/// example 4 and 1 for a 4:1 split or 1 and 10 for a 1:10 reverse split.
///
/// The ratio is stored as two integers instead of the resulting factor, so that
/// it stays exact, for example 1/3 for a 1:3 reverse split.
///
/// Activities before the date are adjusted by the ratio: the quantity is
/// multiplied by it and the unit price divided by it, so that the total value
/// of an activity stays the same.
///
/// Market data is already split-adjusted by the data providers and must not be
/// adjusted again.
model AssetProfileSplit {
createdAt DateTime @default(now())
date DateTime
denominator Int
id String @id @default(uuid())
numerator Int
symbolProfile SymbolProfile @relation(fields: [symbolProfileId], onDelete: Cascade, references: [id])
symbolProfileId String
updatedAt DateTime @updatedAt
@@unique([symbolProfileId, date])
@@index([date])
}
model AuthDevice {
createdAt DateTime @default(now())
credentialId Bytes
@ -203,6 +227,7 @@ model SymbolProfile {
activities Order[]
assetClass AssetClass?
assetProfileOverrides AssetProfileOverrides?
assetProfileSplits AssetProfileSplit[]
assetSubClass AssetSubClass?
comment String?
countries Json?

Loading…
Cancel
Save