Browse Source

Merge branch 'main' into feature/extend-market-data-endpoint-by-lastMarketPrice

pull/3752/head
Thomas Kaul 11 months ago
committed by GitHub
parent
commit
2001ffe689
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 11
      CHANGELOG.md
  2. 15
      apps/api/src/app/account/account.controller.ts
  3. 2
      apps/api/src/app/account/account.module.ts
  4. 3
      apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts
  5. 43
      apps/api/src/app/portfolio/portfolio.service.ts
  6. 21
      apps/api/src/app/portfolio/rules.service.ts
  7. 24
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  8. 5
      apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts
  9. 40
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  10. 23
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  11. 2
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.scss
  12. 5
      apps/client/src/app/components/rule/rule.component.html
  13. 49
      apps/client/src/app/components/rule/rule.component.ts
  14. 10
      apps/client/src/app/services/data.service.ts
  15. 4
      libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts
  16. 61
      libs/common/src/lib/personal-finance-tools.ts
  17. 2
      package.json

11
CHANGELOG.md

@ -10,6 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the current market price column to the historical market data table of the admin control - Added the current market price column to the historical market data table of the admin control
- Introduced filters (`dataSource` and `symbol`) in the accounts endpoint
### Changed
- Switched to the accounts endpoint in the holding detail dialog
## 2.107.1 - 2024-09-12
### Fixed
- Fixed an issue in the activities filters that occurred during destructuring
## 2.107.0 - 2024-09-10 ## 2.107.0 - 2024-09-10

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

