@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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +3 -2
  3. package/src/entries.ts +5 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/savingsRate/bibliography.astro +11 -0
  8. package/src/tool/savingsRate/bibliography.ts +12 -0
  9. package/src/tool/savingsRate/component.astro +326 -0
  10. package/src/tool/savingsRate/content.ts +1877 -0
  11. package/src/tool/savingsRate/entry.ts +31 -0
  12. package/src/tool/savingsRate/i18n/de.ts +3 -0
  13. package/src/tool/savingsRate/i18n/en.ts +1 -0
  14. package/src/tool/savingsRate/i18n/es.ts +3 -0
  15. package/src/tool/savingsRate/i18n/fr.ts +3 -0
  16. package/src/tool/savingsRate/i18n/id.ts +3 -0
  17. package/src/tool/savingsRate/i18n/it.ts +3 -0
  18. package/src/tool/savingsRate/i18n/ja.ts +3 -0
  19. package/src/tool/savingsRate/i18n/ko.ts +3 -0
  20. package/src/tool/savingsRate/i18n/nl.ts +3 -0
  21. package/src/tool/savingsRate/i18n/pl.ts +3 -0
  22. package/src/tool/savingsRate/i18n/pt.ts +3 -0
  23. package/src/tool/savingsRate/i18n/ru.ts +3 -0
  24. package/src/tool/savingsRate/i18n/sv.ts +3 -0
  25. package/src/tool/savingsRate/i18n/tr.ts +3 -0
  26. package/src/tool/savingsRate/i18n/zh.ts +3 -0
  27. package/src/tool/savingsRate/index.ts +11 -0
  28. package/src/tool/savingsRate/logic.test.ts +87 -0
  29. package/src/tool/savingsRate/logic.ts +121 -0
  30. package/src/tool/savingsRate/savings-rate-calculator.css +575 -0
  31. package/src/tool/savingsRate/seo.astro +11 -0
  32. package/src/tool/savingsRate/ui.ts +48 -0
  33. 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,3 @@
1
+ import { createSavingsRateContent } from "../content";
2
+
3
+ export const content = createSavingsRateContent("de");
@@ -0,0 +1 @@
1
+ export { content } from "../content";
@@ -0,0 +1,3 @@
1
+ import { createSavingsRateContent } from "../content";
2
+
3
+ export const content = createSavingsRateContent("es");
@@ -0,0 +1,3 @@
1
+ import { createSavingsRateContent } from "../content";
2
+
3
+ export const content = createSavingsRateContent("fr");
@@ -0,0 +1,3 @@
1
+ import { createSavingsRateContent } from "../content";
2
+
3
+ export const content = createSavingsRateContent("id");
@@ -0,0 +1,3 @@
1
+ import { createSavingsRateContent } from "../content";
2
+
3
+ export const content = createSavingsRateContent("it");
@@ -0,0 +1,3 @@
1
+ import { createSavingsRateContent } from "../content";
2
+
3
+ export const content = createSavingsRateContent("ja");
@@ -0,0 +1,3 @@
1
+ import { createSavingsRateContent } from "../content";
2
+
3
+ export const content = createSavingsRateContent("ko");
@@ -0,0 +1,3 @@
1
+ import { createSavingsRateContent } from "../content";
2
+
3
+ export const content = createSavingsRateContent("nl");
@@ -0,0 +1,3 @@
1
+ import { createSavingsRateContent } from "../content";
2
+
3
+ export const content = createSavingsRateContent("pl");
@@ -0,0 +1,3 @@
1
+ import { createSavingsRateContent } from "../content";
2
+
3
+ export const content = createSavingsRateContent("pt");
@@ -0,0 +1,3 @@
1
+ import { createSavingsRateContent } from "../content";
2
+
3
+ export const content = createSavingsRateContent("ru");
@@ -0,0 +1,3 @@
1
+ import { createSavingsRateContent } from "../content";
2
+
3
+ export const content = createSavingsRateContent("sv");
@@ -0,0 +1,3 @@
1
+ import { createSavingsRateContent } from "../content";
2
+
3
+ export const content = createSavingsRateContent("tr");
@@ -0,0 +1,3 @@
1
+ import { createSavingsRateContent } from "../content";
2
+
3
+ export const content = createSavingsRateContent("zh");
@@ -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
+ }