@jjlmoya/utils-finance 1.34.0 → 1.36.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.
Files changed (67) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +5 -1
  4. package/src/index.ts +1 -0
  5. package/src/pages/[locale]/[slug].astro +1 -1
  6. package/src/tests/locale_completeness.test.ts +2 -2
  7. package/src/tests/tool_validation.test.ts +2 -2
  8. package/src/tool/inflationPurchasingPower/bibliography.astro +14 -0
  9. package/src/tool/inflationPurchasingPower/bibliography.ts +12 -0
  10. package/src/tool/inflationPurchasingPower/component.astro +79 -0
  11. package/src/tool/inflationPurchasingPower/controller.ts +82 -0
  12. package/src/tool/inflationPurchasingPower/dom-views.ts +99 -0
  13. package/src/tool/inflationPurchasingPower/entry.ts +31 -0
  14. package/src/tool/inflationPurchasingPower/evaluator.ts +18 -0
  15. package/src/tool/inflationPurchasingPower/i18n/de.ts +56 -0
  16. package/src/tool/inflationPurchasingPower/i18n/en.ts +131 -0
  17. package/src/tool/inflationPurchasingPower/i18n/es.ts +44 -0
  18. package/src/tool/inflationPurchasingPower/i18n/fr.ts +44 -0
  19. package/src/tool/inflationPurchasingPower/i18n/id.ts +44 -0
  20. package/src/tool/inflationPurchasingPower/i18n/it.ts +44 -0
  21. package/src/tool/inflationPurchasingPower/i18n/ja.ts +44 -0
  22. package/src/tool/inflationPurchasingPower/i18n/ko.ts +44 -0
  23. package/src/tool/inflationPurchasingPower/i18n/nl.ts +44 -0
  24. package/src/tool/inflationPurchasingPower/i18n/pl.ts +44 -0
  25. package/src/tool/inflationPurchasingPower/i18n/pt.ts +44 -0
  26. package/src/tool/inflationPurchasingPower/i18n/ru.ts +44 -0
  27. package/src/tool/inflationPurchasingPower/i18n/sv.ts +44 -0
  28. package/src/tool/inflationPurchasingPower/i18n/tr.ts +44 -0
  29. package/src/tool/inflationPurchasingPower/i18n/zh.ts +44 -0
  30. package/src/tool/inflationPurchasingPower/index.ts +11 -0
  31. package/src/tool/inflationPurchasingPower/inflation-purchasing-power-timeline.css +402 -0
  32. package/src/tool/inflationPurchasingPower/logic.test.ts +35 -0
  33. package/src/tool/inflationPurchasingPower/logic.ts +92 -0
  34. package/src/tool/inflationPurchasingPower/seo.astro +14 -0
  35. package/src/tool/inflationPurchasingPower/storage.ts +29 -0
  36. package/src/tool/inflationPurchasingPower/ui.ts +42 -0
  37. package/src/tool/mortgageExtraPaymentPlanner/bibliography.astro +14 -0
  38. package/src/tool/mortgageExtraPaymentPlanner/bibliography.ts +12 -0
  39. package/src/tool/mortgageExtraPaymentPlanner/component.astro +138 -0
  40. package/src/tool/mortgageExtraPaymentPlanner/controller.ts +150 -0
  41. package/src/tool/mortgageExtraPaymentPlanner/currency-defaults.ts +25 -0
  42. package/src/tool/mortgageExtraPaymentPlanner/dom-views.ts +161 -0
  43. package/src/tool/mortgageExtraPaymentPlanner/entry.ts +31 -0
  44. package/src/tool/mortgageExtraPaymentPlanner/evaluator.ts +24 -0
  45. package/src/tool/mortgageExtraPaymentPlanner/i18n/de.ts +74 -0
  46. package/src/tool/mortgageExtraPaymentPlanner/i18n/en.ts +185 -0
  47. package/src/tool/mortgageExtraPaymentPlanner/i18n/es.ts +49 -0
  48. package/src/tool/mortgageExtraPaymentPlanner/i18n/fr.ts +25 -0
  49. package/src/tool/mortgageExtraPaymentPlanner/i18n/id.ts +18 -0
  50. package/src/tool/mortgageExtraPaymentPlanner/i18n/it.ts +20 -0
  51. package/src/tool/mortgageExtraPaymentPlanner/i18n/ja.ts +18 -0
  52. package/src/tool/mortgageExtraPaymentPlanner/i18n/ko.ts +18 -0
  53. package/src/tool/mortgageExtraPaymentPlanner/i18n/nl.ts +18 -0
  54. package/src/tool/mortgageExtraPaymentPlanner/i18n/pl.ts +18 -0
  55. package/src/tool/mortgageExtraPaymentPlanner/i18n/pt.ts +18 -0
  56. package/src/tool/mortgageExtraPaymentPlanner/i18n/ru.ts +18 -0
  57. package/src/tool/mortgageExtraPaymentPlanner/i18n/sv.ts +18 -0
  58. package/src/tool/mortgageExtraPaymentPlanner/i18n/tr.ts +18 -0
  59. package/src/tool/mortgageExtraPaymentPlanner/i18n/zh.ts +18 -0
  60. package/src/tool/mortgageExtraPaymentPlanner/index.ts +11 -0
  61. package/src/tool/mortgageExtraPaymentPlanner/logic.test.ts +67 -0
  62. package/src/tool/mortgageExtraPaymentPlanner/logic.ts +263 -0
  63. package/src/tool/mortgageExtraPaymentPlanner/mortgage-amortization-extra-payment-planner.css +551 -0
  64. package/src/tool/mortgageExtraPaymentPlanner/seo.astro +14 -0
  65. package/src/tool/mortgageExtraPaymentPlanner/storage.ts +65 -0
  66. package/src/tool/mortgageExtraPaymentPlanner/ui.ts +48 -0
  67. package/src/tools.ts +3 -1
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { calculateMortgage, convertCurrencyAmount, getDefaultCurrency, type MortgageInputs } from './logic';
3
+
4
+ const baseInputs: MortgageInputs = {
5
+ principal: 100000,
6
+ annualRate: 6,
7
+ termYears: 10,
8
+ frequency: 'monthly',
9
+ recurringExtra: 0,
10
+ oneOffExtra: 0,
11
+ oneOffPeriod: 12,
12
+ startDate: '2026-09-01',
13
+ currency: 'EUR',
14
+ };
15
+
16
+ describe('calculateMortgage', () => {
17
+ it('calculates a fixed monthly payment and a complete baseline schedule', () => {
18
+ const result = calculateMortgage(baseInputs);
19
+ expect(result).not.toBeNull();
20
+ expect(result?.regularPayment).toBeCloseTo(1110.21, 2);
21
+ expect(result?.schedule).toHaveLength(120);
22
+ expect(result?.schedule.at(-1)?.balance).toBe(0);
23
+ expect(result?.totalExtra).toBe(0);
24
+ });
25
+
26
+ it('supports a zero interest loan without dividing by zero', () => {
27
+ const result = calculateMortgage({ ...baseInputs, annualRate: 0, termYears: 1 });
28
+ expect(result?.regularPayment).toBeCloseTo(8333.33, 2);
29
+ expect(result?.totalInterest).toBe(0);
30
+ expect(result?.periods).toBe(12);
31
+ });
32
+
33
+ it('reduces interest and duration when extras are paid', () => {
34
+ const result = calculateMortgage({ ...baseInputs, recurringExtra: 300, oneOffExtra: 5000, oneOffPeriod: 12 });
35
+ expect(result?.interestSaved).toBeGreaterThan(0);
36
+ expect(result?.periodsSaved).toBeGreaterThan(0);
37
+ expect(result?.totalExtra).toBeGreaterThan(5000);
38
+ expect(result?.schedule.at(-1)?.balance).toBe(0);
39
+ });
40
+
41
+ it('uses the requested frequency for period counts and dates', () => {
42
+ const result = calculateMortgage({ ...baseInputs, frequency: 'biweekly', termYears: 1 });
43
+ expect(result?.periods).toBe(26);
44
+ expect(result?.schedule[1]?.date).toBe('2026-09-15');
45
+ });
46
+
47
+ it('uses internal currency ratios for a global currency switch', () => {
48
+ expect(convertCurrencyAmount(100, 'EUR', 'EUR')).toBe(100);
49
+ expect(convertCurrencyAmount(100, 'EUR', 'USD')).toBe(117);
50
+ expect(convertCurrencyAmount(117, 'USD', 'EUR')).toBeCloseTo(100, 8);
51
+ });
52
+ });
53
+
54
+ describe('currency preferences', () => {
55
+ it('selects a regional default from the locale', () => {
56
+ expect(getDefaultCurrency('es')).toBe('EUR');
57
+ expect(getDefaultCurrency('en')).toBe('USD');
58
+ expect(getDefaultCurrency('en-GB')).toBe('GBP');
59
+ expect(getDefaultCurrency('ja-JP')).toBe('JPY');
60
+ expect(getDefaultCurrency('pt')).toBe('EUR');
61
+ expect(getDefaultCurrency('tr')).toBe('TRY');
62
+ });
63
+
64
+ it('falls back to EUR for an unknown locale', () => {
65
+ expect(getDefaultCurrency('xx')).toBe('EUR');
66
+ });
67
+ });
@@ -0,0 +1,263 @@
1
+ import { DEFAULT_CURRENCY_BY_LOCALE } from './currency-defaults';
2
+
3
+ export type MortgageFrequency = 'monthly' | 'biweekly' | 'weekly';
4
+
5
+ export type CurrencyCode =
6
+ | 'AUD' | 'BRL' | 'CAD' | 'CHF' | 'CNY' | 'CZK' | 'DKK'
7
+ | 'EUR' | 'GBP' | 'IDR' | 'INR' | 'JPY' | 'KRW' | 'MXN' | 'NOK'
8
+ | 'PLN' | 'RUB' | 'SEK' | 'TRY' | 'USD';
9
+
10
+ export interface MortgageInputs {
11
+ principal: number;
12
+ annualRate: number;
13
+ termYears: number;
14
+ frequency: MortgageFrequency;
15
+ recurringExtra: number;
16
+ oneOffExtra: number;
17
+ oneOffPeriod: number;
18
+ startDate: string;
19
+ currency: CurrencyCode;
20
+ }
21
+
22
+ export interface PaymentRow {
23
+ period: number;
24
+ date: string;
25
+ payment: number;
26
+ interest: number;
27
+ scheduledPrincipal: number;
28
+ extra: number;
29
+ balance: number;
30
+ }
31
+
32
+ export interface AnnualSummary {
33
+ year: number;
34
+ payment: number;
35
+ interest: number;
36
+ principal: number;
37
+ extra: number;
38
+ balance: number;
39
+ }
40
+
41
+ export interface MortgageResult {
42
+ regularPayment: number;
43
+ totalPaid: number;
44
+ totalInterest: number;
45
+ totalExtra: number;
46
+ payoffDate: string;
47
+ periods: number;
48
+ baselinePayment: number;
49
+ baselineInterest: number;
50
+ baselinePeriods: number;
51
+ interestSaved: number;
52
+ periodsSaved: number;
53
+ schedule: PaymentRow[];
54
+ baselineSchedule: PaymentRow[];
55
+ annualSummary: AnnualSummary[];
56
+ }
57
+
58
+ export const FREQUENCY_PERIODS: Record<MortgageFrequency, number> = {
59
+ monthly: 12,
60
+ biweekly: 26,
61
+ weekly: 52,
62
+ };
63
+
64
+ const INTERNAL_CURRENCY_UNITS_PER_EUR: Record<CurrencyCode, number> = {
65
+ AUD: 1.78,
66
+ BRL: 6.1,
67
+ CAD: 1.55,
68
+ CHF: 0.95,
69
+ CNY: 7.85,
70
+ CZK: 24.9,
71
+ DKK: 7.46,
72
+ EUR: 1,
73
+ GBP: 0.86,
74
+ IDR: 19200,
75
+ INR: 95,
76
+ JPY: 172,
77
+ KRW: 1600,
78
+ MXN: 21.8,
79
+ NOK: 11.8,
80
+ PLN: 4.28,
81
+ RUB: 95,
82
+ SEK: 11.1,
83
+ TRY: 50,
84
+ USD: 1.17,
85
+ };
86
+
87
+ const MAX_PERIODS = 5200;
88
+
89
+ export function getPeriodsPerYear(frequency: MortgageFrequency): number {
90
+ return FREQUENCY_PERIODS[frequency];
91
+ }
92
+
93
+ export function getDefaultCurrency(locale: string): CurrencyCode {
94
+ const normalizedLocale = locale.toLowerCase().replace('_', '-');
95
+ const regionalCurrency = DEFAULT_CURRENCY_BY_LOCALE[normalizedLocale];
96
+ if (regionalCurrency) return regionalCurrency;
97
+ const language = normalizedLocale.split('-')[0];
98
+ return DEFAULT_CURRENCY_BY_LOCALE[language] ?? 'EUR';
99
+ }
100
+
101
+ function paymentForLoan(principal: number, rate: number, periods: number): number {
102
+ if (rate === 0) return principal / periods;
103
+ const factor = (1 + rate) ** periods;
104
+ return principal * ((rate * factor) / (factor - 1));
105
+ }
106
+
107
+ function parseIsoDate(date: string): Date {
108
+ return new Date(`${date}T00:00:00Z`);
109
+ }
110
+
111
+ function isValidIsoDate(value: string): boolean {
112
+ const parts = value.split('-').map(Number);
113
+ const date = parseIsoDate(value);
114
+ return parts.length === 3
115
+ && parts.every(Number.isInteger)
116
+ && date.getUTCFullYear() === parts[0]
117
+ && date.getUTCMonth() + 1 === parts[1]
118
+ && date.getUTCDate() === parts[2];
119
+ }
120
+
121
+ function addPeriod(date: Date, frequency: MortgageFrequency): Date {
122
+ const next = new Date(date.getTime());
123
+ if (frequency === 'monthly') next.setUTCMonth(next.getUTCMonth() + 1);
124
+ if (frequency === 'biweekly') next.setUTCDate(next.getUTCDate() + 14);
125
+ if (frequency === 'weekly') next.setUTCDate(next.getUTCDate() + 7);
126
+ return next;
127
+ }
128
+
129
+ function isoDate(date: Date): string {
130
+ return date.toISOString().slice(0, 10);
131
+ }
132
+
133
+ function createSchedule(inputs: MortgageInputs, withExtras: boolean): PaymentRow[] {
134
+ const periodsPerYear = getPeriodsPerYear(inputs.frequency);
135
+ const periods = Math.max(1, Math.round(inputs.termYears * periodsPerYear));
136
+ const periodicRate = inputs.annualRate / 100 / periodsPerYear;
137
+ const regularPayment = paymentForLoan(inputs.principal, periodicRate, periods);
138
+ const rows: PaymentRow[] = [];
139
+ let balance = inputs.principal;
140
+ let date = parseIsoDate(inputs.startDate);
141
+
142
+ for (let period = 1; period <= Math.min(periods, MAX_PERIODS) && balance > 0.005; period += 1) {
143
+ const interest = balance * periodicRate;
144
+ const scheduledPrincipal = Math.min(balance, Math.max(0, regularPayment - interest));
145
+ const extra = calculateExtra(inputs, period, balance - scheduledPrincipal, withExtras);
146
+ const principalPaid = Math.min(balance, scheduledPrincipal + extra);
147
+ balance = Math.max(0, balance - principalPaid);
148
+ rows.push({
149
+ period,
150
+ date: isoDate(date),
151
+ payment: interest + principalPaid,
152
+ interest,
153
+ scheduledPrincipal,
154
+ extra,
155
+ balance,
156
+ });
157
+ date = addPeriod(date, inputs.frequency);
158
+ }
159
+ return rows;
160
+ }
161
+
162
+ function calculateExtra(inputs: MortgageInputs, period: number, remaining: number, withExtras: boolean): number {
163
+ if (!withExtras) return 0;
164
+ const recurring = Math.min(Math.max(0, remaining), Math.max(0, inputs.recurringExtra));
165
+ const oneOffRemaining = Math.max(0, remaining - recurring);
166
+ const oneOff = period === Math.round(inputs.oneOffPeriod) ? Math.min(oneOffRemaining, Math.max(0, inputs.oneOffExtra)) : 0;
167
+ return recurring + oneOff;
168
+ }
169
+
170
+ function summarizeAnnual(rows: PaymentRow[], periodsPerYear: number): AnnualSummary[] {
171
+ const years = new Map<number, AnnualSummary>();
172
+ rows.forEach((row) => {
173
+ const year = Math.ceil(row.period / periodsPerYear);
174
+ const existing = years.get(year) ?? { year, payment: 0, interest: 0, principal: 0, extra: 0, balance: 0 };
175
+ existing.payment += row.payment;
176
+ existing.interest += row.interest;
177
+ existing.principal += row.scheduledPrincipal;
178
+ existing.extra += row.extra;
179
+ existing.balance = row.balance;
180
+ years.set(year, existing);
181
+ });
182
+ return [...years.values()];
183
+ }
184
+
185
+ export function validateMortgageInputs(inputs: MortgageInputs): string[] {
186
+ const checks: Array<[string, boolean]> = [
187
+ ['principal', isPositive(inputs.principal)],
188
+ ['annualRate', isAnnualRate(inputs.annualRate)],
189
+ ['termYears', isTerm(inputs.termYears)],
190
+ ['recurringExtra', isNonNegative(inputs.recurringExtra)],
191
+ ['oneOffExtra', isNonNegative(inputs.oneOffExtra)],
192
+ ['oneOffPeriod', isPeriod(inputs.oneOffPeriod)],
193
+ ['startDate', isValidStartDate(inputs.startDate)],
194
+ ['currency', isValidCurrency(inputs.currency)],
195
+ ];
196
+ return checks.filter(([, valid]) => !valid).map(([name]) => name);
197
+ }
198
+
199
+ function isPositive(value: number): boolean {
200
+ return Number.isFinite(value) && value > 0;
201
+ }
202
+
203
+ function isNonNegative(value: number): boolean {
204
+ return Number.isFinite(value) && value >= 0;
205
+ }
206
+
207
+ function isAnnualRate(value: number): boolean {
208
+ return Number.isFinite(value) && value >= 0 && value <= 100;
209
+ }
210
+
211
+ function isTerm(value: number): boolean {
212
+ return Number.isFinite(value) && value > 0 && value <= 100;
213
+ }
214
+
215
+ function isPeriod(value: number): boolean {
216
+ return Number.isFinite(value) && value >= 1;
217
+ }
218
+
219
+ function isValidStartDate(value: string): boolean {
220
+ return /^\d{4}-\d{2}-\d{2}$/.test(value) && isValidIsoDate(value);
221
+ }
222
+
223
+ function isValidCurrency(value: unknown): value is CurrencyCode {
224
+ return typeof value === 'string' && Object.prototype.hasOwnProperty.call(INTERNAL_CURRENCY_UNITS_PER_EUR, value);
225
+ }
226
+
227
+ function sum(rows: PaymentRow[], key: keyof Pick<PaymentRow, 'payment' | 'interest' | 'extra'>): number {
228
+ return rows.reduce((total, row) => total + row[key], 0);
229
+ }
230
+
231
+ export function calculateMortgage(inputs: MortgageInputs): MortgageResult | null {
232
+ if (validateMortgageInputs(inputs).length > 0) return null;
233
+ const periodsPerYear = getPeriodsPerYear(inputs.frequency);
234
+ const baselineSchedule = createSchedule(inputs, false);
235
+ const schedule = createSchedule(inputs, true);
236
+ const periodicRate = inputs.annualRate / 100 / periodsPerYear;
237
+ const periods = Math.max(1, Math.round(inputs.termYears * periodsPerYear));
238
+ const regularPayment = paymentForLoan(inputs.principal, periodicRate, periods);
239
+ const baselineInterest = sum(baselineSchedule, 'interest');
240
+ const totalInterest = sum(schedule, 'interest');
241
+ const totalPaid = sum(schedule, 'payment');
242
+ const totalExtra = sum(schedule, 'extra');
243
+ return {
244
+ regularPayment,
245
+ totalPaid,
246
+ totalInterest,
247
+ totalExtra,
248
+ payoffDate: schedule.at(-1)?.date ?? inputs.startDate,
249
+ periods: schedule.length,
250
+ baselinePayment: regularPayment,
251
+ baselineInterest,
252
+ baselinePeriods: baselineSchedule.length,
253
+ interestSaved: Math.max(0, baselineInterest - totalInterest),
254
+ periodsSaved: Math.max(0, baselineSchedule.length - schedule.length),
255
+ schedule,
256
+ baselineSchedule,
257
+ annualSummary: summarizeAnnual(schedule, periodsPerYear),
258
+ };
259
+ }
260
+
261
+ export function convertCurrencyAmount(amount: number, from: CurrencyCode, to: CurrencyCode): number {
262
+ return amount * (INTERNAL_CURRENCY_UNITS_PER_EUR[to] / INTERNAL_CURRENCY_UNITS_PER_EUR[from]);
263
+ }