Browse Source

Merge branch 'main' into feature/add-copy-to-clipboard-action-to-access-table-component

pull/3768/head
Thomas Kaul 11 months ago
committed by GitHub
parent
commit
b0b07f43d1
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      CHANGELOG.md
  2. 96
      apps/api/src/app/admin/admin.service.ts
  3. 3
      apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts
  4. 14
      apps/api/src/app/portfolio/portfolio.service.ts
  5. 21
      apps/api/src/app/portfolio/rules.service.ts
  6. 1
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  7. 15
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  8. 2
      apps/client/src/app/components/admin-market-data/admin-market-data.module.ts
  9. 5
      apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts
  10. 40
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  11. 23
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  12. 2
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.scss
  13. 5
      apps/client/src/app/components/rule/rule.component.html
  14. 49
      apps/client/src/app/components/rule/rule.component.ts
  15. 5
      apps/client/src/app/components/toggle/toggle.component.html
  16. 4
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  17. 1
      libs/common/src/lib/interfaces/admin-market-data.interface.ts
  18. 4
      libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts
  19. 53
      libs/common/src/lib/personal-finance-tools.ts

3
CHANGELOG.md

@ -9,11 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added support for bonds in the import dividends dialog
- Added a _Copy link to clipboard_ action to the access table to share the portfolio - Added a _Copy link to clipboard_ action to the access table to share the portfolio
- 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 - Introduced filters (`dataSource` and `symbol`) in the accounts endpoint
### Changed ### Changed
- Improved the usability of the toggle component
- Switched to the accounts endpoint in the holding detail dialog - Switched to the accounts endpoint in the holding detail dialog
## 2.107.1 - 2024-09-12 ## 2.107.1 - 2024-09-12

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

