diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6956d24..70db7601d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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). +## Unreleased + +### Added + +- Extended the _FIRE_ calculator by a retirement date setting + ## 1.243.0 - 2023-03-08 ### Added diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index 97062df9d..668c1bc66 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -3,9 +3,11 @@ import type { DateRange, ViewMode } from '@ghostfolio/common/types'; +import { Type } from 'class-transformer'; import { IsBoolean, IsIn, + IsISO8601, IsNumber, IsOptional, IsString @@ -48,6 +50,14 @@ export class UpdateUserSettingDto { @IsOptional() locale?: string; + @IsNumber() + @IsOptional() + projectedTotalAmount?: number; + + @IsISO8601() + @IsOptional() + retirementDate?: string; + @IsNumber() @IsOptional() savingsRate?: number; diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts index fca1814d0..f1b316df7 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts @@ -91,6 +91,27 @@ export class FirePageComponent implements OnDestroy, OnInit { }); } + public onRetirementDateChange(retirementDate: Date) { + this.dataService + .putUserSetting({ + retirementDate: retirementDate.toISOString(), + projectedTotalAmount: null + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + }); + }); + } + public onSavingsRateChange(savingsRate: number) { this.dataService .putUserSetting({ savingsRate }) @@ -109,6 +130,27 @@ export class FirePageComponent implements OnDestroy, OnInit { }); } + public onProjectedTotalAmountChange(projectedTotalAmount: number) { + this.dataService + .putUserSetting({ + projectedTotalAmount, + retirementDate: null + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + }); + }); + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.html b/apps/client/src/app/pages/portfolio/fire/fire-page.html index 5af5922e2..9a0196c7f 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.html +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.html @@ -17,7 +17,11 @@ [fireWealth]="fireWealth?.toNumber()" [hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings" [locale]="user?.settings?.locale" + [projectedTotalAmount]="user?.settings?.projectedTotalAmount" + [retirementDate]="user?.settings?.retirementDate" [savingsRate]="user?.settings?.savingsRate" + (projectedTotalAmountChanged)="onProjectedTotalAmountChange($event)" + (retirementDateChanged)="onRetirementDateChange($event)" (savingsRateChanged)="onSavingsRateChange($event)" > diff --git a/apps/client/src/app/services/user/user.service.ts b/apps/client/src/app/services/user/user.service.ts index 7f903df3a..dbfad09dd 100644 --- a/apps/client/src/app/services/user/user.service.ts +++ b/apps/client/src/app/services/user/user.service.ts @@ -6,8 +6,9 @@ import { SubscriptionInterstitialDialogParams } from '@ghostfolio/client/compone import { SubscriptionInterstitialDialog } from '@ghostfolio/client/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component'; import { User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { parseISO } from 'date-fns'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { Subject, of } from 'rxjs'; +import { Subject, of, Observable } from 'rxjs'; import { throwError } from 'rxjs'; import { catchError, map, takeUntil } from 'rxjs/operators'; @@ -49,9 +50,13 @@ export class UserService extends ObservableStore { this.setState({ user: null }, UserStoreActions.RemoveUser); } - private fetchUser() { - return this.http.get('/api/v1/user').pipe( + private fetchUser(): Observable { + return this.http.get('/api/v1/user').pipe( map((user) => { + if (user.settings?.retirementDate) { + user.settings.retirementDate = parseISO(user.settings.retirementDate); + } + this.setState({ user }, UserStoreActions.GetUser); if ( diff --git a/apps/client/src/locales/messages.de.xlf b/apps/client/src/locales/messages.de.xlf index 6e3c50bcf..67cf2eed3 100644 --- a/apps/client/src/locales/messages.de.xlf +++ b/apps/client/src/locales/messages.de.xlf @@ -3505,6 +3505,22 @@ 199,201 + + Target Net Worth + Angestrebtes Nettovermögen + + libs/ui/src/lib/fire-calculator/fire-calculator.component.html + 38 + + + + Retirement Date + Pensionierungsdatum + + libs/ui/src/lib/fire-calculator/fire-calculator.component.html + 32 + + diff --git a/apps/client/src/locales/messages.nl.xlf b/apps/client/src/locales/messages.nl.xlf index 2102f633c..8bf46aa05 100644 --- a/apps/client/src/locales/messages.nl.xlf +++ b/apps/client/src/locales/messages.nl.xlf @@ -2434,7 +2434,7 @@ Verwacht totaalbedrag libs/ui/src/lib/fire-calculator/fire-calculator.component.html - 44 + 52 @@ -3505,6 +3505,22 @@ 199,201 + + Target Net Worth + Beoogd Netto Vermogen + + libs/ui/src/lib/fire-calculator/fire-calculator.component.html + 38 + + + + Retirement Date + Pensioen Datum + + libs/ui/src/lib/fire-calculator/fire-calculator.component.html + 32 + + diff --git a/apps/client/src/locales/messages.xlf b/apps/client/src/locales/messages.xlf index d6b01fb19..22764d45a 100644 --- a/apps/client/src/locales/messages.xlf +++ b/apps/client/src/locales/messages.xlf @@ -2193,7 +2193,7 @@ Projected Total Amount libs/ui/src/lib/fire-calculator/fire-calculator.component.html - 44 + 52 @@ -3146,6 +3146,20 @@ 199,201 + + Target Net Worth + + libs/ui/src/lib/fire-calculator/fire-calculator.component.html + 38 + + + + Retirement Date + + libs/ui/src/lib/fire-calculator/fire-calculator.component.html + 32 + + diff --git a/libs/common/src/lib/interfaces/user-settings.interface.ts b/libs/common/src/lib/interfaces/user-settings.interface.ts index 1dedc6277..217d7ba4b 100644 --- a/libs/common/src/lib/interfaces/user-settings.interface.ts +++ b/libs/common/src/lib/interfaces/user-settings.interface.ts @@ -10,6 +10,8 @@ export interface UserSettings { isRestrictedView?: boolean; language?: string; locale?: string; + projectedTotalAmount?: number; + retirementDate?: string; savingsRate?: number; viewMode?: ViewMode; } diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.html b/libs/ui/src/lib/fire-calculator/fire-calculator.component.html index bd73be442..31451872b 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.html +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.html @@ -17,12 +17,6 @@ {{ currency }} - - Investment Horizon - - years - - Annual Interest Rate % - Projected Total Amount + + Retirement Date + + + + + + + + Projected Total Amount + + {{ currency }} +
diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts index e6cb7d587..53a382ae3 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts @@ -13,6 +13,7 @@ import { ViewChild } from '@angular/core'; import { FormBuilder, FormControl } from '@angular/forms'; +import { MatDatepicker } from '@angular/material/datepicker'; import { getTooltipOptions, transformTickToAbbreviation @@ -28,17 +29,25 @@ import { Tooltip } from 'chart.js'; import * as Color from 'color'; -import { getMonth } from 'date-fns'; +import { + add, + addYears, + getMonth, + setMonth, + setYear, + startOfMonth, + sub +} from 'date-fns'; import { isNumber } from 'lodash'; import { Subject, takeUntil } from 'rxjs'; import { FireCalculatorService } from './fire-calculator.service'; @Component({ - selector: 'gf-fire-calculator', changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: './fire-calculator.component.html', - styleUrls: ['./fire-calculator.component.scss'] + selector: 'gf-fire-calculator', + styleUrls: ['./fire-calculator.component.scss'], + templateUrl: './fire-calculator.component.html' }) export class FireCalculatorComponent implements AfterViewInit, OnChanges, OnDestroy @@ -49,8 +58,12 @@ export class FireCalculatorComponent @Input() fireWealth: number; @Input() hasPermissionToUpdateUserSettings: boolean; @Input() locale: string; + @Input() projectedTotalAmount = 0; + @Input() retirementDate: Date; @Input() savingsRate = 0; + @Output() projectedTotalAmountChanged = new EventEmitter(); + @Output() retirementDateChanged = new EventEmitter(); @Output() savingsRateChanged = new EventEmitter(); @ViewChild('chartCanvas') chartCanvas; @@ -59,13 +72,17 @@ export class FireCalculatorComponent annualInterestRate: new FormControl(undefined), paymentPerPeriod: new FormControl(undefined), principalInvestmentAmount: new FormControl(undefined), - time: new FormControl(undefined) + projectedTotalAmount: new FormControl(undefined), + retirementDate: new FormControl(undefined) }); public chart: Chart<'bar'>; public isLoading = true; - public projectedTotalAmount: number; + public periodsToRetire = 0; private readonly CONTRIBUTION_PERIOD = 12; + private readonly DEFAULT_RETIREMENT_DATE = startOfMonth( + addYears(new Date(), 10) + ); private unsubscribeSubject = new Subject(); public constructor( @@ -86,7 +103,8 @@ export class FireCalculatorComponent annualInterestRate: 5, paymentPerPeriod: this.savingsRate, principalInvestmentAmount: 0, - time: 10 + projectedTotalAmount: this.projectedTotalAmount, + retirementDate: this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE }, { emitEvent: false @@ -105,6 +123,18 @@ export class FireCalculatorComponent .subscribe((savingsRate) => { this.savingsRateChanged.emit(savingsRate); }); + this.calculatorForm + .get('projectedTotalAmount') + .valueChanges.pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((projectedTotalAmount) => { + this.projectedTotalAmountChanged.emit(projectedTotalAmount); + }); + this.calculatorForm + .get('retirementDate') + .valueChanges.pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((retirementDate) => { + this.retirementDateChanged.emit(retirementDate); + }); } public ngAfterViewInit() { @@ -113,8 +143,12 @@ export class FireCalculatorComponent // Wait for the chartCanvas this.calculatorForm.patchValue( { - principalInvestmentAmount: this.fireWealth, - paymentPerPeriod: this.savingsRate ?? 0 + paymentPerPeriod: this.getPMT(), + principalInvestmentAmount: this.getP(), + projectedTotalAmount: + Number(this.getProjectedTotalAmount().toFixed(2)) ?? 0, + retirementDate: + this.getRetirementDate() ?? this.DEFAULT_RETIREMENT_DATE }, { emitEvent: false @@ -128,19 +162,32 @@ export class FireCalculatorComponent if (this.hasPermissionToUpdateUserSettings === true) { this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false }); + this.calculatorForm + .get('projectedTotalAmount') + .enable({ emitEvent: false }); + this.calculatorForm.get('retirementDate').enable({ emitEvent: false }); } else { this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false }); + this.calculatorForm + .get('projectedTotalAmount') + .disable({ emitEvent: false }); + this.calculatorForm.get('retirementDate').disable({ emitEvent: false }); } } public ngOnChanges() { + this.periodsToRetire = this.getPeriodsToRetire(); if (isNumber(this.fireWealth) && this.fireWealth >= 0) { setTimeout(() => { // Wait for the chartCanvas this.calculatorForm.patchValue( { principalInvestmentAmount: this.fireWealth, - paymentPerPeriod: this.savingsRate ?? 0 + paymentPerPeriod: this.savingsRate ?? 0, + projectedTotalAmount: + Number(this.getProjectedTotalAmount().toFixed(2)) ?? 0, + retirementDate: + this.getRetirementDate() ?? this.DEFAULT_RETIREMENT_DATE }, { emitEvent: false @@ -154,11 +201,32 @@ export class FireCalculatorComponent if (this.hasPermissionToUpdateUserSettings === true) { this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false }); + this.calculatorForm + .get('projectedTotalAmount') + .enable({ emitEvent: false }); + this.calculatorForm.get('retirementDate').enable({ emitEvent: false }); } else { this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false }); + this.calculatorForm + .get('projectedTotalAmount') + .disable({ emitEvent: false }); + this.calculatorForm.get('retirementDate').disable({ emitEvent: false }); } } + public setMonthAndYear( + normalizedMonthAndYear: Date, + datepicker: MatDatepicker + ) { + const retirementDate = this.calculatorForm.get('retirementDate').value; + const newRetirementDate = setMonth( + setYear(retirementDate, normalizedMonthAndYear.getFullYear()), + normalizedMonthAndYear.getMonth() + ); + this.calculatorForm.get('retirementDate').setValue(newRetirementDate); + datepicker.close(); + } + public ngOnDestroy() { this.chart?.destroy(); @@ -261,17 +329,22 @@ export class FireCalculatorComponent const labels = []; // Principal investment amount - const P: number = - this.calculatorForm.get('principalInvestmentAmount').value || 0; + const P: number = this.getP(); // Payment per period - const PMT = this.calculatorForm.get('paymentPerPeriod').value; + const PMT = this.getPMT(); // Annual interest rate - const r: number = this.calculatorForm.get('annualInterestRate').value / 100; + const r: number = this.getR(); + + // Calculate retirement date + // if we want to retire at month x, we need the projectedTotalAmount at month x-1 + const lastPeriodDate = sub(this.getRetirementDate(), { months: 1 }); + const yearsToRetire = lastPeriodDate.getFullYear() - currentYear; // Time - const t = this.calculatorForm.get('time').value; + // +1 to take into account the current year + const t = yearsToRetire + 1; for (let year = currentYear; year < currentYear + t; year++) { labels.push(year); @@ -308,7 +381,7 @@ export class FireCalculatorComponent for (let period = 1; period <= t; period++) { const periodInMonths = period * this.CONTRIBUTION_PERIOD - monthsPassedInCurrentYear; - const { interest, principal, totalAmount } = + const { interest, principal } = this.fireCalculatorService.calculateCompoundInterest({ P, periodInMonths, @@ -319,10 +392,6 @@ export class FireCalculatorComponent datasetDeposit.data.push(this.fireWealth); datasetInterest.data.push(interest.toNumber()); datasetSavings.data.push(principal.minus(this.fireWealth).toNumber()); - - if (period === t) { - this.projectedTotalAmount = totalAmount.toNumber(); - } } return { @@ -330,4 +399,67 @@ export class FireCalculatorComponent datasets: [datasetDeposit, datasetSavings, datasetInterest] }; } + + private getP() { + return this.fireWealth || 0; + } + + private getPeriodsToRetire(): number { + if (this.projectedTotalAmount) { + const periods = this.fireCalculatorService.calculatePeriodsToRetire({ + P: this.getP(), + totalAmount: this.projectedTotalAmount, + PMT: this.getPMT(), + r: this.getR() + }); + + return periods; + } else { + const today = new Date(); + const retirementDate = + this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE; + + return ( + 12 * (retirementDate.getFullYear() - today.getFullYear()) + + retirementDate.getMonth() - + today.getMonth() + ); + } + } + + private getPMT() { + return this.savingsRate ?? 0; + } + + private getProjectedTotalAmount() { + if (this.projectedTotalAmount) { + return this.projectedTotalAmount || 0; + } else { + const { totalAmount } = + this.fireCalculatorService.calculateCompoundInterest({ + P: this.getP(), + periodInMonths: this.periodsToRetire, + PMT: this.getPMT(), + r: this.getR() + }); + + return totalAmount.toNumber(); + } + } + + private getR() { + return this.calculatorForm.get('annualInterestRate').value / 100; + } + + private getRetirementDate(): Date { + const monthsToRetire = this.periodsToRetire % 12; + const yearsToRetire = Math.floor(this.periodsToRetire / 12); + + return startOfMonth( + add(new Date(), { + months: monthsToRetire, + years: yearsToRetire + }) + ); + } } diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.module.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.module.ts index 44fd48c2e..02b59dc27 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.module.ts +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.module.ts @@ -2,11 +2,11 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { GfValueModule } from '../value'; import { FireCalculatorComponent } from './fire-calculator.component'; import { FireCalculatorService } from './fire-calculator.service'; @@ -16,8 +16,8 @@ import { FireCalculatorService } from './fire-calculator.service'; imports: [ CommonModule, FormsModule, - GfValueModule, MatButtonModule, + MatDatepickerModule, MatFormFieldModule, MatInputModule, NgxSkeletonLoaderModule, diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.service.spec.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.service.spec.ts new file mode 100644 index 000000000..492834366 --- /dev/null +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.service.spec.ts @@ -0,0 +1,69 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import Big from 'big.js'; + +import { FireCalculatorService } from './fire-calculator.service'; + +describe('FireCalculatorService', () => { + let fireCalculatorService: FireCalculatorService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [FireCalculatorService] + }).compile(); + + fireCalculatorService = module.get( + FireCalculatorService + ); + }); + + describe('Test periods to retire', () => { + it('should return the correct amount of periods to retire with no interst rate', async () => { + const r = 0; + const P = 1000; + const totalAmount = 1900; + const PMT = 100; + + const periodsToRetire = fireCalculatorService.calculatePeriodsToRetire({ + P, + r, + PMT, + totalAmount + }); + + expect(periodsToRetire).toBe(9); + }); + + it('should return the 0 when total amount is 0', async () => { + const r = 0.05; + const P = 100000; + const totalAmount = 0; + const PMT = 10000; + + const periodsToRetire = fireCalculatorService.calculatePeriodsToRetire({ + P, + r, + PMT, + totalAmount + }); + + expect(periodsToRetire).toBe(0); + }); + + it('should return the correct amount of periods to retire with interst rate', async () => { + const r = 0.05; + const P = 598478.96; + const totalAmount = 812399.66; + const PMT = 6000; + const expectedPeriods = 24; + + const periodsToRetire = fireCalculatorService.calculatePeriodsToRetire({ + P, + r, + PMT, + totalAmount + }); + + expect(Math.round(periodsToRetire)).toBe(expectedPeriods); + }); + }); +}); diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.service.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.service.ts index c19fdca68..44ea1eb98 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.service.ts +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.service.ts @@ -40,4 +40,36 @@ export class FireCalculatorService { totalAmount }; } + + public calculatePeriodsToRetire({ + P, + PMT, + r, + totalAmount + }: { + P: number; + PMT: number; + r: number; + totalAmount: number; + }) { + if (r == 0) { + // No compound interest + return (totalAmount - P) / PMT; + } else if (totalAmount <= P) { + return 0; + } + + const periodInterest = new Big(r).div(this.COMPOUND_PERIOD); + const numerator1: number = Math.log10( + new Big(totalAmount).plus(new Big(PMT).div(periodInterest)).toNumber() + ); + const numerator2: number = Math.log10( + new Big(P).plus(new Big(PMT).div(periodInterest)).toNumber() + ); + const denominator: number = Math.log10( + new Big(1).plus(periodInterest).toNumber() + ); + + return (numerator1 - numerator2) / denominator; + } }