Browse Source

Merge remote-tracking branch 'origin/main' into task/migrate-deprecated-webpack-executor

pull/7198/head
KenTandrian 1 week ago
parent
commit
1d2aad274d
  1. 27
      CHANGELOG.md
  2. 4
      README.md
  3. 3
      apps/api/src/app/account-balance/account-balance.service.ts
  4. 40
      apps/api/src/app/account/account.controller.ts
  5. 109
      apps/api/src/app/account/account.service.ts
  6. 72
      apps/api/src/app/activities/activities.service.ts
  7. 10
      apps/api/src/app/admin/admin.service.ts
  8. 4
      apps/api/src/app/app.module.ts
  9. 2
      apps/api/src/app/endpoints/tags/tags.controller.ts
  10. 24
      apps/api/src/app/export/export.service.ts
  11. 126
      apps/api/src/app/import/import.service.ts
  12. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  13. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  14. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  15. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  16. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts
  17. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  18. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  19. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  20. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  21. 6
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  22. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  23. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  24. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts
  25. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  26. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  27. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  28. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts
  29. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  30. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  31. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  32. 13
      apps/api/src/app/portfolio/portfolio.service.ts
  33. 4
      apps/api/src/app/subscription/subscription.controller.ts
  34. 32
      apps/api/src/app/subscription/subscription.service.ts
  35. 12
      apps/api/src/helper/account.helper.ts
  36. 4
      apps/api/src/middlewares/html-template.middleware.ts
  37. 3
      apps/api/src/services/configuration/configuration.service.ts
  38. 1
      apps/api/src/services/interfaces/environment.interface.ts
  39. 7
      apps/api/src/services/property/property.service.ts
  40. 9
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts
  41. 16
      apps/api/src/services/tag/tag.service.ts
  42. 4
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  43. 20
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  44. 1
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  45. 2
      apps/client/src/app/components/admin-users/admin-users.component.ts
  46. 15
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  47. 1
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  48. 19
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  49. 90
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  50. 9
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  51. 6
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts
  52. 9
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  53. 12
      apps/client/src/app/pages/portfolio/portfolio-page.component.ts
  54. 72
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  55. 44
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.html
  56. 430
      apps/client/src/locales/messages.ca.xlf
  57. 432
      apps/client/src/locales/messages.de.xlf
  58. 430
      apps/client/src/locales/messages.es.xlf
  59. 430
      apps/client/src/locales/messages.fr.xlf
  60. 430
      apps/client/src/locales/messages.it.xlf
  61. 430
      apps/client/src/locales/messages.ja.xlf
  62. 430
      apps/client/src/locales/messages.ko.xlf
  63. 430
      apps/client/src/locales/messages.nl.xlf
  64. 430
      apps/client/src/locales/messages.pl.xlf
  65. 430
      apps/client/src/locales/messages.pt.xlf
  66. 430
      apps/client/src/locales/messages.tr.xlf
  67. 430
      apps/client/src/locales/messages.uk.xlf
  68. 419
      apps/client/src/locales/messages.xlf
  69. 430
      apps/client/src/locales/messages.zh.xlf
  70. 2
      libs/common/src/lib/config.ts
  71. 11
      libs/common/src/lib/dtos/create-account.dto.ts
  72. 11
      libs/common/src/lib/dtos/update-account.dto.ts
  73. 15
      libs/common/src/lib/helper.ts
  74. 2
      libs/common/src/lib/interfaces/index.ts
  75. 4
      libs/common/src/lib/interfaces/product.interface.ts
  76. 1
      libs/common/src/lib/interfaces/responses/export-response.interface.ts
  77. 273
      libs/common/src/lib/personal-finance-tools.ts
  78. 7
      libs/common/src/lib/types/account-with-platform.type.ts
  79. 3
      libs/common/src/lib/types/account-with-value.type.ts
  80. 6
      libs/common/src/lib/types/index.ts
  81. 12
      libs/common/src/lib/types/product-category.type.ts
  82. 7
      libs/common/src/lib/types/product-platform.type.ts
  83. 7
      libs/common/src/lib/types/property-key.type.ts
  84. 2
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  85. 10
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  86. 6
      libs/ui/src/lib/activities-table/activities-table.component.ts
  87. 33
      libs/ui/src/lib/i18n.ts
  88. 3
      libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts
  89. 8
      libs/ui/src/lib/notifications/confirmation-dialog/confirmation-dialog.component.ts
  90. 3
      libs/ui/src/lib/notifications/prompt-dialog/prompt-dialog.component.ts
  91. 22
      libs/ui/src/lib/tags-selector/tags-selector.component.html
  92. 31
      libs/ui/src/lib/tags-selector/tags-selector.component.ts
  93. 12
      package-lock.json
  94. 4
      package.json
  95. 20
      prisma/migrations/20260705095904_added_tags_on_accounts/migration.sql
  96. 20
      prisma/schema.prisma

27
CHANGELOG.md

@ -9,8 +9,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Set the change detection strategy to `OnPush` in the _FIRE_ page
- Improved the language localization for German (`de`)
## 3.21.0 - 2026-07-05
### Added
- Added support for tags in the account (experimental)
- Exposed the `PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_REMOVE_ON_FAIL` environment variable to control the removal of failed jobs in the portfolio snapshot computation queue
### Changed
- Set the change detection strategy to `OnPush` in the alert dialog component
- Set the change detection strategy to `OnPush` in the confirmation dialog component
- Set the change detection strategy to `OnPush` in the prompt dialog component
- Set the change detection strategy to `OnPush` in the overview of the admin control panel
- Set the change detection strategy to `OnPush` in the portfolio page
- Deprecated the `isExcluded` attribute of the account in favor of the _Exclude from Analysis_ tag
- Improved the language localization in the users table of the admin control panel
- Improved the language localization for German (`de`)
- Upgraded `envalid` from version `8.1.1` to `8.2.0`
- Upgraded `stripe` from version `21.0.1` to `22.2.3`
### Fixed
- Fixed an issue with the custom tags of the user in the import functionality
- Fixed the creation of the _Stripe_ checkout session for languages not supported by _Stripe_ (`ca` and `uk`)
- Fixed the error handling in the endpoint to create a _Stripe_ checkout session
## 3.20.0 - 2026-07-04
### Changed

4
README.md

