Compare commits

...

27 Commits
2.3.0 ... 2.6.0

Author SHA1 Message Date
f79d60014b Release 2.6.0 (#2395) 2023-09-26 18:58:22 +02:00
5b7409d08e Feature/add tag management in admin control panel (#2389)
* Add tag management

* Update locales

* Update changelog
2023-09-26 18:56:09 +02:00
6230aa87e2 Feature/add hacktoberfest 2023 blog post (#2359)
* Add blog post: Hacktoberfest 2023

* Update changelog
2023-09-26 16:08:17 +02:00
8b615d2f56 Feature/upgrade prettier to version 3.0.3 (#2393)
* Upgrade prettier to version 3.0.3

* Update changelog
2023-09-26 15:07:20 +02:00
4100446cac Update OSS Friends (#2385) 2023-09-26 15:05:14 +02:00
ad3e6d637c Improve file name (#2391) 2023-09-26 15:04:32 +02:00
aa87262954 Feature/upgrade yahoo finance2 to version 2.7.0 (#2392)
* Upgrade yahoo-finance2 to version 2.7.0

* Update changelog
2023-09-26 07:51:27 +02:00
01b6bb5b99 Clean up (#2390) 2023-09-25 23:37:52 +02:00
884b7f4de7 Clean up (#2342) 2023-09-24 08:25:25 +02:00
3f8a2b47f9 Release 2.5.0 (#2380) 2023-09-23 20:11:46 +02:00
e2e4c9be3c Feature/skip data gathering for manual data source (#2379)
* Skip data gathering

* Update changelog
2023-09-23 20:10:08 +02:00
0f7c6ff0fe Bugfix/fix asset class of cash position for empty account (#2378)
* Fix assetClass and assetSubClass

* Update changelog
2023-09-23 19:52:28 +02:00
703a96f4db Add guard (#2377) 2023-09-23 19:45:15 +02:00
42c0560422 Feature/translate activity type (#2376)
* Introduce ActivityTypeComponent with localized label

* Update changelog
2023-09-23 16:44:03 +02:00
eb63802d01 Feature/extend supported date formats in activities import (#2362)
* Extend supported date formats in activities import

* Update changelog
2023-09-23 16:14:54 +02:00
6d9191a46f Feature/setup turkish (#2300)
* Setup Turkish

* Add Turkish translations

* Update changelog

---------

Co-authored-by: sadmimye <134071831+sadmimye@users.noreply.github.com>
2023-09-22 20:26:45 +02:00
6744245d8b Feature/extend personal finance tools pages 20230922 (#2369)
* Extend pages

* Refactoring
2023-09-22 20:04:40 +02:00
8f64a77a9d Clean up (#2329) 2023-09-21 19:56:31 +02:00
0d5fc7655b Improve wording (#2358) 2023-09-21 19:55:36 +02:00
c511ec7e33 Release 2.4.0 (#2356) 2023-09-19 20:38:50 +02:00
b12349a148 Feature/add support for interest on account level (#2354)
* Add support for interest

* Update changelog
2023-09-19 20:37:04 +02:00
f7e3a4c727 Update OSS Friends (#2352) 2023-09-19 20:27:14 +02:00
5f276469b7 Feature/upgrade prisma to version 5.3.1 (#2355)
* Upgrade prisma to version 5.3.1

* Update changelog
2023-09-19 19:37:33 +02:00
69e1d92ed3 Feature/unlock experimental features setting for all users (#2351)
* Unlock experimental features setting for all users

* Update changelog
2023-09-19 18:41:12 +02:00
ef2849aa6c Remove this (#2341) 2023-09-19 10:28:07 +02:00
c668d7b456 Feature/improve preselected currency in create or update activity dialog (#2349)
* Preselect currency based on account's currency

* Update changelog
2023-09-18 19:45:02 +02:00
e23bf62859 Fix Memory Leak on Data Gathering when server TZ is behind UTC (#2332)
* Fix for timezones behind UTC (the previous code converted the date to one day before (in local time) then added a day, which resulted in the same day after converting back to UTC and thus generating an infinite loop)

* Update changelog

---------

Co-authored-by: Rafael Claudio <rafacla@github.com>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-09-17 22:19:06 +02:00
87 changed files with 15398 additions and 1360 deletions

View File

@ -5,6 +5,50 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.6.0 - 2023-09-26
### Added
- Added the management of tags in the admin control panel
- Added a blog post: _Hacktoberfest 2023_
### Changed
- Upgraded `prettier` from version `3.0.2` to `3.0.3`
- Upgraded `yahoo-finance2` from version `2.5.0` to `2.7.0`
## 2.5.0 - 2023-09-23
### Added
- Added support for translated activity types in the activities table
- Added support for dates in `DD.MM.YYYY` format in the activities import
- Set up the language localization for Türkçe (`tr`)
### Changed
- Skipped creating queue jobs for asset profiles with `MANUAL` data source on creating a new activity
### Fixed
- Fixed an issue with the cash position in the holdings table
## 2.4.0 - 2023-09-19
### Added
- Added support for interest on account level (experimental)
### Changed
- Improved the preselected currency based on the account's currency in the create or edit activity dialog
- Unlocked the experimental features setting for all users
- Upgraded `prisma` from version `5.2.0` to `5.3.1`
### Fixed
- Fixed a memory leak related to the server's timezone (behind UTC) in the data gathering
## 2.3.0 - 2023-09-17 ## 2.3.0 - 2023-09-17
### Added ### Added

View File

@ -27,7 +27,7 @@ New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
## Ghostfolio Premium ## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs. Our official **[Ghostfolio Premium](https://ghostfol.io/en/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover the costs of the hosting infrastructure and to fund ongoing development.
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section. If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the [Self-hosting](#self-hosting) section.

View File

@ -39,6 +39,7 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module'; import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module'; import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@Module({ @Module({
@ -101,6 +102,7 @@ import { UserModule } from './user/user.module';
SitemapModule, SitemapModule,
SubscriptionModule, SubscriptionModule,
SymbolModule, SymbolModule,
TagModule,
TwitterBotModule, TwitterBotModule,
UserModule UserModule
], ],

View File

@ -78,7 +78,10 @@ export class ExportService {
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
date: date.toISOString(), date: date.toISOString(),
symbol: symbol:
type === 'FEE' || type === 'ITEM' || type === 'LIABILITY' type === 'FEE' ||
type === 'INTEREST' ||
type === 'ITEM' ||
type === 'LIABILITY'
? SymbolProfile.name ? SymbolProfile.name
: SymbolProfile.symbol : SymbolProfile.symbol
}; };

View File

@ -410,7 +410,7 @@ export class ImportService {
currency, currency,
userCurrency userCurrency
), ),
//@ts-ignore // @ts-ignore
SymbolProfile: assetProfile, SymbolProfile: assetProfile,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value, value,

View File

@ -147,8 +147,9 @@ export class OrderController {
userId: this.request.user.id userId: this.request.user.id
}); });
if (!order.isDraft) { if (data.dataSource && !order.isDraft) {
// Gather symbol data in the background, if not draft // Gather symbol data in the background, if data source is set
// (not MANUAL) and not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
dataSource: data.dataSource, dataSource: data.dataSource,

View File

@ -99,6 +99,7 @@ export class OrderService {
if ( if (
data.type === 'FEE' || data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' || data.type === 'ITEM' ||
data.type === 'LIABILITY' data.type === 'LIABILITY'
) { ) {
@ -122,20 +123,22 @@ export class OrderService {
}; };
} }
this.dataGatheringService.addJobToQueue({ if (data.SymbolProfile.connectOrCreate.create.dataSource !== 'MANUAL') {
data: { this.dataGatheringService.addJobToQueue({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, data: {
symbol: data.SymbolProfile.connectOrCreate.create.symbol
},
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol symbol: data.SymbolProfile.connectOrCreate.create.symbol
}) },
} name: GATHER_ASSET_PROFILE_PROCESS,
}); opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
})
}
});
}
delete data.accountId; delete data.accountId;
delete data.assetClass; delete data.assetClass;
@ -155,7 +158,10 @@ export class OrderService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = const isDraft =
data.type === 'FEE' || data.type === 'ITEM' || data.type === 'LIABILITY' data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' ||
data.type === 'LIABILITY'
? false ? false
: isAfter(data.date as Date, endOfToday()); : isAfter(data.date as Date, endOfToday());
@ -203,6 +209,7 @@ export class OrderService {
if ( if (
order.type === 'FEE' || order.type === 'FEE' ||
order.type === 'INTEREST' ||
order.type === 'ITEM' || order.type === 'ITEM' ||
order.type === 'LIABILITY' order.type === 'LIABILITY'
) { ) {
@ -378,6 +385,7 @@ export class OrderService {
if ( if (
data.type === 'FEE' || data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' || data.type === 'ITEM' ||
data.type === 'LIABILITY' data.type === 'LIABILITY'
) { ) {

View File

@ -47,6 +47,7 @@ export class PlatformController {
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
); );
} }
return this.platformService.createPlatform(data); return this.platformService.createPlatform(data);
} }

View File

@ -6,6 +6,18 @@ import { Platform, Prisma } from '@prisma/client';
export class PlatformService { export class PlatformService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async createPlatform(data: Prisma.PlatformCreateInput) {
return this.prismaService.platform.create({
data
});
}
public async deletePlatform(
where: Prisma.PlatformWhereUniqueInput
): Promise<Platform> {
return this.prismaService.platform.delete({ where });
}
public async getPlatform( public async getPlatform(
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput
): Promise<Platform> { ): Promise<Platform> {
@ -56,12 +68,6 @@ export class PlatformService {
}); });
} }
public async createPlatform(data: Prisma.PlatformCreateInput) {
return this.prismaService.platform.create({
data
});
}
public async updatePlatform({ public async updatePlatform({
data, data,
where where
@ -74,10 +80,4 @@ export class PlatformService {
where where
}); });
} }
public async deletePlatform(
where: Prisma.PlatformWhereUniqueInput
): Promise<Platform> {
return this.prismaService.platform.delete({ where });
}
} }

View File

@ -173,8 +173,14 @@ export class PortfolioController {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = { holdings[symbol] = {
...portfolioPosition, ...portfolioPosition,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined, assetClass:
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined, hasDetails || portfolioPosition.assetClass === 'CASH'
? portfolioPosition.assetClass
: undefined,
assetSubClass:
hasDetails || portfolioPosition.assetSubClass === 'CASH'
? portfolioPosition.assetSubClass
: undefined,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined, currency: hasDetails ? portfolioPosition.currency : undefined,
markets: hasDetails ? portfolioPosition.markets : undefined, markets: hasDetails ? portfolioPosition.markets : undefined,

View File

@ -56,12 +56,11 @@ import {
Platform, Platform,
Prisma, Prisma,
Tag, Tag,
Type as TypeOfOrder Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
differenceInDays, differenceInDays,
endOfToday,
format, format,
isAfter, isAfter,
isBefore, isBefore,
@ -1342,36 +1341,6 @@ export class PortfolioService {
return cashPositions; return cashPositions;
} }
private getDividend({
activities,
date = new Date(0),
userCurrency
}: {
activities: OrderWithAccount[];
date?: Date;
userCurrency: string;
}) {
return activities
.filter((activity) => {
// Filter out all activities before given date (drafts) and type dividend
return (
isBefore(date, new Date(activity.date)) &&
activity.type === TypeOfOrder.DIVIDEND
);
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
userCurrency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getDividendsByGroup({ private getDividendsByGroup({
dividends, dividends,
groupBy groupBy
@ -1516,52 +1485,6 @@ export class PortfolioService {
}; };
} }
private getItems(activities: OrderWithAccount[], date = new Date(0)) {
return activities
.filter((activity) => {
// Filter out all activities before given date (drafts) and type item
return (
isBefore(date, new Date(activity.date)) &&
activity.type === TypeOfOrder.ITEM
);
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getLiabilities({
activities,
userCurrency
}: {
activities: OrderWithAccount[];
userCurrency: string;
}) {
return activities
.filter(({ type }) => {
return type === TypeOfOrder.LIABILITY;
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
userCurrency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getStartDate(aDateRange: DateRange, portfolioStart: Date) { private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) { switch (aDateRange) {
case '1d': case '1d':
@ -1650,9 +1573,10 @@ export class PortfolioService {
return account?.isExcluded ?? false; return account?.isExcluded ?? false;
}); });
const dividend = this.getDividend({ const dividend = this.getSumOfActivityType({
activities, activities,
userCurrency userCurrency,
activityType: 'DIVIDEND'
}).toNumber(); }).toNumber();
const emergencyFund = new Big( const emergencyFund = new Big(
Math.max( Math.max(
@ -1662,23 +1586,49 @@ export class PortfolioService {
); );
const fees = this.getFees({ activities, userCurrency }).toNumber(); const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date; const firstOrderDate = activities[0]?.date;
const items = this.getItems(activities).toNumber(); const interest = this.getSumOfActivityType({
const liabilities = this.getLiabilities({
activities, activities,
userCurrency userCurrency,
activityType: 'INTEREST'
}).toNumber();
const items = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'ITEM'
}).toNumber();
const liabilities = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'LIABILITY'
}).toNumber(); }).toNumber();
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY'); const totalBuy = this.getSumOfActivityType({
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL'); activities,
userCurrency,
activityType: 'BUY'
}).toNumber();
const totalSell = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'SELL'
}).toNumber();
const cash = new Big(balanceInBaseCurrency) const cash = new Big(balanceInBaseCurrency)
.minus(emergencyFund) .minus(emergencyFund)
.plus(emergencyFundPositionsValueInBaseCurrency) .plus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(); .toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell); const committedFunds = new Big(totalBuy).minus(totalSell);
const totalOfExcludedActivities = new Big( const totalOfExcludedActivities = this.getSumOfActivityType({
this.getTotalByType(excludedActivities, userCurrency, 'BUY') userCurrency,
).minus(this.getTotalByType(excludedActivities, userCurrency, 'SELL')); activities: excludedActivities,
activityType: 'BUY'
}).minus(
this.getSumOfActivityType({
userCurrency,
activities: excludedActivities,
activityType: 'SELL'
})
);
const cashDetailsWithExcludedAccounts = const cashDetailsWithExcludedAccounts =
await this.accountService.getCashDetails({ await this.accountService.getCashDetails({
@ -1725,6 +1675,7 @@ export class PortfolioService {
excludedAccountsAndActivities, excludedAccountsAndActivities,
fees, fees,
firstOrderDate, firstOrderDate,
interest,
items, items,
liabilities, liabilities,
netWorth, netWorth,
@ -1747,6 +1698,39 @@ export class PortfolioService {
}; };
} }
private getSumOfActivityType({
activities,
activityType,
date = new Date(0),
userCurrency
}: {
activities: OrderWithAccount[];
activityType: ActivityType;
date?: Date;
userCurrency: string;
}) {
return activities
.filter((activity) => {
// Filter out all activities before given date (drafts) and
// activity type
return (
isBefore(date, new Date(activity.date)) &&
activity.type === activityType
);
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
userCurrency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private async getTransactionPoints({ private async getTransactionPoints({
filters, filters,
includeDrafts = false, includeDrafts = false,
@ -1818,6 +1802,21 @@ export class PortfolioService {
}; };
} }
private getUserCurrency(aUser: UserWithSettings) {
return (
aUser.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ??
DEFAULT_CURRENCY
);
}
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
private async getValueOfAccountsAndPlatforms({ private async getValueOfAccountsAndPlatforms({
filters = [], filters = [],
orders, orders,
@ -1961,38 +1960,4 @@ export class PortfolioService {
return { accounts, platforms }; return { accounts, platforms };
} }
private getTotalByType(
orders: OrderWithAccount[],
currency: string,
type: TypeOfOrder
) {
return orders
.filter(
(order) => !isAfter(order.date, endOfToday()) && order.type === type
)
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.SymbolProfile.currency,
currency
);
})
.reduce((previous, current) => previous + current, 0);
}
private getUserCurrency(aUser: UserWithSettings) {
return (
aUser.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ??
DEFAULT_CURRENCY
);
}
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
} }

View File

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class CreateTagDto {
@IsString()
name: string;
}

View File

@ -0,0 +1,104 @@
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto';
import { TagService } from './tag.service';
import { UpdateTagDto } from './update-tag.dto';
@Controller('tag')
export class TagController {
public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly tagService: TagService
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
if (!hasPermission(this.request.user.permissions, permissions.createTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.createTag(data);
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
if (!hasPermission(this.request.user.permissions, permissions.updateTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.updateTag({
data: {
...data
},
where: {
id
}
});
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteTag(@Param('id') id: string) {
if (!hasPermission(this.request.user.permissions, permissions.deleteTag)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalTag = await this.tagService.getTag({
id
});
if (!originalTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.tagService.deleteTag({ id });
}
}

View File

@ -0,0 +1,13 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { TagController } from './tag.controller';
import { TagService } from './tag.service';
@Module({
controllers: [TagController],
exports: [TagService],
imports: [PrismaModule],
providers: [TagService]
})
export class TagModule {}

View File

@ -0,0 +1,79 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client';
@Injectable()
export class TagService {
public constructor(private readonly prismaService: PrismaService) {}
public async createTag(data: Prisma.TagCreateInput) {
return this.prismaService.tag.create({
data
});
}
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
return this.prismaService.tag.delete({ where });
}
public async getTag(
tagWhereUniqueInput: Prisma.TagWhereUniqueInput
): Promise<Tag> {
return this.prismaService.tag.findUnique({
where: tagWhereUniqueInput
});
}
public async getTags({
cursor,
orderBy,
skip,
take,
where
}: {
cursor?: Prisma.TagWhereUniqueInput;
orderBy?: Prisma.TagOrderByWithRelationInput;
skip?: number;
take?: number;
where?: Prisma.TagWhereInput;
} = {}) {
return this.prismaService.tag.findMany({
cursor,
orderBy,
skip,
take,
where
});
}
public async getTagsWithActivityCount() {
const tagsWithOrderCount = await this.prismaService.tag.findMany({
include: {
_count: {
select: { orders: true }
}
}
});
return tagsWithOrderCount.map(({ _count, id, name }) => {
return {
id,
name,
activityCount: _count.orders
};
});
}
public async updateTag({
data,
where
}: {
data: Prisma.TagUpdateInput;
where: Prisma.TagWhereUniqueInput;
}): Promise<Tag> {
return this.prismaService.tag.update({
data,
where
});
}
}

View File

@ -0,0 +1,9 @@
import { IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
id: string;
@IsString()
name: string;
}

View File

@ -58,6 +58,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -142,6 +146,10 @@
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -254,6 +262,10 @@
<loc>https://ghostfol.io/en/blog/2023/09/ghostfolio-2</loc> <loc>https://ghostfol.io/en/blog/2023/09/ghostfolio-2</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/blog/2023/09/hacktoberfest-2023</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/faq</loc> <loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -292,6 +304,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -376,6 +392,10 @@
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc> <loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -558,6 +578,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-campmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -642,6 +666,10 @@
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc> <loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -670,6 +698,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -754,6 +786,10 @@
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockmarketeye</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc> <loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -868,4 +904,8 @@
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc> <loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/tr</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
</urlset> </urlset>

View File

@ -18,7 +18,8 @@ const descriptions = {
fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.', fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.', it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETFs of cryptocurrencies over meerdere platforms bij te houden.', nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETFs of cryptocurrencies over meerdere platforms bij te houden.',
pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.' pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
tr: 'Ghostfolio, hisse senetleri, ETFler veya kripto para birimleri gibi varlıklarınızı birden fazla platformda takip etmenizi sağlayan bir kişisel finans panosudur.'
}; };
const title = 'Ghostfolio Open Source Wealth Management Software'; const title = 'Ghostfolio Open Source Wealth Management Software';
@ -79,6 +80,10 @@ const locales = {
'/en/blog/2023/09/ghostfolio-2': { '/en/blog/2023/09/ghostfolio-2': {
featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg', featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg',
title: `Announcing Ghostfolio 2.0 - ${titleShort}` title: `Announcing Ghostfolio 2.0 - ${titleShort}`
},
'/en/blog/2023/09/hacktoberfest-2023': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 - ${titleShort}`
} }
}; };

View File

@ -13,6 +13,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { Job } from 'bull'; import { Job } from 'bull';
import { import {
addDays,
format, format,
getDate, getDate,
getMonth, getMonth,
@ -101,15 +102,7 @@ export class DataGatheringProcessor {
}); });
} }
// Count month one up for iteration currentDate = addDays(currentDate, 1);
currentDate = new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate) + 1,
0
)
);
} }
await this.marketDataService.updateMany({ data }); await this.marketDataService.updateMany({ data });

View File

@ -127,6 +127,10 @@ export class DataGatheringService {
uniqueAssets = await this.getUniqueAssets(); uniqueAssets = await this.getUniqueAssets();
} }
if (uniqueAssets.length <= 0) {
return;
}
const assetProfiles = const assetProfiles =
await this.dataProviderService.getAssetProfiles(uniqueAssets); await this.dataProviderService.getAssetProfiles(uniqueAssets);
const symbolProfiles = const symbolProfiles =

View File

@ -63,6 +63,10 @@
"baseHref": "/pt/", "baseHref": "/pt/",
"localize": ["pt"] "localize": ["pt"]
}, },
"development-tr": {
"baseHref": "/tr/",
"localize": ["tr"]
},
"production": { "production": {
"fileReplacements": [ "fileReplacements": [
{ {
@ -165,6 +169,9 @@
"development-pt": { "development-pt": {
"browserTarget": "client:build:development-pt" "browserTarget": "client:build:development-pt"
}, },
"development-tr": {
"browserTarget": "client:build:development-tr"
},
"production": { "production": {
"browserTarget": "client:build:production" "browserTarget": "client:build:production"
} }
@ -182,7 +189,8 @@
"messages.fr.xlf", "messages.fr.xlf",
"messages.it.xlf", "messages.it.xlf",
"messages.nl.xlf", "messages.nl.xlf",
"messages.pt.xlf" "messages.pt.xlf",
"messages.tr.xlf"
] ]
} }
}, },
@ -226,6 +234,10 @@
"pt": { "pt": {
"baseHref": "/pt/", "baseHref": "/pt/",
"translation": "apps/client/src/locales/messages.pt.xlf" "translation": "apps/client/src/locales/messages.pt.xlf"
},
"tr": {
"baseHref": "/tr/",
"translation": "apps/client/src/locales/messages.tr.xlf"
} }
}, },
"sourceLocale": "en" "sourceLocale": "en"

View File

@ -152,6 +152,11 @@
<li> <li>
<a href="../pt" title="Ghostfolio in Português">Português</a> <a href="../pt" title="Ghostfolio in Português">Português</a>
</li> </li>
<!--
<li>
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
</li>
-->
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -72,19 +72,6 @@
</div> </div>
</div> </div>
</div> </div>
<div
*ngIf="info?.tags?.length > 0"
class="align-items-start d-flex my-3"
>
<div class="w-50" i18n>Tags</div>
<div class="w-50">
<table>
<tr *ngFor="let tag of info.tags">
<td class="pl-1">{{ tag.name }}</td>
</tr>
</table>
</div>
</div>
<div class="d-flex my-3"> <div class="d-flex my-3">
<div class="w-50" i18n>User Signup</div> <div class="w-50" i18n>User Signup</div>
<div class="w-50"> <div class="w-50">

View File

@ -19,7 +19,7 @@ import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog/create-or-update-account-platform.component'; import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog/create-or-update-platform-dialog.component';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -114,6 +114,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((platforms) => { .subscribe((platforms) => {
this.platforms = platforms; this.platforms = platforms;
this.dataSource = new MatTableDataSource(platforms); this.dataSource = new MatTableDataSource(platforms);
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get; this.dataSource.sortingDataAccessor = get;
@ -130,7 +131,6 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
url: null url: null
} }
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
@ -170,7 +170,6 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
url url
} }
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });

View File

@ -15,8 +15,8 @@ export class CreateOrUpdatePlatformDialog {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog>, @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog>
) {} ) {}
public onCancel() { public onCancel() {

View File

@ -6,7 +6,7 @@ import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { CreateOrUpdatePlatformDialog } from './create-or-update-account-platform.component'; import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog.component';
@NgModule({ @NgModule({
declarations: [CreateOrUpdatePlatformDialog], declarations: [CreateOrUpdatePlatformDialog],

View File

@ -2,14 +2,13 @@
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h2 class="text-center" i18n>Platforms</h2> <h2 class="text-center" i18n>Platforms</h2>
<gf-admin-platform></gf-admin-platform> <gf-admin-platform />
</div> </div>
</div> </div>
<!--
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h2 class="text-center" i18n>Tags</h2> <h2 class="text-center" i18n>Tags</h2>
<gf-admin-tag />
</div> </div>
</div> </div>
-->
</div> </div>

View File

@ -2,12 +2,18 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module'; import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module';
import { AdminSettingsComponent } from './admin-settings.component'; import { AdminSettingsComponent } from './admin-settings.component';
@NgModule({ @NgModule({
declarations: [AdminSettingsComponent], declarations: [AdminSettingsComponent],
imports: [CommonModule, GfAdminPlatformModule, RouterModule], imports: [
CommonModule,
GfAdminPlatformModule,
GfAdminTagModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfAdminSettingsModule {} export class GfAdminSettingsModule {}

View File

@ -0,0 +1,85 @@
<div class="container">
<div class="row">
<div class="col">
<div class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createTagDialog: true }"
[routerLink]="[]"
>
Add Tag
</a>
</div>
<table
class="gf-table w-100"
mat-table
matSort
matSortActive="name"
matSortDirection="asc"
[dataSource]="dataSource"
>
<ng-container matColumnDef="name">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="name"
>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }}
</td>
</ng-container>
<ng-container matColumnDef="activities">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="activityCount"
>
<ng-container i18n>Activities</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.activityCount }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th
*matHeaderCellDef
class="px-1 text-center"
i18n
mat-header-cell
></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="tagMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</button>
<mat-menu #tagMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateTag(element)">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</button>
<button mat-menu-item (click)="onDeleteTag(element.id)">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button>
</mat-menu>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
</div>
</div>
</div>

View File

@ -0,0 +1,5 @@
@import 'apps/client/src/styles/ghostfolio-style';
:host {
display: block;
}

View File

@ -0,0 +1,199 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Tag } from '@prisma/client';
import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-tag',
styleUrls: ['./admin-tag.component.scss'],
templateUrl: './admin-tag.component.html'
})
export class AdminTagComponent implements OnInit, OnDestroy {
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<Tag> = new MatTableDataSource();
public deviceType: string;
public displayedColumns = ['name', 'activities', 'actions'];
public tags: Tag[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private userService: UserService
) {
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createTagDialog']) {
this.openCreateTagDialog();
} else if (params['editTagDialog']) {
if (this.tags) {
const tag = this.tags.find(({ id }) => {
return id === params['tagId'];
});
this.openUpdateTagDialog(tag);
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
}
});
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.fetchTags();
}
public onDeleteTag(aId: string) {
const confirmation = confirm(
$localize`Do you really want to delete this tag?`
);
if (confirmation) {
this.deleteTag(aId);
}
}
public onUpdateTag({ id }: Tag) {
this.router.navigate([], {
queryParams: { editTagDialog: true, tagId: id }
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private deleteTag(aId: string) {
this.adminService
.deleteTag(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchTags();
}
});
}
private fetchTags() {
this.adminService
.fetchTags()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => {
this.tags = tags;
this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
this.changeDetectorRef.markForCheck();
});
}
private openCreateTagDialog() {
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, {
data: {
tag: {
name: null
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
const tag: CreateTagDto = data?.tag;
if (tag) {
this.adminService
.postTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchTags();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private openUpdateTagDialog({ id, name }) {
const dialogRef = this.dialog.open(CreateOrUpdateTagDialog, {
data: {
tag: {
id,
name
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
const tag: UpdateTagDto = data?.tag;
if (tag) {
this.adminService
.putTag(tag)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchTags();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
}

View File

@ -0,0 +1,26 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { AdminTagComponent } from './admin-tag.component';
import { GfCreateOrUpdateTagDialogModule } from './create-or-update-tag-dialog/create-or-update-tag-dialog.module';
@NgModule({
declarations: [AdminTagComponent],
exports: [AdminTagComponent],
imports: [
CommonModule,
GfCreateOrUpdateTagDialogModule,
MatButtonModule,
MatMenuModule,
MatSortModule,
MatTableModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminTagModule {}

View File

@ -0,0 +1,30 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' },
selector: 'gf-create-or-update-tag-dialog',
styleUrls: ['./create-or-update-tag-dialog.scss'],
templateUrl: 'create-or-update-tag-dialog.html'
})
export class CreateOrUpdateTagDialog {
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateTagDialog>
) {}
public onCancel() {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,23 @@
<form #addTagForm="ngForm" class="d-flex flex-column h-100">
<h1 *ngIf="data.tag.id" i18n mat-dialog-title>Update tag</h1>
<h1 *ngIf="!data.tag.id" i18n mat-dialog-title>Add tag</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.tag.name" />
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
[disabled]="!addTagForm.form.valid"
[mat-dialog-close]="data"
>
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

View File

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog.component';
@NgModule({
declarations: [CreateOrUpdateTagDialog],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
]
})
export class GfCreateOrUpdateTagDialogModule {}

View File

@ -0,0 +1,7 @@
:host {
display: block;
.mat-mdc-dialog-content {
max-height: unset;
}
}

View File

@ -0,0 +1,5 @@
import { Tag } from '@prisma/client';
export interface CreateOrUpdateTagDialogParams {
tag: Tag;
}

View File

@ -276,6 +276,18 @@
<div class="row"> <div class="row">
<div class="col"><hr /></div> <div class="col"><hr /></div>
</div> </div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Interest</div>
<div class="justify-content-end">
<gf-value
class="justify-content-end"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.interest"
></gf-value>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate" i18n>Dividend</div> <div class="flex-grow-1 text-truncate" i18n>Dividend</div>
<div class="justify-content-end"> <div class="justify-content-end">

View File

@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-hacktoberfest-2023-page',
standalone: true,
templateUrl: './hacktoberfest-2023-page.html'
})
export class Hacktoberfest2023PageComponent {
public routerLinkAbout = ['/' + $localize`about`];
}

View File

@ -0,0 +1,194 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">Hacktoberfest 2023</h1>
<div class="mb-3 text-muted"><small>2023-09-26</small></div>
<img
alt="Hacktoberfest 2023 with Ghostfolio Teaser"
class="rounded w-100"
src="../assets/images/blog/hacktoberfest-2023.png"
title="Hacktoberfest 2023 with Ghostfolio"
/>
</div>
<section class="mb-4">
<p>
At Ghostfolio, <a [routerLink]="routerLinkAbout">we</a> are very
excited to participate in
<a href="https://hacktoberfest.com">Hacktoberfest</a> for the second
time, looking forward to connecting with new and enthusiastic
open-source contributors. Hacktoberfest is a month-long celebration
of open-source projects, their maintainers, and the entire community
of contributors. Each October, open source maintainers from all over
the world give extra attention to new contributors while guiding
them through their first pull requests on
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>. This
year the event celebrates its 10th anniversary.
</p>
</section>
<section class="mb-4">
<h2 class="h4">About Ghostfolio</h2>
<p>
<a href="https://ghostfol.io">Ghostfolio</a> is a modern web
application for managing personal finances. The software aggregates
your assets and empowers informed decision-making to help you
balance your portfolio or plan for future investments.
</p>
<p>
Ghostfolio is written in
<a href="https://www.typescriptlang.org">TypeScript</a> and
organized as an <a href="https://nx.dev">Nx</a> workspace, utilizing
the latest framework releases. The backend is based on
<a href="https://nestjs.com">NestJS</a> in combination with
<a href="https://www.postgresql.org">PostgreSQL</a> as a database
together with <a href="https://www.prisma.io">Prisma</a> and
<a href="https://redis.io">Redis</a> for caching. The frontend is
built with <a href="https://angular.io">Angular</a>.
</p>
<p>
The software is used daily by a thriving global community. With over
<a [routerLink]="['/open']">2600 stars on GitHub</a> and
<a [routerLink]="['/open']">300000+ pulls on Docker Hub</a>,
Ghostfolio has gained widespread recognition for its user-friendly
experience and simplicity.
</p>
</section>
<section class="mb-4">
<h2 class="h4">How to contribute?</h2>
<p>
Each contribution can make a meaningful impact. Whether it involves
implementing new features, resolving bugs, refactoring code,
enhancing documentation, adding unit tests, or translating content
into another language, you can actively shape our project.
</p>
<p>
Are you not yet familiar with our code base? That is not a problem.
We have applied the label <code>hacktoberfest</code> to a few
<a
href="https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3Ahacktoberfest"
>issues</a
>
that are well suited for newcomers.
</p>
<p>
The official Hacktoberfest website provides some valuable
<a
href="https://hacktoberfest.com/participation/#beginner-resources"
>resources for beginners</a
>
to start contributing in open source.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Get support</h2>
<p>
If you have further questions or ideas, please join our
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>Slack</a
>
community or get in touch on X
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
</p>
<p>
We look forward to hearing from you.<br />
Thomas from Ghostfolio
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Angular</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Community</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Docker</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">GitHub</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Hacktoberfest</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">NestJS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Nx</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">October</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio Tracker</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Prisma</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">TypeScript</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">UX</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web3</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web 3.0</span>
</li>
</ul>
</section>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Hacktoberfest 2023
</li>
</ol>
</nav>
</article>
</div>
</div>
</div>

View File

@ -154,6 +154,15 @@ const routes: Routes = [
(c) => c.Ghostfolio2PageComponent (c) => c.Ghostfolio2PageComponent
), ),
title: 'Ghostfolio 2.0' title: 'Ghostfolio 2.0'
},
{
canActivate: [AuthGuard],
path: '2023/09/hacktoberfest-2023',
loadComponent: () =>
import(
'./2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component'
).then((c) => c.Hacktoberfest2023PageComponent),
title: 'Hacktoberfest 2023'
} }
]; ];

View File

@ -8,6 +8,30 @@
finance</small finance</small
> >
</h1> </h1>
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex overflow-hidden w-100"
href="../en/blog/2023/09/hacktoberfest-2023"
>
<div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">Hacktoberfest 2023</div>
<div class="d-flex text-muted">2023-09-26</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
<mat-card-content> <mat-card-content>
<div class="container p-0"> <div class="container p-0">

View File

@ -142,11 +142,11 @@
> >
<mat-card-content <mat-card-content
><a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> is a fully ><a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> is a fully
managed Ghostfolio cloud offering for ambitious investors. The revenue managed Ghostfolio cloud offering for ambitious investors. Revenue is
is used to cover the hosting infrastructure and to fund the ongoing used to cover the costs of the hosting infrastructure and to fund
development. It is the Open Source code base with some extras like the ongoing development. It is the Open Source code base with some extras
<a [routerLink]="routerLinkMarkets">markets overview</a> and a like the <a [routerLink]="routerLinkMarkets">markets overview</a> and
professional data provider.</mat-card-content a professional data provider.</mat-card-content
> >
</mat-card> </mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">

View File

@ -245,7 +245,7 @@
<h4 i18n>Multi-Language</h4> <h4 i18n>Multi-Language</h4>
<p class="m-0"> <p class="m-0">
Use Ghostfolio in multiple languages: English, Dutch, French, Use Ghostfolio in multiple languages: English, Dutch, French,
German, Italian, Portuguese and Spanish are currently German, Italian, Portuguese, Spanish and Turkish are currently
supported. supported.
</p> </p>
</div> </div>

View File

@ -60,7 +60,7 @@
<div *ngIf="hasPermissionForStatistics" class="row mb-5"> <div *ngIf="hasPermissionForStatistics" class="row mb-5">
<div <div
class="col-md-4 d-flex my-1" class="col-md-4 d-flex my-1"
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }" [ngClass]="{ 'justify-content-center': deviceType !== 'mobile' }"
> >
<a <a
class="d-block" class="d-block"
@ -78,7 +78,7 @@
</div> </div>
<div <div
class="col-md-4 d-flex my-1" class="col-md-4 d-flex my-1"
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }" [ngClass]="{ 'justify-content-center': deviceType !== 'mobile' }"
> >
<a <a
class="d-block" class="d-block"
@ -96,7 +96,7 @@
</div> </div>
<div <div
class="col-md-4 d-flex my-1" class="col-md-4 d-flex my-1"
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }" [ngClass]="{ 'justify-content-center': deviceType !== 'mobile' }"
> >
<a <a
class="d-block" class="d-block"

View File

@ -80,8 +80,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
} }
public ngOnInit() { public ngOnInit() {
const { globalPermissions } = this.dataService.fetchInfo();
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService this.impersonationStorageService

View File

@ -20,7 +20,7 @@ import { translate } from '@ghostfolio/ui/i18n';
import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client'; import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { EMPTY, Observable, Subject, lastValueFrom, of } from 'rxjs'; import { EMPTY, Observable, Subject, lastValueFrom, of } from 'rxjs';
import { catchError, map, startWith, takeUntil } from 'rxjs/operators'; import { catchError, delay, map, startWith, takeUntil } from 'rxjs/operators';
import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces'; import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces';
@ -139,7 +139,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
}); });
this.activityForm.valueChanges this.activityForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(
// Slightly delay until the more specific form control value changes have
// completed
delay(300),
takeUntil(this.unsubscribeSubject)
)
.subscribe(async () => { .subscribe(async () => {
let exchangeRateOfFee = 1; let exchangeRateOfFee = 1;
let exchangeRateOfUnitPrice = 1; let exchangeRateOfUnitPrice = 1;
@ -234,6 +239,28 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.activityForm.controls['accountId'].valueChanges.subscribe(
(accountId) => {
const type = this.activityForm.controls['type'].value;
if (
type === 'FEE' ||
type === 'INTEREST' ||
type === 'ITEM' ||
type === 'LIABILITY'
) {
const currency =
this.data.accounts.find(({ id }) => {
return id === accountId;
})?.currency ?? this.data.user.settings.baseCurrency;
this.activityForm.controls['currency'].setValue(currency);
this.activityForm.controls['currencyOfFee'].setValue(currency);
this.activityForm.controls['currencyOfUnitPrice'].setValue(currency);
}
}
);
this.activityForm.controls['searchSymbol'].valueChanges.subscribe(() => { this.activityForm.controls['searchSymbol'].valueChanges.subscribe(() => {
if (this.activityForm.controls['searchSymbol'].invalid) { if (this.activityForm.controls['searchSymbol'].invalid) {
this.data.activity.SymbolProfile = null; this.data.activity.SymbolProfile = null;
@ -269,19 +296,21 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
Validators.required Validators.required
); );
this.activityForm.controls['accountId'].updateValueAndValidity(); this.activityForm.controls['accountId'].updateValueAndValidity();
this.activityForm.controls['currency'].setValue(
this.data.user.settings.baseCurrency const currency =
); this.data.accounts.find(({ id }) => {
this.activityForm.controls['currencyOfFee'].setValue( return id === this.activityForm.controls['accountId'].value;
this.data.user.settings.baseCurrency })?.currency ?? this.data.user.settings.baseCurrency;
);
this.activityForm.controls['currencyOfUnitPrice'].setValue( this.activityForm.controls['currency'].setValue(currency);
this.data.user.settings.baseCurrency this.activityForm.controls['currencyOfFee'].setValue(currency);
); this.activityForm.controls['currencyOfUnitPrice'].setValue(currency);
this.activityForm.controls['dataSource'].removeValidators( this.activityForm.controls['dataSource'].removeValidators(
Validators.required Validators.required
); );
this.activityForm.controls['dataSource'].updateValueAndValidity(); this.activityForm.controls['dataSource'].updateValueAndValidity();
this.activityForm.controls['feeInCustomCurrency'].reset();
this.activityForm.controls['name'].setValidators(Validators.required); this.activityForm.controls['name'].setValidators(Validators.required);
this.activityForm.controls['name'].updateValueAndValidity(); this.activityForm.controls['name'].updateValueAndValidity();
this.activityForm.controls['quantity'].setValue(1); this.activityForm.controls['quantity'].setValue(1);
@ -291,28 +320,35 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.controls['searchSymbol'].updateValueAndValidity(); this.activityForm.controls['searchSymbol'].updateValueAndValidity();
this.activityForm.controls['updateAccountBalance'].disable(); this.activityForm.controls['updateAccountBalance'].disable();
this.activityForm.controls['updateAccountBalance'].setValue(false); this.activityForm.controls['updateAccountBalance'].setValue(false);
} else if (type === 'FEE' || type === 'LIABILITY') { } else if (
type === 'FEE' ||
type === 'INTEREST' ||
type === 'LIABILITY'
) {
this.activityForm.controls['accountId'].removeValidators( this.activityForm.controls['accountId'].removeValidators(
Validators.required Validators.required
); );
this.activityForm.controls['accountId'].updateValueAndValidity(); this.activityForm.controls['accountId'].updateValueAndValidity();
this.activityForm.controls['currency'].setValue(
this.data.user.settings.baseCurrency const currency =
); this.data.accounts.find(({ id }) => {
this.activityForm.controls['currencyOfFee'].setValue( return id === this.activityForm.controls['accountId'].value;
this.data.user.settings.baseCurrency })?.currency ?? this.data.user.settings.baseCurrency;
);
this.activityForm.controls['currencyOfUnitPrice'].setValue( this.activityForm.controls['currency'].setValue(currency);
this.data.user.settings.baseCurrency this.activityForm.controls['currencyOfFee'].setValue(currency);
); this.activityForm.controls['currencyOfUnitPrice'].setValue(currency);
this.activityForm.controls['dataSource'].removeValidators( this.activityForm.controls['dataSource'].removeValidators(
Validators.required Validators.required
); );
this.activityForm.controls['dataSource'].updateValueAndValidity(); this.activityForm.controls['dataSource'].updateValueAndValidity();
if ( if (
type === 'FEE' && (type === 'FEE' &&
this.activityForm.controls['feeInCustomCurrency'].value === 0 this.activityForm.controls['feeInCustomCurrency'].value === 0) ||
type === 'INTEREST' ||
type === 'LIABILITY'
) { ) {
this.activityForm.controls['feeInCustomCurrency'].reset(); this.activityForm.controls['feeInCustomCurrency'].reset();
} }
@ -322,7 +358,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
if (type === 'FEE') { if (type === 'FEE') {
this.activityForm.controls['quantity'].setValue(0); this.activityForm.controls['quantity'].setValue(0);
} else if (type === 'LIABILITY') { } else if (type === 'INTEREST' || type === 'LIABILITY') {
this.activityForm.controls['quantity'].setValue(1); this.activityForm.controls['quantity'].setValue(1);
} }

View File

@ -36,6 +36,15 @@
>Distribution of corporate earnings</small >Distribution of corporate earnings</small
> >
</mat-option> </mat-option>
<mat-option
*ngIf="data.user?.settings?.isExperimentalFeatures"
value="INTEREST"
>
<span><b>{{ typesTranslationMap['INTEREST'] }}</b></span>
<small class="d-block line-height-1 text-muted text-nowrap" i18n
>Revenue for lending out money</small
>
</mat-option>
<mat-option value="LIABILITY"> <mat-option value="LIABILITY">
<span><b>{{ typesTranslationMap['LIABILITY'] }}</b></span> <span><b>{{ typesTranslationMap['LIABILITY'] }}</b></span>
<small class="d-block line-height-1 text-muted text-nowrap" i18n <small class="d-block line-height-1 text-muted text-nowrap" i18n
@ -133,7 +142,7 @@
</div> </div>
<div <div
class="mb-3" class="mb-3"
[ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'FEE' || activityForm.controls['type']?.value === 'ITEM' || activityForm.controls['type']?.value === 'LIABILITY' }" [ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'FEE' || activityForm.controls['type']?.value === 'INTEREST' || activityForm.controls['type']?.value === 'ITEM' || activityForm.controls['type']?.value === 'LIABILITY' }"
> >
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Quantity</mat-label> <mat-label i18n>Quantity</mat-label>
@ -151,6 +160,7 @@
<ng-container *ngSwitchCase="'DIVIDEND'" i18n <ng-container *ngSwitchCase="'DIVIDEND'" i18n
>Dividend</ng-container >Dividend</ng-container
> >
<ng-container *ngSwitchCase="'INTEREST'" i18n>Value</ng-container>
<ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container> <ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container>
<ng-container *ngSwitchCase="'LIABILITY'" i18n <ng-container *ngSwitchCase="'LIABILITY'" i18n
>Value</ng-container >Value</ng-container
@ -207,6 +217,7 @@
>Dividend</ng-container >Dividend</ng-container
> >
<ng-container *ngSwitchCase="'FEE'" i18n>Value</ng-container> <ng-container *ngSwitchCase="'FEE'" i18n>Value</ng-container>
<ng-container *ngSwitchCase="'INTEREST'" i18n>Value</ng-container>
<ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container> <ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container>
<ng-container *ngSwitchCase="'LIABILITY'" i18n>Value</ng-container> <ng-container *ngSwitchCase="'LIABILITY'" i18n>Value</ng-container>
<ng-container *ngSwitchDefault i18n>Unit Price</ng-container> <ng-container *ngSwitchDefault i18n>Unit Price</ng-container>
@ -220,7 +231,7 @@
</div> </div>
<div <div
class="mb-3" class="mb-3"
[ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'ITEM' || activityForm.controls['type']?.value === 'LIABILITY' }" [ngClass]="{ 'd-none': activityForm.controls['type']?.value === 'INTEREST' || activityForm.controls['type']?.value === 'ITEM' || activityForm.controls['type']?.value === 'LIABILITY' }"
> >
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label> <mat-label i18n>Fee</mat-label>

View File

@ -6,8 +6,8 @@
<p i18n> <p i18n>
Our official Ghostfolio Premium cloud offering is the easiest way to Our official Ghostfolio Premium cloud offering is the easiest way to
get started. Due to the time it saves, this will be the best option get started. Due to the time it saves, this will be the best option
for most people. The revenue is used to cover the hosting for most people. Revenue is used to cover the costs of the hosting
infrastructure and to fund the ongoing development of Ghostfolio. infrastructure and to fund ongoing development.
</p> </p>
<p *ngIf="user?.subscription?.type === 'Basic'"> <p *ngIf="user?.subscription?.type === 'Basic'">
If you plan to open an account at <i>DEGIRO</i>, <i>frankly</i>, If you plan to open an account at <i>DEGIRO</i>, <i>frankly</i>,

View File

@ -96,16 +96,16 @@
Open Source Software Open Source Software
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
<ng-container *ngIf="product1.isOpenSource === true" i18n <ng-container *ngIf="product1.isOpenSource" i18n
>✅ Yes</ng-container >✅ Yes</ng-container
><ng-container *ngIf="product1.isOpenSource === false" i18n ><ng-container *ngIf="!product1.isOpenSource" i18n
>❌ No</ng-container >❌ No</ng-container
> >
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
<ng-container *ngIf="product2.isOpenSource === true" i18n <ng-container *ngIf="product2.isOpenSource" i18n
>✅ Yes</ng-container >✅ Yes</ng-container
><ng-container *ngIf="product2.isOpenSource === false" i18n ><ng-container *ngIf="!product2.isOpenSource" i18n
>❌ No >❌ No
</ng-container> </ng-container>
</td> </td>

View File

@ -1,6 +1,7 @@
import { Product } from '@ghostfolio/common/interfaces'; import { Product } from '@ghostfolio/common/interfaces';
import { AltooPageComponent } from './products/altoo-page.component'; import { AltooPageComponent } from './products/altoo-page.component';
import { CapMonPageComponent } from './products/capmon-page.component';
import { CopilotMoneyPageComponent } from './products/copilot-money-page.component'; import { CopilotMoneyPageComponent } from './products/copilot-money-page.component';
import { DeltaPageComponent } from './products/delta-page.component'; import { DeltaPageComponent } from './products/delta-page.component';
import { DivvyDiaryPageComponent } from './products/divvydiary-page.component'; import { DivvyDiaryPageComponent } from './products/divvydiary-page.component';
@ -22,6 +23,7 @@ import { SeekingAlphaPageComponent } from './products/seeking-alpha-page.compone
import { SharesightPageComponent } from './products/sharesight-page.component'; import { SharesightPageComponent } from './products/sharesight-page.component';
import { SimplePortfolioPageComponent } from './products/simple-portfolio-page.component'; import { SimplePortfolioPageComponent } from './products/simple-portfolio-page.component';
import { SnowballAnalyticsPageComponent } from './products/snowball-analytics-page.component'; import { SnowballAnalyticsPageComponent } from './products/snowball-analytics-page.component';
import { StockMarketEyePageComponent } from './products/stockmarketeye-page.component';
import { SumioPageComponent } from './products/sumio-page.component'; import { SumioPageComponent } from './products/sumio-page.component';
import { UtlunaPageComponent } from './products/utluna-page.component'; import { UtlunaPageComponent } from './products/utluna-page.component';
import { YeekateePageComponent } from './products/yeekatee-page.component'; import { YeekateePageComponent } from './products/yeekatee-page.component';
@ -45,7 +47,7 @@ export const products: Product[] = [
], ],
name: 'Ghostfolio', name: 'Ghostfolio',
origin: $localize`Switzerland`, origin: $localize`Switzerland`,
pricingPerYear: '$19', pricingPerYear: '$24',
region: $localize`Global`, region: $localize`Global`,
slogan: 'Open Source Wealth Management', slogan: 'Open Source Wealth Management',
useAnonymously: true useAnonymously: true
@ -54,18 +56,25 @@ export const products: Product[] = [
component: AltooPageComponent, component: AltooPageComponent,
founded: 2017, founded: 2017,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'altoo', key: 'altoo',
name: 'Altoo Wealth Platform', name: 'Altoo Wealth Platform',
origin: $localize`Switzerland`, origin: $localize`Switzerland`,
slogan: 'Simplicity for Complex Wealth' slogan: 'Simplicity for Complex Wealth'
}, },
{
component: CapMonPageComponent,
founded: 2022,
key: 'capmon',
name: 'CapMon.org',
origin: $localize`Germany`,
note: 'Sunset in 2023',
slogan: 'Next Generation Assets Tracking'
},
{ {
component: CopilotMoneyPageComponent, component: CopilotMoneyPageComponent,
founded: 2019, founded: 2019,
hasFreePlan: false, hasFreePlan: false,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'copilot-money', key: 'copilot-money',
name: 'Copilot Money', name: 'Copilot Money',
origin: $localize`United States`, origin: $localize`United States`,
@ -77,7 +86,6 @@ export const products: Product[] = [
founded: 2017, founded: 2017,
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'delta', key: 'delta',
name: 'Delta Investment Tracker', name: 'Delta Investment Tracker',
note: 'Acquired by eToro', note: 'Acquired by eToro',
@ -89,7 +97,6 @@ export const products: Product[] = [
founded: 2019, founded: 2019,
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'divvydiary', key: 'divvydiary',
languages: ['Deutsch', 'English'], languages: ['Deutsch', 'English'],
name: 'DivvyDiary', name: 'DivvyDiary',
@ -102,7 +109,6 @@ export const products: Product[] = [
founded: 2020, founded: 2020,
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'exirio', key: 'exirio',
name: 'Exirio', name: 'Exirio',
origin: $localize`United States`, origin: $localize`United States`,
@ -113,7 +119,6 @@ export const products: Product[] = [
component: FolisharePageComponent, component: FolisharePageComponent,
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'folishare', key: 'folishare',
languages: ['Deutsch', 'English'], languages: ['Deutsch', 'English'],
name: 'folishare', name: 'folishare',
@ -126,7 +131,6 @@ export const products: Product[] = [
founded: 2020, founded: 2020,
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'getquin', key: 'getquin',
languages: ['Deutsch', 'English'], languages: ['Deutsch', 'English'],
name: 'getquin', name: 'getquin',
@ -138,7 +142,6 @@ export const products: Product[] = [
component: GoSpatzPageComponent, component: GoSpatzPageComponent,
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'gospatz', key: 'gospatz',
name: 'goSPATZ', name: 'goSPATZ',
origin: $localize`Germany`, origin: $localize`Germany`,
@ -149,7 +152,6 @@ export const products: Product[] = [
founded: 2011, founded: 2011,
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'justetf', key: 'justetf',
name: 'justETF', name: 'justETF',
origin: $localize`Germany`, origin: $localize`Germany`,
@ -161,7 +163,6 @@ export const products: Product[] = [
founded: 2019, founded: 2019,
hasFreePlan: false, hasFreePlan: false,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'kubera', key: 'kubera',
name: 'Kubera®', name: 'Kubera®',
origin: $localize`United States`, origin: $localize`United States`,
@ -173,7 +174,6 @@ export const products: Product[] = [
founded: 2022, founded: 2022,
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'markets.sh', key: 'markets.sh',
languages: ['English'], languages: ['English'],
name: 'markets.sh', name: 'markets.sh',
@ -186,7 +186,6 @@ export const products: Product[] = [
component: MaybeFinancePageComponent, component: MaybeFinancePageComponent,
founded: 2021, founded: 2021,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'maybe-finance', key: 'maybe-finance',
languages: ['English'], languages: ['English'],
name: 'Maybe Finance', name: 'Maybe Finance',
@ -200,7 +199,6 @@ export const products: Product[] = [
component: MonsePageComponent, component: MonsePageComponent,
hasFreePlan: false, hasFreePlan: false,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'monse', key: 'monse',
name: 'Monse', name: 'Monse',
pricingPerYear: '$60', pricingPerYear: '$60',
@ -211,7 +209,6 @@ export const products: Product[] = [
founded: 2020, founded: 2020,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
hasFreePlan: true, hasFreePlan: true,
isOpenSource: false,
key: 'parqet', key: 'parqet',
name: 'Parqet', name: 'Parqet',
note: 'Originally named as Tresor One', note: 'Originally named as Tresor One',
@ -224,7 +221,6 @@ export const products: Product[] = [
component: PlannixPageComponent, component: PlannixPageComponent,
founded: 2023, founded: 2023,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'plannix', key: 'plannix',
name: 'Plannix', name: 'Plannix',
origin: $localize`Italy`, origin: $localize`Italy`,
@ -234,7 +230,6 @@ export const products: Product[] = [
component: PortfolioDividendTrackerPageComponent, component: PortfolioDividendTrackerPageComponent,
hasFreePlan: false, hasFreePlan: false,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'portfolio-dividend-tracker', key: 'portfolio-dividend-tracker',
languages: ['English', 'Nederlands'], languages: ['English', 'Nederlands'],
name: 'Portfolio Dividend Tracker', name: 'Portfolio Dividend Tracker',
@ -247,7 +242,6 @@ export const products: Product[] = [
founded: 2021, founded: 2021,
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'portseido', key: 'portseido',
languages: ['Deutsch', 'English', 'Français', 'Nederlands'], languages: ['Deutsch', 'English', 'Français', 'Nederlands'],
name: 'Portseido', name: 'Portseido',
@ -260,7 +254,6 @@ export const products: Product[] = [
founded: 2021, founded: 2021,
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: true, hasSelfHostingAbility: true,
isOpenSource: false,
key: 'projectionlab', key: 'projectionlab',
name: 'ProjectionLab', name: 'ProjectionLab',
origin: $localize`United States`, origin: $localize`United States`,
@ -272,7 +265,6 @@ export const products: Product[] = [
founded: 2004, founded: 2004,
hasFreePlan: false, hasFreePlan: false,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'seeking-alpha', key: 'seeking-alpha',
name: 'Seeking Alpha', name: 'Seeking Alpha',
origin: $localize`United States`, origin: $localize`United States`,
@ -284,7 +276,6 @@ export const products: Product[] = [
founded: 2007, founded: 2007,
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'sharesight', key: 'sharesight',
name: 'Sharesight', name: 'Sharesight',
origin: $localize`New Zealand`, origin: $localize`New Zealand`,
@ -296,7 +287,6 @@ export const products: Product[] = [
component: SimplePortfolioPageComponent, component: SimplePortfolioPageComponent,
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'simple-portfolio', key: 'simple-portfolio',
name: 'Simple Portfolio', name: 'Simple Portfolio',
origin: $localize`Czech Republic`, origin: $localize`Czech Republic`,
@ -308,18 +298,25 @@ export const products: Product[] = [
founded: 2021, founded: 2021,
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'snowball-analytics', key: 'snowball-analytics',
name: 'Snowball Analytics', name: 'Snowball Analytics',
origin: 'France', origin: $localize`France`,
pricingPerYear: '$80', pricingPerYear: '$80',
slogan: 'Simple and powerful portfolio tracker' slogan: 'Simple and powerful portfolio tracker'
}, },
{
component: StockMarketEyePageComponent,
founded: 2008,
key: 'stockmarketeye',
name: 'StockMarketEye',
origin: $localize`France`,
note: 'Sunset in 2023',
slogan: 'A Powerful Portfolio & Investment Tracking App'
},
{ {
component: SumioPageComponent, component: SumioPageComponent,
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'sumio', key: 'sumio',
name: 'Sumio', name: 'Sumio',
origin: $localize`Czech Republic`, origin: $localize`Czech Republic`,
@ -330,7 +327,6 @@ export const products: Product[] = [
component: UtlunaPageComponent, component: UtlunaPageComponent,
hasFreePlan: true, hasFreePlan: true,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'utluna', key: 'utluna',
languages: ['Deutsch', 'English', 'Français'], languages: ['Deutsch', 'English', 'Français'],
name: 'Utluna', name: 'Utluna',
@ -343,7 +339,6 @@ export const products: Product[] = [
component: YeekateePageComponent, component: YeekateePageComponent,
founded: 2021, founded: 2021,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
isOpenSource: false,
key: 'yeekatee', key: 'yeekatee',
name: 'yeekatee', name: 'yeekatee',
origin: $localize`Switzerland`, origin: $localize`Switzerland`,

View File

@ -0,0 +1,31 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
@Component({
host: { class: 'page' },
imports: [CommonModule, MatButtonModule, RouterModule],
selector: 'gf-capmon-page',
standalone: true,
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class CapMonPageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});
public product2 = products.find(({ key }) => {
return key === 'capmon';
});
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkResourcesPersonalFinanceTools = [
'/' + $localize`resources`,
'personal-finance-tools'
];
}

View File

@ -0,0 +1,31 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { products } from '../products';
@Component({
host: { class: 'page' },
imports: [CommonModule, MatButtonModule, RouterModule],
selector: 'gf-stockmarketeye-page',
standalone: true,
styleUrls: ['../product-page-template.scss'],
templateUrl: '../product-page-template.html'
})
export class StockMarketEyePageComponent {
public product1 = products.find(({ key }) => {
return key === 'ghostfolio';
});
public product2 = products.find(({ key }) => {
return key === 'stockmarketeye';
});
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkResourcesPersonalFinanceTools = [
'/' + $localize`resources`,
'personal-finance-tools'
];
}

View File

@ -66,7 +66,8 @@ export class UserAccountPageComponent implements OnDestroy, OnInit {
'fr', 'fr',
'it', 'it',
'nl', 'nl',
'pt' 'pt',
'tr'
]; ];
public price: number; public price: number;
public priceId: string; public priceId: string;

View File

@ -160,6 +160,10 @@
>Português (<ng-container i18n>Community</ng-container >Português (<ng-container i18n>Community</ng-container
>)</mat-option >)</mat-option
> >
<mat-option value="tr"
>Türkçe (<ng-container i18n>Community</ng-container
>)</mat-option
>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
@ -251,7 +255,7 @@
</div> </div>
</div> </div>
<div <div
*ngIf="hasPermissionToUpdateUserSettings && user?.subscription" *ngIf="hasPermissionToUpdateUserSettings"
class="align-items-center d-flex mt-4 py-1" class="align-items-center d-flex mt-4 py-1"
> >
<div class="pr-1 w-50"> <div class="pr-1 w-50">

View File

@ -4,6 +4,8 @@ import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-pr
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto'; import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto'; import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto'; import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
@ -15,7 +17,7 @@ import {
Filter, Filter,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData, Platform, Prisma } from '@prisma/client'; import { DataSource, MarketData, Platform, Prisma, Tag } from '@prisma/client';
import { JobStatus } from 'bull'; import { JobStatus } from 'bull';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { Observable, map } from 'rxjs'; import { Observable, map } from 'rxjs';
@ -64,6 +66,10 @@ export class AdminService {
); );
} }
public deleteTag(aId: string) {
return this.http.delete<void>(`/api/v1/tag/${aId}`);
}
public fetchAdminData() { public fetchAdminData() {
return this.http.get<AdminData>('/api/v1/admin'); return this.http.get<AdminData>('/api/v1/admin');
} }
@ -139,6 +145,10 @@ export class AdminService {
return this.http.get<Platform[]>('/api/v1/platform'); return this.http.get<Platform[]>('/api/v1/platform');
} }
public fetchTags() {
return this.http.get<Tag[]>('/api/v1/tag');
}
public gather7Days() { public gather7Days() {
return this.http.post<void>('/api/v1/admin/gather', {}); return this.http.post<void>('/api/v1/admin/gather', {});
} }
@ -208,6 +218,10 @@ export class AdminService {
return this.http.post<Platform>(`/api/v1/platform`, aPlatform); return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
} }
public postTag(aTag: CreateTagDto) {
return this.http.post<Tag>(`/api/v1/tag`, aTag);
}
public putMarketData({ public putMarketData({
dataSource, dataSource,
date, date,
@ -233,4 +247,8 @@ export class AdminService {
aPlatform aPlatform
); );
} }
public putTag(aTag: UpdateTagDto) {
return this.http.put<Tag>(`/api/v1/tag/${aTag.id}`, aTag);
}
} }

View File

@ -3,8 +3,8 @@ import { Injectable } from '@angular/core';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { parseDate as parseDateHelper } from '@ghostfolio/common/helper';
import { Account, DataSource, Type } from '@prisma/client'; import { Account, DataSource, Type } from '@prisma/client';
import { isMatch, parse, parseISO } from 'date-fns';
import { isFinite } from 'lodash'; import { isFinite } from 'lodash';
import { parse as csvToJson } from 'papaparse'; import { parse as csvToJson } from 'papaparse';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
@ -219,31 +219,12 @@ export class ImportActivitiesService {
item: any; item: any;
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
let date: string;
for (const key of ImportActivitiesService.DATE_KEYS) { for (const key of ImportActivitiesService.DATE_KEYS) {
if (item[key]) { if (item[key]) {
if (isMatch(item[key], 'dd-MM-yyyy') && item[key].length === 10) { try {
// Check length to only match yyyy (and not yy) return parseDateHelper(item[key].toString()).toISOString();
date = parse(item[key], 'dd-MM-yyyy', new Date()).toISOString(); } catch {}
} else if (
isMatch(item[key], 'dd/MM/yyyy') &&
item[key].length === 10
) {
// Check length to only match yyyy (and not yy)
date = parse(item[key], 'dd/MM/yyyy', new Date()).toISOString();
} else if (isMatch(item[key], 'yyyyMMdd') && item[key].length === 8) {
// Check length to only match yyyy (and not yy)
date = parse(item[key], 'yyyyMMdd', new Date()).toISOString();
} else {
try {
date = parseISO(item[key]).toISOString();
} catch {}
}
if (date) {
return date;
}
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -1,5 +1,5 @@
{ {
"createdAt": "2023-09-02T07:02:24.422Z", "createdAt": "2023-09-25T08:15:38.055Z",
"data": [ "data": [
{ {
"name": "Appsmith", "name": "Appsmith",
@ -76,6 +76,21 @@
"description": "Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.", "description": "Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.",
"href": "https://openbb.co" "href": "https://openbb.co"
}, },
{
"name": "OpenStatus",
"description": "Open-source monitoring platform with beautiful status pages",
"href": "https://www.openstatus.dev"
},
{
"name": "Papermark",
"description": "Open-Source Docsend Alternative to securely share documents with real-time analytics.",
"href": "https://www.papermark.io/"
},
{
"name": "Requestly",
"description": "Makes frontend development cycle 10x faster with API Client, Mock Server, Intercept & Modify HTTP Requests and Session Replays.",
"href": "https://requestly.io"
},
{ {
"name": "Rivet", "name": "Rivet",
"description": "Open-source solution to deploy, scale, and operate your multiplayer game.", "description": "Open-source solution to deploy, scale, and operate your multiplayer game.",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -101,7 +101,8 @@ export const SUPPORTED_LANGUAGE_CODES = [
'fr', 'fr',
'it', 'it',
'nl', 'nl',
'pt' 'pt',
'tr'
]; ];
export const UNKNOWN_KEY = 'UNKNOWN'; export const UNKNOWN_KEY = 'UNKNOWN';

View File

@ -1,8 +1,16 @@
import * as currencies from '@dinero.js/currencies'; import * as currencies from '@dinero.js/currencies';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { getDate, getMonth, getYear, parse, subDays } from 'date-fns'; import {
import { de, es, fr, it, nl, pt } from 'date-fns/locale'; getDate,
getMonth,
getYear,
isMatch,
parse,
parseISO,
subDays
} from 'date-fns';
import { de, es, fr, it, nl, pt, tr } from 'date-fns/locale';
import { ghostfolioScraperApiSymbolPrefix, locale } from './config'; import { ghostfolioScraperApiSymbolPrefix, locale } from './config';
import { Benchmark, UniqueAsset } from './interfaces'; import { Benchmark, UniqueAsset } from './interfaces';
@ -96,6 +104,8 @@ export function getDateFnsLocale(aLanguageCode: string) {
return nl; return nl;
} else if (aLanguageCode === 'pt') { } else if (aLanguageCode === 'pt') {
return pt; return pt;
} else if (aLanguageCode === 'tr') {
return tr;
} }
return undefined; return undefined;
@ -282,8 +292,34 @@ export const DATE_FORMAT = 'yyyy-MM-dd';
export const DATE_FORMAT_MONTHLY = 'MMMM yyyy'; export const DATE_FORMAT_MONTHLY = 'MMMM yyyy';
export const DATE_FORMAT_YEARLY = 'yyyy'; export const DATE_FORMAT_YEARLY = 'yyyy';
export function parseDate(date: string) { export function parseDate(date: string): Date | null {
return parse(date, DATE_FORMAT, new Date()); // Transform 'yyyyMMdd' format to supported format by parse function
if (date?.length === 8) {
const match = date.match(/^(\d{4})(\d{2})(\d{2})$/);
if (match) {
const [, year, month, day] = match;
date = `${year}-${month}-${day}`;
}
}
const dateFormat = [
'dd-MM-yyyy',
'dd/MM/yyyy',
'dd.MM.yyyy',
'yyyy-MM-dd',
'yyyy/MM/dd',
'yyyy.MM.dd',
'yyyyMMdd'
].find((format) => {
return isMatch(date, format) && format.length === date.length;
});
if (dateFormat) {
return parse(date, dateFormat, new Date());
}
return parseISO(date);
} }
export function prettifySymbol(aSymbol: string): string { export function prettifySymbol(aSymbol: string): string {

View File

@ -14,6 +14,7 @@ export interface PortfolioSummary extends PortfolioPerformance {
fees: number; fees: number;
fireWealth: number; fireWealth: number;
firstOrderDate: Date; firstOrderDate: Date;
interest: number;
items: number; items: number;
liabilities: number; liabilities: number;
netWorth: number; netWorth: number;

View File

@ -3,7 +3,7 @@ export interface Product {
founded?: number; founded?: number;
hasFreePlan?: boolean; hasFreePlan?: boolean;
hasSelfHostingAbility?: boolean; hasSelfHostingAbility?: boolean;
isOpenSource: boolean; isOpenSource?: boolean;
key: string; key: string;
languages?: string[]; languages?: string[];
name: string; name: string;

View File

@ -7,12 +7,14 @@ export const permissions = {
createAccount: 'createAccount', createAccount: 'createAccount',
createOrder: 'createOrder', createOrder: 'createOrder',
createPlatform: 'createPlatform', createPlatform: 'createPlatform',
createTag: 'createTag',
createUserAccount: 'createUserAccount', createUserAccount: 'createUserAccount',
deleteAccess: 'deleteAccess', deleteAccess: 'deleteAccess',
deleteAccount: 'deleteAcccount', deleteAccount: 'deleteAcccount',
deleteAuthDevice: 'deleteAuthDevice', deleteAuthDevice: 'deleteAuthDevice',
deleteOrder: 'deleteOrder', deleteOrder: 'deleteOrder',
deletePlatform: 'deletePlatform', deletePlatform: 'deletePlatform',
deleteTag: 'deleteTag',
deleteUser: 'deleteUser', deleteUser: 'deleteUser',
enableFearAndGreedIndex: 'enableFearAndGreedIndex', enableFearAndGreedIndex: 'enableFearAndGreedIndex',
enableImport: 'enableImport', enableImport: 'enableImport',
@ -29,6 +31,7 @@ export const permissions = {
updateAuthDevice: 'updateAuthDevice', updateAuthDevice: 'updateAuthDevice',
updateOrder: 'updateOrder', updateOrder: 'updateOrder',
updatePlatform: 'updatePlatform', updatePlatform: 'updatePlatform',
updateTag: 'updateTag',
updateUserSettings: 'updateUserSettings', updateUserSettings: 'updateUserSettings',
updateViewMode: 'updateViewMode' updateViewMode: 'updateViewMode'
}; };
@ -42,16 +45,19 @@ export function getPermissions(aRole: Role): string[] {
permissions.createAccount, permissions.createAccount,
permissions.createOrder, permissions.createOrder,
permissions.createPlatform, permissions.createPlatform,
permissions.createTag,
permissions.deleteAccess, permissions.deleteAccess,
permissions.deleteAccount, permissions.deleteAccount,
permissions.deleteAuthDevice, permissions.deleteAuthDevice,
permissions.deleteOrder, permissions.deleteOrder,
permissions.deletePlatform, permissions.deletePlatform,
permissions.deleteTag,
permissions.deleteUser, permissions.deleteUser,
permissions.updateAccount, permissions.updateAccount,
permissions.updateAuthDevice, permissions.updateAuthDevice,
permissions.updateOrder, permissions.updateOrder,
permissions.updatePlatform, permissions.updatePlatform,
permissions.updateTag,
permissions.updateUserSettings, permissions.updateUserSettings,
permissions.updateViewMode permissions.updateViewMode
]; ];

View File

@ -156,39 +156,7 @@
<ng-container i18n>Type</ng-container> <ng-container i18n>Type</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div <gf-activity-type [activityType]="element.type"></gf-activity-type>
class="d-inline-flex p-1 type-badge"
[ngClass]="{
buy: element.type === 'BUY',
dividend: element.type === 'DIVIDEND',
fee: element.type === 'FEE',
item: element.type === 'ITEM',
liability: element.type === 'LIABILITY',
sell: element.type === 'SELL'
}"
>
<ion-icon
*ngIf="element.type === 'BUY' || element.type === 'DIVIDEND'"
name="arrow-up-circle-outline"
></ion-icon>
<ion-icon
*ngIf="element.type === 'FEE'"
name="hammer-outline"
></ion-icon>
<ion-icon
*ngIf="element.type === 'ITEM'"
name="cube-outline"
></ion-icon>
<ion-icon
*ngIf="element.type === 'LIABILITY'"
name="flame-outline"
></ion-icon>
<ion-icon
*ngIf="element.type === 'SELL'"
name="arrow-down-circle-outline"
></ion-icon>
<span class="d-none d-lg-block mx-1">{{ element.type }}</span>
</div>
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td> <td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container> </ng-container>
@ -508,7 +476,7 @@
</th> </th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell> <td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button <button
*ngIf="this.showActions" *ngIf="showActions"
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button mat-button
[matMenuTriggerFor]="activityMenu" [matMenuTriggerFor]="activityMenu"
@ -551,6 +519,7 @@
hasPermissionToOpenDetails && hasPermissionToOpenDetails &&
!row.isDraft && !row.isDraft &&
row.type !== 'FEE' && row.type !== 'FEE' &&
row.type !== 'INTEREST' &&
row.type !== 'ITEM' && row.type !== 'ITEM' &&
row.type !== 'LIABILITY' row.type !== 'LIABILITY'
}" }"

View File

@ -14,53 +14,6 @@
} }
} }
} }
.mat-mdc-row {
.type-badge {
background-color: rgba(var(--palette-foreground-text), 0.05);
border-radius: 1rem;
line-height: 1em;
ion-icon {
font-size: 1rem;
}
&.buy {
color: var(--green);
}
&.dividend {
color: var(--blue);
}
&.fee {
color: var(--gray);
}
&.item {
color: var(--purple);
}
&.liability {
color: var(--red);
}
&.sell {
color: var(--orange);
}
}
}
}
}
}
:host-context(.is-dark-theme) {
.mat-mdc-table {
.type-badge {
background-color: rgba(
var(--palette-foreground-text-dark),
0.1
) !important;
} }
} }
} }

View File

@ -207,6 +207,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
this.hasPermissionToOpenDetails && this.hasPermissionToOpenDetails &&
!activity.isDraft && !activity.isDraft &&
activity.type !== 'FEE' && activity.type !== 'FEE' &&
activity.type !== 'INTEREST' &&
activity.type !== 'ITEM' && activity.type !== 'ITEM' &&
activity.type !== 'LIABILITY' activity.type !== 'LIABILITY'
) { ) {
@ -391,7 +392,13 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
if (isNumber(valueInBaseCurrency)) { if (isNumber(valueInBaseCurrency)) {
if (type === 'BUY' || type === 'ITEM') { if (type === 'BUY' || type === 'ITEM') {
totalValue = totalValue.plus(valueInBaseCurrency); totalValue = totalValue.plus(valueInBaseCurrency);
} else if (type === 'FEE' || type === 'LIABILITY' || type === 'SELL') { } else if (
type === 'DIVIDEND' ||
type === 'FEE' ||
type === 'INTEREST' ||
type === 'LIABILITY' ||
type === 'SELL'
) {
return null; return null;
} }
} else { } else {

View File

@ -11,6 +11,7 @@ import { RouterModule } from '@angular/router';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module'; import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module'; import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { GfActivityTypeModule } from '@ghostfolio/ui/activity-type';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info'; import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -23,6 +24,7 @@ import { ActivitiesTableComponent } from './activities-table.component';
imports: [ imports: [
CommonModule, CommonModule,
GfActivitiesFilterModule, GfActivitiesFilterModule,
GfActivityTypeModule,
GfNoTransactionsInfoModule, GfNoTransactionsInfoModule,
GfSymbolIconModule, GfSymbolIconModule,
GfSymbolModule, GfSymbolModule,

View File

@ -0,0 +1,32 @@
<div
class="d-inline-flex p-1 activity-type-badge"
[ngClass]="{
buy: activityType === 'BUY',
dividend: activityType === 'DIVIDEND',
fee: activityType === 'FEE',
interest: activityType === 'INTEREST',
item: activityType === 'ITEM',
liability: activityType === 'LIABILITY',
sell: activityType === 'SELL'
}"
>
<ion-icon
*ngIf="activityType === 'BUY'"
name="arrow-up-circle-outline"
></ion-icon>
<ion-icon
*ngIf="activityType === 'DIVIDEND' || activityType === 'INTEREST'"
name="add-circle-outline"
></ion-icon>
<ion-icon *ngIf="activityType === 'FEE'" name="hammer-outline"></ion-icon>
<ion-icon *ngIf="activityType === 'ITEM'" name="cube-outline"></ion-icon>
<ion-icon
*ngIf="activityType === 'LIABILITY'"
name="flame-outline"
></ion-icon>
<ion-icon
*ngIf="activityType === 'SELL'"
name="arrow-down-circle-outline"
></ion-icon>
<span class="d-none d-lg-block mx-1">{{ activityTypeLabel }}</span>
</div>

View File

@ -0,0 +1,47 @@
:host {
display: block;
.activity-type-badge {
background-color: rgba(var(--palette-foreground-text), 0.05);
border-radius: 1rem;
line-height: 1em;
ion-icon {
font-size: 1rem;
}
&.buy {
color: var(--green);
}
&.dividend {
color: var(--blue);
}
&.fee {
color: var(--gray);
}
&.interest {
color: var(--cyan);
}
&.item {
color: var(--purple);
}
&.liability {
color: var(--red);
}
&.sell {
color: var(--orange);
}
}
}
:host-context(.is-dark-theme) {
.activity-type-badge {
background-color: rgba(var(--palette-foreground-text-dark), 0.1) !important;
}
}

View File

@ -0,0 +1,26 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges
} from '@angular/core';
import { translate } from '@ghostfolio/ui/i18n';
import { Type as ActivityType } from '@prisma/client';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-activity-type',
styleUrls: ['./activity-type.component.scss'],
templateUrl: './activity-type.component.html'
})
export class ActivityTypeComponent implements OnChanges {
@Input() activityType: ActivityType;
public activityTypeLabel: string;
public constructor() {}
public ngOnChanges() {
this.activityTypeLabel = translate(this.activityType);
}
}

View File

@ -0,0 +1,12 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { ActivityTypeComponent } from './activity-type.component';
@NgModule({
declarations: [ActivityTypeComponent],
exports: [ActivityTypeComponent],
imports: [CommonModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfActivityTypeModule {}

View File

@ -0,0 +1 @@
export * from './activity-type.module';

View File

@ -30,6 +30,7 @@ const locales = {
BUY: $localize`Buy`, BUY: $localize`Buy`,
DIVIDEND: $localize`Dividend`, DIVIDEND: $localize`Dividend`,
FEE: $localize`Fee`, FEE: $localize`Fee`,
INTEREST: $localize`Interest`,
ITEM: $localize`Valuable`, ITEM: $localize`Valuable`,
LIABILITY: $localize`Liability`, LIABILITY: $localize`Liability`,
SELL: $localize`Sell`, SELL: $localize`Sell`,

View File

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.3.0", "version": "2.6.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",
@ -81,7 +81,7 @@
"@nestjs/platform-express": "10.1.3", "@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2", "@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0", "@nestjs/serve-static": "4.0.0",
"@prisma/client": "5.2.0", "@prisma/client": "5.3.1",
"@simplewebauthn/browser": "5.2.1", "@simplewebauthn/browser": "5.2.1",
"@simplewebauthn/server": "5.2.1", "@simplewebauthn/server": "5.2.1",
"@stripe/stripe-js": "1.47.0", "@stripe/stripe-js": "1.47.0",
@ -122,14 +122,14 @@
"passport": "0.6.0", "passport": "0.6.0",
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0", "passport-jwt": "4.0.0",
"prisma": "5.2.0", "prisma": "5.3.1",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rxjs": "7.5.6", "rxjs": "7.5.6",
"stripe": "11.12.0", "stripe": "11.12.0",
"svgmap": "2.6.0", "svgmap": "2.6.0",
"twitter-api-v2": "1.14.2", "twitter-api-v2": "1.14.2",
"uuid": "9.0.0", "uuid": "9.0.0",
"yahoo-finance2": "2.5.0", "yahoo-finance2": "2.7.0",
"zone.js": "0.13.1" "zone.js": "0.13.1"
}, },
"devDependencies": { "devDependencies": {
@ -189,7 +189,7 @@
"jest-preset-angular": "13.1.1", "jest-preset-angular": "13.1.1",
"nx": "16.7.4", "nx": "16.7.4",
"nx-cloud": "16.3.0", "nx-cloud": "16.3.0",
"prettier": "3.0.2", "prettier": "3.0.3",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Type" ADD VALUE 'INTEREST';

View File

@ -250,6 +250,7 @@ enum Type {
BUY BUY
DIVIDEND DIVIDEND
FEE FEE
INTEREST
ITEM ITEM
LIABILITY LIABILITY
SELL SELL

View File

@ -1,5 +1,5 @@
Date,Code,Currency,Price,Quantity,Action,Fee Date,Code,Currency,Price,Quantity,Action,Fee
16-09-2021,MSFT,USD,298.580,5,buy,19.00
17/11/2021,MSFT,USD,0.62,5,dividend,0.00 17/11/2021,MSFT,USD,0.62,5,dividend,0.00
16/09/2021,MSFT,USD,298.580,5,buy,19.00 01.01.2022,Penthouse Apartment,USD,500000.0,1,item,0.00
01/01/2022,Penthouse Apartment,USD,500000.0,1,item,0.00 20500606,MSFT,USD,0.00,0,buy,0.00
06/06/2050,MSFT,USD,0.00,0,buy,0.00

1 Date Code Currency Price Quantity Action Fee
2 16-09-2021 MSFT USD 298.580 5 buy 19.00
3 17/11/2021 MSFT USD 0.62 5 dividend 0.00
4 16/09/2021 01.01.2022 MSFT Penthouse Apartment USD 298.580 500000.0 5 1 buy item 19.00 0.00
5 01/01/2022 20500606 Penthouse Apartment MSFT USD 500000.0 0.00 1 0 item buy 0.00
06/06/2050 MSFT USD 0.00 0 buy 0.00

View File

@ -4293,22 +4293,22 @@
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@prisma/client@5.2.0": "@prisma/client@5.3.1":
version "5.2.0" version "5.3.1"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.2.0.tgz#cbfdd440614b38736563a7999f39922fcde0ed50" resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.3.1.tgz#fc7fc2d91e814cc4fe18a4bc5e78bf851c26985e"
integrity sha512-AiTjJwR4J5Rh6Z/9ZKrBBLel3/5DzUNntMohOy7yObVnVoTNVFi2kvpLZlFuKO50d7yDspOtW6XBpiAd0BVXbQ== integrity sha512-ArOKjHwdFZIe1cGU56oIfy7wRuTn0FfZjGuU/AjgEBOQh+4rDkB6nF+AGHP8KaVpkBIiHGPQh3IpwQ3xDMdO0Q==
dependencies: dependencies:
"@prisma/engines-version" "5.2.0-25.2804dc98259d2ea960602aca6b8e7fdc03c1758f" "@prisma/engines-version" "5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59"
"@prisma/engines-version@5.2.0-25.2804dc98259d2ea960602aca6b8e7fdc03c1758f": "@prisma/engines-version@5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59":
version "5.2.0-25.2804dc98259d2ea960602aca6b8e7fdc03c1758f" version "5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.2.0-25.2804dc98259d2ea960602aca6b8e7fdc03c1758f.tgz#11366e7ff031c908debf4983248d40046016de37" resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59.tgz#7eb6f5c6b7628b8b39df55c903f411528a6f761c"
integrity sha512-jsnKT5JIDIE01lAeCj2ghY9IwxkedhKNvxQeoyLs6dr4ZXynetD0vTy7u6wMJt8vVPv8I5DPy/I4CFaoXAgbtg== integrity sha512-y5qbUi3ql2Xg7XraqcXEdMHh0MocBfnBzDn5GbV1xk23S3Mq8MGs+VjacTNiBh3dtEdUERCrUUG7Z3QaJ+h79w==
"@prisma/engines@5.2.0": "@prisma/engines@5.3.1":
version "5.2.0" version "5.3.1"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.2.0.tgz#e5dff48eb324c8137393933292d44ea5c3bc2ce7" resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.3.1.tgz#53cc72a5ed176dc27d22305fe5569c64cc78b381"
integrity sha512-dT7FOLUCdZmq+AunLqB1Iz+ZH/IIS1Fz2THmKZQ6aFONrQD/BQ5ecJ7g2wGS2OgyUFf4OaLam6/bxmgdOBDqig== integrity sha512-6QkILNyfeeN67BNEPEtkgh3Xo2tm6D7V+UhrkBbRHqKw9CTaz/vvTP/ROwYSP/3JT2MtIutZm/EnhxUiuOPVDA==
"@radix-ui/number@1.0.1": "@radix-ui/number@1.0.1":
version "1.0.1" version "1.0.1"
@ -15881,10 +15881,10 @@ prettier-plugin-organize-attributes@1.0.0:
resolved "https://registry.yarnpkg.com/prettier-plugin-organize-attributes/-/prettier-plugin-organize-attributes-1.0.0.tgz#037870ee3111b3c1d6371f677b64888de353cc63" resolved "https://registry.yarnpkg.com/prettier-plugin-organize-attributes/-/prettier-plugin-organize-attributes-1.0.0.tgz#037870ee3111b3c1d6371f677b64888de353cc63"
integrity sha512-+NmameaLxbCcylEXsKPmawtzla5EE6ECqvGkpfQz4KM847fXDifB1gFnPQEpoADAq6IXg+cMI8Z0ISJEXa6fhg== integrity sha512-+NmameaLxbCcylEXsKPmawtzla5EE6ECqvGkpfQz4KM847fXDifB1gFnPQEpoADAq6IXg+cMI8Z0ISJEXa6fhg==
prettier@3.0.2: prettier@3.0.3:
version "3.0.2" version "3.0.3"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.0.2.tgz#78fcecd6d870551aa5547437cdae39d4701dca5b" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.0.3.tgz#432a51f7ba422d1469096c0fdc28e235db8f9643"
integrity sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ== integrity sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==
prettier@^2.8.0: prettier@^2.8.0:
version "2.8.8" version "2.8.8"
@ -15918,12 +15918,12 @@ pretty-hrtime@^1.0.3:
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
integrity sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A== integrity sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==
prisma@5.2.0: prisma@5.3.1:
version "5.2.0" version "5.3.1"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.2.0.tgz#a302dc2635cdec1d22d552ece837fb29a03563b9" resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.3.1.tgz#a0932c1c1a5ed4ff449d064b193d9c7e94e8bf77"
integrity sha512-FfFlpjVCkZwrqxDnP4smlNYSH1so+CbfjgdpioFzGGqlQAEm6VHAYSzV7jJgC3ebtY9dNOhDMS2+4/1DDSM7bQ== integrity sha512-Wp2msQIlMPHe+5k5Od6xnsI/WNG7UJGgFUJgqv/ygc7kOECZapcSz/iU4NIEzISs3H1W9sFLjAPbg/gOqqtB7A==
dependencies: dependencies:
"@prisma/engines" "5.2.0" "@prisma/engines" "5.3.1"
prismjs@^1.28.0: prismjs@^1.28.0:
version "1.29.0" version "1.29.0"
@ -17889,6 +17889,13 @@ toidentifier@1.0.1:
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
tough-cookie-file-store@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/tough-cookie-file-store/-/tough-cookie-file-store-2.0.3.tgz#788f7a6fe5cd8f61a1afb71b2f0b964ebf914b80"
integrity sha512-sMpZVcmFf6EYFHFFl+SYH4W1/OnXBYMGDsv2IlbQ2caHyFElW/UR/gpj/KYU1JwmP4dE9xqwv2+vWcmlXHojSw==
dependencies:
tough-cookie "^4.0.0"
tough-cookie@^4.0.0, tough-cookie@^4.1.2: tough-cookie@^4.0.0, tough-cookie@^4.1.2:
version "4.1.3" version "4.1.3"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf"
@ -19035,16 +19042,17 @@ y18n@^5.0.5:
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
yahoo-finance2@2.5.0: yahoo-finance2@2.7.0:
version "2.5.0" version "2.7.0"
resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.5.0.tgz#847834b33d24dc8ce96357401aba7dae1bcfda9f" resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.7.0.tgz#2f3d078409616495a6f9357b1cce1c797903b82c"
integrity sha512-YTniHzTx17lrs7tUonFZMWvY0dF4UJSrPkrTNMqNrb+la7Nde/5KY7mQFf+8VdXhngPup2V9ex27M2WK3ADtbw== integrity sha512-kh/t3P72MDLyf1jCEPTVZXtIwW8tuxhEg2zWHhPINxnBCT6cToQjF59dWnHNosPji5iUmtvhMWLmMbWJkBBSSw==
dependencies: dependencies:
"@types/tough-cookie" "^4.0.2" "@types/tough-cookie" "^4.0.2"
ajv "8.10.0" ajv "8.10.0"
ajv-formats "2.1.1" ajv-formats "2.1.1"
node-fetch "^2.6.1" node-fetch "^2.6.1"
tough-cookie "^4.1.2" tough-cookie "^4.1.2"
tough-cookie-file-store "^2.0.3"
yallist@^3.0.2: yallist@^3.0.2:
version "3.1.1" version "3.1.1"