Compare commits

..

66 Commits

Author SHA1 Message Date
edd3e75730 Release 1.240.0 (#1753) 2023-02-26 17:13:50 +01:00
ab68c2c69a Bugfix/fix feature graphic of umbrel blog post (#1752)
* Fix feature graphic

* Update changelog
2023-02-26 17:12:38 +01:00
cbb95f21a3 Feature/support manual currency for unit price (#1751)
* Support manual currency for unit price

* Update changelog
2023-02-26 17:10:13 +01:00
74d3954335 Release 1.239.0 (#1750) 2023-02-25 20:28:30 +01:00
92449b0369 Feature/remove rimraf (#1739)
* Remove rimraf

* Update changelog
2023-02-25 20:26:56 +01:00
65276483e0 Feature/add umbrel blog post (#1749)
* Add blog post: Ghostfolio meets Umbrel

* Update changelog
2023-02-25 20:14:33 +01:00
dde0d1e465 Add linux/arm/v7 (#1741) 2023-02-25 11:34:22 +01:00
3ad802c6f5 Release 1.238.0 (#1747) 2023-02-25 11:23:05 +01:00
b81377a682 Feature/rename example env file (#1734)
* Rename .env to .env.example

* Ignore .env file

* Update changelog
2023-02-25 11:20:38 +01:00
545180b88f Feature/add reddit and umbrel logos to landing page (#1745)
* Add Reddit and Umbrel logos

* Update changelog
2023-02-25 11:20:04 +01:00
a9819b9e25 Feature/upgrade zone.js to version 0.12.0 (#1740)
* Upgrade zone.js

* Update changelog
2023-02-25 10:33:59 +01:00
897e941e7a Feature/add data provider info to position (#1730)
* Add data provider info

* Update changelog
2023-02-25 10:33:45 +01:00
aef840c2cc Bugfix/fix maximum call stack size exceeded error in value redaction (#1743)
* Bugfix for RangeError: Maximum call stack size exceeded

* Update changelog
2023-02-25 10:15:25 +01:00
80d0638922 Adding Coingecko Data Provider (#1736)
* Adding Coingecko Data Provider

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-02-25 09:45:01 +01:00
494ba36d44 Feature/reset letter spacing in buttons (#1742)
* Reset letter spacing in buttons

* Update changelog
2023-02-24 20:44:20 +01:00
dab9154092 Add support for armv7 processors (#1738)
* Add support for armv7 processors

* Update changelog
2023-02-23 18:09:35 +01:00
cd4a85abbf Improve wording (#1729) 2023-02-21 14:43:21 +01:00
e7977a9fbb Release 1.237.0 (#1733) 2023-02-19 19:26:16 +01:00
684c1e55b0 Bugfix/do not skip manual data source part 2 (#1732)
* Do not skip MANUAL data source

* Update changelog
2023-02-19 19:24:52 +01:00
1ffa831c5c Feature/improve style of symbol search results (#1728)
* Improve style

* Update changelog
2023-02-19 11:20:56 +01:00
40eed0016c Feature/migrate header module to angular material 15 (#1725)
* Migrate GfHeaderModule to Angular Material 15

* Update changelog
2023-02-19 10:36:52 +01:00
b58631083b Increase file size limit for imports (#1726)
* Increase file size limit for imports

* Update changelog
2023-02-19 10:09:13 +01:00
e0c0425d21 Add development guide (#1722) 2023-02-19 10:06:52 +01:00
bf2de5d572 Feature/add support to pricing page (#1723)
* Add support

* Update changelog
2023-02-19 10:02:46 +01:00
2b4a1dc480 Bugfix/fix issue with exact matches in activities filter (#1724)
* Fix issue with exact match

* Update changelog
2023-02-19 10:01:51 +01:00
ce022c024f Feature/upgrade nx to version 15.7.2 (#1721)
* Upgrade Angular and Nx

* Update changelog
2023-02-18 19:34:06 +01:00
0f4bf529d8 Handle impersonation mode with guard (#1714) 2023-02-18 14:59:38 +01:00
dad6bf7095 Release 1.236.0 (#1720) 2023-02-17 19:23:04 +01:00
86ca9eaae6 Bugfix/fix logout url in development (#1715)
* Add language

* Update changelog
2023-02-17 19:20:58 +01:00
9d9b805b0e Bugfix/do not skip manual data source (#1718)
* Do not skip MANUAL data source

* Update changelog
2023-02-17 19:20:14 +01:00
851401be1e Feature/remove ghostfolio as data source type (#1717)
* Remove GHOSTFOLIO

* Update changelog
2023-02-17 19:19:16 +01:00
85052bc9bc Bugfix/fix buying power calculation with emergency fund tag (#1713)
* Fix buying power calculation

* Update changelog
2023-02-17 17:29:48 +01:00
bff09f529d Feature/beautify etf names in asset profiles (#1709)
* Beautify ETF names

* Update changelog
2023-02-17 11:20:46 +01:00
f438458687 Release 1.235.0 (#1708) 2023-02-16 17:20:22 +01:00
7125b12631 Feature/improve styles on about page (#1707)
* Improve styles

* Update changelog
2023-02-16 17:17:30 +01:00
0cbf275a2e Feature/eliminate ghostfolio scraper api service (#1706)
* Eliminate GhostfolioScraperApiService

* Update changelog
2023-02-16 16:25:23 +01:00
0ec50819f5 Release 1.234.0 (#1703) 2023-02-15 10:52:02 +01:00
c9abe818bc Revert import (#1702) 2023-02-15 10:50:19 +01:00
bfa32537a8 Feature/improve usability of import activities action (#1695)
* Improve usability of import activities action

* Update changelog
2023-02-15 10:07:25 +01:00
cef15afab8 Add styling (#1701) 2023-02-15 10:01:35 +01:00
1b9587c454 Update default coupon duration (#1700) 2023-02-15 10:00:04 +01:00
de76b0d8c3 Feature/add data import and export to pricing page (#1697)
* Add data import and export

* Update changelog
2023-02-15 09:52:09 +01:00
e62989c981 Feature/copy logic of ghostfolio scraper api service to manual service (#1691)
* Copy logic of GhostfolioScraperApiService to ManualService

* Update changelog
2023-02-15 09:50:31 +01:00
d6b71e6314 Bugfix/fix links in subscription interstitial dialog (#1696)
* Fix links

* Update changelog
2023-02-14 18:25:12 +01:00
8c59bfd6d7 Feature/upgrade prisma to version 4.10.1 (#1688)
* Upgrade prisma to version 4.10.1

* Update changelog
2023-02-14 11:35:04 +01:00
f32df73256 Feature/migrate pages to angular material 15 (#1689)
* Migrate to Angular Material 15

* Update changelog
2023-02-14 10:04:22 +01:00
9d03a8002c Feature/improve content of faq and landing page (#1687)
* Conditionally show content

* Update changelog
2023-02-13 09:41:25 +01:00
3c36ca29af Feature/upgrade ionicons to version 6.1.2 (#1676)
* Upgrade ionicons to version 6.1.2

* Update changelog
2023-02-12 10:08:04 +01:00
efed7e3c2b Modify default exposed port (#1681)
* Modify default exposed port

* Update changelog
2023-02-11 10:37:44 +01:00
b09d3cea95 Fix landing page by setting a default value for countriesOfSubscribers
* Set default value for countriesOfSubscribers

* Update changelog
2023-02-11 09:57:27 +01:00
eabd2f3934 Add url (#1683) 2023-02-11 09:55:03 +01:00
cc184c2827 Feature/upgrade prettier to version 2.8.4 (#1675)
* Upgrade prettier to version 2.8.4

* Update changelog
2023-02-10 09:27:26 +01:00
436f791fa4 Feature/upgrade chart.js to version 4.2.0 (#1567)
* Upgrade chart.js to version 4.2.0

* Update changelog
2023-02-09 21:22:55 +01:00
e935a57dec Release 1.233.0 (#1678) 2023-02-09 20:30:53 +01:00
203909d917 Feature/upgrade eslint dependencies (#1674)
* Upgrade eslint dependencies

* Update changelog
2023-02-09 10:22:50 +01:00
eed4f57f30 Clean up (#1669) 2023-02-09 09:59:29 +01:00
7878036bac Feature/remove google play badge from landing page (#1672)
* Remove Google Play badge

* Update changelog
2023-02-08 14:17:49 +01:00
75d140b436 Harmonize file name (#1662) 2023-02-07 08:44:22 +01:00
a79f31b006 Feature/add accounts import export (#1635)
* Add accounts to activities export

* Add logic for importing accounts

* Update changelog
2023-02-06 21:59:59 +01:00
45cfd61dbb Feature/improve styling in admin control panel (#1665)
* Improve styling

* Update changelog
2023-02-06 11:35:56 +01:00
7fcfca952e Release 1.232.0 (#1664) 2023-02-05 19:46:18 +01:00
279f16cc67 Feature/extract locales 20230205 (#1663)
* Extract locales

* Update changelog
2023-02-05 19:44:33 +01:00
e7b1d8a5d3 Feature/upgrade ngx markdown to version 15.1.0 (#1657)
* Upgrade ngx-markdown to version 15.1.0

* Update changelog
2023-02-05 18:57:35 +01:00
1b2f8e5586 Feature/extend analytics by country (#1661)
* Extend analytics by country

* Fix Upgrade Plan button of subscription interstitial

* Update changelog
2023-02-05 18:57:12 +01:00
e4468252c6 Feature/upgrade ng extract i18n merge to version 2.5.0 (#1656)
* Upgrade ng-extract-i18n-merge to version 2.5.0

* Update changelog
2023-02-05 11:44:06 +01:00
ad3ebd42bb Feature/migrate mat suffix to angular material 15 (#1655)
* Migrate matSuffix to @angular/material 15

* Update changelog
2023-02-05 09:49:37 +01:00
146 changed files with 10980 additions and 2503 deletions

View File

View File

@ -41,7 +41,7 @@ jobs:
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.output.labels }}

3
.gitignore vendored
View File

@ -25,6 +25,7 @@
# misc
/.angular/cache
.env
.env.prod
/.sass-cache
/connect.lock
@ -38,4 +39,4 @@ yarn-error.log
# System Files
.DS_Store
Thumbs.db
Thumbs.db

View File

@ -1,10 +1,7 @@
module.exports = {
stories: [],
addons: ['@storybook/addon-essentials']
// uncomment the property below if you want to apply some webpack config globally
// webpackFinal: async (config, { configType }) => {
// // Make whatever fine-grained changes you need that should apply to all storybook configs
// // Return the altered config
// return config;
// },

View File

@ -5,6 +5,151 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.240.0 - 2023-02-26
### Added
- Supported a manual currency for the activity unit price
### Fixed
- Fixed the feature graphic of the _Ghostfolio meets Umbrel_ blog post
## 1.239.0 - 2023-02-25
### Added
- Added a blog post: _Ghostfolio meets Umbrel_
### Changed
- Removed the dependency `rimraf`
## 1.238.0 - 2023-02-25
### Added
- Added `COINGECKO` as a new data source type
- Added support for data provider information to the position detail dialog
- Added the configuration to publish a `linux/arm/v7` docker image
- Added _Reddit_ to the _As seen in_ section on the landing page
- Added _Umbrel_ to the _As seen in_ section on the landing page
### Changed
- Renamed the example environment variable file from `.env` to `.env.example`
- Upgraded `zone.js` from version `0.11.8` to `0.12.0`
### Fixed
- Fixed `RangeError: Maximum call stack size exceeded` for values of type `Big` in the value redaction interceptor for the impersonation mode
- Reset the letter spacing in buttons
### Todo
- Ensure that you still have a `.env` file in your project
## 1.237.0 - 2023-02-19
### Added
- Added the support details to the pricing page
### Changed
- Increased the file size limit for the activities import
- Improved the style of the search results for symbols
- Migrated the style of `GfHeaderModule` to `@angular/material` `15` (mdc)
- Upgraded `angular` from version `15.1.2` to `15.1.5`
- Upgraded `Nx` from version `15.6.3` to `15.7.2`
### Fixed
- Fixed an issue with exact matches in the activities table filter (`VT` vs. `VTI`)
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
## 1.236.0 - 2023-02-17
### Changed
- Beautified the ETF names in the asset profile
- Removed the data source type `GHOSTFOLIO`
### Fixed
- Fixed an issue in the data gathering service (do not skip `MANUAL` data source)
- Fixed the buying power calculation if no emergency fund is set but an activity is tagged as _Emergency Fund_
- Fixed the url on logout during the local development
## 1.235.0 - 2023-02-16
### Changed
- Improved the styles on the about page
- Eliminated the `GhostfolioScraperApiService`
## 1.234.0 - 2023-02-15
### Added
- Added the data import and export feature to the pricing page
### Changed
- Copy the logic of `GhostfolioScraperApiService` to `ManualService`
- Improved the content of the landing page
- Improved the content of the Frequently Asked Questions (FAQ) page
- Improved the usability of the _Import Activities..._ action
- Eliminated the permission `enableImport`
- Set the exposed port as an environment variable (`PORT`) in `Dockerfile`
- Migrated the style of `AboutPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `BlogPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `ChangelogPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `ResourcesPageModule` to `@angular/material` `15` (mdc)
- Upgraded `chart.js` from version `4.0.1` to `4.2.0`
- Upgraded `ionicons` from version `6.0.4` to `6.1.2`
- Upgraded `prettier` from version `2.8.1` to `2.8.4`
- Upgraded `prisma` from version `4.9.0` to `4.10.1`
### Fixed
- Fixed an issue on the landing page caused by the global heat map of subscribers
- Fixed the links in the interstitial for the subscription
### Todo
- Remove the environment variable `ENABLE_FEATURE_IMPORT`
- Rename the `dataSource` from `GHOSTFOLIO` to `MANUAL`
- Eliminate `GhostfolioScraperApiService`
## 1.233.0 - 2023-02-09
### Added
- Added support to export accounts
- Added suport to import accounts
### Changed
- Improved the styling in the admin control panel
- Removed the _Google Play_ badge from the landing page
- Upgraded `eslint` dependencies
## 1.232.0 - 2023-02-05
### Changed
- Improved the language localization for German (`de`)
- Migrated the style of `ActivitiesPageModule` to `@angular/material` `15` (mdc)
- Migrated the style of `GfCreateOrUpdateActivityDialogModule` to `@angular/material` `15` (mdc)
- Migrated the style of `GfMarketDataDetailDialogModule` to `@angular/material` `15` (mdc)
- Upgraded `ng-extract-i18n-merge` from version `2.1.2` to `2.5.0`
- Upgraded `ngx-markdown` from version `14.0.1` to `15.1.0`
### Fixed
- Fixed the `Upgrade Plan` button of the interstitial for the subscription
## 1.231.0 - 2023-02-04
### Added
@ -656,7 +801,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added the alias to the `Access` database schema
- Added support for translated time distances
- Added a _GitHub Action_ to create an `arm64` docker image
- Added a _GitHub Action_ to create an `linux/arm64` docker image
### Changed
@ -1269,7 +1414,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Beautified the ETF names in the symbol profile
- Beautified the ETF names in the asset profile
### Fixed

25
DEVELOPMENT.md Normal file
View File

@ -0,0 +1,25 @@
# Ghostfolio Development Guide
## Git
### Rebase
`git rebase -i --autosquash main`
## Dependencies
### Nx
#### Upgrade
1. Run `yarn nx migrate latest`
1. Make sure `package.json` changes make sense and then run `yarn install`
1. Run `yarn nx migrate --run-migrations`
### Prisma
#### Create schema migration (local)
Run `yarn prisma migrate dev --name added_job_title`
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate

View File

@ -57,5 +57,5 @@ RUN apt update && apt install -y \
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
WORKDIR /ghostfolio/apps/api
EXPOSE 3333
EXPOSE ${PORT:-3333}
CMD [ "yarn", "start:prod" ]

View File

@ -75,7 +75,7 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
## Self-hosting
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64` and `linux/arm64`.
We provide official container images hosted on [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio) for `linux/amd64`, `linux/arm/v7` and `linux/arm64`.
<div align="center">
@ -106,7 +106,8 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
- Basic knowledge of Docker
- Installation of [Docker](https://www.docker.com/products/docker-desktop)
- Local copy of this Git repository (clone)
- Create a local copy of this Git repository (clone)
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
#### a. Run environment
@ -150,7 +151,8 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 16)
- [Yarn](https://yarnpkg.com/en/docs/install)
- A local copy of this Git repository (clone)
- Create a local copy of this Git repository (clone)
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
### Setup
@ -175,7 +177,7 @@ Run `yarn start:server`
### Start Client
Run `yarn start:client`
Run `yarn start:client` and open http://localhost:4200/en in your browser
### Start _Storybook_

View File

@ -1,5 +1,4 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Accounts } from '@ghostfolio/common/interfaces';

View File

@ -17,6 +17,10 @@ export class CreateAccountDto {
@IsString()
currency: string;
@IsOptional()
@IsString()
id?: string;
@IsBoolean()
@IsOptional()
isExcluded?: boolean;

View File

@ -244,6 +244,7 @@ export class AdminService {
Analytics: {
select: {
activityCount: true,
country: true,
updatedAt: true
}
},
@ -277,6 +278,7 @@ export class AdminService {
id,
subscription,
accountCount: _count.Account || 0,
country: Analytics.country,
lastActivity: Analytics.updatedAt,
transactionCount: _count.Order || 0
};

View File

@ -61,8 +61,10 @@ export class AuthService {
// Create new user if not found
user = await this.userService.createUser({
provider,
thirdPartyId: principalId
data: {
provider,
thirdPartyId: principalId
}
});
}
@ -96,8 +98,10 @@ export class AuthService {
// Create new user if not found
user = await this.userService.createUser({
provider,
thirdPartyId
data: {
provider,
thirdPartyId
}
});
}

View File

@ -14,6 +14,22 @@ export class ExportService {
activityIds?: string[];
userId: string;
}): Promise<Export> {
const accounts = await this.prismaService.account.findMany({
orderBy: {
name: 'asc'
},
select: {
accountType: true,
balance: true,
currency: true,
id: true,
isExcluded: true,
name: true,
platformId: true
},
where: { userId }
});
let activities = await this.prismaService.order.findMany({
orderBy: { date: 'desc' },
select: {
@ -38,6 +54,7 @@ export class ExportService {
return {
meta: { date: new Date().toISOString(), version: environment.version },
accounts,
activities: activities.map(
({
accountId,

View File

@ -90,6 +90,11 @@ export class FrontendMiddleware implements NestMiddleware {
) {
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
} else if (
request.path.startsWith('/en/blog/2023/02/ghostfolio-meets-umbrel')
) {
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
title = `Ghostfolio meets Umbrel - ${title}`;
}
if (

View File

@ -1,8 +1,15 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Type } from 'class-transformer';
import { IsArray, ValidateNested } from 'class-validator';
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
export class ImportDataDto {
@IsOptional()
@IsArray()
@Type(() => CreateAccountDto)
@ValidateNested({ each: true })
accounts: CreateAccountDto[];
@IsArray()
@Type(() => CreateOrderDto)
@ValidateNested({ each: true })

View File

@ -2,6 +2,7 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ImportResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -38,7 +39,13 @@ export class ImportController {
@Body() importData: ImportDataDto,
@Query('dryRun') isDryRun?: boolean
): Promise<ImportResponse> {
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
if (
!hasPermission(
this.request.user.permissions,
permissions.createAccount
) ||
!hasPermission(this.request.user.permissions, permissions.createOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -60,9 +67,10 @@ export class ImportController {
try {
const activities = await this.importService.import({
maxActivitiesToImport,
isDryRun,
maxActivitiesToImport,
userCurrency,
accountsDto: importData.accounts ?? [],
activitiesDto: importData.activities,
userId: this.request.user.id
});

View File

@ -1,4 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
@ -100,18 +101,75 @@ export class ImportService {
}
public async import({
accountsDto,
activitiesDto,
isDryRun = false,
maxActivitiesToImport,
userCurrency,
userId
}: {
accountsDto: Partial<CreateAccountDto>[];
activitiesDto: Partial<CreateOrderDto>[];
isDryRun?: boolean;
maxActivitiesToImport: number;
userCurrency: string;
userId: string;
}): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {};
if (!isDryRun && accountsDto?.length) {
const existingAccounts = await this.accountService.accounts({
where: {
id: {
in: accountsDto.map(({ id }) => {
return id;
})
}
}
});
for (const account of accountsDto) {
// Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find(
(existingAccount) => existingAccount.id === account.id
);
// If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== userId) {
let oldAccountId: string;
const platformId = account.platformId;
delete account.platformId;
if (accountWithSameId) {
oldAccountId = account.id;
delete account.id;
}
const newAccountObject = {
...account,
User: { connect: { id: userId } }
};
if (platformId) {
Object.assign(newAccountObject, {
Platform: { connect: { id: platformId } }
});
}
const newAccount = await this.accountService.createAccount(
newAccountObject,
userId
);
// Store the new to old account ID mappings for updating activities
if (accountWithSameId && oldAccountId) {
accountIdMapping[oldAccountId] = newAccount.id;
}
}
}
}
for (const activity of activitiesDto) {
if (!activity.dataSource) {
if (activity.type === 'ITEM') {
@ -120,6 +178,13 @@ export class ImportService {
activity.dataSource = this.dataProviderService.getPrimaryDataSource();
}
}
// If a new account is created, then update the accountId in all activities
if (!isDryRun) {
if (Object.keys(accountIdMapping).includes(activity.accountId)) {
activity.accountId = accountIdMapping[activity.accountId];
}
}
}
const assetProfiles = await this.validateActivities({
@ -128,12 +193,18 @@ export class ImportService {
userId
});
const accountIds = (await this.accountService.getAccounts(userId)).map(
const accounts = (await this.accountService.getAccounts(userId)).map(
(account) => {
return account.id;
return { id: account.id, name: account.name };
}
);
if (isDryRun) {
accountsDto.forEach(({ id, name }) => {
accounts.push({ id, name });
});
}
const activities: Activity[] = [];
for (const {
@ -149,11 +220,15 @@ export class ImportService {
unitPrice
} of activitiesDto) {
const date = parseISO(<string>(<unknown>dateString));
const validatedAccountId = accountIds.includes(accountId)
? accountId
: undefined;
const validatedAccount = accounts.find(({ id }) => {
return id === accountId;
});
let order: OrderWithAccount;
let order:
| OrderWithAccount
| (Omit<OrderWithAccount, 'Account'> & {
Account?: { id: string; name: string };
});
if (isDryRun) {
order = {
@ -164,7 +239,7 @@ export class ImportService {
type,
unitPrice,
userId,
accountId: validatedAccountId,
accountId: validatedAccount?.id,
accountUserId: undefined,
createdAt: new Date(),
id: uuidv4(),
@ -187,6 +262,7 @@ export class ImportService {
url: null,
...assetProfiles[symbol]
},
Account: validatedAccount,
symbolProfileId: undefined,
updatedAt: new Date()
};
@ -199,7 +275,7 @@ export class ImportService {
type,
unitPrice,
userId,
accountId: validatedAccountId,
accountId: validatedAccount?.id,
SymbolProfile: {
connectOrCreate: {
create: {
@ -221,6 +297,7 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber();
//@ts-ignore
activities.push({
...order,
value,

View File

@ -72,10 +72,6 @@ export class InfoService {
globalPermissions.push(permissions.enableFearAndGreedIndex);
}
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport);
}
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
isReadOnlyMode = (await this.propertyService.getByKey(
PROPERTY_IS_READ_ONLY_MODE

View File

@ -110,9 +110,6 @@ export class OrderService {
dataSource,
symbol: id
};
} else {
data.SymbolProfile.connectOrCreate.create.symbol =
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
}
await this.dataGatheringService.addJobToQueue(

View File

@ -1,4 +1,5 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
import { GetValueObject } from './interfaces/get-value-object.interface';
@ -48,8 +49,11 @@ export const CurrentRateServiceMock = {
getValues: ({
dataGatheringItems,
dateQuery
}: GetValuesParams): Promise<GetValueObject[]> => {
const result: GetValueObject[] = [];
}: GetValuesParams): Promise<{
dataProviderInfos: DataProviderInfo[];
values: GetValueObject[];
}> => {
const values: GetValueObject[] = [];
if (dateQuery.lt) {
for (
let date = resetHours(dateQuery.gte);
@ -57,7 +61,7 @@ export const CurrentRateServiceMock = {
date = addDays(date, 1)
) {
for (const dataGatheringItem of dataGatheringItems) {
result.push({
values.push({
date,
marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol,
@ -70,7 +74,7 @@ export const CurrentRateServiceMock = {
} else {
for (const date of dateQuery.in) {
for (const dataGatheringItem of dataGatheringItems) {
result.push({
values.push({
date,
marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol,
@ -81,6 +85,6 @@ export const CurrentRateServiceMock = {
}
}
}
return Promise.resolve(result);
return Promise.resolve({ values, dataProviderInfos: [] });
}
};

View File

@ -1,6 +1,7 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service';
@ -103,17 +104,23 @@ describe('CurrentRateService', () => {
},
userCurrency: 'CHF'
})
).toMatchObject<GetValueObject[]>([
{
date: undefined,
marketPriceInBaseCurrency: 1841.823902,
symbol: 'AMZN'
},
{
date: undefined,
marketPriceInBaseCurrency: 1847.839966,
symbol: 'AMZN'
}
]);
).toMatchObject<{
dataProviderInfos: DataProviderInfo[];
values: GetValueObject[];
}>({
dataProviderInfos: [],
values: [
{
date: undefined,
marketPriceInBaseCurrency: 1841.823902,
symbol: 'AMZN'
},
{
date: undefined,
marketPriceInBaseCurrency: 1847.839966,
symbol: 'AMZN'
}
]
});
});
});

View File

@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { resetHours } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash';
@ -22,7 +23,11 @@ export class CurrentRateService {
dataGatheringItems,
dateQuery,
userCurrency
}: GetValuesParams): Promise<GetValueObject[]> {
}: GetValuesParams): Promise<{
dataProviderInfos: DataProviderInfo[];
values: GetValueObject[];
}> {
const dataProviderInfos: DataProviderInfo[] = [];
const includeToday =
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
@ -38,6 +43,14 @@ export class CurrentRateService {
.then((dataResultProvider) => {
const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) {
if (
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
) {
dataProviderInfos.push(
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
);
}
result.push({
date: today,
marketPriceInBaseCurrency:
@ -81,7 +94,10 @@ export class CurrentRateService {
})
);
return flatten(await Promise.all(promises));
return {
dataProviderInfos,
values: flatten(await Promise.all(promises))
};
}
private containsToday(dates: Date[]): boolean {

View File

@ -1,4 +1,5 @@
import {
DataProviderInfo,
EnhancedSymbolProfile,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
@ -7,6 +8,7 @@ import { Tag } from '@prisma/client';
export interface PortfolioPositionDetail {
averagePrice: number;
dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number;
feeInBaseCurrency: number;
firstBuyDate: string;

View File

@ -1,7 +1,11 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import {
DataProviderInfo,
ResponseError,
TimelinePosition
} from '@ghostfolio/common/interfaces';
import { GroupBy } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client';
@ -45,6 +49,7 @@ export class PortfolioCalculator {
private currency: string;
private currentRateService: CurrentRateService;
private dataProviderInfos: DataProviderInfo[];
private orders: PortfolioOrder[];
private transactionPoints: TransactionPoint[];
@ -202,14 +207,17 @@ export class PortfolioCalculator {
symbols[item.symbol] = true;
}
const marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
const { dataProviderInfos, values: marketSymbols } =
await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
this.dataProviderInfos = dataProviderInfos;
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
@ -368,14 +376,17 @@ export class PortfolioCalculator {
dates.push(resetHours(end));
const marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
const { dataProviderInfos, values: marketSymbols } =
await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
this.dataProviderInfos = dataProviderInfos;
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
@ -463,6 +474,10 @@ export class PortfolioCalculator {
};
}
public getDataProviderInfos() {
return this.dataProviderInfos;
}
public getInvestments(): { date: string; investment: Big }[] {
if (this.transactionPoints.length === 0) {
return [];
@ -748,7 +763,7 @@ export class PortfolioCalculator {
let marketSymbols: GetValueObject[] = [];
if (dataGatheringItems.length > 0) {
try {
marketSymbols = await this.currentRateService.getValues({
const { values } = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
@ -757,6 +772,7 @@ export class PortfolioCalculator {
},
userCurrency: this.currency
});
marketSymbols = values;
} catch (error) {
Logger.error(
`Failed to fetch info for date ${startDate} with exception`,

View File

@ -678,6 +678,7 @@ export class PortfolioService {
return {
tags,
averagePrice: undefined,
dataProviderInfo: undefined,
dividendInBaseCurrency: undefined,
feeInBaseCurrency: undefined,
firstBuyDate: undefined,
@ -849,6 +850,7 @@ export class PortfolioService {
tags,
transactionCount,
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee.toNumber(),
@ -911,6 +913,7 @@ export class PortfolioService {
SymbolProfile,
tags,
averagePrice: 0,
dataProviderInfo: undefined,
dividendInBaseCurrency: 0,
feeInBaseCurrency: 0,
firstBuyDate: undefined,
@ -1550,7 +1553,10 @@ export class PortfolioService {
userCurrency
}).toNumber();
const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
Math.max(
emergencyFundPositionsValueInBaseCurrency,
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
)
);
const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date;

View File

@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsOptional()
country?: string;
}

View File

@ -22,6 +22,7 @@ import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { size } from 'lodash';
import { CreateUserDto } from './create-user.dto';
import { UserItem } from './interfaces/user-item.interface';
import { UpdateUserSettingDto } from './update-user-setting.dto';
import { UserService } from './user.service';
@ -65,7 +66,7 @@ export class UserController {
}
@Post()
public async signupUser(): Promise<UserItem> {
public async signupUser(@Body() data: CreateUserDto): Promise<UserItem> {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
@ -79,7 +80,8 @@ export class UserController {
const hasAdmin = await this.userService.hasAdmin();
const { accessToken, id, role } = await this.userService.createUser({
role: hasAdmin ? 'USER' : 'ADMIN'
country: data.country,
data: { role: hasAdmin ? 'USER' : 'ADMIN' }
});
return {

View File

@ -18,6 +18,8 @@ import { Injectable } from '@nestjs/common';
import { Prisma, Role, User } from '@prisma/client';
import { sortBy } from 'lodash';
import { CreateUserDto } from './create-user.dto';
const crypto = require('crypto');
@Injectable()
@ -231,7 +233,10 @@ export class UserService {
return hash.digest('hex');
}
public async createUser(data: Prisma.UserCreateInput): Promise<User> {
public async createUser({
country,
data
}: CreateUserDto & { data: Prisma.UserCreateInput }): Promise<User> {
if (!data?.provider) {
data.provider = 'ANONYMOUS';
}
@ -256,6 +261,15 @@ export class UserService {
}
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
await this.prismaService.analytics.create({
data: {
country,
User: { connect: { id: user.id } }
}
});
}
if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken(
user.id,

View File

@ -1,3 +1,4 @@
import Big from 'big.js';
import { cloneDeep, isArray, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
@ -59,7 +60,10 @@ export function redactAttributes({
return redactAttributes({ options, object: currentObject });
}
);
} else if (isObject(redactedObject[property])) {
} else if (
isObject(redactedObject[property]) &&
!(redactedObject[property] instanceof Big)
) {
// Recursively call the function on the nested object
redactedObject[property] = redactAttributes({
options,

View File

@ -1,6 +1,7 @@
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import * as bodyParser from 'body-parser';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
@ -33,6 +34,9 @@ async function bootstrap() {
})
);
// Support 10mb csv/json files for importing activities
app.use(bodyParser.json({ limit: '10mb' }));
const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333;
await app.listen(PORT, HOST, () => {

View File

@ -19,12 +19,11 @@ export class ConfigurationService {
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({
default: [DataSource.GHOSTFOLIO, DataSource.MANUAL, DataSource.YAHOO]
default: [DataSource.MANUAL, DataSource.YAHOO]
}),
ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_IMPORT: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }),

View File

@ -207,10 +207,6 @@ export class DataGatheringService {
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
if (dataSource === 'MANUAL') {
continue;
}
await this.addJobToQueue(
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
{
@ -253,11 +249,6 @@ export class DataGatheringService {
},
scraperConfiguration: true,
symbol: true
},
where: {
dataSource: {
not: 'MANUAL'
}
}
})
).map((symbolProfile) => {
@ -278,7 +269,6 @@ export class DataGatheringService {
return symbolProfiles
.filter(({ dataSource }) => {
return (
dataSource !== DataSource.GHOSTFOLIO &&
dataSource !== DataSource.MANUAL &&
dataSource !== DataSource.RAPID_API
);
@ -300,11 +290,6 @@ export class DataGatheringService {
dataSource: true,
scraperConfiguration: true,
symbol: true
},
where: {
dataSource: {
not: 'MANUAL'
}
}
});

View File

@ -0,0 +1,200 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
SymbolProfile
} from '@prisma/client';
import bent from 'bent';
import { format, fromUnixTime, getUnixTime } from 'date-fns';
@Injectable()
export class CoinGeckoService implements DataProviderInterface {
private baseCurrency: string;
private readonly URL = 'https://api.coingecko.com/api/v3';
public constructor(
private readonly configurationService: ConfigurationService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) {
return true;
}
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = {
assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: this.baseCurrency,
dataSource: this.getName(),
symbol: aSymbol
};
try {
const get = bent(`${this.URL}/coins/${aSymbol}`, 'GET', 'json', 200);
const { name } = await get();
response.name = name;
} catch (error) {
Logger.error(error, 'CoinGeckoService');
}
return response;
}
public async getDividends({
from,
granularity = 'day',
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
return {};
}
public async getHistorical(
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
try {
const get = bent(
`${
this.URL
}/coins/${aSymbol}/market_chart/range?vs_currency=${this.baseCurrency.toLowerCase()}&from=${getUnixTime(
from
)}&to=${getUnixTime(to)}`,
'GET',
'json',
200
);
const { prices } = await get();
const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {
[aSymbol]: {}
};
for (const [timestamp, marketPrice] of prices) {
result[aSymbol][format(fromUnixTime(timestamp / 1000), DATE_FORMAT)] = {
marketPrice
};
}
return result;
} catch (error) {
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}
public getMaxNumberOfSymbolsPerRequest() {
return 50;
}
public getName(): DataSource {
return DataSource.COINGECKO;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
const results: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) {
return {};
}
try {
const get = bent(
`${this.URL}/simple/price?ids=${aSymbols.join(
','
)}&vs_currencies=${this.baseCurrency.toLowerCase()}`,
'GET',
'json',
200
);
const response = await get();
for (const symbol in response) {
if (Object.prototype.hasOwnProperty.call(response, symbol)) {
results[symbol] = {
currency: this.baseCurrency,
dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.COINGECKO,
marketPrice: response[symbol][this.baseCurrency.toLowerCase()],
marketState: 'open'
};
}
}
} catch (error) {
Logger.error(error, 'CoinGeckoService');
}
return results;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = [];
if (aQuery.length <= 2) {
return { items };
}
try {
const get = bent(
`${this.URL}/search?query=${aQuery}`,
'GET',
'json',
200
);
const { coins } = await get();
items = coins.map(({ id: symbol, name }) => {
return {
name,
symbol,
currency: this.baseCurrency,
dataSource: this.getName()
};
});
} catch (error) {
Logger.error(error, 'CoinGeckoService');
}
return { items };
}
private getDataProviderInfo(): DataProviderInfo {
return {
name: 'CoinGecko',
url: 'https://coingecko.com'
};
}
}

View File

@ -1,8 +1,8 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
@ -22,9 +22,9 @@ import { DataProviderService } from './data-provider.service';
],
providers: [
AlphaVantageService,
CoinGeckoService,
DataProviderService,
EodHistoricalDataService,
GhostfolioScraperApiService,
GoogleSheetsService,
ManualService,
RapidApiService,
@ -32,8 +32,8 @@ import { DataProviderService } from './data-provider.service';
{
inject: [
AlphaVantageService,
CoinGeckoService,
EodHistoricalDataService,
GhostfolioScraperApiService,
GoogleSheetsService,
ManualService,
RapidApiService,
@ -42,16 +42,16 @@ import { DataProviderService } from './data-provider.service';
provide: 'DataProviderInterfaces',
useFactory: (
alphaVantageService,
coinGeckoService,
eodHistoricalDataService,
ghostfolioScraperApiService,
googleSheetsService,
manualService,
rapidApiService,
yahooFinanceService
) => [
alphaVantageService,
coinGeckoService,
eodHistoricalDataService,
ghostfolioScraperApiService,
googleSheetsService,
manualService,
rapidApiService,
@ -59,10 +59,6 @@ import { DataProviderService } from './data-provider.service';
]
}
],
exports: [
DataProviderService,
GhostfolioScraperApiService,
YahooFinanceService
]
exports: [DataProviderService, YahooFinanceService]
})
export class DataProviderModule {}

View File

@ -1,194 +0,0 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
DATE_FORMAT,
extractNumberFromString,
getYesterday
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import * as cheerio from 'cheerio';
import { addDays, format, isBefore } from 'date-fns';
@Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface {
public constructor(
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public canHandle(symbol: string) {
return true;
}
public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
};
}
public async getDividends({
from,
granularity = 'day',
symbol,
to
}: {
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
return {};
}
public async getHistorical(
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
try {
const symbol = aSymbol;
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration;
if (defaultMarketPrice) {
const historical: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {
[symbol]: {}
};
let date = from;
while (isBefore(date, to)) {
historical[symbol][format(date, DATE_FORMAT)] = {
marketPrice: defaultMarketPrice
};
date = addDays(date, 1);
}
return historical;
} else if (selector === undefined || url === undefined) {
return {};
}
const get = bent(url, 'GET', 'string', 200, {});
const html = await get();
const $ = cheerio.load(html);
const value = extractNumberFromString($(selector).text());
return {
[symbol]: {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: value
}
}
};
} catch (error) {
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}
public getName(): DataSource {
return DataSource.GHOSTFOLIO;
}
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) {
return response;
}
try {
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
const marketData = await this.prismaService.marketData.findMany({
distinct: ['symbol'],
orderBy: {
date: 'desc'
},
take: aSymbols.length,
where: {
symbol: {
in: aSymbols
}
}
});
for (const symbolProfile of symbolProfiles) {
response[symbolProfile.symbol] = {
currency: symbolProfile.currency,
dataSource: this.getName(),
marketPrice: marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
}).marketPrice,
marketState: 'delayed'
};
}
return response;
} catch (error) {
Logger.error(error, 'GhostfolioScraperApiService');
}
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
OR: [
{
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
}
}
]
}
});
return { items };
}
}

View File

@ -6,9 +6,17 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
DATE_FORMAT,
extractNumberFromString,
getYesterday
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import * as cheerio from 'cheerio';
import { addDays, format, isBefore } from 'date-fns';
@Injectable()
export class ManualService implements DataProviderInterface {
@ -18,7 +26,7 @@ export class ManualService implements DataProviderInterface {
) {}
public canHandle(symbol: string) {
return false;
return true;
}
public async getAssetProfile(
@ -51,7 +59,57 @@ export class ManualService implements DataProviderInterface {
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
return {};
try {
const symbol = aSymbol;
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration ?? {};
if (defaultMarketPrice) {
const historical: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {
[symbol]: {}
};
let date = from;
while (isBefore(date, to)) {
historical[symbol][format(date, DATE_FORMAT)] = {
marketPrice: defaultMarketPrice
};
date = addDays(date, 1);
}
return historical;
} else if (selector === undefined || url === undefined) {
return {};
}
const get = bent(url, 'GET', 'string', 200, {});
const html = await get();
const $ = cheerio.load(html);
const value = extractNumberFromString($(selector).text());
return {
[symbol]: {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: value
}
}
};
} catch (error) {
throw new Error(
`Could not get historical market data for ${aSymbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}
public getName(): DataSource {
@ -88,10 +146,9 @@ export class ManualService implements DataProviderInterface {
response[symbolProfile.symbol] = {
currency: symbolProfile.currency,
dataSource: this.getName(),
marketPrice:
marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
})?.marketPrice ?? 0,
marketPrice: marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
})?.marketPrice,
marketState: 'delayed'
};
}

View File

@ -441,8 +441,10 @@ export class YahooFinanceService implements DataProviderInterface {
let name = longName;
if (name) {
name = name.replace('Amundi Index Solutions - ', '');
name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', '');
name = name.replace('iShares V PLC - ', '');
name = name.replace('iShares VI Public Limited Company - ', '');
name = name.replace('iShares VII PLC - ', '');
name = name.replace('Multi Units Luxembourg - ', '');

View File

@ -10,7 +10,6 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_IMPORT: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_STATISTICS: boolean;

View File

@ -1,4 +1,4 @@
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { DataProviderInfo, UniqueAsset } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types';
import {
Account,
@ -28,6 +28,7 @@ export interface IDataProviderHistoricalResponse {
export interface IDataProviderResponse {
currency: string;
dataProviderInfo?: DataProviderInfo;
dataSource: DataSource;
marketPrice: number;
marketState: MarketState;

View File

@ -123,6 +123,13 @@ const routes: Routes = [
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
},
{
path: 'blog/2023/02/ghostfolio-meets-umbrel',
loadChildren: () =>
import(
'./pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.module'
).then((m) => m.GhostfolioMeetsUmbrelPageModule)
},
{
path: 'demo',
loadChildren: () =>

View File

@ -104,7 +104,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.tokenStorageService.signOut();
this.userService.remove();
document.location.href = '/';
document.location.href = `/${document.documentElement.lang}`;
}
public ngOnDestroy() {

View File

@ -40,7 +40,6 @@
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="true"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[showActions]="false"

View File

@ -1,6 +1,6 @@
<form class="d-flex flex-column h-100">
<h1 i18n mat-dialog-title>Details for {{ data.symbol }}</h1>
<div class="flex-grow-1" mat-dialog-content>
<div class="flex-grow-1 pt-3" mat-dialog-content>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Date</mat-label>
@ -11,7 +11,7 @@
[matDatepicker]="date"
[(ngModel)]="data.date"
/>
<mat-datepicker-toggle matSuffix [for]="date">
<mat-datepicker-toggle class="mr-2" matSuffix [for]="date">
<ion-icon
class="text-muted"
matDatepickerToggleIcon
@ -21,7 +21,7 @@
<mat-datepicker #date disabled="true"></mat-datepicker>
</mat-form-field>
</div>
<div>
<div class="align-items-start d-flex">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Market Price</mat-label>
<input
@ -30,16 +30,16 @@
type="number"
[(ngModel)]="data.marketPrice"
/>
<span class="ml-2" matSuffix>{{ data.currency }}</span>
<button
mat-icon-button
matSuffix
title="Fetch market price"
(click)="onFetchSymbolForDate()"
>
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
</button>
<span class="ml-2" matTextSuffix>{{ data.currency }}</span>
</mat-form-field>
<button
class="apply-current-market-price ml-2 no-min-width"
mat-button
title="Fetch market price"
(click)="onFetchSymbolForDate()"
>
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
</button>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>

View File

@ -2,10 +2,10 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatButtonModule } from '@angular/material/button';
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
import { MatLegacyFormFieldModule as MatFormFieldModule } from '@angular/material/legacy-form-field';
import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MarketDataDetailDialog } from './market-data-detail-dialog.component';

View File

@ -4,19 +4,9 @@
.mat-dialog-content {
max-height: unset;
.mat-form-field-appearance-outline {
::ng-deep {
.mat-form-field-suffix {
top: -0.3rem;
}
.mat-form-field-wrapper {
padding-bottom: 0;
}
}
ion-icon {
font-size: 130%;
.mat-mdc-button {
&.apply-current-market-price {
height: 56px;
}
}
}

View File

@ -29,7 +29,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-overview.html'
})
export class AdminOverviewComponent implements OnDestroy, OnInit {
public couponDuration: StringValue = '30 days';
public couponDuration: StringValue = '14 days';
public coupons: Coupon[];
public customCurrencies: string[];
public exchangeRates: { label1: string; label2: string; value: number }[];

View File

@ -151,16 +151,23 @@
>
<div class="w-50" i18n>Coupons</div>
<div class="w-50">
<div *ngFor="let coupon of coupons">
<span>{{ coupon.code }} ({{ coupon.duration }})</span>
<button
class="mini-icon mx-1 no-min-width px-2"
mat-button
(click)="onDeleteCoupon(coupon.code)"
>
<ion-icon name="trash-outline"></ion-icon>
</button>
</div>
<table>
<tr *ngFor="let coupon of coupons">
<td class="text-monospace">{{ coupon.code }}</td>
<td class="d-flex justify-content-end pl-2">
{{ coupon.duration }}
</td>
<td>
<button
class="mini-icon mx-1 no-min-width px-2"
mat-button
(click)="onDeleteCoupon(coupon.code)"
>
<ion-icon name="trash-outline"></ion-icon>
</button>
</td>
</tr>
</table>
<div class="mt-2">
<form #couponForm="ngForm" class="align-items-center d-flex">
<mat-form-field

View File

@ -1,7 +1,9 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { AdminData, User } from '@ghostfolio/common/interfaces';
import { getEmojiFlag } from '@ghostfolio/common/helper';
import { AdminData, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
differenceInSeconds,
formatDistanceToNowStrict,
@ -16,6 +18,9 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html'
})
export class AdminUsersComponent implements OnDestroy, OnInit {
public getEmojiFlag = getEmojiFlag;
public hasPermissionForSubscription: boolean;
public info: InfoItem;
public user: User;
public users: AdminData['users'];
@ -26,6 +31,13 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
private dataService: DataService,
private userService: UserService
) {
this.info = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
);
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {

View File

@ -7,7 +7,13 @@
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right">#</th>
<th class="mat-header-cell px-1 py-2" i18n>User</th>
<th class="mat-header-cell px-1 py-2 text-right">
<th
*ngIf="hasPermissionForSubscription"
class="mat-header-cell px-1 py-2"
>
<ng-container i18n>Country</ng-container>
</th>
<th class="mat-header-cell px-1 py-2">
<ng-container i18n>Registration</ng-container>
</th>
<th class="mat-header-cell px-1 py-2 text-right">
@ -16,7 +22,10 @@
<th class="mat-header-cell px-1 py-2 text-right">
<ng-container i18n>Activities</ng-container>
</th>
<th class="mat-header-cell px-1 py-2 text-right">
<th
*ngIf="hasPermissionForSubscription"
class="mat-header-cell px-1 py-2 text-right"
>
<ng-container i18n>Engagement per Day</ng-container>
</th>
<th class="mat-header-cell px-1 py-2" i18n>Last Request</th>
@ -28,10 +37,10 @@
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
<td class="mat-cell px-1 py-2">
<div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block"
<span class="d-none d-sm-inline-block text-monospace"
>{{ userItem.id }}</span
>
<span class="d-inline-block d-sm-none"
<span class="d-inline-block d-sm-none text-monospace"
>{{ (userItem.id | slice:0:5) + '...' }}</span
>
<gf-premium-indicator
@ -41,7 +50,15 @@
></gf-premium-indicator>
</div>
</td>
<td class="mat-cell px-1 py-2 text-right">
<td
*ngIf="hasPermissionForSubscription"
class="mat-cell px-1 py-2"
>
<span class="h5" [title]="userItem.country"
>{{ getEmojiFlag(userItem.country) }}</span
>
</td>
<td class="mat-cell px-1 py-2">
{{ formatDistanceToNow(userItem.createdAt) }}
</td>
<td class="mat-cell px-1 py-2 text-right">
@ -58,7 +75,10 @@
[value]="userItem.transactionCount"
></gf-value>
</td>
<td class="mat-cell px-1 py-2 text-right">
<td
*ngIf="hasPermissionForSubscription"
class="mat-cell px-1 py-2 text-right"
>
<gf-value
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"

View File

@ -27,6 +27,7 @@ import { ColorScheme } from '@ghostfolio/common/types';
import { SymbolProfile } from '@prisma/client';
import {
Chart,
ChartData,
LineController,
LineElement,
LinearScale,
@ -57,7 +58,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@ViewChild('chartCanvas') chartCanvas;
public chart: Chart<any>;
public chart: Chart<'line'>;
public constructor() {
Chart.register(
@ -89,14 +90,14 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
}
private initialize() {
const data = {
const data: ChartData<'line'> = {
datasets: [
{
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
data: this.performanceDataItems.map(({ date, value }) => {
return { x: parseDate(date), y: value };
return { x: parseDate(date).getTime(), y: value };
}),
label: $localize`Portfolio`
},
@ -105,7 +106,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderWidth: 2,
data: this.benchmarkDataItems.map(({ date, value }) => {
return { x: parseDate(date), y: value };
return { x: parseDate(date).getTime(), y: value };
}),
label: $localize`Benchmark`
}

View File

@ -110,11 +110,7 @@
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<ng-container *ngIf="user?.access?.length > 0">
<button
class="align-items-center d-flex"
mat-menu-item
(click)="impersonateAccount(null)"
>
<button mat-menu-item (click)="impersonateAccount(null)">
<ion-icon
*ngIf="user?.access?.length > 0"
class="mr-2"
@ -128,7 +124,6 @@
</button>
<button
*ngFor="let accessItem of user?.access"
class="align-items-center d-flex"
mat-menu-item
(click)="impersonateAccount(accessItem.id)"
>
@ -147,7 +142,7 @@
<hr class="m-0" />
</ng-container>
<a
class="d-block d-sm-none"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
@ -157,7 +152,7 @@
>Overview</a
>
<a
class="d-block d-sm-none"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
@ -167,7 +162,7 @@
>Portfolio</a
>
<a
class="d-block d-sm-none"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
@ -175,7 +170,6 @@
>Accounts</a
>
<a
class="align-items-center d-flex"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
@ -184,7 +178,7 @@
>
<a
*ngIf="hasPermissionToAccessAdminControl"
class="d-block d-sm-none"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
@ -193,7 +187,7 @@
>
<hr class="m-0" />
<a
class="d-block d-sm-none"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{
@ -206,7 +200,7 @@
*ngIf="
hasPermissionForSubscription && user?.subscription?.type === 'Basic'
"
class="d-block d-sm-none"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
@ -214,14 +208,14 @@
>Pricing</a
>
<a
class="d-block d-sm-none"
class="d-flex d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
[routerLink]="['/about']"
>About Ghostfolio</a
>
<hr class="d-block d-sm-none m-0" />
<hr class="d-flex d-sm-none m-0" />
<button mat-menu-item (click)="onSignOut()">Logout</button>
</mat-menu>
</ng-container>
@ -283,9 +277,9 @@
>Markets</a
>
<a
class="d-none d-sm-block mx-1 no-min-width px-1"
class="d-none d-sm-block no-min-width"
href="https://github.com/ghostfolio/ghostfolio"
mat-flat-button
mat-icon-button
><ion-icon name="logo-github"></ion-icon
></a>
<button class="mx-1" mat-flat-button (click)="openLoginDialog()">

View File

@ -11,7 +11,9 @@
flex: 1 1 auto;
}
.mat-flat-button {
.mdc-button {
height: unset;
&:not(.mat-primary) {
background-color: transparent;
text-decoration-color: rgba(var(--palette-primary-500), 1) !important;

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';

View File

@ -29,6 +29,7 @@ import {
BarController,
BarElement,
Chart,
ChartData,
LineController,
LineElement,
LinearScale,
@ -62,7 +63,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
@ViewChild('chartCanvas') chartCanvas;
public chart: Chart<any>;
public chart: Chart<'bar' | 'line'>;
private investments: InvestmentItem[];
private values: LineChartItem[];
@ -142,7 +143,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
});
}
const chartData = {
const chartData: ChartData<'line'> = {
labels: this.historicalDataItems.map(({ date }) => {
return parseDate(date);
}),
@ -153,7 +154,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
borderWidth: this.groupBy ? 0 : 1,
data: this.investments.map(({ date, investment }) => {
return {
x: parseDate(date),
x: parseDate(date).getTime(),
y: this.isInPercent ? investment * 100 : investment
};
}),
@ -173,7 +174,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
borderWidth: 2,
data: this.values.map(({ date, value }) => {
return {
x: parseDate(date),
x: parseDate(date).getTime(),
y: this.isInPercent ? value * 100 : value
};
}),

View File

@ -50,7 +50,7 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
public onEditEmergencyFund() {
const emergencyFundInput = prompt(
$localize`Please enter the amount of your emergency fund:`,
this.summary.emergencyFund.toString()
this.summary.emergencyFund?.toString() ?? '0'
);
const emergencyFund = parseFloat(emergencyFundInput?.trim());

View File

@ -13,6 +13,7 @@ import {
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import {
DataProviderInfo,
EnhancedSymbolProfile,
LineChartItem
} from '@ghostfolio/common/interfaces';
@ -40,6 +41,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public countries: {
[code: string]: { name: string; value: number };
};
public dataProviderInfo: DataProviderInfo;
public dividendInBaseCurrency: number;
public feeInBaseCurrency: number;
public firstBuyDate: string;
@ -83,6 +85,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
.subscribe(
({
averagePrice,
dataProviderInfo,
dividendInBaseCurrency,
feeInBaseCurrency,
firstBuyDate,
@ -105,6 +108,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.averagePrice = averagePrice;
this.benchmarkDataItems = [];
this.countries = {};
this.dataProviderInfo = dataProviderInfo;
this.dividendInBaseCurrency = dividendInBaseCurrency;
this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate;

View File

@ -227,6 +227,12 @@
</div>
</ng-template>
</ng-container>
<div *ngIf="dataProviderInfo" class="col-md-12 mb-3 text-center">
<hr />
<gf-data-provider-credits [dataProviderInfos]="[dataProviderInfo]">
</gf-data-provider-credits>
<hr />
</div>
</div>
<div class="row" [ngClass]="{ 'd-none': !orders?.length }">
@ -239,7 +245,6 @@
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="true"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data.locale"
[showActions]="false"
@ -250,7 +255,7 @@
</div>
<div *ngIf="tags?.length > 0" class="row">
<div class="col mb-3">
<div class="col">
<div class="h5" i18n>Tags</div>
<mat-chip-list>
<mat-chip *ngFor="let tag of tags">{{ tag.name }}</mat-chip>
@ -262,7 +267,7 @@
*ngIf="data.hasPermissionToReportDataGlitch === true && orders?.length > 0"
class="row"
>
<div class="col mb-3">
<div class="col">
<hr />
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
><ion-icon class="mr-1" name="flag-outline"></ion-icon

View File

@ -6,6 +6,7 @@ import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/lega
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfDataProviderCreditsModule } from '@ghostfolio/ui/data-provider-credits/data-provider-credits.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -18,6 +19,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
imports: [
CommonModule,
GfActivitiesTableModule,
GfDataProviderCreditsModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfLineChartModule,

View File

@ -19,7 +19,7 @@ export class SubscriptionInterstitialDialog {
public dialogRef: MatDialogRef<SubscriptionInterstitialDialog>
) {}
public onCancel() {
public closeDialog() {
this.dialogRef.close({});
}
}

View File

@ -1,6 +1,9 @@
<h1 class="align-items-center d-flex" mat-dialog-title>
<span>Ghostfolio Premium</span>
<gf-premium-indicator class="ml-1"></gf-premium-indicator>
<gf-premium-indicator
class="ml-1"
[enableLink]="false"
></gf-premium-indicator>
</h1>
<div class="flex-grow-1" mat-dialog-content>
<p class="h5" i18n>
@ -28,14 +31,19 @@
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon class="mr-1" name="checkmark-circle-outline"></ion-icon>
<a i18n [routerLink]="['/features']">and more Features...</a>
<span i18n>and more Features...</span>
</li>
</ul>
<p>Refine your personal investment strategy now.</p>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Skip</button>
<a color="primary" mat-flat-button [routerLink]="['/pricing']">
<button i18n mat-button (click)="closeDialog()">Skip</button>
<a
color="primary"
mat-flat-button
[routerLink]="['/pricing']"
(click)="closeDialog()"
>
<span i18n>Upgrade Plan</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a>

View File

@ -48,7 +48,7 @@
>hi@ghostfol.io</a
></ng-container
>
or open an issue at
or start a discussion at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
@ -62,7 +62,7 @@
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter" size="large"></ion-icon>
<ion-icon name="logo-twitter"></ion-icon>
</a>
<a
class="mx-2"
@ -70,7 +70,7 @@
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail" size="large"></ion-icon>
<ion-icon name="mail"></ion-icon>
</a>
<a
class="mx-2"
@ -78,7 +78,7 @@
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack" size="large"></ion-icon>
<ion-icon name="logo-slack"></ion-icon>
</a>
<a
class="mx-2"
@ -86,7 +86,7 @@
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
<ion-icon name="logo-github"></ion-icon>
</a>
</p>
<div
@ -119,7 +119,7 @@
<div *ngIf="hasPermissionForStatistics" class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center">Ghostfolio in Numbers</h3>
<mat-card>
<mat-card appearance="outlined">
<mat-card-content>
<div class="row">
<div class="col-xs-12 col-md-4 my-2">
@ -191,7 +191,7 @@
<div class="row">
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/faq']"
@ -203,7 +203,7 @@
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
>
<a
class="py-2 w-100"
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/about', 'changelog']"
@ -212,7 +212,7 @@
</div>
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/about', 'privacy-policy']"
@ -221,7 +221,7 @@
</div>
<div *ngIf="hasPermissionForBlog" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
class="py-4 w-100"
color="primary"
mat-flat-button
[routerLink]="['/blog']"

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AboutPageRoutingModule } from './about-page-routing.module';

View File

@ -2,7 +2,7 @@
<div class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Changelog</h3>
<mat-card class="changelog">
<mat-card appearance="outlined" class="changelog">
<mat-card-content>
<markdown [src]="'../assets/CHANGELOG.md'"></markdown>
</mat-card-content>
@ -13,7 +13,7 @@
<div class="row">
<div class="col">
<h3 class="mb-3 text-center" i18n>License</h3>
<mat-card>
<mat-card appearance="outlined">
<mat-card-content>
<markdown [src]="'../assets/LICENSE'"></markdown>
</mat-card-content>

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { MatCardModule } from '@angular/material/card';
import { MarkdownModule } from 'ngx-markdown';
import { ChangelogPageRoutingModule } from './changelog-page-routing.module';

View File

@ -2,20 +2,18 @@
color: rgb(var(--dark-primary-text));
display: block;
.mat-card {
&.changelog {
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
}
.mat-mdc-card {
&.changelog {
::ng-deep {
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
markdown {
h1,
p {

View File

@ -257,7 +257,7 @@
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50" i18n>User ID</div>
<div class="pl-1 w-50">{{ user?.id }}</div>
<div class="pl-1 text-monospace w-50">{{ user?.id }}</div>
</div>
</mat-card-content>
</mat-card>

View File

@ -30,7 +30,7 @@
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large"></ion-icon>
<ion-icon class="mt-2" name="add-outline" size="large"></ion-icon>
</a>
</div>
</div>

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { GfAccountDetailDialogModule } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.module';
import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-table/accounts-table.module';

View File

@ -1,15 +1,11 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu';
import { MatLegacyTabsModule as MatTabsModule } from '@angular/material/legacy-tabs';
import { GfAdminJobsModule } from '@ghostfolio/client/components/admin-jobs/admin-jobs.module';
import { GfAdminMarketDataModule } from '@ghostfolio/client/components/admin-market-data/admin-market-data.module';
import { GfAdminOverviewModule } from '@ghostfolio/client/components/admin-overview/admin-overview.module';
import { GfAdminUsersModule } from '@ghostfolio/client/components/admin-users/admin-users.module';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AdminPageRoutingModule } from './admin-page-routing.module';
import { AdminPageComponent } from './admin-page.component';
@ -24,10 +20,6 @@ import { AdminPageComponent } from './admin-page.component';
GfAdminMarketDataModule,
GfAdminOverviewModule,
GfAdminUsersModule,
GfValueModule,
MatButtonModule,
MatCardModule,
MatMenuModule,
MatTabsModule
],
providers: [CacheService],

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { TheImportanceOfTrackingYourPersonalFinancesRoutingModule } from './the-importance-of-tracking-your-personal-finances-page-routing.module';

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { GhostfolioMeetsUmbrelPageComponent } from './ghostfolio-meets-umbrel-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: GhostfolioMeetsUmbrelPageComponent,
path: '',
title: 'Ghostfolio meets Umbrel'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class GhostfolioMeetsUmbrelPageRoutingModule {}

View File

@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'page' },
selector: 'gf-ghostfolio-meets-umbrel-page',
styleUrls: ['./ghostfolio-meets-umbrel-page.scss'],
templateUrl: './ghostfolio-meets-umbrel-page.html'
})
export class GhostfolioMeetsUmbrelPageComponent {}

View File

@ -0,0 +1,200 @@
<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">Ghostfolio meets Umbrel</h1>
<div class="mb-3 text-muted"><small>2023-02-25</small></div>
<img
alt="Ghostfolio meets Umbrel Teaser"
class="border rounded w-100"
src="../assets/images/blog/ghostfolio-x-umbrel.png"
title="Ghostfolio meets Umbrel"
/>
</div>
<section class="mb-4">
<p>
We are happy to announce that
<a href="https://ghostfol.io">Ghostfolio</a>, the web-based personal
finance management software, is now available in the
<a href="https://umbrel.com" target="_blank">Umbrel</a> App Store, a
home server OS for self-hosting.
</p>
<p>
In recent years, we have seen an increasing number of individuals
and organizations moving their data to the cloud. While cloud
computing has its benefits, such as accessibility and scalability,
it also comes with some concerns regarding data privacy and
security. However, there is an alternative to cloud computing that
provides the convenience of the cloud while giving you ownership and
control of your data: personal servers.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Umbrel A personal server OS for self-hosting</h2>
<p>
<a href="https://github.com/getumbrel/umbrel" target="_blank"
>Umbrel</a
>
is an operating system based on
<a href="https://www.docker.com" target="_blank">Docker</a> that
allows you to run a personal server in your home. With it, you can
self-host open source apps directly from an integrated app store.
This means that you can discover self-hosted apps directly in the
<a href="https://github.com/getumbrel/umbrel-apps" target="_blank"
>Umbrel App Store</a
>
and install them in one click. You can get up and running Umbrel on
a Raspberry Pi 4, any Ubuntu / Debian system, or a VPS in only 5
minutes.
</p>
<p>
Umbrel offers numerous advantages for running a personal server in
your home, such as enhanced data privacy and security, ownership and
control of your data, and access to a diverse selection of
self-hosted apps.
</p>
</section>
<section class="mb-4">
<h2 class="h4">
Ghostfolio Track your portfolio without being tracked
</h2>
<p>
Keeping track of multiple assets can make managing your personal
finance a challenging task. However, there are tools available
beyond spreadsheets that can help you streamline the process and
make well-informed investment decisions based on data.
</p>
<p>
<a href="https://github.com/ghostfolio/ghostfolio" target="_blank"
>Ghostfolio</a
>
is a modern open source web application designed to manage your
personal finance with ease and confidence. It presents your current
assets in real-time, including stocks, ETFs, cryptocurrencies,
commodities, and more. It allows you to track and analyze your
investments in one place.
</p>
<p>
The application has a range of features such as real-time asset
tracking, data import and export and advanced portfolio analytics
tools.
</p>
</section>
<section class="mb-4">
<p>
To participate in the ongoing development of Ghostfolio, please feel
free to reach out to us on our
<a href="https://ghostfolio.slack.com" target="_blank"
>Slack channel</a
>
or via Twitter
<a href="https://twitter.com/ghostfolio_" target="_blank"
>@ghostfolio_</a
>. We look forward to hearing from you!
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">Announcement</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">App Store</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Assets</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cloud</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Commodity</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cryptocurrency</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Debian</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Development</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">ETF</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">Home Server</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investing</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Linux</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">Operating System</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OS</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">Personal Server</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">Privacy</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Raspberry Pi</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Security</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">Spreadsheet</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Stocks</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ubuntu</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Umbrel</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">VPS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
</ul>
</section>
</article>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { GhostfolioMeetsUmbrelPageRoutingModule } from './ghostfolio-meets-umbrel-page-routing.module';
import { GhostfolioMeetsUmbrelPageComponent } from './ghostfolio-meets-umbrel-page.component';
@NgModule({
declarations: [GhostfolioMeetsUmbrelPageComponent],
imports: [CommonModule, GhostfolioMeetsUmbrelPageRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GhostfolioMeetsUmbrelPageModule {}

View File

@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@ -2,7 +2,33 @@
<div class="mb-5 row">
<div class="col">
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Blog</h3>
<mat-card class="mb-3">
<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/02/ghostfolio-meets-umbrel"
>
<div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">
Ghostfolio meets Umbrel
</div>
<div class="d-flex text-muted">2023-02-25</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-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
@ -28,7 +54,7 @@
</div>
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
@ -54,7 +80,11 @@
</div>
</mat-card-content>
</mat-card>
<mat-card *ngIf="hasPermissionForSubscription" class="mb-3">
<mat-card
*ngIf="hasPermissionForSubscription"
appearance="outlined"
class="mb-3"
>
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
@ -78,7 +108,7 @@
</div>
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
@ -102,7 +132,7 @@
</div>
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
@ -126,7 +156,7 @@
</div>
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
@ -152,7 +182,7 @@
</div>
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
@ -178,7 +208,7 @@
</div>
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
@ -204,7 +234,7 @@
</div>
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
@ -228,7 +258,7 @@
</div>
</mat-card-content>
</mat-card>
<mat-card class="mb-3">
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">

View File

@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { MatCardModule } from '@angular/material/card';
import { BlogPageRoutingModule } from './blog-page-routing.module';
import { BlogPageComponent } from './blog-page.component';

View File

@ -1,5 +1,7 @@
import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { ChangeDetectorRef, Component, OnDestroy } from '@angular/core';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { Subject, takeUntil } from 'rxjs';
@Component({
host: { class: 'page' },
@ -8,9 +10,26 @@ import { Subject } from 'rxjs';
templateUrl: './faq-page.html'
})
export class FaqPageComponent implements OnDestroy {
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor() {}
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private userService: UserService
) {}
public ngOnInit() {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();

View File

@ -115,7 +115,7 @@
>.</mat-card-content
>
</mat-card>
<mat-card class="mb-3">
<mat-card *ngIf="user?.subscription?.type === 'Premium'" class="mb-3">
<mat-card-title
>I cannot find my broker in the list of platforms. What can I
do?</mat-card-title

View File

@ -52,8 +52,11 @@ export class LandingPageComponent implements OnDestroy, OnInit {
private dataService: DataService,
private deviceService: DeviceDetectorService
) {
const { countriesOfSubscribers, globalPermissions, statistics } =
this.dataService.fetchInfo();
const {
countriesOfSubscribers = [],
globalPermissions,
statistics
} = this.dataService.fetchInfo();
for (const country of countriesOfSubscribers) {
this.countriesOfSubscribersMap[country] = {

View File

@ -52,6 +52,7 @@
<div *ngIf="hasPermissionForStatistics" class="row mb-5">
<div
*ngIf="hasPermissionForSubscription"
class="col-md-4 d-flex my-1"
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
>
@ -68,6 +69,24 @@
>
</a>
</div>
<div
*ngIf="!hasPermissionForSubscription"
class="col-md-4 d-flex my-1"
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
>
<a
class="d-block"
title="Ghostfolio in Numbers: Contributors on GitHub"
[routerLink]="['/about']"
>
<gf-value
icon="people-outline"
size="large"
[value]="statistics?.gitHubContributors ?? '-'"
>Contributors on GitHub</gf-value
>
</a>
</div>
<div
class="col-md-4 d-flex my-1"
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
@ -146,6 +165,14 @@
title="Product Hunt The best new products in tech."
></a>
</div>
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-reddit mask"
href="https://www.reddit.com"
target="_blank"
title="Reddit - Dive into anything"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-sackgeld mask"
@ -162,6 +189,14 @@
title="SourceForge: The Complete Open-Source and Business Software Platform"
></a>
</div>
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-umbrel mask"
href="https://umbrel.com"
target="_blank"
title="Umbrel — A personal server OS for self-hosting"
></a>
</div>
<div class="col-md-3 d-flex justify-content-center my-1">
<a
class="d-block logo logo-unraid mask"
@ -300,7 +335,7 @@
</div>
</div>
<div class="row my-3">
<div *ngIf="hasPermissionForSubscription" class="row my-3">
<div class="col-12">
<h2 class="h4 mb-1 text-center">
How does <strong>Ghostfolio</strong> work?
@ -357,15 +392,6 @@
</div>
</div>
</div>
<div class="downloads my-5 row justify-content-center">
<a
href="https://play.google.com/store/apps/details?id=ch.dotsilver.ghostfolio.twa"
title="Get Ghostfolio on Google Play"
>
<img alt="Google Play Badge" src="../assets/badge-en-google-play.png" />
</a>
</div>
</div>
<div class="container">

View File

@ -13,12 +13,6 @@
aspect-ratio: 16 / 9;
}
.downloads {
img {
height: 2.5rem;
}
}
.intro {
font-size: 4vw;
line-height: 1;
@ -75,6 +69,11 @@
filter: grayscale(1);
}
&.logo-reddit {
mask-image: url('/assets/images/logo-reddit.svg');
max-height: 1rem;
}
&.logo-sackgeld {
mask-image: url('/assets/images/logo-sackgeld.png');
}
@ -83,6 +82,11 @@
mask-image: url('/assets/images/logo-sourceforge.svg');
}
&.logo-umbrel {
mask-image: url('/assets/images/logo-umbrel.svg');
max-height: 1.5rem;
}
&.logo-unraid {
mask-image: url('/assets/images/logo-unraid.svg');
}
@ -120,8 +124,10 @@
&.logo-agplv3,
&.logo-alternative-to,
&.logo-privacy-tools,
&.logo-reddit,
&.logo-sackgeld,
&.logo-sourceforge,
&.logo-umbrel,
&.logo-unraid {
background-color: rgba(var(--light-primary-text));
}

View File

@ -36,7 +36,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
public hasImpersonationId: boolean;
public hasPermissionToCreateActivity: boolean;
public hasPermissionToDeleteActivity: boolean;
public hasPermissionToImportActivities: boolean;
public routeQueryParams: Subscription;
public user: User;
@ -91,10 +90,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
this.hasPermissionToImportActivities =
hasPermission(globalPermissions, permissions.enableImport) &&
!this.hasImpersonationId;
});
this.userService.stateChanged
@ -356,13 +351,11 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
return account.isDefault;
})?.id;
this.hasPermissionToCreateActivity = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.hasPermissionToDeleteActivity = hasPermission(
this.user.permissions,
permissions.deleteOrder
);
this.hasPermissionToCreateActivity =
!this.hasImpersonationId &&
hasPermission(this.user.permissions, permissions.createOrder);
this.hasPermissionToDeleteActivity =
!this.hasImpersonationId &&
hasPermission(this.user.permissions, permissions.deleteOrder);
}
}

View File

@ -8,7 +8,6 @@
[deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
[hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToImportActivities]="hasPermissionToImportActivities"
[locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteActivity && !user.settings.isRestrictedView"
(activityDeleted)="onDeleteActivity($event)"
@ -33,7 +32,7 @@
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large"></ion-icon>
<ion-icon class="mt-2" name="add-outline" size="large"></ion-icon>
</a>
</div>
</div>

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacySnackBarModule as MatSnackBarModule } from '@angular/material/legacy-snack-bar';
import { MatButtonModule } from '@angular/material/button';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router';
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';

View File

@ -10,7 +10,7 @@ import {
} from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MatLegacyAutocompleteSelectedEvent as MatAutocompleteSelectedEvent } from '@angular/material/legacy-autocomplete';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import {
MAT_LEGACY_DIALOG_DATA as MAT_DIALOG_DATA,
MatLegacyDialogRef as MatDialogRef
@ -106,6 +106,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.data.activity?.SymbolProfile?.currency,
Validators.required
],
currencyOfUnitPrice: [
this.data.activity?.SymbolProfile?.currency,
Validators.required
],
dataSource: [
this.data.activity?.SymbolProfile?.dataSource,
Validators.required
@ -131,16 +135,23 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
})
],
type: [undefined, Validators.required], // Set after value changes subscription
unitPrice: [this.data.activity?.unitPrice, Validators.required]
unitPrice: [this.data.activity?.unitPrice, Validators.required],
unitPriceInCustomCurrency: [
this.data.activity?.unitPrice,
Validators.required
]
});
this.activityForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(async () => {
let exchangeRate = 1;
let exchangeRateOfFee = 1;
let exchangeRateOfUnitPrice = 1;
const currency = this.activityForm.controls['currency'].value;
const currencyOfFee = this.activityForm.controls['currencyOfFee'].value;
const currencyOfUnitPrice =
this.activityForm.controls['currencyOfUnitPrice'].value;
const date = this.activityForm.controls['date'].value;
if (currency && currencyOfFee && currency !== currencyOfFee && date) {
@ -154,18 +165,49 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject))
);
exchangeRate = marketPrice;
exchangeRateOfFee = marketPrice;
} catch {}
}
const feeInCustomCurrency =
this.activityForm.controls['feeInCustomCurrency'].value *
exchangeRate;
exchangeRateOfFee;
this.activityForm.controls['fee'].setValue(feeInCustomCurrency, {
emitEvent: false
});
if (
currency &&
currencyOfUnitPrice &&
currency !== currencyOfUnitPrice &&
date
) {
try {
const { marketPrice } = await lastValueFrom(
this.dataService
.fetchExchangeRateForDate({
date,
symbol: `${currencyOfUnitPrice}-${currency}`
})
.pipe(takeUntil(this.unsubscribeSubject))
);
exchangeRateOfUnitPrice = marketPrice;
} catch {}
}
const unitPriceInCustomCurrency =
this.activityForm.controls['unitPriceInCustomCurrency'].value *
exchangeRateOfUnitPrice;
this.activityForm.controls['unitPrice'].setValue(
unitPriceInCustomCurrency,
{
emitEvent: false
}
);
if (
this.activityForm.controls['type'].value === 'BUY' ||
this.activityForm.controls['type'].value === 'ITEM'
@ -231,6 +273,9 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.controls['currencyOfFee'].setValue(
this.data.user.settings.baseCurrency
);
this.activityForm.controls['currencyOfUnitPrice'].setValue(
this.data.user.settings.baseCurrency
);
this.activityForm.controls['dataSource'].removeValidators(
Validators.required
);
@ -288,7 +333,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public applyCurrentMarketPrice() {
this.activityForm.patchValue({
unitPrice: this.currentMarketPrice
currencyOfUnitPrice: this.activityForm.controls['currency'].value,
unitPriceInCustomCurrency: this.currentMarketPrice
});
}
@ -415,6 +461,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
.subscribe(({ currency, dataSource, marketPrice }) => {
this.activityForm.controls['currency'].setValue(currency);
this.activityForm.controls['currencyOfFee'].setValue(currency);
this.activityForm.controls['currencyOfUnitPrice'].setValue(currency);
this.activityForm.controls['dataSource'].setValue(dataSource);
this.currentMarketPrice = marketPrice;

View File

@ -6,7 +6,7 @@
>
<h1 *ngIf="data.activity.id" i18n mat-dialog-title>Update activity</h1>
<h1 *ngIf="!data.activity.id" i18n mat-dialog-title>Add activity</h1>
<div class="flex-grow-1" mat-dialog-content>
<div class="flex-grow-1 pt-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
@ -54,11 +54,14 @@
<ng-container>
<mat-option
*ngFor="let lookupItem of filteredLookupItemsObservable | async"
class="autocomplete"
class="line-height-1"
[value]="lookupItem"
>
<span class="mr-2 symbol">{{ lookupItem.symbol | gfSymbol }}</span
><span><b>{{ lookupItem.name }}</b></span>
<span><b>{{ lookupItem.name }}</b></span>
<br />
<small class="text-muted"
>{{ lookupItem.symbol | gfSymbol }}</small
>
</mat-option>
</ng-container>
</mat-autocomplete>
@ -76,7 +79,7 @@
<div class="d-none">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label>
<mat-select class="no-arrow" formControlName="currency">
<mat-select formControlName="currency">
<mat-option *ngFor="let currency of currencies" [value]="currency"
>{{ currency }}</mat-option
>
@ -93,7 +96,7 @@
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Date</mat-label>
<input formControlName="date" matInput [matDatepicker]="date" />
<mat-datepicker-toggle matSuffix [for]="date">
<mat-datepicker-toggle class="mr-2" matSuffix [for]="date">
<ion-icon
class="text-muted"
matDatepickerToggleIcon
@ -109,7 +112,46 @@
<input formControlName="quantity" matInput type="number" />
</mat-form-field>
</div>
<div>
<div class="align-items-start d-flex">
<mat-form-field appearance="outline" class="w-100">
<mat-label
><ng-container [ngSwitch]="activityForm.controls['type']?.value">
<ng-container *ngSwitchCase="'DIVIDEND'" i18n
>Dividend</ng-container
>
<ng-container *ngSwitchCase="'ITEM'" i18n>Value</ng-container>
<ng-container *ngSwitchDefault i18n>Unit Price</ng-container>
</ng-container>
</mat-label>
<input
formControlName="unitPriceInCustomCurrency"
matInput
type="number"
/>
<div
class="ml-2"
matTextSuffix
[ngClass]="{ 'd-none': !activityForm.controls['currency']?.value }"
>
<mat-select formControlName="currencyOfUnitPrice">
<mat-option *ngFor="let currency of currencies" [value]="currency">
{{ currency }}
</mat-option>
</mat-select>
</div>
</mat-form-field>
<button
*ngIf="currentMarketPrice && (data.activity.type === 'BUY' || data.activity.type === 'SELL')"
class="apply-current-market-price ml-2 no-min-width"
mat-button
title="Apply current market price"
type="button"
(click)="applyCurrentMarketPrice()"
>
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
</button>
</div>
<div class="d-none">
<mat-form-field appearance="outline" class="w-100">
<mat-label
><ng-container [ngSwitch]="activityForm.controls['type']?.value">
@ -121,19 +163,9 @@
</ng-container>
</mat-label>
<input formControlName="unitPrice" matInput type="number" />
<span class="ml-2" matSuffix
<span class="ml-2" matTextSuffix
>{{ activityForm.controls['currency'].value }}</span
>
<button
*ngIf="currentMarketPrice && (data.activity.type === 'BUY' || data.activity.type === 'SELL')"
mat-icon-button
matSuffix
title="Apply current market price"
type="button"
(click)="applyCurrentMarketPrice()"
>
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
</button>
</mat-form-field>
</div>
<div>
@ -142,7 +174,7 @@
<input formControlName="feeInCustomCurrency" matInput type="number" />
<div
class="ml-2"
matSuffix
matTextSuffix
[ngClass]="{ 'd-none': !activityForm.controls['currency']?.value }"
>
<mat-select formControlName="currencyOfFee">
@ -157,7 +189,7 @@
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Fee</mat-label>
<input formControlName="fee" matInput type="number" />
<span class="ml-2" matSuffix
<span class="ml-2" matTextSuffix
>{{ activityForm.controls['currency'].value }}</span
>
</mat-form-field>
@ -207,8 +239,8 @@
<div [ngClass]="{ 'd-none': tags?.length <= 0 }">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tags</mat-label>
<mat-chip-list #tagsChipList>
<mat-chip
<mat-chip-grid #tagsChipList>
<mat-chip-row
*ngFor="let tag of activityForm.controls['tags']?.value"
matChipRemove
[removable]="true"
@ -216,7 +248,7 @@
>
{{ tag.name }}
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
</mat-chip>
</mat-chip-row>
<input
#tagInput
name="close-outline"
@ -224,7 +256,7 @@
[matChipInputFor]="tagsChipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
/>
</mat-chip-list>
</mat-chip-grid>
<mat-autocomplete
#autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)"

View File

@ -2,14 +2,14 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatLegacyAutocompleteModule as MatAutocompleteModule } from '@angular/material/legacy-autocomplete';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyChipsModule as MatChipsModule } from '@angular/material/legacy-chips';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatLegacyDialogModule as MatDialogModule } from '@angular/material/legacy-dialog';
import { MatLegacyFormFieldModule as MatFormFieldModule } from '@angular/material/legacy-form-field';
import { MatLegacyInputModule as MatInputModule } from '@angular/material/legacy-input';
import { MatLegacyProgressSpinnerModule as MatProgressSpinnerModule } from '@angular/material/legacy-progress-spinner';
import { MatLegacySelectModule as MatSelectModule } from '@angular/material/legacy-select';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfValueModule } from '@ghostfolio/ui/value';

View File

@ -10,55 +10,24 @@
.mat-dialog-content {
max-height: unset;
.autocomplete {
font-size: 90%;
height: 2.5rem;
.symbol {
display: inline-block;
min-width: 4rem;
}
}
.mat-chip {
cursor: pointer;
min-height: 1.5rem !important;
}
.mat-form-field-appearance-outline {
::ng-deep {
.mat-form-field-suffix {
top: -0.3rem;
}
}
ion-icon {
font-size: 130%;
}
}
.mat-select {
&.no-arrow {
::ng-deep {
.mat-select-arrow {
opacity: 0;
}
}
}
}
.mat-datepicker-input {
&.mat-input-element:disabled {
&.mat-mdc-input-element:disabled {
color: var(--dark-primary-text);
}
}
.mat-mdc-button {
&.apply-current-market-price {
height: 56px;
}
}
}
}
:host-context(.is-dark-theme) {
.mat-dialog-content {
.mat-datepicker-input {
&.mat-input-element:disabled {
&.mat-mdc-input-element:disabled {
color: var(--light-primary-text);
}
}

View File

@ -11,6 +11,7 @@ import {
MatLegacyDialogRef as MatDialogRef
} from '@angular/material/legacy-dialog';
import { MatLegacySnackBar as MatSnackBar } from '@angular/material/legacy-snack-bar';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service';
@ -28,6 +29,7 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
templateUrl: 'import-activities-dialog.html'
})
export class ImportActivitiesDialog implements OnDestroy {
public accounts: CreateAccountDto[] = [];
public activities: Activity[] = [];
public details: any[] = [];
public errorMessages: string[] = [];
@ -91,9 +93,10 @@ export class ImportActivitiesDialog implements OnDestroy {
try {
this.snackBar.open('⏳ ' + $localize`Importing data...`);
await this.importActivitiesService.importSelectedActivities(
this.selectedActivities
);
await this.importActivitiesService.importSelectedActivities({
accounts: this.accounts,
activities: this.selectedActivities
});
this.snackBar.open(
'✅ ' + $localize`Import has been completed`,
@ -163,6 +166,8 @@ export class ImportActivitiesDialog implements OnDestroy {
if (file.name.endsWith('.json')) {
const content = JSON.parse(fileContent);
this.accounts = content.accounts;
if (!isArray(content.activities)) {
if (isArray(content.orders)) {
this.handleImportError({
@ -180,10 +185,13 @@ export class ImportActivitiesDialog implements OnDestroy {
}
try {
this.activities = await this.importActivitiesService.importJson({
content: content.activities,
isDryRun: true
});
const { activities } =
await this.importActivitiesService.importJson({
accounts: content.accounts,
activities: content.activities,
isDryRun: true
});
this.activities = activities;
} catch (error) {
console.error(error);
this.handleImportError({ error, activities: content.activities });
@ -192,11 +200,12 @@ export class ImportActivitiesDialog implements OnDestroy {
return;
} else if (file.name.endsWith('.csv')) {
try {
this.activities = await this.importActivitiesService.importCsv({
const data = await this.importActivitiesService.importCsv({
fileContent,
isDryRun: true,
userAccounts: this.data.user.accounts
});
this.activities = data.activities;
} catch (error) {
console.error(error);
this.handleImportError({

View File

@ -70,7 +70,6 @@
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="false"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data?.user?.settings?.locale"
[showActions]="false"

View File

@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -14,6 +15,15 @@ import { takeUntil } from 'rxjs/operators';
export class PricingPageComponent implements OnDestroy, OnInit {
public baseCurrency: string;
public coupon: number;
public importAndExportTooltipBasic = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC'
);
public importAndExportTooltipOSS = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS'
);
public importAndExportTooltipPremium = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_PREMIUM'
);
public isLoggedIn: boolean;
public price: number;
public user: User;

View File

@ -85,6 +85,20 @@
></ion-icon>
<span i18n>FIRE Calculator</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1"
name="checkmark-circle-outline"
></ion-icon>
<span i18n>Data Import and Export</span>
<span
class="align-items-center d-flex ml-1"
matTooltipPosition="above"
[matTooltip]="importAndExportTooltipOSS"
>
<ion-icon name="information-circle-outline"></ion-icon>
</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1"
@ -92,6 +106,13 @@
></ion-icon>
<a i18n [routerLink]="['/features']">and more Features...</a>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1"
name="checkmark-circle-outline"
></ion-icon>
<span i18n>Community Support</span>
</li>
</ul>
</div>
<p i18n>Self-hosted, update manually.</p>
@ -144,35 +165,19 @@
></ion-icon>
<span i18n>Portfolio Performance</span>
</li>
<li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="invisible"
name="checkmark-circle-outline"
></ion-icon>
</li>
<li>
<ion-icon
class="invisible"
name="checkmark-circle-outline"
></ion-icon>
</li>
<li>
<ion-icon
class="invisible"
name="checkmark-circle-outline"
></ion-icon>
</li>
<li>
<ion-icon
class="invisible"
name="checkmark-circle-outline"
></ion-icon>
</li>
<li>
<ion-icon
class="invisible"
class="mr-1"
name="checkmark-circle-outline"
></ion-icon>
<span i18n>Data Import and Export</span>
<span
class="align-items-center d-flex ml-1"
matTooltipPosition="above"
[matTooltip]="importAndExportTooltipBasic"
>
<ion-icon name="information-circle-outline"></ion-icon>
</span>
</li>
</ul>
</div>
@ -261,6 +266,20 @@
></ion-icon>
<span i18n>FIRE Calculator</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1"
name="checkmark-circle-outline"
></ion-icon>
<span i18n>Data Import and Export</span>
<span
class="align-items-center d-flex ml-1"
matTooltipPosition="above"
[matTooltip]="importAndExportTooltipPremium"
>
<ion-icon name="information-circle-outline"></ion-icon>
</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1"
@ -268,6 +287,13 @@
></ion-icon>
<a i18n [routerLink]="['/features']">and more Features...</a>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1"
name="checkmark-circle-outline"
></ion-icon>
<span i18n>Email and Chat Support</span>
</li>
</ul>
</div>
<p i18n>Fully managed Ghostfolio cloud offering.</p>

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button';
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { MatLegacyTooltipModule as MatTooltipModule } from '@angular/material/legacy-tooltip';
import { RouterModule } from '@angular/router';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
@ -15,6 +16,7 @@ import { PricingPageComponent } from './pricing-page.component';
GfPremiumIndicatorModule,
MatButtonModule,
MatCardModule,
MatTooltipModule,
PricingPageRoutingModule,
RouterModule
],

View File

@ -4,6 +4,7 @@ import { Router } from '@angular/router';
import { DataService } from '@ghostfolio/client/services/data.service';
import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { InfoItem, LineChartItem } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Role } from '@prisma/client';
@ -37,7 +38,8 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
private dialog: MatDialog,
private internetIdentityService: InternetIdentityService,
private router: Router,
private tokenStorageService: TokenStorageService
private tokenStorageService: TokenStorageService,
private userService: UserService
) {
this.info = this.dataService.fetchInfo();
@ -61,7 +63,7 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
public async createAccount() {
this.dataService
.postUser()
.postUser({ country: this.userService.getCountry() })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accessToken, authToken, role }) => {
this.openShowAccessTokenDialog(accessToken, authToken, role);

View File

@ -1,7 +1,7 @@
<div class="container">
<div class="row">
<div class="col">
<h1 class="d-none d-sm-block mb-3 text-center" i18n>Resources</h1>
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Resources</h1>
<h2 class="h4 mb-3">Guides</h2>
<div class="mb-5">
<div class="mb-4 media">

View File

@ -1,13 +1,12 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card';
import { ResourcesPageRoutingModule } from './resources-page-routing.module';
import { ResourcesPageComponent } from './resources-page.component';
@NgModule({
declarations: [ResourcesPageComponent],
imports: [CommonModule, MatCardModule, ResourcesPageRoutingModule],
imports: [CommonModule, ResourcesPageRoutingModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class ResourcesPageModule {}

View File

@ -405,8 +405,8 @@ export class DataService {
return this.http.post<OrderModel>(`/api/v1/order`, aOrder);
}
public postUser() {
return this.http.post<UserItem>(`/api/v1/user`, {});
public postUser({ country }: { country: string }) {
return this.http.post<UserItem>(`/api/v1/user`, { country });
}
public putAccount(aAccount: UpdateAccountDto) {

View File

@ -1,5 +1,6 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { Account, DataSource, Type } from '@prisma/client';
@ -33,7 +34,9 @@ export class ImportActivitiesService {
fileContent: string;
isDryRun?: boolean;
userAccounts: Account[];
}): Promise<Activity[]> {
}): Promise<{
activities: Activity[];
}> {
const content = csvToJson(fileContent, {
dynamicTyping: true,
header: true,
@ -55,20 +58,26 @@ export class ImportActivitiesService {
});
}
return await this.importJson({ isDryRun, content: activities });
return await this.importJson({ activities, isDryRun });
}
public importJson({
content,
accounts,
activities,
isDryRun = false
}: {
content: CreateOrderDto[];
activities: CreateOrderDto[];
accounts?: CreateAccountDto[];
isDryRun?: boolean;
}): Promise<Activity[]> {
}): Promise<{
activities: Activity[];
accounts?: CreateAccountDto[];
}> {
return new Promise((resolve, reject) => {
this.postImport(
{
activities: content
accounts,
activities
},
isDryRun
)
@ -80,22 +89,29 @@ export class ImportActivitiesService {
)
.subscribe({
next: (data) => {
resolve(data.activities);
resolve(data);
}
});
});
}
public importSelectedActivities(
selectedActivities: Activity[]
): Promise<Activity[]> {
public importSelectedActivities({
accounts,
activities
}: {
accounts: CreateAccountDto[];
activities: Activity[];
}): Promise<{
activities: Activity[];
accounts?: CreateAccountDto[];
}> {
const importData: CreateOrderDto[] = [];
for (const activity of selectedActivities) {
for (const activity of activities) {
importData.push(this.convertToCreateOrderDto(activity));
}
return this.importJson({ content: importData });
return this.importJson({ accounts, activities: importData });
}
private convertToCreateOrderDto({
@ -347,7 +363,7 @@ export class ImportActivitiesService {
}
private postImport(
aImportData: { activities: CreateOrderDto[] },
aImportData: { accounts: CreateAccountDto[]; activities: CreateOrderDto[] },
aIsDryRun = false
) {
return this.http.post<{ activities: Activity[] }>(

Some files were not shown because too many files have changed in this diff Show More