@jjlmoya/utils-finance 1.40.0 → 1.41.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 +3 -2
- package/src/entries.ts +5 -1
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/savingsRate/bibliography.astro +11 -0
- package/src/tool/savingsRate/bibliography.ts +12 -0
- package/src/tool/savingsRate/component.astro +326 -0
- package/src/tool/savingsRate/content.ts +1877 -0
- package/src/tool/savingsRate/entry.ts +31 -0
- package/src/tool/savingsRate/i18n/de.ts +3 -0
- package/src/tool/savingsRate/i18n/en.ts +1 -0
- package/src/tool/savingsRate/i18n/es.ts +3 -0
- package/src/tool/savingsRate/i18n/fr.ts +3 -0
- package/src/tool/savingsRate/i18n/id.ts +3 -0
- package/src/tool/savingsRate/i18n/it.ts +3 -0
- package/src/tool/savingsRate/i18n/ja.ts +3 -0
- package/src/tool/savingsRate/i18n/ko.ts +3 -0
- package/src/tool/savingsRate/i18n/nl.ts +3 -0
- package/src/tool/savingsRate/i18n/pl.ts +3 -0
- package/src/tool/savingsRate/i18n/pt.ts +3 -0
- package/src/tool/savingsRate/i18n/ru.ts +3 -0
- package/src/tool/savingsRate/i18n/sv.ts +3 -0
- package/src/tool/savingsRate/i18n/tr.ts +3 -0
- package/src/tool/savingsRate/i18n/zh.ts +3 -0
- package/src/tool/savingsRate/index.ts +11 -0
- package/src/tool/savingsRate/logic.test.ts +87 -0
- package/src/tool/savingsRate/logic.ts +121 -0
- package/src/tool/savingsRate/savings-rate-calculator.css +575 -0
- package/src/tool/savingsRate/seo.astro +11 -0
- package/src/tool/savingsRate/ui.ts +48 -0
- package/src/tools.ts +2 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { FinanceToolEntry, ToolLocaleContent } from "../../types";
|
|
2
|
+
import type { SavingsRateUI } from "./ui";
|
|
3
|
+
|
|
4
|
+
export type SavingsRateLocaleContent = ToolLocaleContent<SavingsRateUI>;
|
|
5
|
+
|
|
6
|
+
export const savingsRate: FinanceToolEntry<SavingsRateUI> = {
|
|
7
|
+
id: "savings-rate-calculator",
|
|
8
|
+
icons: {
|
|
9
|
+
bg: "mdi:wallet-outline",
|
|
10
|
+
fg: "mdi:chart-donut",
|
|
11
|
+
},
|
|
12
|
+
i18n: {
|
|
13
|
+
en: () => import("./i18n/en").then((module) => module.content),
|
|
14
|
+
de: () => import("./i18n/de").then((module) => module.content),
|
|
15
|
+
es: () => import("./i18n/es").then((module) => module.content),
|
|
16
|
+
fr: () => import("./i18n/fr").then((module) => module.content),
|
|
17
|
+
id: () => import("./i18n/id").then((module) => module.content),
|
|
18
|
+
it: () => import("./i18n/it").then((module) => module.content),
|
|
19
|
+
ja: () => import("./i18n/ja").then((module) => module.content),
|
|
20
|
+
ko: () => import("./i18n/ko").then((module) => module.content),
|
|
21
|
+
nl: () => import("./i18n/nl").then((module) => module.content),
|
|
22
|
+
pl: () => import("./i18n/pl").then((module) => module.content),
|
|
23
|
+
pt: () => import("./i18n/pt").then((module) => module.content),
|
|
24
|
+
ru: () => import("./i18n/ru").then((module) => module.content),
|
|
25
|
+
sv: () => import("./i18n/sv").then((module) => module.content),
|
|
26
|
+
tr: () => import("./i18n/tr").then((module) => module.content),
|
|
27
|
+
zh: () => import("./i18n/zh").then((module) => module.content),
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export { bibliography } from "./bibliography";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { content } from "../content";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { savingsRate } from "./entry";
|
|
2
|
+
import type { ToolDefinition } from "../../types";
|
|
3
|
+
|
|
4
|
+
export * from "./entry";
|
|
5
|
+
|
|
6
|
+
export const SAVINGS_RATE_TOOL: ToolDefinition = {
|
|
7
|
+
entry: savingsRate,
|
|
8
|
+
Component: () => import("./component.astro"),
|
|
9
|
+
SEOComponent: () => import("./seo.astro"),
|
|
10
|
+
BibliographyComponent: () => import("./bibliography.astro"),
|
|
11
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { calculateSavingsRate } from "./logic";
|
|
3
|
+
|
|
4
|
+
describe("calculateSavingsRate", () => {
|
|
5
|
+
it("calculates the current rate, annualized totals, and target gap", () => {
|
|
6
|
+
const result = calculateSavingsRate({
|
|
7
|
+
income: 4000,
|
|
8
|
+
saved: 1200,
|
|
9
|
+
extraContribution: 200,
|
|
10
|
+
targetRate: 40,
|
|
11
|
+
incomeChange: 0,
|
|
12
|
+
spendingChange: 0,
|
|
13
|
+
period: "monthly",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
expect(result).toMatchObject({
|
|
17
|
+
spending: 2600,
|
|
18
|
+
saved: 1400,
|
|
19
|
+
rate: 35,
|
|
20
|
+
annualIncome: 48000,
|
|
21
|
+
annualSaved: 16800,
|
|
22
|
+
targetGap: 5,
|
|
23
|
+
additionalToTarget: 200,
|
|
24
|
+
});
|
|
25
|
+
expect(result?.scenario.rate).toBe(35);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("shows how income and spending changes alter the rate", () => {
|
|
29
|
+
const result = calculateSavingsRate({
|
|
30
|
+
income: 3000,
|
|
31
|
+
saved: 900,
|
|
32
|
+
extraContribution: 0,
|
|
33
|
+
targetRate: 40,
|
|
34
|
+
incomeChange: 10,
|
|
35
|
+
spendingChange: -5,
|
|
36
|
+
period: "monthly",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(result?.scenario.income).toBe(3300);
|
|
40
|
+
expect(result?.scenario.spending).toBe(1995);
|
|
41
|
+
expect(result?.scenario.saved).toBe(1305);
|
|
42
|
+
expect(result?.scenario.rate).toBe(39.55);
|
|
43
|
+
expect(result?.scenario.changeFromBase).toBe(9.55);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not ask for more savings when the target is already met", () => {
|
|
47
|
+
const result = calculateSavingsRate({
|
|
48
|
+
income: 5000,
|
|
49
|
+
saved: 2500,
|
|
50
|
+
extraContribution: 0,
|
|
51
|
+
targetRate: 40,
|
|
52
|
+
incomeChange: 0,
|
|
53
|
+
spendingChange: 0,
|
|
54
|
+
period: "yearly",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(result?.additionalToTarget).toBe(0);
|
|
58
|
+
expect(result?.targetGap).toBe(-10);
|
|
59
|
+
expect(result?.annualIncome).toBe(5000);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("rejects impossible or incomplete inputs", () => {
|
|
63
|
+
expect(
|
|
64
|
+
calculateSavingsRate({
|
|
65
|
+
income: 0,
|
|
66
|
+
saved: 0,
|
|
67
|
+
extraContribution: 0,
|
|
68
|
+
targetRate: 20,
|
|
69
|
+
incomeChange: 0,
|
|
70
|
+
spendingChange: 0,
|
|
71
|
+
period: "monthly",
|
|
72
|
+
}),
|
|
73
|
+
).toBeNull();
|
|
74
|
+
|
|
75
|
+
expect(
|
|
76
|
+
calculateSavingsRate({
|
|
77
|
+
income: 3000,
|
|
78
|
+
saved: 3500,
|
|
79
|
+
extraContribution: 0,
|
|
80
|
+
targetRate: 20,
|
|
81
|
+
incomeChange: 0,
|
|
82
|
+
spendingChange: 0,
|
|
83
|
+
period: "monthly",
|
|
84
|
+
}),
|
|
85
|
+
).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
export type SavingsPeriod = "monthly" | "yearly";
|
|
2
|
+
|
|
3
|
+
export interface SavingsRateInput {
|
|
4
|
+
income: number;
|
|
5
|
+
saved: number;
|
|
6
|
+
extraContribution: number;
|
|
7
|
+
targetRate: number;
|
|
8
|
+
incomeChange: number;
|
|
9
|
+
spendingChange: number;
|
|
10
|
+
period: SavingsPeriod;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SavingsRateScenario {
|
|
14
|
+
income: number;
|
|
15
|
+
spending: number;
|
|
16
|
+
saved: number;
|
|
17
|
+
rate: number;
|
|
18
|
+
changeFromBase: number;
|
|
19
|
+
targetGap: number;
|
|
20
|
+
additionalToTarget: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SavingsRateResult {
|
|
24
|
+
period: SavingsPeriod;
|
|
25
|
+
income: number;
|
|
26
|
+
spending: number;
|
|
27
|
+
saved: number;
|
|
28
|
+
rate: number;
|
|
29
|
+
annualIncome: number;
|
|
30
|
+
annualSaved: number;
|
|
31
|
+
targetRate: number;
|
|
32
|
+
targetGap: number;
|
|
33
|
+
additionalToTarget: number;
|
|
34
|
+
scenario: SavingsRateScenario;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const round = (value: number, decimals = 2): number => {
|
|
38
|
+
const factor = 10 ** decimals;
|
|
39
|
+
return Math.round((value + Number.EPSILON) * factor) / factor;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const safeRate = (saved: number, income: number): number => {
|
|
43
|
+
if (income <= 0) return 0;
|
|
44
|
+
return round((saved / income) * 100);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const annualize = (value: number, period: SavingsPeriod): number =>
|
|
48
|
+
period === "monthly" ? value * 12 : value;
|
|
49
|
+
|
|
50
|
+
const isValidInput = (input: SavingsRateInput): boolean => {
|
|
51
|
+
const values = [
|
|
52
|
+
input.income,
|
|
53
|
+
input.saved,
|
|
54
|
+
input.extraContribution,
|
|
55
|
+
input.targetRate,
|
|
56
|
+
input.incomeChange,
|
|
57
|
+
input.spendingChange,
|
|
58
|
+
];
|
|
59
|
+
return (
|
|
60
|
+
!values.some((value) => !Number.isFinite(value)) &&
|
|
61
|
+
input.income > 0 &&
|
|
62
|
+
input.saved >= 0 &&
|
|
63
|
+
input.extraContribution >= 0 &&
|
|
64
|
+
input.targetRate >= 0 &&
|
|
65
|
+
input.targetRate <= 100 &&
|
|
66
|
+
["monthly", "yearly"].includes(input.period) &&
|
|
67
|
+
input.saved + input.extraContribution <= input.income
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const buildScenario = (
|
|
72
|
+
input: SavingsRateInput,
|
|
73
|
+
spending: number,
|
|
74
|
+
baseRate: number,
|
|
75
|
+
): SavingsRateScenario => {
|
|
76
|
+
const scenarioIncome = input.income * (1 + input.incomeChange / 100);
|
|
77
|
+
const scenarioSpending = Math.max(
|
|
78
|
+
0,
|
|
79
|
+
spending * (1 + input.spendingChange / 100),
|
|
80
|
+
);
|
|
81
|
+
const scenarioSaved = scenarioIncome - scenarioSpending;
|
|
82
|
+
const scenarioRate = safeRate(scenarioSaved, scenarioIncome);
|
|
83
|
+
return {
|
|
84
|
+
income: round(scenarioIncome),
|
|
85
|
+
spending: round(scenarioSpending),
|
|
86
|
+
saved: round(scenarioSaved),
|
|
87
|
+
rate: scenarioRate,
|
|
88
|
+
changeFromBase: round(scenarioRate - baseRate),
|
|
89
|
+
targetGap: round(input.targetRate - scenarioRate),
|
|
90
|
+
additionalToTarget: round(
|
|
91
|
+
Math.max(0, (input.targetRate / 100) * scenarioIncome - scenarioSaved),
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export function calculateSavingsRate(
|
|
97
|
+
input: SavingsRateInput,
|
|
98
|
+
): SavingsRateResult | null {
|
|
99
|
+
if (!isValidInput(input)) return null;
|
|
100
|
+
const saved = input.saved + input.extraContribution;
|
|
101
|
+
const spending = input.income - saved;
|
|
102
|
+
const rate = safeRate(saved, input.income);
|
|
103
|
+
const additionalToTarget = Math.max(
|
|
104
|
+
0,
|
|
105
|
+
(input.targetRate / 100) * input.income - saved,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
period: input.period,
|
|
110
|
+
income: round(input.income),
|
|
111
|
+
spending: round(spending),
|
|
112
|
+
saved: round(saved),
|
|
113
|
+
rate,
|
|
114
|
+
annualIncome: round(annualize(input.income, input.period)),
|
|
115
|
+
annualSaved: round(annualize(saved, input.period)),
|
|
116
|
+
targetRate: round(input.targetRate),
|
|
117
|
+
targetGap: round(input.targetRate - rate),
|
|
118
|
+
additionalToTarget: round(additionalToTarget),
|
|
119
|
+
scenario: buildScenario(input, spending, rate),
|
|
120
|
+
};
|
|
121
|
+
}
|