@ -3,6 +3,8 @@ import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.servic
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { import {
@ -26,6 +28,7 @@ import {
Param, Param,
Post, Post,
Put, Put,
Query,
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
@ -44,6 +47,7 @@ export class AccountController {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly apiService: ApiService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
@ -84,13 +88,22 @@ export class AccountController {
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts( public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string
): Promise<Accounts> { ): Promise<Accounts> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const filters = this.apiService.buildFiltersFromQueryParams({
filterByDataSource,
filterBySymbol
});
return this.portfolioService.getAccountsWithAggregations({ return this.portfolioService.getAccountsWithAggregations({
filters,
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true withExcludedAccounts: true
}); });

2
apps/api/src/app/account/account.module.ts

@ -1,6 +1,7 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
@ -16,6 +17,7 @@ import { AccountService } from './account.service';
exports: [AccountService], exports: [AccountService],
imports: [ imports: [
AccountBalanceModule, AccountBalanceModule,
ApiModule,
ConfigurationModule, ConfigurationModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,

3
apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts

@ -5,10 +5,9 @@ import {
HistoricalDataItem HistoricalDataItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Account, Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
export interface PortfolioHoldingDetail { export interface PortfolioHoldingDetail {
accounts: Account[];
averagePrice: number; averagePrice: number;
dataProviderInfo: DataProviderInfo; dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number; dividendInBaseCurrency: number;

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

@ -73,7 +73,7 @@ import {
parseISO, parseISO,
set set
} from 'date-fns'; } from 'date-fns';
import { isEmpty, last, uniq, uniqBy } from 'lodash'; import { isEmpty, last, uniq } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { import {
@ -115,12 +115,33 @@ export class PortfolioService {
}): Promise<AccountWithValue[]> { }): Promise<AccountWithValue[]> {
const where: Prisma.AccountWhereInput = { userId }; const where: Prisma.AccountWhereInput = { userId };
const accountFilter = filters?.find(({ type }) => { const filterByAccount = filters?.find(({ type }) => {
return type === 'ACCOUNT'; return type === 'ACCOUNT';
}); })?.id;
const filterByDataSource = filters?.find(({ type }) => {
return type === 'DATA_SOURCE';
})?.id;
if (accountFilter) { const filterBySymbol = filters?.find(({ type }) => {
where.id = accountFilter.id; return type === 'SYMBOL';
})?.id;
if (filterByAccount) {
where.id = filterByAccount;
}
if (filterByDataSource && filterBySymbol) {
where.Order = {
some: {
SymbolProfile: {
AND: [
{ dataSource: <DataSource>filterByDataSource },
{ symbol: filterBySymbol }
]
}
}
};
} }
const [accounts, details] = await Promise.all([ const [accounts, details] = await Promise.all([
@ -604,7 +625,6 @@ export class PortfolioService {
if (activities.length === 0) { if (activities.length === 0) {
return { return {
accounts: [],
averagePrice: undefined, averagePrice: undefined,
dataProviderInfo: undefined, dataProviderInfo: undefined,
dividendInBaseCurrency: undefined, dividendInBaseCurrency: undefined,
@ -678,15 +698,6 @@ export class PortfolioService {
); );
}); });
const accounts: PortfolioHoldingDetail['accounts'] = uniqBy(
activitiesOfPosition.filter(({ Account }) => {
return Account;
}),
'Account.id'
).map(({ Account }) => {
return Account;
});
const dividendYieldPercent = getAnnualizedPerformancePercent({ const dividendYieldPercent = getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercentage: timeWeightedInvestment.eq(0) netPerformancePercentage: timeWeightedInvestment.eq(0)
@ -767,7 +778,6 @@ export class PortfolioService {
} }
return { return {
accounts,
firstBuyDate, firstBuyDate,
marketPrice, marketPrice,
maxPrice, maxPrice,
@ -862,7 +872,6 @@ export class PortfolioService {
maxPrice, maxPrice,
minPrice, minPrice,
SymbolProfile, SymbolProfile,
accounts: [],
averagePrice: 0, averagePrice: 0,
dataProviderInfo: undefined, dataProviderInfo: undefined,
dividendInBaseCurrency: 0, dividendInBaseCurrency: 0,

21
apps/api/src/app/portfolio/rules.service.ts

@ -1,6 +1,9 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { UserSettings } from '@ghostfolio/common/interfaces'; import {
PortfolioReportRule,
UserSettings
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -11,19 +14,23 @@ export class RulesService {
public async evaluate<T extends RuleSettings>( public async evaluate<T extends RuleSettings>(
aRules: Rule<T>[], aRules: Rule<T>[],
aUserSettings: UserSettings aUserSettings: UserSettings
) { ): Promise<PortfolioReportRule[]> {
return aRules.map((rule) => { return aRules.map((rule) => {
if (rule.getSettings(aUserSettings)?.isActive) { const settings = rule.getSettings(aUserSettings);
const { evaluation, value } = rule.evaluate(
rule.getSettings(aUserSettings) if (settings?.isActive) {
); const { evaluation, value } = rule.evaluate(settings);
return { return {
evaluation, evaluation,
value, value,
isActive: true, isActive: true,
key: rule.getKey(), key: rule.getKey(),
name: rule.getName() name: rule.getName(),
settings: <PortfolioReportRule['settings']>{
thresholdMax: settings['thresholdMax'],
thresholdMin: settings['thresholdMin']
}
}; };
} else { } else {
return { return {

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

@ -9,6 +9,7 @@ import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { import {
DataProviderInfo, DataProviderInfo,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter,
LineChartItem, LineChartItem,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -152,6 +153,11 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
tags: <string[]>[] tags: <string[]>[]
}); });
const filters: Filter[] = [
{ id: this.data.dataSource, type: 'DATA_SOURCE' },
{ id: this.data.symbol, type: 'SYMBOL' }
];
this.tagsAvailable = tags.map(({ id, name }) => { this.tagsAvailable = tags.map(({ id, name }) => {
return { return {
id, id,
@ -173,12 +179,20 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
.subscribe(); .subscribe();
}); });
this.dataService
.fetchAccounts({
filters
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accounts }) => {
this.accounts = accounts;
this.changeDetectorRef.markForCheck();
});
this.dataService this.dataService
.fetchActivities({ .fetchActivities({
filters: [ filters,
{ id: this.data.dataSource, type: 'DATA_SOURCE' },
{ id: this.data.symbol, type: 'SYMBOL' }
],
sortColumn: this.sortColumn, sortColumn: this.sortColumn,
sortDirection: this.sortDirection sortDirection: this.sortDirection
}) })
@ -197,7 +211,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe( .subscribe(
({ ({
accounts,
averagePrice, averagePrice,
dataProviderInfo, dataProviderInfo,
dividendInBaseCurrency, dividendInBaseCurrency,
@ -219,7 +232,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
transactionCount, transactionCount,
value value
}) => { }) => {
this.accounts = accounts;
this.averagePrice = averagePrice; this.averagePrice = averagePrice;
this.benchmarkDataItems = []; this.benchmarkDataItems = [];
this.countries = {}; this.countries = {};

5
apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts

@ -0,0 +1,5 @@
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
export interface IRuleSettingsDialogParams {
rule: PortfolioReportRule;
}

40
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts

@ -0,0 +1,40 @@
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { IRuleSettingsDialogParams } from './interfaces/interfaces';
@Component({
imports: [
CommonModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule
],
selector: 'gf-rule-settings-dialog',
standalone: true,
styleUrls: ['./rule-settings-dialog.scss'],
templateUrl: './rule-settings-dialog.html'
})
export class GfRuleSettingsDialogComponent {
public settings: PortfolioReportRule['settings'];
public constructor(
@Inject(MAT_DIALOG_DATA) public data: IRuleSettingsDialogParams,
public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent>
) {
console.log(this.data.rule);
this.settings = this.data.rule.settings;
}
}

23
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html

@ -0,0 +1,23 @@
<div mat-dialog-title>{{ data.rule.name }}</div>
<div class="py-3" mat-dialog-content>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Threshold Min</mat-label>
<input matInput name="thresholdMin" type="number" />
</mat-form-field>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Threshold Max</mat-label>
<input matInput name="thresholdMax" type="number" />
</mat-form-field>
</div>
<div align="end" mat-dialog-actions>
<button i18n mat-button (click)="dialogRef.close()">Close</button>
<button
color="primary"
mat-flat-button
(click)="dialogRef.close({ settings })"
>
<ng-container i18n>Save</ng-container>
</button>
</div>

2
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.scss

@ -0,0 +1,2 @@
:host {
}

5
apps/client/src/app/components/rule/rule.component.html

@ -62,6 +62,11 @@
<ion-icon name="ellipsis-horizontal" /> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #rulesMenu="matMenu" xPosition="before"> <mat-menu #rulesMenu="matMenu" xPosition="before">
@if (rule?.isActive && !isEmpty(rule.settings) && false) {
<button mat-menu-item (click)="onCustomizeRule(rule)">
<ng-container i18n>Customize</ng-container>...
</button>
}
<button mat-menu-item (click)="onUpdateRule(rule)"> <button mat-menu-item (click)="onUpdateRule(rule)">
@if (rule?.isActive) { @if (rule?.isActive) {
<ng-container i18n>Deactivate</ng-container> <ng-container i18n>Deactivate</ng-container>

49
apps/client/src/app/components/rule/rule.component.ts

@ -9,6 +9,13 @@ import {
OnInit, OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { isEmpty } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { IRuleSettingsDialogParams } from './rule-settings-dialog/interfaces/interfaces';
import { GfRuleSettingsDialogComponent } from './rule-settings-dialog/rule-settings-dialog.component';
@Component({ @Component({
selector: 'gf-rule', selector: 'gf-rule',
@ -23,9 +30,42 @@ export class RuleComponent implements OnInit {
@Output() ruleUpdated = new EventEmitter<UpdateUserSettingDto>(); @Output() ruleUpdated = new EventEmitter<UpdateUserSettingDto>();
public constructor() {} public isEmpty = isEmpty;
private deviceType: string;
private unsubscribeSubject = new Subject<void>();
public constructor(
private deviceService: DeviceDetectorService,
private dialog: MatDialog
) {}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
public onCustomizeRule(rule: PortfolioReportRule) {
const dialogRef = this.dialog.open(GfRuleSettingsDialogComponent, {
data: <IRuleSettingsDialogParams>{
rule
},
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
public ngOnInit() {} dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({ settings }: { settings: PortfolioReportRule['settings'] }) => {
if (settings) {
console.log(settings);
// TODO
// this.ruleUpdated.emit(settings);
}
}
);
}
public onUpdateRule(rule: PortfolioReportRule) { public onUpdateRule(rule: PortfolioReportRule) {
const settings: UpdateUserSettingDto = { const settings: UpdateUserSettingDto = {
@ -36,4 +76,9 @@ export class RuleComponent implements OnInit {
this.ruleUpdated.emit(settings); this.ruleUpdated.emit(settings);
} }
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

10
apps/client/src/app/services/data.service.ts

@ -72,11 +72,11 @@ export class DataService {
ACCOUNT: filtersByAccount, ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass, ASSET_CLASS: filtersByAssetClass,
ASSET_SUB_CLASS: filtersByAssetSubClass, ASSET_SUB_CLASS: filtersByAssetSubClass,
DATA_SOURCE: [filterByDataSource], DATA_SOURCE: [filterByDataSource] = [],
HOLDING_TYPE: filtersByHoldingType, HOLDING_TYPE: filtersByHoldingType,
PRESET_ID: filtersByPresetId, PRESET_ID: filtersByPresetId,
SEARCH_QUERY: filtersBySearchQuery, SEARCH_QUERY: filtersBySearchQuery,
SYMBOL: [filterBySymbol], SYMBOL: [filterBySymbol] = [],
TAG: filtersByTag TAG: filtersByTag
} = groupBy(filters, (filter) => { } = groupBy(filters, (filter) => {
return filter.type; return filter.type;
@ -173,8 +173,10 @@ export class DataService {
); );
} }
public fetchAccounts() { public fetchAccounts({ filters }: { filters?: Filter[] } = {}) {
return this.http.get<Accounts>('/api/v1/account'); const params = this.buildFiltersAsQueryParams({ filters });
return this.http.get<Accounts>('/api/v1/account', { params });
} }
public fetchActivities({ public fetchActivities({

4
libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts

@ -3,5 +3,9 @@ export interface PortfolioReportRule {
isActive: boolean; isActive: boolean;
key: string; key: string;
name: string; name: string;
settings?: {
thresholdMax?: number;
thresholdMin?: number;
};
value?: boolean; value?: boolean;
} }

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

@ -44,6 +44,15 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$120', pricingPerYear: '$120',
slogan: 'Analyze and track your portfolio.' slogan: 'Analyze and track your portfolio.'
}, },
{
hasFreePlan: false,
hasSelfHostingAbility: true,
key: 'banktivity',
name: 'Banktivity',
origin: 'United States',
pricingPerYear: '$59.99',
slogan: 'Proactive money management app for macOS & iOS'
},
{ {
founded: 2022, founded: 2022,
hasFreePlan: true, hasFreePlan: true,
@ -62,6 +71,17 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$100', pricingPerYear: '$100',
slogan: 'Stock Portfolio Tracker for Smart Investors' slogan: 'Stock Portfolio Tracker for Smart Investors'
}, },
{
founded: 2007,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'buxfer',
name: 'Buxfer',
origin: 'United States',
pricingPerYear: '$48',
regions: ['Global'],
slogan: 'Take control of your financial future'
},
{ {
founded: 2013, founded: 2013,
hasFreePlan: true, hasFreePlan: true,
@ -163,6 +183,14 @@ export const personalFinanceTools: Product[] = [
origin: 'United States', origin: 'United States',
slogan: 'Portfolio Tracker Designed by Professional Investors' slogan: 'Portfolio Tracker Designed by Professional Investors'
}, },
{
founded: 2010,
hasFreePlan: false,
key: 'etops',
name: 'etops',
origin: 'Switzerland',
slogan: 'Your financial superpower'
},
{ {
founded: 2020, founded: 2020,
hasFreePlan: true, hasFreePlan: true,
@ -321,6 +349,13 @@ export const personalFinanceTools: Product[] = [
regions: ['Global'], regions: ['Global'],
slogan: 'Track your investments' slogan: 'Track your investments'
}, },
{
founded: 2010,
key: 'masttro',
name: 'Masttro',
origin: 'United States',
slogan: 'Your platform for wealth in full view'
},
{ {
founded: 2021, founded: 2021,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
@ -344,6 +379,14 @@ export const personalFinanceTools: Product[] = [
regions: ['Canada', 'United States'], regions: ['Canada', 'United States'],
slogan: 'The smartest way to track your crypto' slogan: 'The smartest way to track your crypto'
}, },
{
founded: 1991,
hasSelfHostingAbility: true,
key: 'microsoft-money',
name: 'Microsoft Money',
note: 'Microsoft Money was discontinued in 2010',
origin: 'United States'
},
{ {
founded: 2019, founded: 2019,
hasFreePlan: false, hasFreePlan: false,
@ -354,6 +397,16 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$99.99', pricingPerYear: '$99.99',
slogan: 'The modern way to manage your money' slogan: 'The modern way to manage your money'
}, },
{
founded: 1999,
hasFreePlan: false,
hasSelfHostingAbility: true,
key: 'moneydance',
name: 'Moneydance',
origin: 'Scotland',
pricingPerYear: '$100',
slogan: 'Personal Finance Manager for Mac, Windows, and Linux'
},
{ {
hasFreePlan: false, hasFreePlan: false,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,
@ -632,6 +685,14 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$50', pricingPerYear: '$50',
slogan: 'See all your investments in one place' slogan: 'See all your investments in one place'
}, },
{
founded: 2018,
hasFreePlan: true,
key: 'wealthposition',
name: 'WealthPosition',
pricingPerYear: '$60',
slogan: 'Personal Finance & Budgeting App'
},
{ {
founded: 2018, founded: 2018,
hasSelfHostingAbility: false, hasSelfHostingAbility: false,

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.107.0", "version": "2.107.1",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",

Loading…
Cancel
Save