@ -15,7 +15,11 @@ import {
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { isCurrency, getCurrencyFromSymbol } from '@ghostfolio/common/helper'; import {
getAssetProfileIdentifier,
getCurrencyFromSymbol,
isCurrency
} from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -261,6 +265,37 @@ export class AdminService {
this.prismaService.symbolProfile.count({ where }) this.prismaService.symbolProfile.count({ where })
]); ]);
const lastMarketPrices = await this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: assetProfiles.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: assetProfiles.map(({ symbol }) => {
return symbol;
})
}
}
});
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
let marketData: AdminMarketDataItem[] = await Promise.all( let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map( assetProfiles.map(
async ({ async ({
@ -281,6 +316,11 @@ export class AdminService {
const countriesCount = countries const countriesCount = countries
? Object.keys(countries).length ? Object.keys(countries).length
: 0; : 0;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount = const marketDataItemCount =
marketDataItems.find((marketDataItem) => { marketDataItems.find((marketDataItem) => {
return ( return (
@ -288,6 +328,7 @@ export class AdminService {
marketDataItem.symbol === symbol marketDataItem.symbol === symbol
); );
})?._count ?? 0; })?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0; const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return { return {
@ -298,6 +339,7 @@ export class AdminService {
countriesCount, countriesCount,
dataSource, dataSource,
id, id,
lastMarketPrice,
name, name,
symbol, symbol,
marketDataItemCount, marketDataItemCount,
@ -511,15 +553,47 @@ export class AdminService {
} }
private async getMarketDataForCurrencies(): Promise<AdminMarketData> { private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const marketDataItems = await this.prismaService.marketData.groupBy({ const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
const [lastMarketPrices, marketDataItems] = await Promise.all([
this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: currencyPairs.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: currencyPairs.map(({ symbol }) => {
return symbol;
})
}
}
}),
this.prismaService.marketData.groupBy({
_count: true, _count: true,
by: ['dataSource', 'symbol'] by: ['dataSource', 'symbol']
}); })
]);
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
const marketDataPromise: Promise<AdminMarketDataItem>[] = const marketDataPromise: Promise<AdminMarketDataItem>[] = currencyPairs.map(
this.exchangeRateDataService async ({ dataSource, symbol }) => {
.getCurrencyPairs()
.map(async ({ dataSource, symbol }) => {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-'; let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
@ -530,6 +604,10 @@ export class AdminService {
await this.orderService.getStatisticsByCurrency(currency)); await this.orderService.getStatisticsByCurrency(currency));
} }
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount = const marketDataItemCount =
marketDataItems.find((marketDataItem) => { marketDataItems.find((marketDataItem) => {
return ( return (
@ -542,6 +620,7 @@ export class AdminService {
activitiesCount, activitiesCount,
currency, currency,
dataSource, dataSource,
lastMarketPrice,
marketDataItemCount, marketDataItemCount,
symbol, symbol,
assetClass: AssetClass.LIQUIDITY, assetClass: AssetClass.LIQUIDITY,
@ -552,7 +631,8 @@ export class AdminService {
name: symbol, name: symbol,
sectorsCount: 0 sectorsCount: 0
}; };
}); }
);
const marketData = await Promise.all(marketDataPromise); const marketData = await Promise.all(marketDataPromise);
return { marketData, count: marketData.length }; return { marketData, count: marketData.length };

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;

14
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 {
@ -625,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,
@ -699,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)
@ -788,7 +778,6 @@ export class PortfolioService {
} }
return { return {
accounts,
firstBuyDate, firstBuyDate,
marketPrice, marketPrice,
maxPrice, maxPrice,
@ -883,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 {

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

@ -142,6 +142,7 @@ export class AdminMarketDataComponent
'dataSource', 'dataSource',
'assetClass', 'assetClass',
'assetSubClass', 'assetSubClass',
'lastMarketPrice',
'date', 'date',
'activitiesCount', 'activitiesCount',
'marketDataItemCount', 'marketDataItemCount',

15
apps/client/src/app/components/admin-market-data/admin-market-data.html

@ -99,6 +99,21 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="lastMarketPrice">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Market Price</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[value]="element.lastMarketPrice ?? ''"
/>
</div>
</td>
</ng-container>
<ng-container matColumnDef="date"> <ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" mat-header-cell> <th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>First Activity</ng-container> <ng-container i18n>First Activity</ng-container>

2
apps/client/src/app/components/admin-market-data/admin-market-data.module.ts

@ -1,6 +1,7 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -27,6 +28,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
GfCreateAssetProfileDialogModule, GfCreateAssetProfileDialogModule,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
GfSymbolModule, GfSymbolModule,
GfValueComponent,
MatButtonModule, MatButtonModule,
MatCheckboxModule, MatCheckboxModule,
MatMenuModule, MatMenuModule,

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();
}
} }

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

@ -7,7 +7,10 @@
<mat-radio-button <mat-radio-button
class="d-inline-flex" class="d-inline-flex"
[disabled]="isLoading" [disabled]="isLoading"
[ngClass]="{ 'cursor-pointer': !isLoading }" [ngClass]="{
'cursor-default': option.value === optionFormControl.value,
'cursor-pointer': !isLoading && option.value !== optionFormControl.value
}"
[value]="option.value" [value]="option.value"
>{{ option.label }}</mat-radio-button >{{ option.label }}</mat-radio-button
> >

4
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -93,6 +93,10 @@ export class ImportActivitiesDialog implements OnDestroy {
{ {
id: AssetClass.EQUITY, id: AssetClass.EQUITY,
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
},
{
id: AssetClass.FIXED_INCOME,
type: 'ASSET_CLASS'
} }
], ],
range: 'max' range: 'max'

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

@ -16,6 +16,7 @@ export interface AdminMarketDataItem {
id: string; id: string;
isBenchmark?: boolean; isBenchmark?: boolean;
isUsedByUsersWithSubscription?: boolean; isUsedByUsersWithSubscription?: boolean;
lastMarketPrice: number;
marketDataItemCount: number; marketDataItemCount: number;
name: string; name: string;
sectorsCount: number; sectorsCount: number;

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;
} }

53
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,
@ -329,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,
@ -352,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,
@ -362,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,
@ -640,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,

Loading…
Cancel
Save