From 96288594c18fedc9fedf5565585a9dc960bd0642 Mon Sep 17 00:00:00 2001 From: Robbert Coeckelbergh Date: Mon, 20 Feb 2023 21:16:28 +0100 Subject: [PATCH] feat(fire): Add retirement date to the fire component - Add targetNetWorth input to the fire component form - Add targetNetWorth to the settings in the db - Calculate retirement date based on targetNetWorth and 4% rule --- .../src/app/user/update-user-setting.dto.ts | 4 + .../portfolio/fire/fire-page.component.ts | 22 +++++- .../app/pages/portfolio/fire/fire-page.html | 2 + .../lib/interfaces/user-settings.interface.ts | 1 + .../fire-calculator.component.html | 19 +++++ .../fire-calculator.component.ts | 57 +++++++++++++-- .../fire-calculator.service.spec.ts | 73 +++++++++++++++++++ .../fire-calculator.service.ts | 32 ++++++++ 8 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 libs/ui/src/lib/fire-calculator/fire-calculator.service.spec.ts 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..f925e400c 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -52,6 +52,10 @@ export class UpdateUserSettingDto { @IsOptional() savingsRate?: number; + @IsNumber() + @IsOptional() + targetNetWorth?: number; + @IsIn(['DEFAULT', 'ZEN']) @IsOptional() viewMode?: ViewMode; 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..d48864f09 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 @@ -39,7 +39,6 @@ export class FirePageComponent implements OnDestroy, OnInit { public ngOnInit() { this.isLoading = true; this.deviceType = this.deviceService.getDeviceInfo().deviceType; - this.dataService .fetchPortfolioDetails({}) .pipe(takeUntil(this.unsubscribeSubject)) @@ -92,6 +91,7 @@ export class FirePageComponent implements OnDestroy, OnInit { } public onSavingsRateChange(savingsRate: number) { + console.log(this.user.settings); this.dataService .putUserSetting({ savingsRate }) .pipe(takeUntil(this.unsubscribeSubject)) @@ -109,6 +109,26 @@ export class FirePageComponent implements OnDestroy, OnInit { }); } + public onTargetNetWorthChanged(targetNetWorth: number) { + console.log('onTargetNetWorthChanged: ' + targetNetWorth); + console.log(this.user.settings); + this.dataService + .putUserSetting({ targetNetWorth }) + .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..dee64b405 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.html +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.html @@ -18,7 +18,9 @@ [hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings" [locale]="user?.settings?.locale" [savingsRate]="user?.settings?.savingsRate" + [targetNetWorth]="user?.settings?.targetNetWorth" (savingsRateChanged)="onSavingsRateChange($event)" + (targetNetWorthChanged)="onTargetNetWorthChanged($event)" > diff --git a/libs/common/src/lib/interfaces/user-settings.interface.ts b/libs/common/src/lib/interfaces/user-settings.interface.ts index 1dedc6277..5ece060a6 100644 --- a/libs/common/src/lib/interfaces/user-settings.interface.ts +++ b/libs/common/src/lib/interfaces/user-settings.interface.ts @@ -11,5 +11,6 @@ export interface UserSettings { language?: string; locale?: string; savingsRate?: number; + targetNetWorth?: 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..1ff2d4660 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.html +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.html @@ -33,6 +33,16 @@ />
%
+ + Target Net Worth + + {{ currency }} + Projected Total Amount + Retirement Date
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..8fa68e5c5 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts @@ -28,11 +28,12 @@ import { Tooltip } from 'chart.js'; import * as Color from 'color'; -import { getMonth } from 'date-fns'; +import { add, format, getMonth } from 'date-fns'; import { isNumber } from 'lodash'; import { Subject, takeUntil } from 'rxjs'; import { FireCalculatorService } from './fire-calculator.service'; +import { getDateFormatString } from '@ghostfolio/common/helper'; @Component({ selector: 'gf-fire-calculator', @@ -50,8 +51,10 @@ export class FireCalculatorComponent @Input() hasPermissionToUpdateUserSettings: boolean; @Input() locale: string; @Input() savingsRate = 0; + @Input() targetNetWorth = 0; @Output() savingsRateChanged = new EventEmitter(); + @Output() targetNetWorthChanged = new EventEmitter(); @ViewChild('chartCanvas') chartCanvas; @@ -59,11 +62,13 @@ export class FireCalculatorComponent annualInterestRate: new FormControl(undefined), paymentPerPeriod: new FormControl(undefined), principalInvestmentAmount: new FormControl(undefined), - time: new FormControl(undefined) + time: new FormControl(undefined), + retirementNetWorth: new FormControl(undefined) }); public chart: Chart<'bar'>; public isLoading = true; public projectedTotalAmount: number; + public retirementDate: string; private readonly CONTRIBUTION_PERIOD = 12; private unsubscribeSubject = new Subject(); @@ -86,7 +91,8 @@ export class FireCalculatorComponent annualInterestRate: 5, paymentPerPeriod: this.savingsRate, principalInvestmentAmount: 0, - time: 10 + time: 10, + retirementNetWorth: this.targetNetWorth }, { emitEvent: false @@ -105,6 +111,12 @@ export class FireCalculatorComponent .subscribe((savingsRate) => { this.savingsRateChanged.emit(savingsRate); }); + this.calculatorForm + .get('retirementNetWorth') + .valueChanges.pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((targetNetWorth) => { + this.targetNetWorthChanged.emit(targetNetWorth); + }); } public ngAfterViewInit() { @@ -114,7 +126,8 @@ export class FireCalculatorComponent this.calculatorForm.patchValue( { principalInvestmentAmount: this.fireWealth, - paymentPerPeriod: this.savingsRate ?? 0 + paymentPerPeriod: this.savingsRate ?? 0, + retirementNetWorth: this.targetNetWorth ?? 0 }, { emitEvent: false @@ -128,8 +141,14 @@ export class FireCalculatorComponent if (this.hasPermissionToUpdateUserSettings === true) { this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false }); + this.calculatorForm + .get('retirementNetWorth') + .enable({ emitEvent: false }); } else { this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false }); + this.calculatorForm + .get('retirementNetWorth') + .disable({ emitEvent: false }); } } @@ -140,7 +159,8 @@ export class FireCalculatorComponent this.calculatorForm.patchValue( { principalInvestmentAmount: this.fireWealth, - paymentPerPeriod: this.savingsRate ?? 0 + paymentPerPeriod: this.savingsRate ?? 0, + retirementNetWorth: this.targetNetWorth ?? 0 }, { emitEvent: false @@ -154,8 +174,14 @@ export class FireCalculatorComponent if (this.hasPermissionToUpdateUserSettings === true) { this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false }); + this.calculatorForm + .get('retirementNetWorth') + .enable({ emitEvent: false }); } else { this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false }); + this.calculatorForm + .get('retirementNetWorth') + .disable({ emitEvent: false }); } } @@ -273,6 +299,8 @@ export class FireCalculatorComponent // Time const t = this.calculatorForm.get('time').value; + const targetNetWorth = this.calculatorForm.get('retirementNetWorth').value; + for (let year = currentYear; year < currentYear + t; year++) { labels.push(year); } @@ -325,6 +353,25 @@ export class FireCalculatorComponent } } + // Calculate retirement date + const periodsToRetire = this.fireCalculatorService.calculatePeriodsToRetire( + { + P, + totalAmount: targetNetWorth, + PMT, + r + } + ); + + const years = Math.floor(periodsToRetire / 12); + const months = periodsToRetire % 12; + + const retirementDate = add(new Date(), { + years, + months + }); + this.retirementDate = format(retirementDate, 'MMMM yyyy'); + return { labels, datasets: [datasetDeposit, datasetSavings, datasetInterest] 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..bf48bfa50 --- /dev/null +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.service.spec.ts @@ -0,0 +1,73 @@ +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 + ); + }); + + it('true', async () => { + expect(true).toBe(true); + }); + + 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..8dcd58cda 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 <= 0) { + 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; + } }