@jjlmoya/utils-finance 1.45.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 +5 -1
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/mfe_assets_contract.test.ts +48 -0
- 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/tool/renterInsuranceCoverageGapCalculator/bibliography.astro +11 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/bibliography.ts +6 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/component.astro +80 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/controller.ts +102 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/currency.test.ts +17 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/currency.ts +69 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/dom-views.ts +68 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/entry.ts +28 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/evaluator.ts +17 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/de.ts +40 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/en.ts +98 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/es.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/fr.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/id.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/it.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/ja.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/ko.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/nl.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/pl.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/pt.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/ru.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/sv.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/tr.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/zh.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/index.ts +11 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/logic.test.ts +52 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/logic.ts +108 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/renter-insurance-coverage-gap-calculator.css +462 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/seo.astro +11 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/storage.ts +23 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/ui.ts +48 -0
- package/src/tools.ts +3 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export const currencyOptions = [
|
|
2
|
+
{ code: "AUD", symbol: "A$", name: "Australian dollar" },
|
|
3
|
+
{ code: "BRL", symbol: "R$", name: "Brazilian real" },
|
|
4
|
+
{ code: "CAD", symbol: "C$", name: "Canadian dollar" },
|
|
5
|
+
{ code: "CHF", symbol: "CHF", name: "Swiss franc" },
|
|
6
|
+
{ code: "CNY", symbol: "¥", name: "Chinese yuan" },
|
|
7
|
+
{ code: "CZK", symbol: "Kč", name: "Czech koruna" },
|
|
8
|
+
{ code: "DKK", symbol: "kr", name: "Danish krone" },
|
|
9
|
+
{ code: "EUR", symbol: "€", name: "Euro" },
|
|
10
|
+
{ code: "GBP", symbol: "£", name: "British pound" },
|
|
11
|
+
{ code: "IDR", symbol: "Rp", name: "Indonesian rupiah" },
|
|
12
|
+
{ code: "INR", symbol: "₹", name: "Indian rupee" },
|
|
13
|
+
{ code: "JPY", symbol: "¥", name: "Japanese yen" },
|
|
14
|
+
{ code: "KRW", symbol: "₩", name: "South Korean won" },
|
|
15
|
+
{ code: "MXN", symbol: "MX$", name: "Mexican peso" },
|
|
16
|
+
{ code: "NOK", symbol: "kr", name: "Norwegian krone" },
|
|
17
|
+
{ code: "PLN", symbol: "zł", name: "Polish złoty" },
|
|
18
|
+
{ code: "RUB", symbol: "₽", name: "Russian ruble" },
|
|
19
|
+
{ code: "SEK", symbol: "kr", name: "Swedish krona" },
|
|
20
|
+
{ code: "TRY", symbol: "₺", name: "Turkish lira" },
|
|
21
|
+
{ code: "USD", symbol: "$", name: "US dollar" },
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
export type LoanAprCurrencyCode = (typeof currencyOptions)[number]["code"];
|
|
25
|
+
|
|
26
|
+
export const currencyFormatLocale: Record<LoanAprCurrencyCode, string> = {
|
|
27
|
+
AUD: "en-AU",
|
|
28
|
+
BRL: "pt-BR",
|
|
29
|
+
CAD: "en-CA",
|
|
30
|
+
CHF: "de-CH",
|
|
31
|
+
CNY: "zh-CN",
|
|
32
|
+
CZK: "cs-CZ",
|
|
33
|
+
DKK: "da-DK",
|
|
34
|
+
EUR: "es-ES",
|
|
35
|
+
GBP: "en-GB",
|
|
36
|
+
IDR: "id-ID",
|
|
37
|
+
INR: "en-IN",
|
|
38
|
+
JPY: "ja-JP",
|
|
39
|
+
KRW: "ko-KR",
|
|
40
|
+
MXN: "es-MX",
|
|
41
|
+
NOK: "nb-NO",
|
|
42
|
+
PLN: "pl-PL",
|
|
43
|
+
RUB: "ru-RU",
|
|
44
|
+
SEK: "sv-SE",
|
|
45
|
+
TRY: "tr-TR",
|
|
46
|
+
USD: "en-US",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const defaultCurrencyByLocale: Record<string, LoanAprCurrencyCode> = {
|
|
50
|
+
de: "EUR",
|
|
51
|
+
en: "USD",
|
|
52
|
+
es: "EUR",
|
|
53
|
+
fr: "EUR",
|
|
54
|
+
id: "IDR",
|
|
55
|
+
it: "EUR",
|
|
56
|
+
ja: "JPY",
|
|
57
|
+
ko: "KRW",
|
|
58
|
+
nl: "EUR",
|
|
59
|
+
pl: "PLN",
|
|
60
|
+
pt: "EUR",
|
|
61
|
+
ru: "RUB",
|
|
62
|
+
sv: "SEK",
|
|
63
|
+
tr: "TRY",
|
|
64
|
+
zh: "CNY",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const approximateUsdFactors: Record<LoanAprCurrencyCode, number> = {
|
|
68
|
+
AUD: 1.52,
|
|
69
|
+
BRL: 5.1,
|
|
70
|
+
CAD: 1.38,
|
|
71
|
+
CHF: 0.88,
|
|
72
|
+
CNY: 7.2,
|
|
73
|
+
CZK: 23,
|
|
74
|
+
DKK: 6.85,
|
|
75
|
+
USD: 1,
|
|
76
|
+
EUR: 0.92,
|
|
77
|
+
GBP: 0.79,
|
|
78
|
+
IDR: 16000,
|
|
79
|
+
INR: 84,
|
|
80
|
+
JPY: 150,
|
|
81
|
+
KRW: 1380,
|
|
82
|
+
MXN: 18,
|
|
83
|
+
NOK: 10.7,
|
|
84
|
+
PLN: 3.95,
|
|
85
|
+
RUB: 90,
|
|
86
|
+
SEK: 10.5,
|
|
87
|
+
TRY: 34,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const isLoanAprCurrencyCode = (
|
|
91
|
+
value: string,
|
|
92
|
+
): value is LoanAprCurrencyCode =>
|
|
93
|
+
currencyOptions.some(({ code }) => code === value);
|
|
94
|
+
|
|
95
|
+
export const convertLoanCurrency = (
|
|
96
|
+
amount: number,
|
|
97
|
+
from: LoanAprCurrencyCode,
|
|
98
|
+
to: LoanAprCurrencyCode,
|
|
99
|
+
): number =>
|
|
100
|
+
Number(
|
|
101
|
+
(
|
|
102
|
+
(amount * approximateUsdFactors[to]) /
|
|
103
|
+
approximateUsdFactors[from]
|
|
104
|
+
).toFixed(2),
|
|
105
|
+
);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { LoanAprScenarioCalculatorUI } from "./ui";
|
|
2
|
+
import type { ComparisonResult, LoanScenarioResult } from "./logic";
|
|
3
|
+
import { currencyFormatLocale, type LoanAprCurrencyCode } from "./currency";
|
|
4
|
+
import { getDifferenceLabel, getScenarioBadge } from "./evaluator";
|
|
5
|
+
|
|
6
|
+
function money(
|
|
7
|
+
value: number,
|
|
8
|
+
locale: string,
|
|
9
|
+
currency: LoanAprCurrencyCode,
|
|
10
|
+
): string {
|
|
11
|
+
return new Intl.NumberFormat(currencyFormatLocale[currency] ?? locale, {
|
|
12
|
+
style: "currency",
|
|
13
|
+
currency,
|
|
14
|
+
maximumFractionDigits: 2,
|
|
15
|
+
}).format(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function percent(value: number): string {
|
|
19
|
+
return `${value.toFixed(2)}%`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function escapeText(value: string): string {
|
|
23
|
+
return value.replace(
|
|
24
|
+
/[&<>"']/g,
|
|
25
|
+
(character) =>
|
|
26
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
27
|
+
character
|
|
28
|
+
] ?? character,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type MetricTone = "better" | "worse" | "neutral" | "";
|
|
33
|
+
|
|
34
|
+
function metric(
|
|
35
|
+
label: string,
|
|
36
|
+
value: string,
|
|
37
|
+
emphasis = false,
|
|
38
|
+
tone: MetricTone = "",
|
|
39
|
+
): string {
|
|
40
|
+
const classes = [
|
|
41
|
+
"loan-apr-metric",
|
|
42
|
+
emphasis ? "loan-apr-metric-emphasis" : "",
|
|
43
|
+
tone ? `loan-apr-metric-${tone}` : "",
|
|
44
|
+
]
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join(" ");
|
|
47
|
+
return `<div class="${classes}"><dt>${escapeText(label)}</dt><dd>${escapeText(value)}</dd></div>`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function timeline(input: {
|
|
51
|
+
result: LoanScenarioResult;
|
|
52
|
+
side: "a" | "b";
|
|
53
|
+
ui: LoanAprScenarioCalculatorUI;
|
|
54
|
+
locale: string;
|
|
55
|
+
currency: LoanAprCurrencyCode;
|
|
56
|
+
}): string {
|
|
57
|
+
const { result, side, ui, locale, currency } = input;
|
|
58
|
+
const firstPayment = money(
|
|
59
|
+
result.monthlyPayment + result.monthlyFees,
|
|
60
|
+
locale,
|
|
61
|
+
currency,
|
|
62
|
+
);
|
|
63
|
+
const finalPayment = money(
|
|
64
|
+
result.monthlyPayment + result.monthlyFees + result.finalFees,
|
|
65
|
+
locale,
|
|
66
|
+
currency,
|
|
67
|
+
);
|
|
68
|
+
return `<div class="loan-apr-timeline loan-apr-timeline-${side}" aria-label="${escapeText(result.name)} ${escapeText(ui.labelTimeline)}"><div class="loan-apr-timeline-track"><span class="loan-apr-timeline-dot loan-apr-timeline-start"></span><span class="loan-apr-timeline-line"></span><span class="loan-apr-timeline-dot loan-apr-timeline-end"></span></div><div class="loan-apr-timeline-labels"><span>${escapeText(ui.labelStart)}<strong>${escapeText(money(result.netCashReceived, locale, currency))}</strong></span><span>${escapeText(ui.labelMonthly)}<strong>${escapeText(firstPayment)}</strong></span><span>${escapeText(ui.labelEnd)}<strong>${escapeText(finalPayment)}</strong></span></div></div>`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function errorMessage(
|
|
72
|
+
error: string | undefined,
|
|
73
|
+
ui: LoanAprScenarioCalculatorUI,
|
|
74
|
+
): string {
|
|
75
|
+
if (error === "principal") return ui.labelInvalidPrincipal;
|
|
76
|
+
if (error === "term") return ui.labelInvalidTerm;
|
|
77
|
+
if (error === "fees") return ui.labelInvalidFees;
|
|
78
|
+
if (error === "rate") return ui.labelInvalidRate;
|
|
79
|
+
return ui.labelInvalidPayment;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ViewContext {
|
|
83
|
+
ui: LoanAprScenarioCalculatorUI;
|
|
84
|
+
locale: string;
|
|
85
|
+
currency: LoanAprCurrencyCode;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function badgeText(
|
|
89
|
+
badge: ReturnType<typeof getScenarioBadge>,
|
|
90
|
+
ui: LoanAprScenarioCalculatorUI,
|
|
91
|
+
): string {
|
|
92
|
+
if (badge === "lower") return ui.labelLowerCost;
|
|
93
|
+
if (badge === "higher") return ui.labelHigherCost;
|
|
94
|
+
if (badge === "neutral") return ui.labelTie;
|
|
95
|
+
return ui.labelNeedValidInputs;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function metricTone(
|
|
99
|
+
current: number,
|
|
100
|
+
other: number,
|
|
101
|
+
higherIsBetter = false,
|
|
102
|
+
): MetricTone {
|
|
103
|
+
if (!Number.isFinite(current) || !Number.isFinite(other)) return "";
|
|
104
|
+
if (Math.abs(current - other) < 0.005) return "neutral";
|
|
105
|
+
const currentIsBetter = higherIsBetter ? current > other : current < other;
|
|
106
|
+
return currentIsBetter ? "better" : "worse";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function visualComparison(
|
|
110
|
+
first: LoanScenarioResult,
|
|
111
|
+
second: LoanScenarioResult,
|
|
112
|
+
comparison: ComparisonResult,
|
|
113
|
+
ui: LoanAprScenarioCalculatorUI,
|
|
114
|
+
): string {
|
|
115
|
+
const firstBadge = getScenarioBadge("a", first, comparison);
|
|
116
|
+
const secondBadge = getScenarioBadge("b", second, comparison);
|
|
117
|
+
const aprValue = (result: LoanScenarioResult): string =>
|
|
118
|
+
result.valid ? percent(result.effectiveApr) : "-";
|
|
119
|
+
return `<div class="loan-apr-visual-comparison" aria-label="${escapeText(ui.labelEffectiveApr)}"><div class="loan-apr-visual-side loan-apr-visual-${firstBadge}"><span class="loan-apr-visual-index">A</span><span class="loan-apr-visual-name">${escapeText(first.name)}</span><strong>${escapeText(aprValue(first))}</strong><small>${escapeText(badgeText(firstBadge, ui))}</small></div><div class="loan-apr-visual-vs" aria-hidden="true">VS</div><div class="loan-apr-visual-side loan-apr-visual-${secondBadge}"><span class="loan-apr-visual-index">B</span><span class="loan-apr-visual-name">${escapeText(second.name)}</span><strong>${escapeText(aprValue(second))}</strong><small>${escapeText(badgeText(secondBadge, ui))}</small></div></div>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function scenarioPanel(input: {
|
|
123
|
+
result: LoanScenarioResult;
|
|
124
|
+
other: LoanScenarioResult;
|
|
125
|
+
side: "a" | "b";
|
|
126
|
+
comparison: ComparisonResult;
|
|
127
|
+
context: ViewContext;
|
|
128
|
+
}): string {
|
|
129
|
+
const { result, other, side, comparison, context } = input;
|
|
130
|
+
const { ui, locale, currency } = context;
|
|
131
|
+
const badge = getScenarioBadge(side, result, comparison);
|
|
132
|
+
const statusText = badgeText(badge, ui);
|
|
133
|
+
if (!result.valid)
|
|
134
|
+
return `<article class="loan-apr-result-panel loan-apr-result-invalid"><header><span class="loan-apr-result-index">${side.toUpperCase()}</span><h3>${escapeText(result.name)}</h3><span class="loan-apr-badge loan-apr-badge-invalid">${escapeText(statusText)}</span></header><p>${escapeText(errorMessage(result.error, ui))}</p></article>`;
|
|
135
|
+
return `<article class="loan-apr-result-panel loan-apr-result-${badge}"><header><span class="loan-apr-result-index">${side.toUpperCase()}</span><h3>${escapeText(result.name)}</h3><span class="loan-apr-badge">${escapeText(statusText)}</span></header>${timeline({ result, side, ui, locale, currency })}<dl class="loan-apr-metrics">${metric(ui.labelEffectiveApr, percent(result.effectiveApr), true, metricTone(result.effectiveApr, other.effectiveApr))}${metric(ui.labelMonthlyPaymentResult, money(result.monthlyPayment, locale, currency), false, metricTone(result.monthlyPayment, other.monthlyPayment))}${metric(ui.labelTotalPaid, money(result.totalPaid, locale, currency), false, metricTone(result.totalPaid, other.totalPaid))}${metric(ui.labelTotalFees, money(result.totalFees, locale, currency), false, metricTone(result.totalFees, other.totalFees))}${metric(ui.labelFinanceCost, money(result.financeCost, locale, currency), false, metricTone(result.financeCost, other.financeCost))}${metric(ui.labelNetCashReceived, money(result.netCashReceived, locale, currency), false, metricTone(result.netCashReceived, other.netCashReceived, true))}</dl></article>`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function winnerText(
|
|
139
|
+
first: LoanScenarioResult,
|
|
140
|
+
second: LoanScenarioResult,
|
|
141
|
+
comparison: ComparisonResult,
|
|
142
|
+
ui: LoanAprScenarioCalculatorUI,
|
|
143
|
+
): string {
|
|
144
|
+
if (comparison.winner === "a")
|
|
145
|
+
return `${first.name} ${ui.labelLowerCost.toLowerCase()}`;
|
|
146
|
+
if (comparison.winner === "b")
|
|
147
|
+
return `${second.name} ${ui.labelLowerCost.toLowerCase()}`;
|
|
148
|
+
if (comparison.winner === "tie") return ui.labelTie;
|
|
149
|
+
return ui.labelNoComparison;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function renderComparison(input: {
|
|
153
|
+
root: HTMLElement;
|
|
154
|
+
first: LoanScenarioResult;
|
|
155
|
+
second: LoanScenarioResult;
|
|
156
|
+
comparison: ComparisonResult;
|
|
157
|
+
context: ViewContext;
|
|
158
|
+
}): void {
|
|
159
|
+
const { root, first, second, comparison, context } = input;
|
|
160
|
+
const { ui } = context;
|
|
161
|
+
root.innerHTML = `<div class="loan-apr-result-intro"><div><h2>${escapeText(ui.labelWinner)}</h2></div><p>${escapeText(winnerText(first, second, comparison, ui))}<br><span>${escapeText(getDifferenceLabel(comparison))}</span></p></div>${visualComparison(first, second, comparison, ui)}<div class="loan-apr-result-panels">${scenarioPanel({ result: first, other: second, side: "a", comparison, context })}${scenarioPanel({ result: second, other: first, side: "b", comparison, context })}</div><div class="loan-apr-method"><span>${escapeText(ui.labelFormula)}</span><p>${escapeText(ui.labelFormulaText)}</p><small>${escapeText(ui.labelApproximation)}</small></div>`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function copyWinner(
|
|
165
|
+
first: LoanScenarioResult,
|
|
166
|
+
second: LoanScenarioResult,
|
|
167
|
+
comparison: ComparisonResult,
|
|
168
|
+
ui: LoanAprScenarioCalculatorUI,
|
|
169
|
+
): string {
|
|
170
|
+
if (comparison.winner === "a") return first.name;
|
|
171
|
+
if (comparison.winner === "b") return second.name;
|
|
172
|
+
if (comparison.winner === "tie") return ui.labelTie;
|
|
173
|
+
return ui.labelNoComparison;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function copyLine(result: LoanScenarioResult, context: ViewContext): string {
|
|
177
|
+
if (!result.valid)
|
|
178
|
+
return `${result.name}: ${context.ui.labelNeedValidInputs}`;
|
|
179
|
+
return `${result.name}: ${percent(result.effectiveApr)} effective APR, ${money(result.monthlyPayment, context.locale, context.currency)} monthly payment, ${money(result.totalPaid, context.locale, context.currency)} total paid`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function formatResultForCopy(input: {
|
|
183
|
+
first: LoanScenarioResult;
|
|
184
|
+
second: LoanScenarioResult;
|
|
185
|
+
comparison: ComparisonResult;
|
|
186
|
+
context: ViewContext;
|
|
187
|
+
}): string {
|
|
188
|
+
const { first, second, comparison, context } = input;
|
|
189
|
+
return `${context.ui.labelWinner}: ${copyWinner(first, second, comparison, context.ui)}\n${copyLine(first, context)}\n${copyLine(second, context)}`;
|
|
190
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FinanceToolEntry, ToolLocaleContent } from "../../types";
|
|
2
|
+
import type { LoanAprScenarioCalculatorUI } from "./ui";
|
|
3
|
+
|
|
4
|
+
export type LoanAprScenarioCalculatorLocaleContent =
|
|
5
|
+
ToolLocaleContent<LoanAprScenarioCalculatorUI>;
|
|
6
|
+
|
|
7
|
+
export const loanAprScenarioCalculator: FinanceToolEntry<LoanAprScenarioCalculatorUI> =
|
|
8
|
+
{
|
|
9
|
+
id: "loan-apr-scenario-calculator",
|
|
10
|
+
icons: { bg: "mdi:cash-multiple", fg: "mdi:scale-balance" },
|
|
11
|
+
i18n: {
|
|
12
|
+
de: () => import("./i18n/de").then((module) => module.content),
|
|
13
|
+
en: () => import("./i18n/en").then((module) => module.content),
|
|
14
|
+
es: () => import("./i18n/es").then((module) => module.content),
|
|
15
|
+
fr: () => import("./i18n/fr").then((module) => module.content),
|
|
16
|
+
id: () => import("./i18n/id").then((module) => module.content),
|
|
17
|
+
it: () => import("./i18n/it").then((module) => module.content),
|
|
18
|
+
ja: () => import("./i18n/ja").then((module) => module.content),
|
|
19
|
+
ko: () => import("./i18n/ko").then((module) => module.content),
|
|
20
|
+
nl: () => import("./i18n/nl").then((module) => module.content),
|
|
21
|
+
pl: () => import("./i18n/pl").then((module) => module.content),
|
|
22
|
+
pt: () => import("./i18n/pt").then((module) => module.content),
|
|
23
|
+
ru: () => import("./i18n/ru").then((module) => module.content),
|
|
24
|
+
sv: () => import("./i18n/sv").then((module) => module.content),
|
|
25
|
+
tr: () => import("./i18n/tr").then((module) => module.content),
|
|
26
|
+
zh: () => import("./i18n/zh").then((module) => module.content),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ComparisonResult, LoanScenarioResult } from "./logic";
|
|
2
|
+
|
|
3
|
+
export type ScenarioBadge = "lower" | "higher" | "neutral" | "invalid";
|
|
4
|
+
|
|
5
|
+
export function getScenarioBadge(
|
|
6
|
+
side: "a" | "b",
|
|
7
|
+
result: LoanScenarioResult,
|
|
8
|
+
comparison: ComparisonResult,
|
|
9
|
+
): ScenarioBadge {
|
|
10
|
+
if (!result.valid || comparison.message === "invalid") return "invalid";
|
|
11
|
+
if (comparison.winner === "tie") return "neutral";
|
|
12
|
+
return comparison.winner === side ? "lower" : "higher";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getDifferenceLabel(comparison: ComparisonResult): string {
|
|
16
|
+
if (comparison.message === "invalid")
|
|
17
|
+
return "Enter valid values to compare both offers.";
|
|
18
|
+
if (comparison.message === "tie")
|
|
19
|
+
return "The two scenarios produce nearly the same effective annual cost.";
|
|
20
|
+
return `${comparison.difference.toFixed(2)} percentage points separate the effective annual costs.`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { ToolLocaleContent } from "../../../types";
|
|
2
|
+
import { bibliography } from "../bibliography";
|
|
3
|
+
import type { LoanAprScenarioCalculatorUI } from "../ui";
|
|
4
|
+
|
|
5
|
+
const ui: LoanAprScenarioCalculatorUI = {
|
|
6
|
+
labelScenarioA: "Angebot A",
|
|
7
|
+
labelScenarioB: "Angebot B",
|
|
8
|
+
labelScenarioName: "Name des Angebots",
|
|
9
|
+
labelCurrency: "Währung",
|
|
10
|
+
labelApproximateRates:
|
|
11
|
+
"Näherungswerte für die Planung, keine Live-Wechselkurse.",
|
|
12
|
+
labelPrincipal: "Geliehener Betrag",
|
|
13
|
+
labelTerm: "Laufzeit",
|
|
14
|
+
labelMonths: "Monate",
|
|
15
|
+
labelInputBasis: "Welche Angabe kennen Sie?",
|
|
16
|
+
labelAnnualRate: "Nominaler Jahreszins",
|
|
17
|
+
labelMonthlyPayment: "Bekannte Monatsrate",
|
|
18
|
+
labelUpfrontFees: "Anfangsgebühren",
|
|
19
|
+
labelMonthlyFees: "Monatliche Gebühren",
|
|
20
|
+
labelFinalFees: "Schlussgebühr",
|
|
21
|
+
labelOfferNameA: "Angebot A",
|
|
22
|
+
labelOfferNameB: "Angebot B",
|
|
23
|
+
labelRateMode: "Ich kenne den Zinssatz",
|
|
24
|
+
labelPaymentMode: "Ich kenne die Monatsrate",
|
|
25
|
+
labelPreset: "Vergleich starten",
|
|
26
|
+
labelPresetBalanced: "Ausgewogene Angebote",
|
|
27
|
+
labelPresetLowFee: "Niedriger Zins mit Gebühren",
|
|
28
|
+
labelPresetLongTerm: "Längere Laufzeit",
|
|
29
|
+
labelReset: "Werte zurücksetzen",
|
|
30
|
+
labelCopy: "Vergleich kopieren",
|
|
31
|
+
labelCopied: "Vergleich kopiert",
|
|
32
|
+
labelEffectiveApr: "Effektive jährliche Kosten",
|
|
33
|
+
labelNominalRate: "Nominalzins",
|
|
34
|
+
labelMonthlyPaymentResult: "Monatsrate",
|
|
35
|
+
labelTotalPaid: "Gesamtzahlung",
|
|
36
|
+
labelTotalFees: "Gebühren im Szenario",
|
|
37
|
+
labelFinanceCost: "Kosten über dem Darlehen",
|
|
38
|
+
labelNetCashReceived: "Auszahlung nach Anfangsgebühren",
|
|
39
|
+
labelWinner: "Welches Angebot kostet weniger?",
|
|
40
|
+
labelLowerCost: "Geringere effektive Kosten",
|
|
41
|
+
labelHigherCost: "Höhere effektive Kosten",
|
|
42
|
+
labelTie: "Fast gleich",
|
|
43
|
+
labelNeedValidInputs: "Gültige Angaben erforderlich",
|
|
44
|
+
labelInvalidPrincipal:
|
|
45
|
+
"Der geliehene Betrag muss größer als null und höher als die Anfangsgebühren sein.",
|
|
46
|
+
labelInvalidTerm: "Verwenden Sie eine ganze Laufzeit von 1 bis 600 Monaten.",
|
|
47
|
+
labelInvalidFees: "Gebühren dürfen nicht negativ sein.",
|
|
48
|
+
labelInvalidPayment:
|
|
49
|
+
"Geben Sie eine positive Monatsrate ein, die die Auszahlung zurückzahlt.",
|
|
50
|
+
labelInvalidRate:
|
|
51
|
+
"Verwenden Sie einen nominalen Jahreszins von 0 % bis 100 %.",
|
|
52
|
+
labelNoComparison: "Vervollständigen Sie beide Angebote für den Vergleich.",
|
|
53
|
+
labelTimeline: "Zahlungsverlauf",
|
|
54
|
+
labelStart: "Start",
|
|
55
|
+
labelMonthly: "Monatlich",
|
|
56
|
+
labelEnd: "Letzter Monat",
|
|
57
|
+
labelFormula: "So funktioniert der Vergleich",
|
|
58
|
+
labelFormulaText:
|
|
59
|
+
"Die effektiven jährlichen Kosten sind der annualisierte Monatszins, bei dem Auszahlung und abgezinste Raten samt Gebühren übereinstimmen.",
|
|
60
|
+
labelApproximation:
|
|
61
|
+
"Die Schätzung setzt gleiche Monatsraten, keine tilgungsfreie Zeit und nur die eingegebenen Gebühren voraus. Sie ersetzt nicht die Angaben des Kreditgebers.",
|
|
62
|
+
labelScenarioSummary: "Szenarioübersicht",
|
|
63
|
+
currencyCode: "EUR",
|
|
64
|
+
currencyLocale: "de-DE",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const faq = [
|
|
68
|
+
{
|
|
69
|
+
question:
|
|
70
|
+
"Was ist der Unterschied zwischen Zinssatz und effektiven jährlichen Kosten?",
|
|
71
|
+
answer:
|
|
72
|
+
"Der nominale Zinssatz beschreibt die Zinsen auf den Saldo. Die effektiven jährlichen Kosten berücksichtigen zusätzlich Zeitpunkt und Höhe der eingegebenen Anfangs-, Monats- und Schlussgebühren.",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
question: "Kann ich statt eines Zinssatzes eine Monatsrate eingeben?",
|
|
76
|
+
answer:
|
|
77
|
+
"Ja. Wählen Sie für das Angebot die bekannte Monatsrate. Der Rechner ermittelt den Monatszins, der Auszahlung, Zahlungsstrom und Gebühren dieses Szenarios entspricht.",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
question: "Welches Angebot ist günstiger?",
|
|
81
|
+
answer:
|
|
82
|
+
"Vergleichen Sie zuerst die effektiven jährlichen Kosten und prüfen Sie danach Gesamtzahlung und Auszahlung. Bei unterschiedlicher Laufzeit oder Kredithöhe sollte die Entscheidung alle drei Signale berücksichtigen.",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
question: "Sind das die offiziellen Kreditkosten des Anbieters?",
|
|
86
|
+
answer:
|
|
87
|
+
"Nein. Es ist eine transparente Szenarioschätzung anhand Ihrer Eingaben. Kreditgeber und Länder können Kosten, Zahlungstermine, Steuern und Versicherungen anders berücksichtigen.",
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const howTo = [
|
|
92
|
+
{
|
|
93
|
+
name: "Betrag und Laufzeit eingeben",
|
|
94
|
+
text: "Geben Sie für jedes Angebot den ausgezahlten Betrag und die Zahl der Monatsraten ein.",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "Bekannte Angabe auswählen",
|
|
98
|
+
text: "Geben Sie je nach Angebot den nominalen Jahreszins oder die bekannte Monatsrate ein.",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: "Gebühren ergänzen",
|
|
102
|
+
text: "Tragen Sie Gebühren zu Beginn, pro Monat und am Ende ein, damit der Zahlungsstrom vollständig ist.",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: "Vergleich lesen",
|
|
106
|
+
text: "Nutzen Sie die effektiven jährlichen Kosten und prüfen Sie anschließend Gesamtzahlung und Auszahlung.",
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
const schemas = [
|
|
111
|
+
{
|
|
112
|
+
"@context": "https://schema.org",
|
|
113
|
+
"@type": "SoftwareApplication",
|
|
114
|
+
name: "Kredit APR Szenario Rechner",
|
|
115
|
+
description:
|
|
116
|
+
"Vergleicht zwei Kreditszenarien mit Zinsen, Raten und Gebühren.",
|
|
117
|
+
applicationCategory: "FinanceApplication",
|
|
118
|
+
operatingSystem: "All",
|
|
119
|
+
offers: { "@type": "Offer", price: "0", priceCurrency: "EUR" },
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"@context": "https://schema.org",
|
|
123
|
+
"@type": "FAQPage",
|
|
124
|
+
mainEntity: faq.map((item) => ({
|
|
125
|
+
"@type": "Question",
|
|
126
|
+
name: item.question,
|
|
127
|
+
acceptedAnswer: { "@type": "Answer", text: item.answer },
|
|
128
|
+
})),
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"@context": "https://schema.org",
|
|
132
|
+
"@type": "HowTo",
|
|
133
|
+
name: "Zwei Kreditangebote vergleichen",
|
|
134
|
+
description: "Vergleicht Zahlungsströme und effektive jährliche Kosten.",
|
|
135
|
+
step: howTo.map((step, index) => ({
|
|
136
|
+
"@type": "HowToStep",
|
|
137
|
+
position: index + 1,
|
|
138
|
+
name: step.name,
|
|
139
|
+
text: step.text,
|
|
140
|
+
})),
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
export const content: ToolLocaleContent<LoanAprScenarioCalculatorUI> = {
|
|
145
|
+
slug: "kredit-apr-szenario-rechner",
|
|
146
|
+
title: "Kredit APR Szenario Rechner",
|
|
147
|
+
description:
|
|
148
|
+
"Vergleichen Sie zwei Kreditangebote mit Zinsen, Zahlungsplan und Gebühren und schätzen Sie die effektiven jährlichen Kosten.",
|
|
149
|
+
ui,
|
|
150
|
+
faq,
|
|
151
|
+
bibliography,
|
|
152
|
+
howTo,
|
|
153
|
+
schemas,
|
|
154
|
+
seo: [
|
|
155
|
+
{
|
|
156
|
+
type: "title",
|
|
157
|
+
text: "Kreditangebote nach effektiven Kosten vergleichen",
|
|
158
|
+
level: 2,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
type: "paragraph",
|
|
162
|
+
html: "<p>Ein niedriger Werbezins kann eine Bearbeitungsgebühr, eine monatliche Servicegebühr oder eine Schlusszahlung verbergen. Dieser Rechner macht daraus zwei vergleichbare Zahlungsströme und zeigt die Kosten des tatsächlich ausgezahlten Geldes.</p>",
|
|
163
|
+
},
|
|
164
|
+
{ type: "title", text: "Was die Schätzung enthält", level: 3 },
|
|
165
|
+
{
|
|
166
|
+
type: "paragraph",
|
|
167
|
+
html: "<p>Geben Sie Betrag, Laufzeit, Zinssatz oder Monatsrate sowie Gebühren zu verschiedenen Zeitpunkten ein. Der Rechner findet den Monatszins, der Auszahlung und abgezinste Zahlungen samt Gebühren ausgleicht, und annualisiert ihn.</p>",
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
type: "paragraph",
|
|
171
|
+
html: "<p>Damit wird der Preis eines Kredits breiter betrachtet als nur der Nominalzins. Das Ergebnis bleibt eine Schätzung, weil offizielle Angaben je nach Land weitere Kosten und Annahmen enthalten können.</p>",
|
|
172
|
+
},
|
|
173
|
+
{ type: "title", text: "Ergebnis richtig lesen", level: 3 },
|
|
174
|
+
{
|
|
175
|
+
type: "list",
|
|
176
|
+
items: [
|
|
177
|
+
"Vergleichen Sie die effektiven Kosten bei gleicher Kredithöhe und Laufzeit.",
|
|
178
|
+
"Prüfen Sie die Gesamtzahlung für die absolute Belastung.",
|
|
179
|
+
"Beachten Sie die Auszahlung nach Anfangsgebühren.",
|
|
180
|
+
"Nutzen Sie den Zahlungsverlauf, um den Zeitpunkt jeder Kostenart zu sehen.",
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
type: "tip",
|
|
185
|
+
title: "Kreditunterlagen bleiben die Referenz",
|
|
186
|
+
html: "<p>Vergleichen Sie bei einem echten Vertrag die offiziellen Angaben des Kreditgebers mit denselben Annahmen. Fragen Sie nach Versicherungen, Steuern, variablen Zinsen und vorzeitigen Rückzahlungskosten.</p>",
|
|
187
|
+
},
|
|
188
|
+
{ type: "title", text: "Grenzen der Szenarioschätzung", level: 3 },
|
|
189
|
+
{
|
|
190
|
+
type: "paragraph",
|
|
191
|
+
html: "<p>Das Tool setzt gleiche Monatsraten, keine tilgungsfreie Zeit und Gebühren zu Beginn, während der Laufzeit oder im letzten Monat voraus. Es bestätigt weder Leistbarkeit noch Genehmigung oder den offiziellen APR eines Produkts.</p>",
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
};
|