@jjlmoya/utils-finance 1.46.0 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +4 -1
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/loanAprScenarioCalculator/bibliography.astro +11 -0
- package/src/tool/loanAprScenarioCalculator/bibliography.ts +29 -0
- package/src/tool/loanAprScenarioCalculator/component.astro +62 -0
- package/src/tool/loanAprScenarioCalculator/controller.ts +205 -0
- package/src/tool/loanAprScenarioCalculator/currency-dom.ts +107 -0
- package/src/tool/loanAprScenarioCalculator/currency.test.ts +57 -0
- package/src/tool/loanAprScenarioCalculator/currency.ts +105 -0
- package/src/tool/loanAprScenarioCalculator/dom-views.ts +190 -0
- package/src/tool/loanAprScenarioCalculator/entry.ts +28 -0
- package/src/tool/loanAprScenarioCalculator/evaluator.ts +21 -0
- package/src/tool/loanAprScenarioCalculator/i18n/de.ts +194 -0
- package/src/tool/loanAprScenarioCalculator/i18n/en.ts +194 -0
- package/src/tool/loanAprScenarioCalculator/i18n/es.ts +195 -0
- package/src/tool/loanAprScenarioCalculator/i18n/fr.ts +195 -0
- package/src/tool/loanAprScenarioCalculator/i18n/id.ts +193 -0
- package/src/tool/loanAprScenarioCalculator/i18n/it.ts +193 -0
- package/src/tool/loanAprScenarioCalculator/i18n/ja.ts +186 -0
- package/src/tool/loanAprScenarioCalculator/i18n/ko.ts +186 -0
- package/src/tool/loanAprScenarioCalculator/i18n/nl.ts +194 -0
- package/src/tool/loanAprScenarioCalculator/i18n/pl.ts +194 -0
- package/src/tool/loanAprScenarioCalculator/i18n/pt.ts +194 -0
- package/src/tool/loanAprScenarioCalculator/i18n/ru.ts +193 -0
- package/src/tool/loanAprScenarioCalculator/i18n/sv.ts +189 -0
- package/src/tool/loanAprScenarioCalculator/i18n/tr.ts +193 -0
- package/src/tool/loanAprScenarioCalculator/i18n/zh.ts +181 -0
- package/src/tool/loanAprScenarioCalculator/index.ts +11 -0
- package/src/tool/loanAprScenarioCalculator/loan-apr-scenario-calculator.css +699 -0
- package/src/tool/loanAprScenarioCalculator/logic.test.ts +65 -0
- package/src/tool/loanAprScenarioCalculator/logic.ts +259 -0
- package/src/tool/loanAprScenarioCalculator/presets.ts +51 -0
- package/src/tool/loanAprScenarioCalculator/scenario-dom.ts +101 -0
- package/src/tool/loanAprScenarioCalculator/seo.astro +11 -0
- package/src/tool/loanAprScenarioCalculator/storage.ts +60 -0
- package/src/tool/loanAprScenarioCalculator/ui.ts +55 -0
- package/src/tool/loanAprScenarioCalculator/validation.ts +24 -0
- package/src/tools.ts +2 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
calculateScenario,
|
|
4
|
+
compareScenarios,
|
|
5
|
+
monthlyPaymentFromRate,
|
|
6
|
+
} from "./logic";
|
|
7
|
+
|
|
8
|
+
const baseScenario = {
|
|
9
|
+
name: "Offer A",
|
|
10
|
+
principal: 10000,
|
|
11
|
+
termMonths: 36,
|
|
12
|
+
mode: "rate" as const,
|
|
13
|
+
annualRate: 6,
|
|
14
|
+
monthlyPayment: 0,
|
|
15
|
+
upfrontFees: 0,
|
|
16
|
+
monthlyFees: 0,
|
|
17
|
+
finalFees: 0,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("loan APR scenario logic", () => {
|
|
21
|
+
it("calculates an amortising payment from a nominal annual rate", () => {
|
|
22
|
+
expect(monthlyPaymentFromRate(10000, 0, 10)).toBe(1000);
|
|
23
|
+
expect(monthlyPaymentFromRate(10000, 6, 36)).toBeCloseTo(304.22, 1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns the nominal rate as the effective APR when no fees are present", () => {
|
|
27
|
+
const result = calculateScenario(baseScenario);
|
|
28
|
+
expect(result.valid).toBe(true);
|
|
29
|
+
expect(result.effectiveApr).toBeCloseTo(6.168, 3);
|
|
30
|
+
expect(result.totalFees).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("raises effective APR when an upfront fee reduces the cash received", () => {
|
|
34
|
+
const result = calculateScenario({ ...baseScenario, upfrontFees: 450 });
|
|
35
|
+
expect(result.valid).toBe(true);
|
|
36
|
+
expect(result.effectiveApr).toBeGreaterThan(6);
|
|
37
|
+
expect(result.netCashReceived).toBe(9550);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("can infer an effective APR from a known monthly payment", () => {
|
|
41
|
+
const result = calculateScenario({
|
|
42
|
+
...baseScenario,
|
|
43
|
+
mode: "payment",
|
|
44
|
+
annualRate: 0,
|
|
45
|
+
monthlyPayment: 304.22,
|
|
46
|
+
});
|
|
47
|
+
expect(result.valid).toBe(true);
|
|
48
|
+
expect(result.effectiveApr).toBeCloseTo(6.168, 2);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("rejects non-positive cash received and malformed terms", () => {
|
|
52
|
+
expect(
|
|
53
|
+
calculateScenario({ ...baseScenario, upfrontFees: 10000 }).valid,
|
|
54
|
+
).toBe(false);
|
|
55
|
+
expect(calculateScenario({ ...baseScenario, termMonths: 0 }).valid).toBe(
|
|
56
|
+
false,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("identifies the lower effective cost", () => {
|
|
61
|
+
const first = calculateScenario(baseScenario);
|
|
62
|
+
const second = calculateScenario({ ...baseScenario, upfrontFees: 450 });
|
|
63
|
+
expect(compareScenarios(first, second).winner).toBe("a");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
export type LoanMode = "rate" | "payment";
|
|
2
|
+
|
|
3
|
+
export interface LoanScenarioInput {
|
|
4
|
+
name: string;
|
|
5
|
+
principal: number;
|
|
6
|
+
termMonths: number;
|
|
7
|
+
mode: LoanMode;
|
|
8
|
+
annualRate: number;
|
|
9
|
+
monthlyPayment: number;
|
|
10
|
+
upfrontFees: number;
|
|
11
|
+
monthlyFees: number;
|
|
12
|
+
finalFees: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface LoanScenarioResult {
|
|
16
|
+
valid: boolean;
|
|
17
|
+
name: string;
|
|
18
|
+
principal: number;
|
|
19
|
+
termMonths: number;
|
|
20
|
+
annualRate: number;
|
|
21
|
+
monthlyPayment: number;
|
|
22
|
+
upfrontFees: number;
|
|
23
|
+
monthlyFees: number;
|
|
24
|
+
finalFees: number;
|
|
25
|
+
totalPaid: number;
|
|
26
|
+
totalFees: number;
|
|
27
|
+
financeCost: number;
|
|
28
|
+
netCashReceived: number;
|
|
29
|
+
effectiveApr: number;
|
|
30
|
+
monthlyEffectiveRate: number;
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ComparisonResult {
|
|
35
|
+
winner: "a" | "b" | "tie" | "none";
|
|
36
|
+
difference: number;
|
|
37
|
+
message: "lower" | "tie" | "invalid";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isFiniteNonNegative(value: number): boolean {
|
|
41
|
+
return Number.isFinite(value) && value >= 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function invalidResult(
|
|
45
|
+
input: LoanScenarioInput,
|
|
46
|
+
error: string,
|
|
47
|
+
): LoanScenarioResult {
|
|
48
|
+
return {
|
|
49
|
+
valid: false,
|
|
50
|
+
name: input.name,
|
|
51
|
+
principal: input.principal,
|
|
52
|
+
termMonths: input.termMonths,
|
|
53
|
+
annualRate: input.annualRate,
|
|
54
|
+
monthlyPayment: input.monthlyPayment,
|
|
55
|
+
upfrontFees: input.upfrontFees,
|
|
56
|
+
monthlyFees: input.monthlyFees,
|
|
57
|
+
finalFees: input.finalFees,
|
|
58
|
+
totalPaid: 0,
|
|
59
|
+
totalFees: 0,
|
|
60
|
+
financeCost: 0,
|
|
61
|
+
netCashReceived: 0,
|
|
62
|
+
effectiveApr: 0,
|
|
63
|
+
monthlyEffectiveRate: 0,
|
|
64
|
+
error,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function monthlyPaymentFromRate(
|
|
69
|
+
principal: number,
|
|
70
|
+
annualRate: number,
|
|
71
|
+
termMonths: number,
|
|
72
|
+
): number {
|
|
73
|
+
const monthlyRate = annualRate / 100 / 12;
|
|
74
|
+
if (monthlyRate === 0) return principal / termMonths;
|
|
75
|
+
const growth = (1 + monthlyRate) ** termMonths;
|
|
76
|
+
return (principal * (monthlyRate * growth)) / (growth - 1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function presentValue(
|
|
80
|
+
monthlyOutflow: number,
|
|
81
|
+
finalFee: number,
|
|
82
|
+
termMonths: number,
|
|
83
|
+
monthlyRate: number,
|
|
84
|
+
): number {
|
|
85
|
+
let value = 0;
|
|
86
|
+
for (let month = 1; month <= termMonths; month += 1) {
|
|
87
|
+
value += monthlyOutflow / (1 + monthlyRate) ** month;
|
|
88
|
+
}
|
|
89
|
+
return value + finalFee / (1 + monthlyRate) ** termMonths;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findRateUpperBound(input: {
|
|
93
|
+
netCashReceived: number;
|
|
94
|
+
monthlyOutflow: number;
|
|
95
|
+
finalFee: number;
|
|
96
|
+
termMonths: number;
|
|
97
|
+
}): number | null {
|
|
98
|
+
let high = 1;
|
|
99
|
+
while (
|
|
100
|
+
presentValue(input.monthlyOutflow, input.finalFee, input.termMonths, high) >
|
|
101
|
+
input.netCashReceived &&
|
|
102
|
+
high < 128
|
|
103
|
+
)
|
|
104
|
+
high *= 2;
|
|
105
|
+
return high >= 128 &&
|
|
106
|
+
presentValue(input.monthlyOutflow, input.finalFee, input.termMonths, high) >
|
|
107
|
+
input.netCashReceived
|
|
108
|
+
? null
|
|
109
|
+
: high;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function refineMonthlyRate(input: {
|
|
113
|
+
netCashReceived: number;
|
|
114
|
+
monthlyOutflow: number;
|
|
115
|
+
finalFee: number;
|
|
116
|
+
termMonths: number;
|
|
117
|
+
high: number;
|
|
118
|
+
}): number {
|
|
119
|
+
let low = -0.99;
|
|
120
|
+
let high = input.high;
|
|
121
|
+
for (let iteration = 0; iteration < 80; iteration += 1) {
|
|
122
|
+
const middle = (low + high) / 2;
|
|
123
|
+
if (
|
|
124
|
+
presentValue(
|
|
125
|
+
input.monthlyOutflow,
|
|
126
|
+
input.finalFee,
|
|
127
|
+
input.termMonths,
|
|
128
|
+
middle,
|
|
129
|
+
) > input.netCashReceived
|
|
130
|
+
)
|
|
131
|
+
low = middle;
|
|
132
|
+
else high = middle;
|
|
133
|
+
}
|
|
134
|
+
return (low + high) / 2;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function solveMonthlyRate(
|
|
138
|
+
netCashReceived: number,
|
|
139
|
+
monthlyOutflow: number,
|
|
140
|
+
finalFee: number,
|
|
141
|
+
termMonths: number,
|
|
142
|
+
): number | null {
|
|
143
|
+
const input = { netCashReceived, monthlyOutflow, finalFee, termMonths };
|
|
144
|
+
if (
|
|
145
|
+
presentValue(monthlyOutflow, finalFee, termMonths, -0.99) < netCashReceived
|
|
146
|
+
)
|
|
147
|
+
return null;
|
|
148
|
+
const high = findRateUpperBound(input);
|
|
149
|
+
return high === null ? null : refineMonthlyRate({ ...input, high });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function validateBasics(input: LoanScenarioInput): string | undefined {
|
|
153
|
+
if (!Number.isFinite(input.principal) || input.principal <= 0)
|
|
154
|
+
return "principal";
|
|
155
|
+
if (
|
|
156
|
+
!Number.isInteger(input.termMonths) ||
|
|
157
|
+
input.termMonths <= 0 ||
|
|
158
|
+
input.termMonths > 600
|
|
159
|
+
)
|
|
160
|
+
return "term";
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function validateFees(input: LoanScenarioInput): string | undefined {
|
|
165
|
+
if (
|
|
166
|
+
!isFiniteNonNegative(input.upfrontFees) ||
|
|
167
|
+
!isFiniteNonNegative(input.monthlyFees) ||
|
|
168
|
+
!isFiniteNonNegative(input.finalFees)
|
|
169
|
+
)
|
|
170
|
+
return "fees";
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function validateMode(input: LoanScenarioInput): string | undefined {
|
|
175
|
+
if (
|
|
176
|
+
input.mode === "rate" &&
|
|
177
|
+
(!isFiniteNonNegative(input.annualRate) || input.annualRate > 100)
|
|
178
|
+
)
|
|
179
|
+
return "rate";
|
|
180
|
+
if (
|
|
181
|
+
input.mode === "payment" &&
|
|
182
|
+
(!isFiniteNonNegative(input.monthlyPayment) || input.monthlyPayment === 0)
|
|
183
|
+
)
|
|
184
|
+
return "payment";
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function validateScenario(input: LoanScenarioInput): string | undefined {
|
|
189
|
+
return validateBasics(input) ?? validateFees(input) ?? validateMode(input);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildResult(
|
|
193
|
+
input: LoanScenarioInput,
|
|
194
|
+
payment: number,
|
|
195
|
+
netCashReceived: number,
|
|
196
|
+
monthlyEffectiveRate: number,
|
|
197
|
+
): LoanScenarioResult {
|
|
198
|
+
const totalFees =
|
|
199
|
+
input.upfrontFees + input.monthlyFees * input.termMonths + input.finalFees;
|
|
200
|
+
const totalPaid =
|
|
201
|
+
payment * input.termMonths +
|
|
202
|
+
input.monthlyFees * input.termMonths +
|
|
203
|
+
input.finalFees;
|
|
204
|
+
return {
|
|
205
|
+
valid: true,
|
|
206
|
+
name: input.name,
|
|
207
|
+
principal: input.principal,
|
|
208
|
+
termMonths: input.termMonths,
|
|
209
|
+
annualRate: input.mode === "rate" ? input.annualRate : 0,
|
|
210
|
+
monthlyPayment: payment,
|
|
211
|
+
upfrontFees: input.upfrontFees,
|
|
212
|
+
monthlyFees: input.monthlyFees,
|
|
213
|
+
finalFees: input.finalFees,
|
|
214
|
+
totalPaid,
|
|
215
|
+
totalFees,
|
|
216
|
+
financeCost: totalPaid - input.principal,
|
|
217
|
+
netCashReceived,
|
|
218
|
+
effectiveApr: ((1 + monthlyEffectiveRate) ** 12 - 1) * 100,
|
|
219
|
+
monthlyEffectiveRate,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function calculateScenario(
|
|
224
|
+
input: LoanScenarioInput,
|
|
225
|
+
): LoanScenarioResult {
|
|
226
|
+
const validationError = validateScenario(input);
|
|
227
|
+
if (validationError) return invalidResult(input, validationError);
|
|
228
|
+
const payment =
|
|
229
|
+
input.mode === "rate"
|
|
230
|
+
? monthlyPaymentFromRate(
|
|
231
|
+
input.principal,
|
|
232
|
+
input.annualRate,
|
|
233
|
+
input.termMonths,
|
|
234
|
+
)
|
|
235
|
+
: input.monthlyPayment;
|
|
236
|
+
const netCashReceived = input.principal - input.upfrontFees;
|
|
237
|
+
if (netCashReceived <= 0) return invalidResult(input, "principal");
|
|
238
|
+
const monthlyEffectiveRate = solveMonthlyRate(
|
|
239
|
+
netCashReceived,
|
|
240
|
+
payment + input.monthlyFees,
|
|
241
|
+
input.finalFees,
|
|
242
|
+
input.termMonths,
|
|
243
|
+
);
|
|
244
|
+
if (monthlyEffectiveRate === null) return invalidResult(input, "payment");
|
|
245
|
+
return buildResult(input, payment, netCashReceived, monthlyEffectiveRate);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function compareScenarios(
|
|
249
|
+
first: LoanScenarioResult,
|
|
250
|
+
second: LoanScenarioResult,
|
|
251
|
+
): ComparisonResult {
|
|
252
|
+
if (!first.valid || !second.valid)
|
|
253
|
+
return { winner: "none", difference: 0, message: "invalid" };
|
|
254
|
+
const difference = Math.abs(first.effectiveApr - second.effectiveApr);
|
|
255
|
+
if (difference < 0.005) return { winner: "tie", difference, message: "tie" };
|
|
256
|
+
return first.effectiveApr < second.effectiveApr
|
|
257
|
+
? { winner: "a", difference, message: "lower" }
|
|
258
|
+
: { winner: "b", difference, message: "lower" };
|
|
259
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { defaultState } from "./storage";
|
|
2
|
+
import { writeScenario } from "./scenario-dom";
|
|
3
|
+
|
|
4
|
+
function lowFeeState() {
|
|
5
|
+
const state = defaultState();
|
|
6
|
+
return {
|
|
7
|
+
...state,
|
|
8
|
+
scenarioA: {
|
|
9
|
+
...state.scenarioA,
|
|
10
|
+
annualRate: 6.8,
|
|
11
|
+
monthlyPayment: 307.72,
|
|
12
|
+
upfrontFees: 0,
|
|
13
|
+
monthlyFees: 0,
|
|
14
|
+
},
|
|
15
|
+
scenarioB: {
|
|
16
|
+
...state.scenarioB,
|
|
17
|
+
annualRate: 6.4,
|
|
18
|
+
monthlyPayment: 305.95,
|
|
19
|
+
upfrontFees: 500,
|
|
20
|
+
monthlyFees: 4,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function longTermState() {
|
|
26
|
+
const state = defaultState();
|
|
27
|
+
return {
|
|
28
|
+
...state,
|
|
29
|
+
scenarioA: {
|
|
30
|
+
...state.scenarioA,
|
|
31
|
+
termMonths: 60,
|
|
32
|
+
annualRate: 5.8,
|
|
33
|
+
monthlyPayment: 192.26,
|
|
34
|
+
},
|
|
35
|
+
scenarioB: {
|
|
36
|
+
...state.scenarioB,
|
|
37
|
+
termMonths: 60,
|
|
38
|
+
annualRate: 5.1,
|
|
39
|
+
monthlyPayment: 189.11,
|
|
40
|
+
upfrontFees: 350,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function applyPreset(root: HTMLElement, preset: string): void {
|
|
46
|
+
let state = defaultState();
|
|
47
|
+
if (preset === "low-fee") state = lowFeeState();
|
|
48
|
+
if (preset === "long-term") state = longTermState();
|
|
49
|
+
writeScenario(root, "a", state.scenarioA);
|
|
50
|
+
writeScenario(root, "b", state.scenarioB);
|
|
51
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { LoanMode, LoanScenarioInput } from "./logic";
|
|
2
|
+
|
|
3
|
+
export type ScenarioSide = "a" | "b";
|
|
4
|
+
|
|
5
|
+
function numberValue(root: HTMLElement, selector: string): number {
|
|
6
|
+
return Number(root.querySelector<HTMLInputElement>(selector)?.value ?? 0);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function textValue(
|
|
10
|
+
root: HTMLElement,
|
|
11
|
+
selector: string,
|
|
12
|
+
fallback: string,
|
|
13
|
+
): string {
|
|
14
|
+
return (
|
|
15
|
+
root.querySelector<HTMLInputElement>(selector)?.value.trim() || fallback
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function modeValue(root: HTMLElement, side: ScenarioSide): LoanMode {
|
|
20
|
+
return (
|
|
21
|
+
(root.querySelector<HTMLButtonElement>(
|
|
22
|
+
`[data-mode-side="${side}"][aria-pressed="true"]`,
|
|
23
|
+
)?.dataset.mode as LoanMode) ?? "rate"
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readScenario(
|
|
28
|
+
root: HTMLElement,
|
|
29
|
+
side: ScenarioSide,
|
|
30
|
+
): LoanScenarioInput {
|
|
31
|
+
const selector = `[data-scenario="${side}"]`;
|
|
32
|
+
return {
|
|
33
|
+
name: textValue(
|
|
34
|
+
root,
|
|
35
|
+
`${selector} [data-field="name"]`,
|
|
36
|
+
side === "a" ? "Offer A" : "Offer B",
|
|
37
|
+
),
|
|
38
|
+
principal: numberValue(root, `${selector} [data-field="principal"]`),
|
|
39
|
+
termMonths: numberValue(root, `${selector} [data-field="term"]`),
|
|
40
|
+
mode: modeValue(root, side),
|
|
41
|
+
annualRate: numberValue(root, `${selector} [data-field="rate"]`),
|
|
42
|
+
monthlyPayment: numberValue(root, `${selector} [data-field="payment"]`),
|
|
43
|
+
upfrontFees: numberValue(root, `${selector} [data-field="upfrontFees"]`),
|
|
44
|
+
monthlyFees: numberValue(root, `${selector} [data-field="monthlyFees"]`),
|
|
45
|
+
finalFees: numberValue(root, `${selector} [data-field="finalFees"]`),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function setInput(
|
|
50
|
+
root: HTMLElement,
|
|
51
|
+
selector: string,
|
|
52
|
+
value: string | number,
|
|
53
|
+
): void {
|
|
54
|
+
const input = root.querySelector<HTMLInputElement>(selector);
|
|
55
|
+
if (input) input.value = String(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function setMode(
|
|
59
|
+
root: HTMLElement,
|
|
60
|
+
side: ScenarioSide,
|
|
61
|
+
mode: LoanMode,
|
|
62
|
+
): void {
|
|
63
|
+
root
|
|
64
|
+
.querySelectorAll<HTMLButtonElement>(`[data-mode-side="${side}"]`)
|
|
65
|
+
.forEach((button) =>
|
|
66
|
+
button.setAttribute("aria-pressed", String(button.dataset.mode === mode)),
|
|
67
|
+
);
|
|
68
|
+
const rateField = root.querySelector<HTMLElement>(
|
|
69
|
+
`[data-scenario="${side}"] [data-mode-field="rate"]`,
|
|
70
|
+
);
|
|
71
|
+
const paymentField = root.querySelector<HTMLElement>(
|
|
72
|
+
`[data-scenario="${side}"] [data-mode-field="payment"]`,
|
|
73
|
+
);
|
|
74
|
+
if (rateField) rateField.hidden = mode !== "rate";
|
|
75
|
+
if (paymentField) paymentField.hidden = mode !== "payment";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function writeScenario(
|
|
79
|
+
root: HTMLElement,
|
|
80
|
+
side: ScenarioSide,
|
|
81
|
+
scenario: LoanScenarioInput,
|
|
82
|
+
): void {
|
|
83
|
+
const selector = `[data-scenario="${side}"]`;
|
|
84
|
+
setInput(root, `${selector} [data-field="name"]`, scenario.name);
|
|
85
|
+
setInput(root, `${selector} [data-field="principal"]`, scenario.principal);
|
|
86
|
+
setInput(root, `${selector} [data-field="term"]`, scenario.termMonths);
|
|
87
|
+
setInput(root, `${selector} [data-field="rate"]`, scenario.annualRate);
|
|
88
|
+
setInput(root, `${selector} [data-field="payment"]`, scenario.monthlyPayment);
|
|
89
|
+
setInput(
|
|
90
|
+
root,
|
|
91
|
+
`${selector} [data-field="upfrontFees"]`,
|
|
92
|
+
scenario.upfrontFees,
|
|
93
|
+
);
|
|
94
|
+
setInput(
|
|
95
|
+
root,
|
|
96
|
+
`${selector} [data-field="monthlyFees"]`,
|
|
97
|
+
scenario.monthlyFees,
|
|
98
|
+
);
|
|
99
|
+
setInput(root, `${selector} [data-field="finalFees"]`, scenario.finalFees);
|
|
100
|
+
setMode(root, side, scenario.mode);
|
|
101
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { loanAprScenarioCalculator } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props { locale?: KnownLocale; }
|
|
7
|
+
const { locale = 'en' } = Astro.props;
|
|
8
|
+
const content = await loanAprScenarioCalculator.i18n[locale]?.() ?? await loanAprScenarioCalculator.i18n.en?.();
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
{content && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { LoanMode, LoanScenarioInput } from "./logic";
|
|
2
|
+
import { isLoanAprCurrencyCode, type LoanAprCurrencyCode } from "./currency";
|
|
3
|
+
|
|
4
|
+
export interface LoanAprStoredState {
|
|
5
|
+
scenarioA: LoanScenarioInput;
|
|
6
|
+
scenarioB: LoanScenarioInput;
|
|
7
|
+
currency: LoanAprCurrencyCode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const STORAGE_KEY = "loan-apr-scenario-calculator:v1";
|
|
11
|
+
|
|
12
|
+
export function loadStoredState(): LoanAprStoredState | null {
|
|
13
|
+
try {
|
|
14
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
15
|
+
if (!raw) return null;
|
|
16
|
+
const value = JSON.parse(raw) as LoanAprStoredState;
|
|
17
|
+
if (!value || !value.scenarioA || !value.scenarioB) return null;
|
|
18
|
+
return {
|
|
19
|
+
...value,
|
|
20
|
+
currency: isLoanAprCurrencyCode(value.currency) ? value.currency : "USD",
|
|
21
|
+
};
|
|
22
|
+
} catch {}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function saveStoredState(state: LoanAprStoredState): void {
|
|
27
|
+
try {
|
|
28
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
29
|
+
} catch {}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function defaultScenario(
|
|
33
|
+
name: string,
|
|
34
|
+
mode: LoanMode = "rate",
|
|
35
|
+
): LoanScenarioInput {
|
|
36
|
+
return {
|
|
37
|
+
name,
|
|
38
|
+
principal: 10000,
|
|
39
|
+
termMonths: 36,
|
|
40
|
+
mode,
|
|
41
|
+
annualRate: 6,
|
|
42
|
+
monthlyPayment: 304.22,
|
|
43
|
+
upfrontFees: 0,
|
|
44
|
+
monthlyFees: 0,
|
|
45
|
+
finalFees: 0,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function defaultState(): LoanAprStoredState {
|
|
50
|
+
return {
|
|
51
|
+
scenarioA: defaultScenario("Offer A"),
|
|
52
|
+
scenarioB: {
|
|
53
|
+
...defaultScenario("Offer B"),
|
|
54
|
+
annualRate: 7.25,
|
|
55
|
+
monthlyPayment: 309.88,
|
|
56
|
+
upfrontFees: 150,
|
|
57
|
+
},
|
|
58
|
+
currency: "USD",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface LoanAprScenarioCalculatorUI {
|
|
2
|
+
labelScenarioA: string;
|
|
3
|
+
labelScenarioB: string;
|
|
4
|
+
labelScenarioName: string;
|
|
5
|
+
labelCurrency: string;
|
|
6
|
+
labelApproximateRates: string;
|
|
7
|
+
labelPrincipal: string;
|
|
8
|
+
labelTerm: string;
|
|
9
|
+
labelMonths: string;
|
|
10
|
+
labelInputBasis: string;
|
|
11
|
+
labelAnnualRate: string;
|
|
12
|
+
labelMonthlyPayment: string;
|
|
13
|
+
labelUpfrontFees: string;
|
|
14
|
+
labelMonthlyFees: string;
|
|
15
|
+
labelFinalFees: string;
|
|
16
|
+
labelOfferNameA: string;
|
|
17
|
+
labelOfferNameB: string;
|
|
18
|
+
labelRateMode: string;
|
|
19
|
+
labelPaymentMode: string;
|
|
20
|
+
labelPreset: string;
|
|
21
|
+
labelPresetBalanced: string;
|
|
22
|
+
labelPresetLowFee: string;
|
|
23
|
+
labelPresetLongTerm: string;
|
|
24
|
+
labelReset: string;
|
|
25
|
+
labelCopy: string;
|
|
26
|
+
labelCopied: string;
|
|
27
|
+
labelEffectiveApr: string;
|
|
28
|
+
labelNominalRate: string;
|
|
29
|
+
labelMonthlyPaymentResult: string;
|
|
30
|
+
labelTotalPaid: string;
|
|
31
|
+
labelTotalFees: string;
|
|
32
|
+
labelFinanceCost: string;
|
|
33
|
+
labelNetCashReceived: string;
|
|
34
|
+
labelWinner: string;
|
|
35
|
+
labelLowerCost: string;
|
|
36
|
+
labelHigherCost: string;
|
|
37
|
+
labelTie: string;
|
|
38
|
+
labelNeedValidInputs: string;
|
|
39
|
+
labelInvalidPrincipal: string;
|
|
40
|
+
labelInvalidTerm: string;
|
|
41
|
+
labelInvalidFees: string;
|
|
42
|
+
labelInvalidPayment: string;
|
|
43
|
+
labelInvalidRate: string;
|
|
44
|
+
labelNoComparison: string;
|
|
45
|
+
labelTimeline: string;
|
|
46
|
+
labelStart: string;
|
|
47
|
+
labelMonthly: string;
|
|
48
|
+
labelEnd: string;
|
|
49
|
+
labelFormula: string;
|
|
50
|
+
labelFormulaText: string;
|
|
51
|
+
labelApproximation: string;
|
|
52
|
+
labelScenarioSummary: string;
|
|
53
|
+
currencyCode: string;
|
|
54
|
+
currencyLocale: string;
|
|
55
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const validation = {
|
|
2
|
+
reviewedAt: "2026-09-08",
|
|
3
|
+
methodology:
|
|
4
|
+
"The tool models equal monthly payments, upfront fees, recurring fees and an optional final fee. It solves the monthly rate that equates the net cash received with the discounted payment stream and compounds that rate for an annual effective result.",
|
|
5
|
+
sources: [
|
|
6
|
+
"https://clientebancario.bde.es/pcb/es/menu-horizontal/podemosayudarte/simuladores/calculo_tae_prestamo_personal.html",
|
|
7
|
+
"https://www.consumerfinance.gov/ask-cfpb/what-is-the-difference-between-a-loan-interest-rate-and-the-apr-en-733/",
|
|
8
|
+
],
|
|
9
|
+
referenceCases: [
|
|
10
|
+
{
|
|
11
|
+
case: "No fees at 6% nominal annual rate",
|
|
12
|
+
expected: "Effective annual result is approximately 6%.",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
case: "Positive upfront fee",
|
|
16
|
+
expected: "Effective annual result is higher than the nominal rate.",
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
limitations: [
|
|
20
|
+
"This is a scenario estimate, not a lender disclosure or legal calculation.",
|
|
21
|
+
"It assumes equal monthly payments, no grace period and fees entered by the user.",
|
|
22
|
+
"It does not model variable rates, insurance exclusions, taxes, late charges or jurisdiction-specific APR rules.",
|
|
23
|
+
],
|
|
24
|
+
};
|
package/src/tools.ts
CHANGED
|
@@ -27,5 +27,6 @@ import { RENTAL_DEPOSIT_RETURN_TOOL } from './tool/rentalDepositReturn';
|
|
|
27
27
|
import { CAPITAL_GAINS_TAX_SCENARIO_CALCULATOR_TOOL } from './tool/capitalGainsTaxScenarioCalculator';
|
|
28
28
|
import { INSURANCE_DEDUCTIBLE_BREAK_EVEN_CALCULATOR_TOOL } from './tool/insuranceDeductibleBreakEvenCalculator';
|
|
29
29
|
import { RENTER_INSURANCE_COVERAGE_GAP_CALCULATOR_TOOL } from './tool/renterInsuranceCoverageGapCalculator';
|
|
30
|
+
import { LOAN_APR_SCENARIO_CALCULATOR_TOOL } from './tool/loanAprScenarioCalculator';
|
|
30
31
|
|
|
31
|
-
export const ALL_TOOLS: ToolDefinition[] = [COMPOUND_INTEREST_TOOL, FINANCIAL_FREEDOM_TOOL, SAVINGS_RATE_TOOL, PROFITABILITY_TOOL, MORTGAGE_TOOL, INFLATION_TOOL, PERCENTAGE_CALCULATOR_TOOL, LATE_INTEREST_TOOL, IBAN_BIC_SWIFT_TOOL, RENT_INCREASE_TOOL, LOTTERY_OPTIMIZER_TOOL, COURT_FEE_CALCULATOR_TOOL, LEGAL_INTEREST_RATE_TOOL, FIRE_CALCULATOR_TOOL, DEBT_SNOWBALL_TOOL, REVOLVING_CARD_CALCULATOR_TOOL, PESETA_VALUE_TOOL, SALARY_EMPLOYER_COST_TOOL, DIVIDEND_YIELD_TOOL, MORTGAGE_EXTRA_PAYMENT_PLANNER_TOOL, INFLATION_PURCHASING_POWER_TOOL, SUBSCRIPTION_COST_ANNUALIZER_TOOL, LOAN_TO_INCOME_RATIO_TOOL, RENTAL_DEPOSIT_RETURN_TOOL, CAPITAL_GAINS_TAX_SCENARIO_CALCULATOR_TOOL, INSURANCE_DEDUCTIBLE_BREAK_EVEN_CALCULATOR_TOOL, RENTER_INSURANCE_COVERAGE_GAP_CALCULATOR_TOOL];
|
|
32
|
+
export const ALL_TOOLS: ToolDefinition[] = [COMPOUND_INTEREST_TOOL, FINANCIAL_FREEDOM_TOOL, SAVINGS_RATE_TOOL, PROFITABILITY_TOOL, MORTGAGE_TOOL, INFLATION_TOOL, PERCENTAGE_CALCULATOR_TOOL, LATE_INTEREST_TOOL, IBAN_BIC_SWIFT_TOOL, RENT_INCREASE_TOOL, LOTTERY_OPTIMIZER_TOOL, COURT_FEE_CALCULATOR_TOOL, LEGAL_INTEREST_RATE_TOOL, FIRE_CALCULATOR_TOOL, DEBT_SNOWBALL_TOOL, REVOLVING_CARD_CALCULATOR_TOOL, PESETA_VALUE_TOOL, SALARY_EMPLOYER_COST_TOOL, DIVIDEND_YIELD_TOOL, MORTGAGE_EXTRA_PAYMENT_PLANNER_TOOL, INFLATION_PURCHASING_POWER_TOOL, SUBSCRIPTION_COST_ANNUALIZER_TOOL, LOAN_TO_INCOME_RATIO_TOOL, RENTAL_DEPOSIT_RETURN_TOOL, CAPITAL_GAINS_TAX_SCENARIO_CALCULATOR_TOOL, INSURANCE_DEDUCTIBLE_BREAK_EVEN_CALCULATOR_TOOL, RENTER_INSURANCE_COVERAGE_GAP_CALCULATOR_TOOL, LOAN_APR_SCENARIO_CALCULATOR_TOOL];
|