@ -378,10 +378,6 @@ If you like to support this project, get [**Ghostfolio Premium**](https://ghostf
</a>
</div>
## Analytics
![Alt](https://repobeats.axiom.co/api/embed/281a80b2d0c4af1162866c24c803f1f18e5ed60e.svg 'Repobeats analytics image')
## License
© 2021 - 2026 [Ghostfolio](https://ghostfol.io)

3
apps/api/src/app/account-balance/account-balance.service.ts

@ -1,4 +1,5 @@
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -154,7 +155,7 @@ export class AccountBalanceService {
}
if (withExcludedAccounts === false) {
where.account = { isExcluded: false };
where.account = WHERE_ACCOUNT_NOT_EXCLUDED;
}
const balances = await this.prismaService.accountBalance.findMany({

40
apps/api/src/app/account/account.controller.ts

@ -156,27 +156,31 @@ export class AccountController {
public async createAccount(
@Body() data: CreateAccountDto
): Promise<AccountModel> {
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
const { tags: tagIds, ...accountData } = data;
if (accountData.platformId) {
const platformId = accountData.platformId;
delete accountData.platformId;
return this.accountService.createAccount(
{
...data,
...accountData,
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
this.request.user.id
this.request.user.id,
tagIds
);
} else {
delete data.platformId;
delete accountData.platformId;
return this.accountService.createAccount(
{
...data,
...accountData,
user: { connect: { id: this.request.user.id } }
},
this.request.user.id
this.request.user.id,
tagIds
);
}
}
@ -253,14 +257,16 @@ export class AccountController {
);
}
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
const { tags: tagIds, ...accountData } = data;
if (accountData.platformId) {
const platformId = accountData.platformId;
delete accountData.platformId;
return this.accountService.updateAccount(
{
data: {
...data,
...accountData,
platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } }
},
@ -271,16 +277,17 @@ export class AccountController {
}
}
},
this.request.user.id
this.request.user.id,
tagIds
);
} else {
// platformId is null, remove it
delete data.platformId;
delete accountData.platformId;
return this.accountService.updateAccount(
{
data: {
...data,
...accountData,
platform: originalAccount.platformId
? { disconnect: true }
: undefined,
@ -293,7 +300,8 @@ export class AccountController {
}
}
},
this.request.user.id
this.request.user.id,
tagIds
);
}
}

109
apps/api/src/app/account/account.service.ts

@ -1,11 +1,12 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { HttpException, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
Account,
@ -13,10 +14,12 @@ import {
Order,
Platform,
Prisma,
SymbolProfile
SymbolProfile,
Tag
} from '@prisma/client';
import { Big } from 'big.js';
import { format } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface';
@ -66,17 +69,27 @@ export class AccountService {
activities?: (Order & { SymbolProfile?: SymbolProfile })[];
balances?: AccountBalance[];
platform?: Platform;
tags?: Tag[];
})[]
> {
const { include = {}, skip, take, cursor, where, orderBy } = params;
const isBalancesIncluded = !!include.balances;
const isTagsIncluded = !!include.tags;
include.balances = {
orderBy: { date: 'desc' },
...(isBalancesIncluded ? {} : { take: 1 })
};
if (isTagsIncluded) {
include.tags = {
include: {
tag: true
}
};
}
const accounts = await this.prismaService.account.findMany({
cursor,
include,
@ -87,22 +100,48 @@ export class AccountService {
});
return accounts.map((account) => {
account = { ...account, balance: account.balances[0]?.value ?? 0 };
const result = {
...account,
balance: account.balances[0]?.value ?? 0,
tags: isTagsIncluded
? (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => {
return tag;
})
: undefined
};
if (!isBalancesIncluded) {
delete account.balances;
delete result.balances;
}
if (!isTagsIncluded) {
delete result.tags;
}
return account;
return result;
});
}
public async createAccount(
data: Prisma.AccountCreateInput,
aUserId: string
aUserId: string,
tagIds?: string[]
): Promise<Account> {
await this.validateTagIds(tagIds, aUserId);
const account = await this.prismaService.account.create({
data
data: {
...data,
tags: tagIds
? {
create: tagIds.map((tagId) => {
return {
tag: { connect: { id: tagId } }
};
})
}
: undefined
}
});
await this.accountBalanceService.createOrUpdateAccountBalance({
@ -143,7 +182,8 @@ export class AccountService {
const accounts = await this.accounts({
include: {
activities: true,
platform: true
platform: true,
tags: true
},
orderBy: { name: 'asc' },
where: { userId: aUserId }
@ -184,7 +224,7 @@ export class AccountService {
};
if (withExcludedAccounts === false) {
where.isExcluded = false;
where.AND = [WHERE_ACCOUNT_NOT_EXCLUDED];
}
const { ACCOUNT: filtersByAccount = [] } = groupBy(filters, ({ type }) => {
@ -222,22 +262,37 @@ export class AccountService {
where: Prisma.AccountWhereUniqueInput;
data: Prisma.AccountUpdateInput;
},
aUserId: string
aUserId: string,
tagIds?: string[]
): Promise<Account> {
const { data, where } = params;
await this.validateTagIds(tagIds, aUserId);
const account = await this.prismaService.account.update({
data: {
...data,
tags: tagIds
? {
create: tagIds.map((tagId) => {
return {
tag: { connect: { id: tagId } }
};
}),
deleteMany: {}
}
: undefined
},
where
});
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: data.id as string,
accountId: account.id,
balance: data.balance as number,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});
const account = await this.prismaService.account.update({
data,
where
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
@ -285,4 +340,26 @@ export class AccountService {
});
}
}
private async validateTagIds(tagIds: string[], userId: string) {
if (!tagIds?.length) {
return;
}
const uniqueTagIds = Array.from(new Set(tagIds));
const tagsCount = await this.prismaService.tag.count({
where: {
id: { in: uniqueTagIds },
OR: [{ userId }, { userId: null }]
}
});
if (tagsCount !== uniqueTagIds.length) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

72
apps/api/src/app/activities/activities.service.ts

@ -3,6 +3,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -568,18 +569,15 @@ export class ActivitiesService {
{ date: 'asc' }
];
const where: Prisma.OrderWhereInput = { userId };
const andConditions: Prisma.OrderWhereInput[] = [];
const where: Prisma.OrderWhereInput = { userId, AND: andConditions };
if (endDate || startDate) {
where.AND = [];
if (endDate) {
where.AND.push({ date: { lte: endDate } });
}
if (endDate) {
andConditions.push({ date: { lte: endDate } });
}
if (startDate) {
where.AND.push({ date: { gt: startDate } });
}
if (startDate) {
andConditions.push({ date: { gt: startDate } });
}
const {
@ -682,13 +680,30 @@ export class ActivitiesService {
}
if (filtersByTag.length > 0) {
where.tags = {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
};
andConditions.push({
OR: [
{
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
}
},
{
account: {
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return { tagId: id };
})
}
}
}
}
]
});
}
if (sortColumn) {
@ -700,13 +715,9 @@ export class ActivitiesService {
}
if (withExcludedAccountsAndActivities === false) {
where.OR = [
{ account: null },
{ account: { NOT: { isExcluded: true } } }
];
where.OR = [{ account: null }, { account: WHERE_ACCOUNT_NOT_EXCLUDED }];
where.tags = {
...where.tags,
none: {
id: TAG_ID_EXCLUDE_FROM_ANALYSIS
}
@ -721,7 +732,12 @@ export class ActivitiesService {
include: {
account: {
include: {
platform: true
platform: true,
tags: {
include: {
tag: true
}
}
}
},
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -733,6 +749,16 @@ export class ActivitiesService {
this.prismaService.order.count({ where })
]);
for (const order of orders) {
if (order.account) {
order.account.tags = (
order.account.tags as unknown as { tag: Tag }[]
).map(({ tag }) => {
return tag;
});
}
}
const assetProfileIdentifiers = uniqBy(
orders.map(({ SymbolProfile }) => {
return {

10
apps/api/src/app/admin/admin.service.ts

@ -21,6 +21,7 @@ import {
AdminUsersResponse,
AssetProfileIdentifier
} from '@ghostfolio/common/interfaces';
import { PropertyKey } from '@ghostfolio/common/types';
import {
BadRequestException,
@ -347,9 +348,14 @@ export class AdminService {
let response: Property;
if (value) {
response = await this.propertyService.put({ key, value });
response = await this.propertyService.put({
value,
key: key as PropertyKey
});
} else {
response = await this.propertyService.delete({ key });
response = await this.propertyService.delete({
key: key as PropertyKey
});
}
if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') {

4
apps/api/src/app/app.module.ts

@ -147,7 +147,9 @@ import { UserModule } from './user/user.module';
.split(',')[0]
.split('-')[0];
if (SUPPORTED_LANGUAGE_CODES.includes(code)) {
if (
(SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(code)
) {
languageCode = code;
}
} catch {}

2
apps/api/src/app/endpoints/tags/tags.controller.ts

@ -83,7 +83,7 @@ export class TagsController {
@HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() {
return this.tagService.getTagsWithActivityCount();
return this.tagService.getTagsWithAccountAndActivityCount();
}
@HasPermission(permissions.updateTag)

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

@ -72,7 +72,8 @@ export class ExportService {
where,
include: {
balances: true,
platform: true
platform: true,
tags: true
},
orderBy: {
name: 'asc'
@ -96,7 +97,8 @@ export class ExportService {
isExcluded,
name,
platform,
platformId
platformId,
tags
}) => {
if (platformId) {
platformsMap[platformId] = platform;
@ -112,7 +114,10 @@ export class ExportService {
id,
isExcluded,
name,
platformId
platformId,
tags: tags.map(({ id: tagId }) => {
return tagId;
})
};
}
);
@ -151,11 +156,14 @@ export class ExportService {
.filter(({ id, isUsed }) => {
return (
isUsed &&
activities.some((activity) => {
return activity.tags.some(({ id: tagId }) => {
return tagId === id;
});
})
(accounts.some(({ tags: tagIds }) => {
return tagIds.includes(id);
}) ||
activities.some((activity) => {
return activity.tags.some(({ id: tagId }) => {
return tagId === id;
});
}))
);
})
.map(({ id, name }) => {

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

@ -10,11 +10,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe
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 {
CreateAssetProfileDto,
CreateAccountDto,
CreateOrderDto
} from '@ghostfolio/common/dtos';
import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos';
import {
getAssetProfileIdentifier,
parseDate
@ -194,6 +190,60 @@ export class ImportService {
const tagIdMapping: { [oldTagId: string]: string } = {};
const userCurrency = user.settings.settings.baseCurrency;
const existingTagsOfUser =
tagsDto?.length || (!isDryRun && accountsWithBalancesDto?.length)
? await this.tagService.getTagsForUser(user.id)
: [];
if (tagsDto?.length) {
const canCreateOwnTag = hasPermission(
user.permissions,
permissions.createOwnTag
);
for (const tag of tagsDto) {
const existingTagOfUser = existingTagsOfUser.find(({ id }) => {
return id === tag.id;
});
if (!existingTagOfUser) {
if (!canCreateOwnTag) {
throw new Error(
`Insufficient permissions to create custom tag ("${tag.name}")`
);
}
if (!isDryRun) {
const existingTag = await this.tagService.getTag({ id: tag.id });
let oldTagId: string;
if (existingTag) {
oldTagId = tag.id;
delete tag.id;
}
const tagObject: Prisma.TagCreateInput = {
...tag,
user: { connect: { id: user.id } }
};
const newTag = await this.tagService.createTag(tagObject);
if (existingTag && oldTagId) {
tagIdMapping[oldTagId] = newTag.id;
}
existingTagsOfUser.push({
id: newTag.id,
isUsed: false,
name: newTag.name,
userId: newTag.userId
});
}
}
}
}
if (!isDryRun && accountsWithBalancesDto?.length) {
const [existingAccounts, existingPlatforms] = await Promise.all([
this.accountService.accounts({
@ -208,6 +258,12 @@ export class ImportService {
this.platformService.getPlatforms()
]);
const existingTagIds = new Set(
existingTagsOfUser.map(({ id }) => {
return id;
})
);
for (const accountWithBalances of accountsWithBalancesDto) {
// Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find((existingAccount) => {
@ -216,10 +272,7 @@ 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: CreateAccountDto = omit(
accountWithBalances,
'balances'
);
const account = omit(accountWithBalances, ['balances', 'tags']);
let oldAccountId: string;
const platformId = account.platformId;
@ -231,6 +284,14 @@ export class ImportService {
delete account.id;
}
const tagIds = (accountWithBalances.tags ?? [])
.map((tagId) => {
return tagIdMapping[tagId] ?? tagId;
})
.filter((tagId) => {
return existingTagIds.has(tagId);
});
let accountObject: Prisma.AccountCreateInput = {
...account,
balances: {
@ -252,7 +313,8 @@ export class ImportService {
const newAccount = await this.accountService.createAccount(
accountObject,
user.id
user.id,
tagIds
);
// Store the new to old account ID mappings for updating activities
@ -320,50 +382,6 @@ export class ImportService {
}
}
if (tagsDto?.length) {
const existingTagsOfUser = await this.tagService.getTagsForUser(user.id);
const canCreateOwnTag = hasPermission(
user.permissions,
permissions.createOwnTag
);
for (const tag of tagsDto) {
const existingTagOfUser = existingTagsOfUser.find(({ id }) => {
return id === tag.id;
});
if (!existingTagOfUser || existingTagOfUser.userId !== null) {
if (!canCreateOwnTag) {
throw new Error(
`Insufficient permissions to create custom tag ("${tag.name}")`
);
}
if (!isDryRun) {
const existingTag = await this.tagService.getTag({ id: tag.id });
let oldTagId: string;
if (existingTag) {
oldTagId = tag.id;
delete tag.id;
}
const tagObject: Prisma.TagCreateInput = {
...tag,
user: { connect: { id: user.id } }
};
const newTag = await this.tagService.createTag(tagObject);
if (existingTag && oldTagId) {
tagIdMapping[oldTagId] = newTag.id;
}
}
}
}
}
for (const activity of activitiesDto) {
if (!activity.dataSource) {
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) {

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts

@ -65,7 +65,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -65,7 +65,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -65,7 +65,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts

@ -65,7 +65,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts

@ -87,7 +87,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -75,7 +75,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -77,7 +77,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts

@ -75,7 +75,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -75,7 +75,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

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

@ -125,7 +125,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
@ -148,15 +148,15 @@ describe('PortfolioCalculator', () => {
balances: [
{
accountId,
id: randomUUID(),
date: parseDate('2023-12-31'),
id: randomUUID(),
value: 1000,
valueInBaseCurrency: 850
},
{
accountId,
id: randomUUID(),
date: parseDate('2024-12-31'),
id: randomUUID(),
value: 2000,
valueInBaseCurrency: 1800
}

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts

@ -65,7 +65,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -77,7 +77,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts

@ -78,7 +78,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts

@ -65,7 +65,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts

@ -60,7 +60,7 @@ describe('PortfolioCalculator', () => {
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -65,7 +65,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts

@ -60,7 +60,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -78,7 +78,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -78,7 +78,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts

@ -65,7 +65,7 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);

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

@ -41,6 +41,7 @@ import {
DATE_FORMAT,
getAssetProfileIdentifier,
getSum,
isAccountExcluded,
parseDate
} from '@ghostfolio/common/helper';
import {
@ -169,7 +170,8 @@ export class PortfolioService {
where,
include: {
activities: { include: { SymbolProfile: true } },
platform: true
platform: true,
tags: true
},
orderBy: { name: 'asc' }
}),
@ -1879,7 +1881,7 @@ export class PortfolioService {
for (const activity of activities) {
if (
activity.account?.isExcluded ||
(activity.account && isAccountExcluded(activity.account)) ||
activity.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
})
@ -2123,13 +2125,14 @@ export class PortfolioService {
let currentAccounts: (Account & {
Order?: Order[];
platform?: Platform;
tags?: Tag[];
})[] = [];
if (filters.length === 0) {
currentAccounts = await this.accountService.getAccounts(userId);
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
currentAccounts = await this.accountService.accounts({
include: { platform: true },
include: { platform: true, tags: true },
where: { id: filters[0].id }
});
} else {
@ -2146,13 +2149,13 @@ export class PortfolioService {
);
currentAccounts = await this.accountService.accounts({
include: { platform: true },
include: { platform: true, tags: true },
where: { id: { in: accountIds } }
});
}
currentAccounts = currentAccounts.filter((account) => {
return withExcludedAccounts || account.isExcluded === false;
return withExcludedAccounts || !isAccountExcluded(account);
});
// Iterate over the accounts plus a null entry to group activities without

4
apps/api/src/app/subscription/subscription.controller.ts

@ -116,11 +116,11 @@ export class SubscriptionController {
@Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public createStripeCheckoutSession(
public async createStripeCheckoutSession(
@Body() { couponId, priceId }: { couponId?: string; priceId: string }
): Promise<CreateStripeCheckoutSessionResponse> {
try {
return this.subscriptionService.createStripeCheckoutSession({
return await this.subscriptionService.createStripeCheckoutSession({
couponId,
priceId,
user: this.request.user

32
apps/api/src/app/subscription/subscription.service.ts

@ -3,7 +3,8 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG
PROPERTY_STRIPE_CONFIG,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { parseDate } from '@ghostfolio/common/helper';
@ -75,10 +76,7 @@ export class SubscriptionService {
quantity: 1
}
],
locale:
(user.settings?.settings
?.language as Stripe.Checkout.SessionCreateParams.Locale) ??
DEFAULT_LANGUAGE_CODE,
locale: this.getStripeLocale(user.settings?.settings?.language),
metadata: subscriptionOffer
? { subscriptionOffer: JSON.stringify(subscriptionOffer) }
: {},
@ -246,4 +244,28 @@ export class SubscriptionService {
isRenewal: key.startsWith('renewal')
};
}
private getStripeLocale(
languageCode: string
): Stripe.Checkout.SessionCreateParams.Locale {
const unsupportedLanguageCodes: Record<
Exclude<
(typeof SUPPORTED_LANGUAGE_CODES)[number],
Stripe.Checkout.SessionCreateParams.Locale
>,
true
> = {
ca: true,
uk: true
};
if (
(SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(languageCode) &&
!(languageCode in unsupportedLanguageCodes)
) {
return languageCode as Stripe.Checkout.SessionCreateParams.Locale;
}
return DEFAULT_LANGUAGE_CODE;
}
}

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

@ -0,0 +1,12 @@
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config';
import { Prisma } from '@prisma/client';
export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = {
isExcluded: false,
tags: {
none: {
tagId: TAG_ID_EXCLUDE_FROM_ANALYSIS
}
}
};

4
apps/api/src/middlewares/html-template.middleware.ts

@ -117,7 +117,9 @@ export class HtmlTemplateMiddleware implements NestMiddleware {
const path = request.originalUrl.replace(/\/$/, '');
let languageCode = path.substr(1, 2);
if (!SUPPORTED_LANGUAGE_CODES.includes(languageCode)) {
if (
!(SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(languageCode)
) {
languageCode = DEFAULT_LANGUAGE_CODE;
}

3
apps/api/src/services/configuration/configuration.service.ts

@ -96,6 +96,9 @@ export class ConfigurationService {
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY
}),
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_REMOVE_ON_FAIL: bool({
default: true
}),
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: num({
default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT
}),

1
apps/api/src/services/interfaces/environment.interface.ts

@ -47,6 +47,7 @@ export interface Environment extends CleanedEnvAccessors {
PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: number;
PROCESSOR_GATHER_STATISTICS_CONCURRENCY: number;
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: number;
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_REMOVE_ON_FAIL: boolean;
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: number;
REDIS_DB: number;
REDIS_HOST: string;

7
apps/api/src/services/property/property.service.ts

@ -3,6 +3,7 @@ import {
PROPERTY_CURRENCIES,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import { PropertyKey } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
@ -12,7 +13,7 @@ import { PropertyValue } from './interfaces/interfaces';
export class PropertyService {
public constructor(private readonly prismaService: PrismaService) {}
public async delete({ key }: { key: string }) {
public async delete({ key }: { key: PropertyKey }) {
return this.prismaService.property.delete({
where: { key }
});
@ -40,7 +41,7 @@ export class PropertyService {
return response;
}
public async getByKey<TValue extends PropertyValue>(aKey: string) {
public async getByKey<TValue extends PropertyValue>(aKey: PropertyKey) {
const properties = await this.get();
return properties[aKey] as TValue;
}
@ -51,7 +52,7 @@ export class PropertyService {
);
}
public async put({ key, value }: { key: string; value: string }) {
public async put({ key, value }: { key: PropertyKey; value: string }) {
return this.prismaService.property.upsert({
create: { key, value },
update: { value },

9
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts

@ -1,3 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE } from '@ghostfolio/common/config';
import { InjectQueue } from '@nestjs/bull';
@ -9,6 +10,7 @@ import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue
@Injectable()
export class PortfolioSnapshotService {
public constructor(
private readonly configurationService: ConfigurationService,
@InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
private readonly portfolioSnapshotQueue: Queue
) {}
@ -22,7 +24,12 @@ export class PortfolioSnapshotService {
name: string;
opts?: JobOptions;
}) {
return this.portfolioSnapshotQueue.add(name, data, opts);
return this.portfolioSnapshotQueue.add(name, data, {
...opts,
removeOnFail: this.configurationService.get(
'PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_REMOVE_ON_FAIL'
)
});
}
public async getJob(jobId: string) {

16
apps/api/src/services/tag/tag.service.ts

@ -52,6 +52,11 @@ export class TagService {
include: {
_count: {
select: {
accounts: {
where: {
userId
}
},
activities: {
where: {
userId
@ -80,27 +85,28 @@ export class TagService {
id,
name,
userId,
isUsed: _count.activities > 0
isUsed: _count.accounts > 0 || _count.activities > 0
}))
.sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
}
public async getTagsWithActivityCount() {
const tagsWithOrderCount = await this.prismaService.tag.findMany({
public async getTagsWithAccountAndActivityCount() {
const tagsWithAccountAndOrderCount = await this.prismaService.tag.findMany({
include: {
_count: {
select: { activities: true }
select: { accounts: true, activities: true }
}
}
});
return tagsWithOrderCount.map(({ _count, id, name, userId }) => {
return tagsWithAccountAndOrderCount.map(({ _count, id, name, userId }) => {
return {
id,
name,
userId,
accountCount: _count.accounts,
activityCount: _count.activities
};
});

4
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -27,6 +27,7 @@ import { GfValueComponent } from '@ghostfolio/ui/value';
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
@ -64,6 +65,7 @@ import {
import ms, { StringValue } from 'ms';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ClipboardModule,
CommonModule,
@ -140,6 +142,8 @@ export class GfAdminOverviewComponent implements OnInit {
permissions.toggleReadOnlyMode
);
}
this.changeDetectorRef.markForCheck();
});
addIcons({

20
apps/client/src/app/components/admin-tag/admin-tag.component.html

@ -35,6 +35,24 @@
</td>
</ng-container>
<ng-container matColumnDef="accounts">
<th
*matHeaderCellDef
class="justify-content-end px-1"
mat-header-cell
mat-sort-header="accountCount"
>
<ng-container i18n>Accounts</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[locale]="locale()"
[value]="element.accountCount"
/>
</td>
</ng-container>
<ng-container matColumnDef="activities">
<th
*matHeaderCellDef
@ -74,7 +92,7 @@
<hr class="m-0" />
<button
mat-menu-item
[disabled]="element.activityCount > 0"
[disabled]="element.accountCount > 0 || element.activityCount > 0"
(click)="onDeleteTag(element.id)"
>
<span class="align-items-center d-flex">

1
apps/client/src/app/components/admin-tag/admin-tag.component.ts

@ -62,6 +62,7 @@ export class GfAdminTagComponent implements OnInit {
protected readonly displayedColumns = [
'name',
'userId',
'accounts',
'activities',
'actions'
];

2
apps/client/src/app/components/admin-users/admin-users.component.ts

@ -208,7 +208,7 @@ export class GfAdminUsersComponent implements OnInit {
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
60
? 'just now'
? $localize`just now`
: distanceString;
}

15
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -77,7 +77,7 @@ import {
swapVerticalOutline,
walletOutline
} from 'ionicons/icons';
import { isNumber, round } from 'lodash';
import { isNumber, round, uniqBy } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { switchMap } from 'rxjs/operators';
@ -179,6 +179,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
protected sortColumn = 'date';
protected sortDirection: SortDirection = 'desc';
protected tagsAvailable: Tag[];
protected tagsOfAccounts: Tag[];
protected readonly translate = translate;
protected user: User;
protected value: number;
@ -265,6 +266,18 @@ export class GfHoldingDetailDialogComponent implements OnInit {
.subscribe(({ accounts }) => {
this.accounts = accounts;
this.tagsOfAccounts = uniqBy(
accounts.flatMap(({ tags }) => {
return tags ?? [];
}),
'id'
).map((tag) => {
return {
...tag,
name: translate(tag.name)
};
});
this.changeDetectorRef.markForCheck();
});

1
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -427,6 +427,7 @@
[hasPermissionToCreateTag]="hasPermissionToCreateOwnTag"
[readonly]="!data.hasPermissionToUpdateActivity"
[tagsAvailable]="tagsAvailable"
[tagsReadOnly]="tagsOfAccounts"
/>
</form>

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

@ -25,7 +25,7 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Account as AccountModel } from '@prisma/client';
import { Account as AccountModel, Tag } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
@ -188,8 +188,9 @@ export class GfAccountsPageComponent implements OnInit {
id,
isExcluded,
name,
platformId
}: AccountModel) {
platformId,
tags
}: AccountModel & { tags?: Tag[] }) {
const dialogRef = this.dialog.open<
GfCreateOrUpdateAccountDialogComponent,
CreateOrUpdateAccountDialogParams
@ -202,8 +203,10 @@ export class GfAccountsPageComponent implements OnInit {
id,
isExcluded,
name,
platformId
}
platformId,
tags
},
user: this.user
},
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
@ -277,8 +280,10 @@ export class GfAccountsPageComponent implements OnInit {
id: null,
isExcluded: false,
name: null,
platformId: null
}
platformId: null,
tags: []
},
user: this.user
} satisfies CreateOrUpdateAccountDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'

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

@ -1,11 +1,22 @@
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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { validateObjectForForm } from '@ghostfolio/common/utils';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { translate } from '@ghostfolio/ui/i18n';
import { DataService } from '@ghostfolio/ui/services';
import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector';
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
AbstractControl,
FormBuilder,
@ -24,7 +35,7 @@ import {
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { Platform } from '@prisma/client';
import { Platform, Tag } from '@prisma/client';
import { Observable } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
@ -37,6 +48,7 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
CommonModule,
GfCurrencySelectorComponent,
GfEntityLogoComponent,
GfTagsSelectorComponent,
MatAutocompleteModule,
MatButtonModule,
MatCheckboxModule,
@ -53,19 +65,41 @@ export class GfCreateOrUpdateAccountDialogComponent {
protected accountForm: FormGroup;
protected currencies: string[] = [];
protected filteredPlatforms: Observable<Platform[]> | undefined;
protected hasPermissionToCreateOwnTag: boolean | undefined;
protected platforms: Platform[] = [];
protected tagsAvailable: Tag[] = [];
protected readonly data =
inject<CreateOrUpdateAccountDialogParams>(MAT_DIALOG_DATA);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly dialogRef =
inject<MatDialogRef<GfCreateOrUpdateAccountDialogComponent>>(MatDialogRef);
private readonly formBuilder = inject(FormBuilder);
private readonly userService = inject(UserService);
public ngOnInit() {
const { currencies } = this.dataService.fetchInfo();
this.currencies = currencies;
this.hasPermissionToCreateOwnTag =
this.data.user?.settings?.isExperimentalFeatures &&
hasPermission(this.data.user?.permissions, permissions.createOwnTag);
this.tagsAvailable = [
...(this.data.user?.tags ?? []),
{
id: TAG_ID_EXCLUDE_FROM_ANALYSIS,
name: 'EXCLUDE_FROM_ANALYSIS',
userId: null
}
].map((tag) => {
return {
...tag,
name: translate(tag.name)
};
});
this.accountForm = this.formBuilder.group({
accountId: [{ disabled: true, value: this.data.account.id }],
balance: [this.data.account.balance, Validators.required],
@ -73,9 +107,48 @@ export class GfCreateOrUpdateAccountDialogComponent {
currency: [this.data.account.currency, Validators.required],
isExcluded: [this.data.account.isExcluded],
name: [this.data.account.name, Validators.required],
platformId: [null, this.autocompleteObjectValidator()]
platformId: [null, this.autocompleteObjectValidator()],
tags: [
this.data.account.tags?.map(({ id, name }) => {
return {
id,
name: translate(name)
};
})
]
});
this.accountForm
.get('tags')
?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tags: Tag[]) => {
const newTag = tags.find(({ id }) => {
return id === undefined;
});
if (newTag && this.hasPermissionToCreateOwnTag) {
this.dataService
.postTag({ ...newTag, userId: this.data.user.id })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tag) => {
this.accountForm.get('tags')?.setValue(
tags.map((currentTag) => {
if (currentTag.id === undefined) {
return tag;
}
return currentTag;
})
);
this.userService
.get(true)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
});
}
});
this.dataService.fetchPlatforms().subscribe(({ platforms }) => {
this.platforms = platforms;
@ -132,7 +205,16 @@ export class GfCreateOrUpdateAccountDialogComponent {
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
platformId: this.accountForm.get('platformId')?.value?.id || null,
tags: this.accountForm
.get('tags')
?.value?.filter(({ id }: Tag) => {
// Skip tags which have not been created yet
return !!id;
})
.map(({ id }: Tag) => {
return id;
})
};
try {

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

@ -85,6 +85,15 @@
></textarea>
</mat-form-field>
</div>
@if (data.user?.settings?.isExperimentalFeatures) {
<div class="mb-3">
<gf-tags-selector
formControlName="tags"
[hasPermissionToCreateTag]="hasPermissionToCreateOwnTag"
[tagsAvailable]="tagsAvailable"
/>
</div>
}
<div class="mb-3 px-2">
<mat-checkbox color="primary" formControlName="isExcluded" i18n
>Exclude from Analysis</mat-checkbox

6
apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts

@ -1,7 +1,11 @@
import { Account } from '@prisma/client';
import { User } from '@ghostfolio/common/interfaces';
import { Account, Tag } from '@prisma/client';
export interface CreateOrUpdateAccountDialogParams {
account: Omit<Account, 'createdAt' | 'id' | 'updatedAt' | 'userId'> & {
id: string | null;
tags?: Tag[];
};
user: User;
}

9
apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

@ -14,6 +14,7 @@ import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
@ -29,6 +30,7 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
FormsModule,
@ -89,6 +91,7 @@ export class GfFirePageComponent implements OnInit {
: 0
}
};
if (this.user.subscription?.type === SubscriptionType.Basic) {
this.fireWealth = {
today: {
@ -107,6 +110,8 @@ export class GfFirePageComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.changeDetectorRef.markForCheck();
});
this.safeWithdrawalRateControl.valueChanges
@ -135,9 +140,9 @@ export class GfFirePageComponent implements OnInit {
);
this.calculateWithdrawalRates();
this.changeDetectorRef.markForCheck();
}
this.changeDetectorRef.markForCheck();
});
}

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

@ -6,7 +6,12 @@ import {
TabConfiguration
} from '@ghostfolio/ui/page-tabs';
import { ChangeDetectorRef, Component, DestroyRef } from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { addIcons } from 'ionicons';
import {
@ -18,6 +23,7 @@ import {
} from 'ionicons/icons';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [GfPageTabsComponent],
selector: 'gf-portfolio-page',
@ -67,9 +73,9 @@ export class PortfolioPageComponent {
}
];
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
this.changeDetectorRef.markForCheck();
});
addIcons({

72
apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts

@ -29,6 +29,7 @@ export class GfProductPageComponent {
});
protected readonly product1 = computed<Product>(() => ({
categories: ['FINANCIAL_PLANNING', 'NET_WORTH_TRACKING', 'STOCK_TRACKING'],
founded: 2021,
hasFreePlan: true,
hasSelfHostingAbility: true,
@ -49,6 +50,7 @@ export class GfProductPageComponent {
],
name: 'Ghostfolio',
origin: getCountryName({ code: 'CH' }),
platforms: ['ANDROID', 'WEB'],
regions: [$localize`Global`],
slogan: 'Open Source Wealth Management',
useAnonymously: true
@ -89,39 +91,47 @@ export class GfProductPageComponent {
const product1 = this.product1();
const product2 = this.product2();
return [
product1.name,
product1.origin,
product2.name,
product2.origin,
$localize`Alternative`,
$localize`App`,
$localize`Budgeting`,
$localize`Community`,
$localize`Family Office`,
`Fintech`,
$localize`Investment`,
$localize`Investor`,
$localize`Open Source`,
`OSS`,
$localize`Personal Finance`,
$localize`Privacy`,
$localize`Portfolio`,
$localize`Software`,
$localize`Tool`,
$localize`User Experience`,
$localize`Wealth`,
$localize`Wealth Management`,
`WealthTech`
]
.filter((item): item is string => {
return !!item;
})
.sort((a, b) => {
return a.localeCompare(b, undefined, { sensitivity: 'base' });
});
return Array.from(
new Set(
[
...[product1, product2].flatMap(
({ categories, name, origin, platforms }) => {
return [
...[...(categories ?? []), ...(platforms ?? [])].map((key) => {
return translate(key);
}),
name,
origin
];
}
),
$localize`Alternative`,
$localize`App`,
$localize`Community`,
`Fintech`,
$localize`Investment`,
$localize`Investor`,
$localize`Open Source`,
`OSS`,
$localize`Personal Finance`,
$localize`Portfolio`,
$localize`Privacy`,
$localize`Software`,
$localize`Tool`,
$localize`User Experience`,
$localize`Wealth`,
`WealthTech`
].filter((item): item is string => {
return !!item;
})
)
).sort((a, b) => {
return a.localeCompare(b, undefined, { sensitivity: 'base' });
});
});
protected readonly translate = translate;
private readonly dataService = inject(DataService);
private readonly route = inject(ActivatedRoute);
}

44
apps/client/src/app/pages/resources/personal-finance-tools/product-page.html

@ -73,6 +73,27 @@
<td class="mat-mdc-cell px-1 py-2">{{ product1().slogan }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product2().slogan }}</td>
</tr>
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Category</td>
<td class="mat-mdc-cell px-1 py-2">
@for (
category of product1().categories;
track category;
let isLast = $last
) {
{{ translate(category) }}{{ isLast ? '' : ', ' }}
}
</td>
<td class="mat-mdc-cell px-1 py-2">
@for (
category of product2().categories;
track category;
let isLast = $last
) {
{{ translate(category) }}{{ isLast ? '' : ', ' }}
}
</td>
</tr>
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Founded</td>
<td class="mat-mdc-cell px-1 py-2">{{ product1().founded }}</td>
@ -104,6 +125,29 @@
}
</td>
</tr>
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>
Available on
</td>
<td class="mat-mdc-cell px-1 py-2">
@for (
platform of product1().platforms;
track platform;
let isLast = $last
) {
{{ translate(platform) }}{{ isLast ? '' : ', ' }}
}
</td>
<td class="mat-mdc-cell px-1 py-2">
@for (
platform of product2().platforms;
track platform;
let isLast = $last
) {
{{ translate(platform) }}{{ isLast ? '' : ', ' }}
}
</td>
</tr>
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>
Available in

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

2
libs/common/src/lib/config.ts

@ -323,7 +323,7 @@ export const SUPPORTED_LANGUAGE_CODES = [
'tr',
'uk',
'zh'
];
] as const;
export const TAG_ID_EMERGENCY_FUND = '4452656d-9fa4-4bd0-ba38-70492e31d180';
export const TAG_ID_EXCLUDE_FROM_ANALYSIS =

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

@ -2,6 +2,8 @@ import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { Transform, TransformFnParams } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsNumber,
IsOptional,
@ -28,6 +30,9 @@ export class CreateAccountDto {
@IsString()
id?: string;
/**
* @deprecated Use the "Exclude from Analysis" tag (`TAG_ID_EXCLUDE_FROM_ANALYSIS`) instead
*/
@IsBoolean()
@IsOptional()
isExcluded?: boolean;
@ -38,4 +43,10 @@ export class CreateAccountDto {
@IsString()
@ValidateIf((_object, value) => value !== null)
platformId: string | null;
@ArrayUnique()
@IsArray()
@IsOptional()
@IsString({ each: true })
tags?: string[];
}

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

@ -2,6 +2,8 @@ import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { Transform, TransformFnParams } from 'class-transformer';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsNumber,
IsOptional,
@ -27,6 +29,9 @@ export class UpdateAccountDto {
@IsString()
id: string;
/**
* @deprecated Use the "Exclude from Analysis" tag (`TAG_ID_EXCLUDE_FROM_ANALYSIS`) instead
*/
@IsBoolean()
@IsOptional()
isExcluded?: boolean;
@ -37,4 +42,10 @@ export class UpdateAccountDto {
@IsString()
@ValidateIf((_object, value) => value !== null)
platformId: string | null;
@ArrayUnique()
@IsArray()
@IsOptional()
@IsString({ each: true })
tags?: string[];
}

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

@ -40,7 +40,8 @@ import {
DERIVED_CURRENCIES,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioScraperApiSymbolPrefix
ghostfolioScraperApiSymbolPrefix,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from './config';
import {
AssetProfileIdentifier,
@ -419,6 +420,18 @@ export function interpolate(template: string, context: any) {
});
}
export function isAccountExcluded(account: {
isExcluded: boolean;
tags?: { id: string }[];
}) {
return (
account.isExcluded ||
account.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
}) === true
);
}
export function isCurrency(aCurrency: string) {
if (!aCurrency) {
return false;

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

@ -35,7 +35,7 @@ import type { PortfolioPerformance } from './portfolio-performance.interface';
import type { PortfolioPosition } from './portfolio-position.interface';
import type { PortfolioReportRule } from './portfolio-report-rule.interface';
import type { PortfolioSummary } from './portfolio-summary.interface';
import type { Product } from './product';
import type { Product } from './product.interface';
import type { ReferralPartner } from './referral-partner.interface';
import type { AccessTokenResponse } from './responses/access-token-response.interface';
import type { AccountBalancesResponse } from './responses/account-balances-response.interface';

4
libs/common/src/lib/interfaces/product.ts → libs/common/src/lib/interfaces/product.interface.ts

@ -1,5 +1,8 @@
import { ProductCategory, ProductPlatform } from '@ghostfolio/common/types';
export interface Product {
alias?: string;
categories?: ProductCategory[];
founded?: number;
hasFreePlan?: boolean;
hasSelfHostingAbility?: boolean;
@ -10,6 +13,7 @@ export interface Product {
name: string;
note?: string;
origin?: string;
platforms?: ProductPlatform[];
pricingPerYear?: string;
regions?: string[];
slogan?: string;

1
libs/common/src/lib/interfaces/responses/export-response.interface.ts

@ -8,6 +8,7 @@ import { UserSettings } from '../user-settings.interface';
export interface ExportResponse {
accounts: (Omit<Account, 'createdAt' | 'updatedAt' | 'userId'> & {
balances: AccountBalance[];
tags?: string[];
})[];
activities: (Omit<
Order,

273
libs/common/src/lib/personal-finance-tools.ts

File diff suppressed because it is too large

7
libs/common/src/lib/types/account-with-platform.type.ts

@ -1,3 +1,6 @@
import { Account, Platform } from '@prisma/client';
import { Account, Platform, Tag } from '@prisma/client';
export type AccountWithPlatform = Account & { platform?: Platform };
export type AccountWithPlatform = Account & {
platform?: Platform;
tags?: Tag[];
};

3
libs/common/src/lib/types/account-with-value.type.ts

@ -1,4 +1,4 @@
import { Account as AccountModel, Platform } from '@prisma/client';
import { Account as AccountModel, Platform, Tag } from '@prisma/client';
export type AccountWithValue = AccountModel & {
activitiesCount: number;
@ -7,6 +7,7 @@ export type AccountWithValue = AccountModel & {
dividendInBaseCurrency: number;
interestInBaseCurrency: number;
platform?: Platform;
tags?: Tag[];
value: number;
valueInBaseCurrency: number;
};

6
libs/common/src/lib/types/index.ts

@ -16,6 +16,9 @@ import type { MarketDataPreset } from './market-data-preset.type';
import type { MarketState } from './market-state.type';
import type { Market } from './market.type';
import type { OrderWithAccount } from './order-with-account.type';
import type { ProductCategory } from './product-category.type';
import type { ProductPlatform } from './product-platform.type';
import type { PropertyKey } from './property-key.type';
import type { RequestWithUser } from './request-with-user.type';
import type { SectorName } from './sector-name.type';
import type { SubscriptionOfferKey } from './subscription-offer-key.type';
@ -41,6 +44,9 @@ export type {
MarketDataPreset,
MarketState,
OrderWithAccount,
ProductCategory,
ProductPlatform,
PropertyKey,
RequestWithUser,
SectorName,
SubscriptionOfferKey,

12
libs/common/src/lib/types/product-category.type.ts

@ -0,0 +1,12 @@
export type ProductCategory =
| 'BUDGETING'
| 'CRYPTOCURRENCY'
| 'DIVIDEND_TRACKING'
| 'ETF_TRACKING'
| 'FAMILY_OFFICE'
| 'FINANCIAL_PLANNING'
| 'INVESTMENT_RESEARCH'
| 'NET_WORTH_TRACKING'
| 'STOCK_TRACKING'
| 'TAX_REPORTING'
| 'WEALTH_MANAGEMENT';

7
libs/common/src/lib/types/product-platform.type.ts

@ -0,0 +1,7 @@
export type ProductPlatform =
| 'ANDROID'
| 'IOS'
| 'LINUX'
| 'MACOS'
| 'WEB'
| 'WINDOWS';

7
libs/common/src/lib/types/property-key.type.ts

@ -0,0 +1,7 @@
import type * as config from '../config';
export type PropertyKey = {
[Key in keyof typeof config]: Key extends `PROPERTY_${string}`
? (typeof config)[Key]
: never;
}[keyof typeof config];

2
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -33,7 +33,7 @@
mat-cell
>
<div class="d-flex justify-content-center">
@if (element.isExcluded) {
@if (isExcluded(element)) {
<ion-icon name="eye-off-outline" />
}
</div>

10
libs/ui/src/lib/accounts-table/accounts-table.component.ts

@ -1,5 +1,9 @@
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, getLowercase } from '@ghostfolio/common/helper';
import {
getLocale,
getLowercase,
isAccountExcluded
} from '@ghostfolio/common/helper';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfValueComponent } from '@ghostfolio/ui/value';
@ -137,6 +141,10 @@ export class GfAccountsTableComponent {
});
}
protected isExcluded(account: Account & { tags?: { id: string }[] }) {
return isAccountExcluded(account);
}
protected onDeleteAccount(aId: string) {
this.notificationService.confirm({
confirmFn: () => {

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

@ -3,7 +3,7 @@ import {
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale } from '@ghostfolio/common/helper';
import { getLocale, isAccountExcluded } from '@ghostfolio/common/helper';
import {
Activity,
AssetProfileIdentifier
@ -267,10 +267,10 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
public isExcludedFromAnalysis(activity: Activity) {
return (
activity.account?.isExcluded ??
(activity.account && isAccountExcluded(activity.account)) ||
activity.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
})
}) === true
);
}

33
libs/ui/src/lib/i18n.ts

@ -1,4 +1,8 @@
import type { SectorName } from '@ghostfolio/common/types';
import type {
ProductCategory,
ProductPlatform,
SectorName
} from '@ghostfolio/common/types';
import '@angular/localize/init';
@ -94,8 +98,31 @@ const locales = {
Other: $localize`Other`,
'Real Estate': $localize`Real Estate`,
Technology: $localize`Technology`,
Utilities: $localize`Utilities`
} satisfies Record<SectorName, string> & Record<string, string>;
Utilities: $localize`Utilities`,
// Product categories (CRYPTOCURRENCY is defined above under AssetSubClass)
BUDGETING: $localize`Budgeting`,
DIVIDEND_TRACKING: $localize`Dividend Tracking`,
ETF_TRACKING: $localize`ETF Tracking`,
FAMILY_OFFICE: $localize`Family Office`,
FINANCIAL_PLANNING: $localize`Financial Planning`,
INVESTMENT_RESEARCH: $localize`Investment Research`,
NET_WORTH_TRACKING: $localize`Net Worth Tracking`,
STOCK_TRACKING: $localize`Stock Tracking`,
TAX_REPORTING: $localize`Tax Reporting`,
WEALTH_MANAGEMENT: $localize`Wealth Management`,
// Product platforms
ANDROID: 'Android',
IOS: 'iOS',
LINUX: 'Linux',
MACOS: 'macOS',
WEB: $localize`Web`,
WINDOWS: 'Windows'
} satisfies Record<ProductCategory, string> &
Record<ProductPlatform, string> &
Record<SectorName, string> &
Record<string, string>;
export function translate(aKey: string): string {
return locales[aKey] ?? aKey;

3
libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts

@ -1,10 +1,11 @@
import { Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { AlertDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatButtonModule, MatDialogModule],
selector: 'gf-alert-dialog',
styleUrls: ['./alert-dialog.scss'],

8
libs/ui/src/lib/notifications/confirmation-dialog/confirmation-dialog.component.ts

@ -1,12 +1,18 @@
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { Component, HostListener, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
HostListener,
inject
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { ConfirmDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatButtonModule, MatDialogModule],
selector: 'gf-confirmation-dialog',
styleUrls: ['./confirmation-dialog.scss'],

3
libs/ui/src/lib/notifications/prompt-dialog/prompt-dialog.component.ts

@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
@ -8,6 +8,7 @@ import { MatInputModule } from '@angular/material/input';
import { PromptDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatButtonModule,
MatDialogModule,

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

@ -2,10 +2,19 @@
<div class="col">
@if (readonly) {
<div class="h5" i18n>Tags</div>
@if (tags && tags.length > 0) {
@if (
(tags && tags.length > 0) || (tagsReadOnly && tagsReadOnly.length > 0)
) {
<mat-chip-listbox>
@for (tag of tags; track tag) {
<mat-chip-option disabled>{{ tag.name }}</mat-chip-option>
@if (tagsReadOnly) {
@for (tag of tagsReadOnly; track tag) {
<mat-chip-option disabled>{{ tag.name }}</mat-chip-option>
}
}
@if (tags) {
@for (tag of tags; track tag) {
<mat-chip-option disabled>{{ tag.name }}</mat-chip-option>
}
}
</mat-chip-listbox>
} @else {
@ -15,6 +24,13 @@
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList>
@if (tagsReadOnly) {
@for (tag of tagsReadOnly; track tag.id) {
<mat-chip-row disabled [removable]="false">
{{ tag.name }}
</mat-chip-row>
}
}
@for (tag of tagsSelected(); track tag.id) {
<mat-chip-row
matChipRemove

31
libs/ui/src/lib/tags-selector/tags-selector.component.ts

@ -9,6 +9,7 @@ import {
OnChanges,
OnInit,
signal,
SimpleChanges,
viewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -27,7 +28,6 @@ import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { IonIcon } from '@ionic/angular/standalone';
import { Tag } from '@prisma/client';
import { addIcons } from 'ionicons';
import { addCircleOutline, closeOutline } from 'ionicons/icons';
import { BehaviorSubject, Subject } from 'rxjs';
@ -65,6 +65,7 @@ export class GfTagsSelectorComponent
@Input() readonly = false;
@Input() tags: SelectedTag[];
@Input() tagsAvailable: SelectedTag[];
@Input() tagsReadOnly: SelectedTag[];
public readonly filteredOptions: Subject<SelectedTag[]> = new BehaviorSubject(
[]
@ -87,12 +88,15 @@ export class GfTagsSelectorComponent
}
public ngOnInit() {
this.tagsSelected.set(this.tags);
this.tagsSelected.set(this.tags ?? []);
this.updateFilters();
}
public ngOnChanges() {
this.tagsSelected.set(this.tags);
public ngOnChanges(changes: SimpleChanges) {
if (changes.tags) {
this.tagsSelected.set(this.tags ?? []);
}
this.updateFilters();
}
@ -123,7 +127,7 @@ export class GfTagsSelectorComponent
this.tagInputControl.setValue(null);
}
public onRemoveTag(tag: Tag) {
public onRemoveTag(tag: SelectedTag) {
this.tagsSelected.update((tags) => {
return tags.filter(({ id }) => {
return id !== tag.id;
@ -159,15 +163,20 @@ export class GfTagsSelectorComponent
private filterTags(query: string = ''): SelectedTag[] {
const tags = this.tagsSelected() ?? [];
const tagIds = tags.map(({ id }) => {
const tagIds = [...tags, ...(this.tagsReadOnly ?? [])].map(({ id }) => {
return id;
});
return this.tagsAvailable.filter(({ id, name }) => {
return (
!tagIds.includes(id) && name.toLowerCase().includes(query.toLowerCase())
);
});
return this.tagsAvailable
.filter(({ id, name }) => {
return (
!tagIds.includes(id) &&
name.toLowerCase().includes(query.toLowerCase())
);
})
.sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars

12
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "3.20.0",
"version": "3.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "3.20.0",
"version": "3.21.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -66,7 +66,7 @@
"date-fns": "4.4.0",
"dotenv": "17.2.3",
"dotenv-expand": "12.0.3",
"envalid": "8.1.1",
"envalid": "8.2.0",
"fast-redact": "3.5.0",
"fuse.js": "7.3.0",
"google-spreadsheet": "3.2.0",
@ -19368,9 +19368,9 @@
}
},
"node_modules/envalid": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/envalid/-/envalid-8.1.1.tgz",
"integrity": "sha512-vOUfHxAFFvkBjbVQbBfgnCO9d3GcNfMMTtVfgqSU2rQGMFEVqWy9GBuoSfHnwGu7EqR0/GeukQcL3KjFBaga9w==",
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/envalid/-/envalid-8.2.0.tgz",
"integrity": "sha512-CkPvea95dwMYE1wnKX5mQXkOpiMs9O+ncv8NqZy+gW7FuzpUp06KTdUHb18xFG8CqQHmfmRqGLF5DuGaBWNrSw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"

4
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "3.20.0",
"version": "3.21.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -110,7 +110,7 @@
"date-fns": "4.4.0",
"dotenv": "17.2.3",
"dotenv-expand": "12.0.3",
"envalid": "8.1.1",
"envalid": "8.2.0",
"fast-redact": "3.5.0",
"fuse.js": "7.3.0",
"google-spreadsheet": "3.2.0",

20
prisma/migrations/20260705095904_added_tags_on_accounts/migration.sql

@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "TagsOnAccounts" (
"accountId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"tagId" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "TagsOnAccounts_pkey" PRIMARY KEY ("accountId","tagId","userId")
);
-- CreateIndex
CREATE INDEX "TagsOnAccounts_tagId_idx" ON "TagsOnAccounts"("tagId");
-- AddForeignKey
ALTER TABLE "TagsOnAccounts" ADD CONSTRAINT "TagsOnAccounts_accountId_userId_fkey" FOREIGN KEY ("accountId", "userId") REFERENCES "Account"("id", "userId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TagsOnAccounts" ADD CONSTRAINT "TagsOnAccounts_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;

20
prisma/schema.prisma

@ -33,10 +33,12 @@ 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?
tags TagsOnAccounts[]
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], onDelete: Cascade, references: [id])
userId String
@ -251,16 +253,30 @@ model Subscription {
}
model Tag {
accounts TagsOnAccounts[]
activities Order[]
id String @id @default(uuid())
id String @id @default(uuid())
name String
user User? @relation(fields: [userId], onDelete: Cascade, references: [id])
user User? @relation(fields: [userId], onDelete: Cascade, references: [id])
userId String?
@@unique([name, userId])
@@index([name])
}
model TagsOnAccounts {
account Account @relation(fields: [accountId, userId], onDelete: Cascade, references: [id, userId])
accountId String
createdAt DateTime @default(now())
tag Tag @relation(fields: [tagId], onDelete: Cascade, references: [id])
tagId String
updatedAt DateTime @updatedAt
userId String
@@id([accountId, tagId, userId])
@@index([tagId])
}
model User {
accessesGet Access[] @relation("accessGet")
accessesGive Access[] @relation("accessGive")

Loading…
Cancel
Save