Feature/extend fire calculator by retirement date (#1748)
* Extend fire calculator by retirement date * Update changelog --------- Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
This commit is contained in:
parent
6301c0c21c
commit
fce9e7fb0c
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
@ -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)"
|
||||
></gf-fire-calculator>
|
||||
</div>
|
||||
|
@ -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<UserStoreState> {
|
||||
this.setState({ user: null }, UserStoreActions.RemoveUser);
|
||||
}
|
||||
|
||||
private fetchUser() {
|
||||
return this.http.get<User>('/api/v1/user').pipe(
|
||||
private fetchUser(): Observable<User> {
|
||||
return this.http.get<any>('/api/v1/user').pipe(
|
||||
map((user) => {
|
||||
if (user.settings?.retirementDate) {
|
||||
user.settings.retirementDate = parseISO(user.settings.retirementDate);
|
||||
}
|
||||
|
||||
this.setState({ user }, UserStoreActions.GetUser);
|
||||
|
||||
if (
|
||||
|
@ -3505,6 +3505,22 @@
|
||||
<context context-type="linenumber">199,201</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="46d3a0f17b741c93f9e61aa7157820da41506f53" datatype="html">
|
||||
<source>Target Net Worth</source>
|
||||
<target state="new">Angestrebtes Nettovermögen</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
|
||||
<context context-type="linenumber">38</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7383cd391b1967e03f0636c231d20f036d5c37ee" datatype="html">
|
||||
<source>Retirement Date</source>
|
||||
<target state="translated">Pensionierungsdatum</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
|
||||
<context context-type="linenumber">32</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
@ -2434,7 +2434,7 @@
|
||||
<target state="translated">Verwacht totaalbedrag</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
|
||||
<context context-type="linenumber">44</context>
|
||||
<context context-type="linenumber">52</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1054498214311181686" datatype="html">
|
||||
@ -3505,6 +3505,22 @@
|
||||
<context context-type="linenumber">199,201</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="46d3a0f17b741c93f9e61aa7157820da41506f53" datatype="html">
|
||||
<source>Target Net Worth</source>
|
||||
<target state="translated">Beoogd Netto Vermogen</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
|
||||
<context context-type="linenumber">38</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7383cd391b1967e03f0636c231d20f036d5c37ee" datatype="html">
|
||||
<source>Retirement Date</source>
|
||||
<target state="translated">Pensioen Datum</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
|
||||
<context context-type="linenumber">32</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
@ -2193,7 +2193,7 @@
|
||||
<source>Projected Total Amount</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
|
||||
<context context-type="linenumber">44</context>
|
||||
<context context-type="linenumber">52</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1054498214311181686" datatype="html">
|
||||
@ -3146,6 +3146,20 @@
|
||||
<context context-type="linenumber">199,201</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="46d3a0f17b741c93f9e61aa7157820da41506f53" datatype="html">
|
||||
<source>Target Net Worth</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
|
||||
<context context-type="linenumber">38</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7383cd391b1967e03f0636c231d20f036d5c37ee" datatype="html">
|
||||
<source>Retirement Date</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
|
||||
<context context-type="linenumber">32</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
@ -10,6 +10,8 @@ export interface UserSettings {
|
||||
isRestrictedView?: boolean;
|
||||
language?: string;
|
||||
locale?: string;
|
||||
projectedTotalAmount?: number;
|
||||
retirementDate?: string;
|
||||
savingsRate?: number;
|
||||
viewMode?: ViewMode;
|
||||
}
|
||||
|
@ -17,12 +17,6 @@
|
||||
<span class="ml-2" matTextSuffix>{{ currency }}</span>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Investment Horizon</mat-label>
|
||||
<input formControlName="time" matInput type="number" />
|
||||
<span class="ml-2" i18n matTextSuffix>years</span>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Annual Interest Rate</mat-label>
|
||||
<input
|
||||
@ -34,15 +28,35 @@
|
||||
<div class="ml-2" matTextSuffix>%</div>
|
||||
</mat-form-field>
|
||||
|
||||
<gf-value
|
||||
i18n
|
||||
size="large"
|
||||
[currency]="currency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="projectedTotalAmount"
|
||||
>Projected Total Amount</gf-value
|
||||
>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Retirement Date</mat-label>
|
||||
<input
|
||||
formControlName="retirementDate"
|
||||
matInput
|
||||
[matDatepicker]="datepicker"
|
||||
/>
|
||||
<mat-datepicker-toggle
|
||||
matIconSuffix
|
||||
[for]="datepicker"
|
||||
></mat-datepicker-toggle>
|
||||
<mat-datepicker
|
||||
#datepicker
|
||||
startView="multi-year"
|
||||
(monthSelected)="setMonthAndYear($event, datepicker)"
|
||||
>
|
||||
</mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Projected Total Amount</mat-label>
|
||||
<input
|
||||
formControlName="projectedTotalAmount"
|
||||
matInput
|
||||
step="100"
|
||||
type="number"
|
||||
/>
|
||||
<span class="ml-2" matTextSuffix>{{ currency }}</span>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-9 text-center">
|
||||
|
@ -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<number>();
|
||||
@Output() retirementDateChanged = new EventEmitter<Date>();
|
||||
@Output() savingsRateChanged = new EventEmitter<number>();
|
||||
|
||||
@ViewChild('chartCanvas') chartCanvas;
|
||||
@ -59,13 +72,17 @@ export class FireCalculatorComponent
|
||||
annualInterestRate: new FormControl<number>(undefined),
|
||||
paymentPerPeriod: new FormControl<number>(undefined),
|
||||
principalInvestmentAmount: new FormControl<number>(undefined),
|
||||
time: new FormControl<number>(undefined)
|
||||
projectedTotalAmount: new FormControl<number>(undefined),
|
||||
retirementDate: new FormControl<Date>(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<void>();
|
||||
|
||||
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<Date>
|
||||
) {
|
||||
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
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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>(
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user