@jjlmoya/utils-finance 1.40.0 → 1.42.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 +8 -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/rentalDepositReturn/bibliography.astro +11 -0
- package/src/tool/rentalDepositReturn/bibliography.ts +6 -0
- package/src/tool/rentalDepositReturn/component.astro +53 -0
- package/src/tool/rentalDepositReturn/controller.ts +156 -0
- package/src/tool/rentalDepositReturn/currency.ts +67 -0
- package/src/tool/rentalDepositReturn/dom-views.ts +31 -0
- package/src/tool/rentalDepositReturn/entry.ts +28 -0
- package/src/tool/rentalDepositReturn/i18n/content-factory.ts +36 -0
- package/src/tool/rentalDepositReturn/i18n/de.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/en.ts +116 -0
- package/src/tool/rentalDepositReturn/i18n/es.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/fr.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/id.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/it.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/ja.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/ko.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/nl.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/pl.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/pt.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/ru.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/sv.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/tr.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/translations.ts +34 -0
- package/src/tool/rentalDepositReturn/i18n/zh.ts +3 -0
- package/src/tool/rentalDepositReturn/index.ts +11 -0
- package/src/tool/rentalDepositReturn/logic.test.ts +53 -0
- package/src/tool/rentalDepositReturn/logic.ts +108 -0
- package/src/tool/rentalDepositReturn/rental-deposit-return-calculator.css +591 -0
- package/src/tool/rentalDepositReturn/seo.astro +11 -0
- package/src/tool/rentalDepositReturn/ui.ts +53 -0
- 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 +3 -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
|
+
}
|