@jjlmoya/utils-finance 1.42.0 → 1.44.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 -1
- package/src/entries.ts +7 -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/capitalGainsTaxScenarioCalculator/bibliography.astro +11 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/bibliography.ts +12 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/capital-gains-tax-scenario-calculator.css +469 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/component.astro +328 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/entry.ts +26 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/de.ts +3 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/en.ts +181 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/es.ts +3 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/fr.ts +3 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/id.ts +3 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/it.ts +3 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/ja.ts +3 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/ko.ts +3 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/nl.ts +3 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/pl.ts +3 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/pt.ts +3 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/ru.ts +3 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/shared.ts +153 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/sv.ts +3 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/tr.ts +3 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/i18n/zh.ts +3 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/index.ts +11 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/logic.test.ts +71 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/logic.ts +137 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/seo.astro +11 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/ui.ts +40 -0
- package/src/tool/capitalGainsTaxScenarioCalculator/validation.ts +34 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/bibliography.astro +10 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/bibliography.ts +6 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/component.astro +172 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/currency.test.ts +13 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/currency.ts +26 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/entry.ts +28 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/content-factory.ts +147 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/de.ts +2 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/en.ts +100 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/es.ts +2 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/fr.ts +2 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/id.ts +2 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/it.ts +2 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/ja.ts +2 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/ko.ts +2 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/nl.ts +2 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/pl.ts +2 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/pt.ts +2 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/ru.ts +2 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/sv.ts +2 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/tr.ts +2 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/i18n/zh.ts +2 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/index.ts +11 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/insurance-deductible-break-even-calculator.css +424 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/logic.test.ts +40 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/logic.ts +72 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/seo.astro +11 -0
- package/src/tool/insuranceDeductibleBreakEvenCalculator/ui.ts +33 -0
- package/src/tools.ts +3 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { capitalGainsTaxScenarioCalculator } from './entry';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props { locale?: KnownLocale; }
|
|
7
|
+
const { locale = 'en' } = Astro.props;
|
|
8
|
+
const content = await capitalGainsTaxScenarioCalculator.i18n[locale]?.() ?? await capitalGainsTaxScenarioCalculator.i18n.en?.();
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
{content?.seo && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface CapitalGainsTaxScenarioCalculatorUI extends Record<string, string> {
|
|
2
|
+
labelPurchasePrice: string;
|
|
3
|
+
labelAcquisitionCosts: string;
|
|
4
|
+
labelImprovements: string;
|
|
5
|
+
labelSalePrice: string;
|
|
6
|
+
labelSellingCosts: string;
|
|
7
|
+
labelTaxRate: string;
|
|
8
|
+
labelAlternativeSalePrice: string;
|
|
9
|
+
labelAlternativeSellingCosts: string;
|
|
10
|
+
labelCurrency: string;
|
|
11
|
+
labelCurrentScenario: string;
|
|
12
|
+
labelAlternativeScenario: string;
|
|
13
|
+
labelAcquisitionBasis: string;
|
|
14
|
+
labelNetSaleValue: string;
|
|
15
|
+
labelGainOrLoss: string;
|
|
16
|
+
labelEstimatedTax: string;
|
|
17
|
+
labelNetGainAfterTax: string;
|
|
18
|
+
labelNetProceeds: string;
|
|
19
|
+
labelBetterScenario: string;
|
|
20
|
+
labelCurrentIsBetter: string;
|
|
21
|
+
labelAlternativeIsBetter: string;
|
|
22
|
+
labelSameOutcome: string;
|
|
23
|
+
labelGainStatus: string;
|
|
24
|
+
labelLossStatus: string;
|
|
25
|
+
labelBreakEvenStatus: string;
|
|
26
|
+
labelTaxableGainNote: string;
|
|
27
|
+
labelLossNote: string;
|
|
28
|
+
labelVisual: string;
|
|
29
|
+
labelComparison: string;
|
|
30
|
+
labelDifference: string;
|
|
31
|
+
labelCopy: string;
|
|
32
|
+
labelCopied: string;
|
|
33
|
+
labelReset: string;
|
|
34
|
+
labelInvalidInput: string;
|
|
35
|
+
labelMethodTitle: string;
|
|
36
|
+
labelMethodText: string;
|
|
37
|
+
labelPrivacyNote: string;
|
|
38
|
+
labelAmountPlaceholder: string;
|
|
39
|
+
labelPercentPlaceholder: string;
|
|
40
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const validationEvidence = {
|
|
2
|
+
reviewedAt: '2026-09-06',
|
|
3
|
+
methodology:
|
|
4
|
+
'The tool estimates a user-defined scenario as net sale value minus an entered acquisition basis, then applies the entered percentage only to a positive gain. It is a planning model, not a tax return or a jurisdiction-specific assessment.',
|
|
5
|
+
sources: [
|
|
6
|
+
{
|
|
7
|
+
title: 'Agencia Tributaria: Reglas generales de cálculo',
|
|
8
|
+
url: 'https://sede.agenciatributaria.gob.es/Sede/ayuda/manuales-videos-folletos/manuales-practicos/irpf-2025/c11-ganancias-perdidas-patrimoniales/determinacion-importe-ganancias-perdidas-patrimon-generales/derivadas-transmisiones-onerosas-lucrativas/reglas-generales-calculo.html',
|
|
9
|
+
role: 'Primary Spanish reference for acquisition and transmission value components.',
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
title: 'IRS Publication 551: Basis of Assets',
|
|
13
|
+
url: 'https://www.irs.gov/publications/p551',
|
|
14
|
+
role: 'Primary reference for basis and sale gain/loss concepts.',
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
referenceCases: [
|
|
18
|
+
{
|
|
19
|
+
name: 'Gain scenario',
|
|
20
|
+
input: { purchasePrice: 100000, acquisitionCosts: 5000, improvements: 10000, salePrice: 180000, sellingCosts: 6000, taxRate: 20 },
|
|
21
|
+
expected: { acquisitionBasis: 115000, gainOrLoss: 59000, estimatedTax: 11800, netGainAfterTax: 47200 },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'Loss scenario',
|
|
25
|
+
input: { purchasePrice: 100000, acquisitionCosts: 5000, improvements: 10000, salePrice: 90000, sellingCosts: 6000, taxRate: 20 },
|
|
26
|
+
expected: { taxableGain: 0, estimatedTax: 0 },
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
limitations: [
|
|
30
|
+
'The entered percentage is not looked up or validated against any country, year, asset type or taxpayer situation.',
|
|
31
|
+
'The model does not include allowances, losses carried forward, depreciation, exemptions, inflation adjustments, separate improvements, or special regimes.',
|
|
32
|
+
'Use the result to compare private scenarios and prepare questions; consult the relevant tax authority or a qualified adviser for a filing.',
|
|
33
|
+
],
|
|
34
|
+
} as const;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { insuranceDeductibleBreakEvenCalculator } from './entry';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
interface Props { locale?: KnownLocale; }
|
|
6
|
+
const { locale = 'en' } = Astro.props;
|
|
7
|
+
const content = await insuranceDeductibleBreakEvenCalculator.i18n[locale]?.() ?? await insuranceDeductibleBreakEvenCalculator.i18n.en?.();
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
{content && <SharedBibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{ name: 'NAIC: Homeowners insurance deductibles and claims', url: 'https://content.naic.org/insurance-topics/homeowners-insurance' },
|
|
5
|
+
{ name: 'NAIC: How does insurance work?', url: 'https://content.naic.org/consumer/how-does-insurance-work' },
|
|
6
|
+
];
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { KnownLocale } from '../../types';
|
|
3
|
+
import { currencyOptions, defaultCurrencyByLocale } from './currency';
|
|
4
|
+
import type { InsuranceDeductibleInput } from './logic';
|
|
5
|
+
import type { InsuranceDeductibleBreakEvenCalculatorUI } from './ui';
|
|
6
|
+
|
|
7
|
+
interface Props { locale?: KnownLocale; ui?: InsuranceDeductibleBreakEvenCalculatorUI; }
|
|
8
|
+
const { ui } = Astro.props;
|
|
9
|
+
const t = ui as InsuranceDeductibleBreakEvenCalculatorUI;
|
|
10
|
+
const currentLocale = Astro.props.locale ?? 'en';
|
|
11
|
+
const defaultCurrency = defaultCurrencyByLocale[currentLocale] ?? 'USD';
|
|
12
|
+
const initial: InsuranceDeductibleInput = { currency: defaultCurrency, damageCost: 2500, deductible: 500, annualPremium: 1200, premiumIncreasePercent: 10, surchargeYears: 3 };
|
|
13
|
+
const config = JSON.stringify({ ui: t, initial, locale: currentLocale, currency: defaultCurrency, currencies: currencyOptions });
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<div class="idb-tool" data-idb-tool data-claim={t.labelClaimRecommended} data-pay-yourself={t.labelPayYourselfRecommended} data-break-even={t.labelBreakEvenRecommendation} data-invalid={t.labelInvalidInput} data-copied={t.labelCopied} data-copy-failed={t.labelCopyFailed} data-locale={currentLocale}>
|
|
17
|
+
<form class="idb-card" data-idb-form novalidate>
|
|
18
|
+
<div class="idb-glance" aria-live="polite">
|
|
19
|
+
<div><span>{t.labelRecommendation}</span><strong data-idb-recommendation>{t.labelClaimRecommended}</strong></div>
|
|
20
|
+
<div><span>{t.labelDifference}</span><strong data-idb-difference>0</strong></div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="idb-inputs">
|
|
24
|
+
<div class="idb-field idb-currency-field">
|
|
25
|
+
<span class="idb-label" id="idb-currency-label">{t.labelCurrency}</span>
|
|
26
|
+
<div class="idb-currency-select">
|
|
27
|
+
<button type="button" class="idb-currency-trigger" data-idb-currency-trigger aria-expanded="false" aria-haspopup="listbox" aria-labelledby="idb-currency-label">
|
|
28
|
+
<span class="idb-currency-trigger-main"><strong data-idb-selected-symbol>$</strong><span data-idb-selected-code>{defaultCurrency}</span></span><span class="idb-currency-arrow" aria-hidden="true"></span>
|
|
29
|
+
</button>
|
|
30
|
+
<div class="idb-currency-menu" data-idb-currency-menu role="listbox" aria-label={t.labelCurrency} hidden>
|
|
31
|
+
{currencyOptions.map(({ code, symbol, name }) => <button type="button" class="idb-currency-option" data-idb-currency={code} role="option" aria-selected={code === defaultCurrency} aria-label={`${symbol} ${name}`}><strong>{symbol}</strong><span>{code}</span></button>)}
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
<small class="idb-help">{t.labelApproximateRates}</small>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="idb-field">
|
|
37
|
+
<label for="idb-damage">{t.labelDamageCost}</label>
|
|
38
|
+
<div class="idb-input-wrap"><input id="idb-damage" data-field="damageCost" type="number" min="0.01" max="1000000000000" step="0.01" value={initial.damageCost} placeholder={t.labelAmountPlaceholder} inputmode="decimal" required /><span data-idb-unit aria-hidden="true">$</span></div>
|
|
39
|
+
<small data-error-for="damageCost" hidden></small>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="idb-field">
|
|
42
|
+
<label for="idb-deductible">{t.labelDeductible}</label>
|
|
43
|
+
<div class="idb-input-wrap"><input id="idb-deductible" data-field="deductible" type="number" min="0" max="1000000000000" step="0.01" value={initial.deductible} placeholder={t.labelAmountPlaceholder} inputmode="decimal" required /><span data-idb-unit aria-hidden="true">$</span></div>
|
|
44
|
+
<small data-error-for="deductible" hidden></small>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="idb-field">
|
|
47
|
+
<label for="idb-premium">{t.labelAnnualPremium}</label>
|
|
48
|
+
<div class="idb-input-wrap"><input id="idb-premium" data-field="annualPremium" type="number" min="0" max="1000000000000" step="0.01" value={initial.annualPremium} placeholder={t.labelAmountPlaceholder} inputmode="decimal" required /><span data-idb-unit aria-hidden="true">$</span></div>
|
|
49
|
+
<small data-error-for="annualPremium" hidden></small>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="idb-field">
|
|
52
|
+
<label for="idb-increase">{t.labelPremiumIncrease}</label>
|
|
53
|
+
<div class="idb-input-wrap"><input id="idb-increase" data-field="premiumIncreasePercent" type="number" min="0" max="100" step="0.1" value={initial.premiumIncreasePercent} placeholder={t.labelPercentPlaceholder} inputmode="decimal" required /><span aria-hidden="true">%</span></div>
|
|
54
|
+
<small data-error-for="premiumIncreasePercent" hidden></small>
|
|
55
|
+
</div>
|
|
56
|
+
<div class="idb-field">
|
|
57
|
+
<label for="idb-years">{t.labelSurchargeYears}</label>
|
|
58
|
+
<div class="idb-input-wrap"><input id="idb-years" data-field="surchargeYears" type="number" min="0.5" max="20" step="0.5" value={initial.surchargeYears} placeholder={t.labelYearsPlaceholder} inputmode="decimal" required /><span aria-hidden="true">yr</span></div>
|
|
59
|
+
<small data-error-for="surchargeYears" hidden></small>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div class="idb-actions"><button type="button" class="idb-button idb-button-secondary" data-idb-reset>{t.labelReset}</button><button type="button" class="idb-button idb-button-primary" data-idb-copy>{t.labelCopy}</button><span data-idb-copy-status role="status" aria-live="polite"></span></div>
|
|
64
|
+
|
|
65
|
+
<section class="idb-output" aria-live="polite" aria-atomic="true">
|
|
66
|
+
<div class="idb-visual" data-idb-visual role="img" aria-label={t.labelVisual}>
|
|
67
|
+
<div class="idb-visual-heading"><span>{t.labelClaimRoute}</span><span>{t.labelPayYourselfRoute}</span></div>
|
|
68
|
+
<div class="idb-bars"><div class="idb-bar-side"><strong data-idb-claim-value>0</strong><div class="idb-bar-track"><i data-idb-claim-bar></i></div></div><div class="idb-scale" aria-hidden="true">⇄</div><div class="idb-bar-side"><strong data-idb-pay-value>0</strong><div class="idb-bar-track"><i data-idb-pay-bar></i></div></div></div>
|
|
69
|
+
</div>
|
|
70
|
+
<p class="idb-error-summary" data-idb-error-summary hidden>{t.labelInvalidInput}</p>
|
|
71
|
+
<div class="idb-result-grid">
|
|
72
|
+
<div><span>{t.labelClaimCost}</span><strong data-idb-claim-cost>0</strong></div>
|
|
73
|
+
<div><span>{t.labelPayYourselfCost}</span><strong data-idb-pay-cost>0</strong></div>
|
|
74
|
+
<div><span>{t.labelPremiumSurcharge}</span><strong data-idb-surcharge>0</strong></div>
|
|
75
|
+
<div><span>{t.labelInsurerShare}</span><strong data-idb-insurer-share>0</strong></div>
|
|
76
|
+
<div class="idb-emphasis"><span>{t.labelBreakEvenDamage}</span><strong data-idb-break-even>0</strong></div>
|
|
77
|
+
<div class="idb-emphasis"><span>{t.labelDifference}</span><strong data-idb-result-difference>0</strong></div>
|
|
78
|
+
</div>
|
|
79
|
+
<p class="idb-result-note" data-idb-result-note></p>
|
|
80
|
+
</section>
|
|
81
|
+
|
|
82
|
+
<p class="idb-privacy">{t.labelPrivacyNote}</p>
|
|
83
|
+
<details class="idb-method"><summary>{t.labelMethodTitle}</summary><p>{t.labelMethodText}</p></details>
|
|
84
|
+
</form>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<script is:inline type="application/json" data-idb-config set:html={config}></script>
|
|
88
|
+
<script>
|
|
89
|
+
import { convertInsuranceCurrency, type InsuranceCurrencyCode } from './currency';
|
|
90
|
+
import { calculateInsuranceDeductibleBreakEven, validateInsuranceInput, type InsuranceDeductibleInput } from './logic';
|
|
91
|
+
|
|
92
|
+
const configElement = document.querySelector<HTMLScriptElement>('[data-idb-config]');
|
|
93
|
+
const root = document.querySelector<HTMLElement>('[data-idb-tool]');
|
|
94
|
+
const form = root?.querySelector<HTMLFormElement>('[data-idb-form]');
|
|
95
|
+
if (root && form && configElement?.textContent) {
|
|
96
|
+
const config = JSON.parse(configElement.textContent) as { initial: InsuranceDeductibleInput; locale: string; currency: InsuranceCurrencyCode; };
|
|
97
|
+
let selectedCurrency = config.currency;
|
|
98
|
+
const fields = Array.from(form.querySelectorAll<HTMLInputElement>('[data-field]'));
|
|
99
|
+
const moneyKeys = ['damageCost', 'deductible', 'annualPremium'] as const;
|
|
100
|
+
const getNumber = (key: keyof InsuranceDeductibleInput): number => Number(form.querySelector<HTMLInputElement>(`[data-field="${key}"]`)?.value ?? Number.NaN);
|
|
101
|
+
const readInput = (): InsuranceDeductibleInput => ({ currency: selectedCurrency, damageCost: getNumber('damageCost'), deductible: getNumber('deductible'), annualPremium: getNumber('annualPremium'), premiumIncreasePercent: getNumber('premiumIncreasePercent'), surchargeYears: getNumber('surchargeYears') });
|
|
102
|
+
const formatMoney = (value: number): string => new Intl.NumberFormat(config.locale, { style: 'currency', currency: selectedCurrency, maximumFractionDigits: 2 }).format(value);
|
|
103
|
+
const formatSigned = (value: number): string => `${value > 0 ? '+' : ''}${formatMoney(value)}`;
|
|
104
|
+
const recommendationKeyFor = (recommendation: string): 'claim' | 'payYourself' | 'breakEven' => {
|
|
105
|
+
if (recommendation === 'claim') return 'claim';
|
|
106
|
+
if (recommendation === 'payYourself') return 'payYourself';
|
|
107
|
+
return 'breakEven';
|
|
108
|
+
};
|
|
109
|
+
const setText = (selector: string, value: string): void => { const element = root.querySelector<HTMLElement>(selector); if (element) element.textContent = value; };
|
|
110
|
+
const setCurrencyState = (): void => {
|
|
111
|
+
root.querySelectorAll<HTMLButtonElement>('[data-idb-currency]').forEach((button) => button.setAttribute('aria-selected', String(button.dataset.idbCurrency === selectedCurrency)));
|
|
112
|
+
const selectedOption = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-idb-currency]')).find((button) => button.dataset.idbCurrency === selectedCurrency);
|
|
113
|
+
const symbol = selectedOption?.querySelector('strong')?.textContent ?? selectedCurrency;
|
|
114
|
+
setText('[data-idb-selected-symbol]', symbol);
|
|
115
|
+
setText('[data-idb-selected-code]', selectedCurrency);
|
|
116
|
+
root.querySelectorAll<HTMLElement>('[data-idb-unit]').forEach((unit) => { unit.textContent = symbol; });
|
|
117
|
+
};
|
|
118
|
+
const clearResults = (): void => { ['claimCost', 'payCost', 'surcharge', 'insurerShare', 'breakEven', 'resultDifference', 'difference'].forEach((key) => setText(`[data-idb-${key === 'resultDifference' ? 'result-difference' : key}]`, '—')); root.querySelector<HTMLElement>('[data-idb-claim-bar]')?.style.setProperty('width', '0%'); root.querySelector<HTMLElement>('[data-idb-pay-bar]')?.style.setProperty('width', '0%'); setText('[data-idb-result-note]', ''); root.dataset.state = 'error'; };
|
|
119
|
+
const showErrors = (input: InsuranceDeductibleInput): boolean => {
|
|
120
|
+
const errors = validateInsuranceInput(input);
|
|
121
|
+
fields.forEach((field) => { const key = field.dataset.field as keyof InsuranceDeductibleInput; const invalid = Boolean(errors[key]); const error = form.querySelector<HTMLElement>(`[data-error-for="${key}"]`); field.setAttribute('aria-invalid', String(invalid)); if (error) { error.hidden = !invalid; error.textContent = invalid ? root.dataset.invalid ?? '' : ''; } });
|
|
122
|
+
const summary = root.querySelector<HTMLElement>('[data-idb-error-summary]');
|
|
123
|
+
if (summary) summary.hidden = Object.keys(errors).length === 0;
|
|
124
|
+
if (Object.keys(errors).length > 0) clearResults();
|
|
125
|
+
return Object.keys(errors).length > 0;
|
|
126
|
+
};
|
|
127
|
+
const update = (): void => {
|
|
128
|
+
const input = readInput();
|
|
129
|
+
if (showErrors(input)) return;
|
|
130
|
+
const result = calculateInsuranceDeductibleBreakEven(input);
|
|
131
|
+
if (!result) return;
|
|
132
|
+
const recommendationKey = recommendationKeyFor(result.recommendation);
|
|
133
|
+
setText('[data-idb-recommendation]', root.dataset[recommendationKey] ?? '');
|
|
134
|
+
setText('[data-idb-difference]', formatSigned(result.difference));
|
|
135
|
+
setText('[data-idb-claim-cost]', formatMoney(result.claimCost));
|
|
136
|
+
setText('[data-idb-pay-cost]', formatMoney(result.payYourselfCost));
|
|
137
|
+
setText('[data-idb-surcharge]', formatMoney(result.premiumSurcharge));
|
|
138
|
+
setText('[data-idb-insurer-share]', formatMoney(result.insurerShare));
|
|
139
|
+
setText('[data-idb-break-even]', formatMoney(result.breakEvenDamage));
|
|
140
|
+
setText('[data-idb-result-difference]', formatSigned(result.difference));
|
|
141
|
+
const maxCost = Math.max(result.claimCost, result.payYourselfCost, 1);
|
|
142
|
+
root.querySelector<HTMLElement>('[data-idb-claim-bar]')?.style.setProperty('width', `${Math.max(8, result.claimCost / maxCost * 100)}%`);
|
|
143
|
+
root.querySelector<HTMLElement>('[data-idb-pay-bar]')?.style.setProperty('width', `${Math.max(8, result.payYourselfCost / maxCost * 100)}%`);
|
|
144
|
+
setText('[data-idb-claim-value]', formatMoney(result.claimCost));
|
|
145
|
+
setText('[data-idb-pay-value]', formatMoney(result.payYourselfCost));
|
|
146
|
+
const resultNote = result.insurerShare > 0 ? `${formatMoney(result.insurerShare)} is the estimated amount above the deductible. Confirm the covered amount, limits and reporting rules before acting.` : 'The damage is at or below the deductible in this model, so the estimated insurer contribution is zero.';
|
|
147
|
+
setText('[data-idb-result-note]', resultNote);
|
|
148
|
+
root.dataset.state = result.recommendation;
|
|
149
|
+
};
|
|
150
|
+
fields.forEach((field) => field.addEventListener('input', update));
|
|
151
|
+
const currencyMenu = root.querySelector<HTMLElement>('[data-idb-currency-menu]');
|
|
152
|
+
const currencyTrigger = root.querySelector<HTMLButtonElement>('[data-idb-currency-trigger]');
|
|
153
|
+
const closeCurrencyMenu = (): void => { if (currencyMenu && currencyTrigger) { currencyMenu.hidden = true; currencyTrigger.setAttribute('aria-expanded', 'false'); } };
|
|
154
|
+
currencyTrigger?.addEventListener('click', () => { if (currencyMenu) { currencyMenu.hidden = !currencyMenu.hidden; currencyTrigger.setAttribute('aria-expanded', String(!currencyMenu.hidden)); } });
|
|
155
|
+
root.querySelectorAll<HTMLButtonElement>('[data-idb-currency]').forEach((button) => button.addEventListener('click', () => {
|
|
156
|
+
const nextCurrency = button.dataset.idbCurrency as InsuranceCurrencyCode;
|
|
157
|
+
moneyKeys.forEach((key) => { const field = form.querySelector<HTMLInputElement>(`[data-field="${key}"]`); const value = Number(field?.value); if (field && Number.isFinite(value)) field.value = String(convertInsuranceCurrency(value, selectedCurrency, nextCurrency)); });
|
|
158
|
+
selectedCurrency = nextCurrency;
|
|
159
|
+
setCurrencyState();
|
|
160
|
+
closeCurrencyMenu();
|
|
161
|
+
update();
|
|
162
|
+
}));
|
|
163
|
+
form.querySelector('[data-idb-reset]')?.addEventListener('click', () => { fields.forEach((field) => { const key = field.dataset.field as keyof InsuranceDeductibleInput; field.value = String(config.initial[key]); }); selectedCurrency = config.currency; setCurrencyState(); closeCurrencyMenu(); update(); });
|
|
164
|
+
form.querySelector('[data-idb-copy]')?.addEventListener('click', () => { const result = calculateInsuranceDeductibleBreakEven(readInput()); if (!result) return; const summary = `${root.dataset[recommendationKeyFor(result.recommendation)] ?? ''}. Claim: ${formatMoney(result.claimCost)}. Pay yourself: ${formatMoney(result.payYourselfCost)}. Break-even damage: ${formatMoney(result.breakEvenDamage)}.`; navigator.clipboard?.writeText(summary).then(() => setText('[data-idb-copy-status]', root.dataset.copied ?? '')).catch(() => setText('[data-idb-copy-status]', root.dataset.copyFailed ?? '')); });
|
|
165
|
+
setCurrencyState();
|
|
166
|
+
update();
|
|
167
|
+
}
|
|
168
|
+
</script>
|
|
169
|
+
|
|
170
|
+
<style>
|
|
171
|
+
@import './insurance-deductible-break-even-calculator.css';
|
|
172
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { convertInsuranceCurrency, currencyOptions } from './currency';
|
|
3
|
+
|
|
4
|
+
describe('insurance currency display', () => {
|
|
5
|
+
it('offers every base locale currency', () => {
|
|
6
|
+
expect(currencyOptions.map(({ code }) => code)).toEqual(['USD', 'EUR', 'IDR', 'JPY', 'KRW', 'PLN', 'RUB', 'SEK', 'TRY', 'CNY']);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('converts an entered amount with an explicitly approximate factor', () => {
|
|
10
|
+
expect(convertInsuranceCurrency(100, 'USD', 'EUR')).toBe(92);
|
|
11
|
+
expect(convertInsuranceCurrency(92, 'EUR', 'USD')).toBe(100);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const currencyOptions = [
|
|
2
|
+
{ code: 'USD', symbol: '$', name: 'US dollar' },
|
|
3
|
+
{ code: 'EUR', symbol: '€', name: 'Euro' },
|
|
4
|
+
{ code: 'IDR', symbol: 'Rp', name: 'Indonesian rupiah' },
|
|
5
|
+
{ code: 'JPY', symbol: '¥', name: 'Japanese yen' },
|
|
6
|
+
{ code: 'KRW', symbol: '₩', name: 'South Korean won' },
|
|
7
|
+
{ code: 'PLN', symbol: 'zł', name: 'Polish złoty' },
|
|
8
|
+
{ code: 'RUB', symbol: '₽', name: 'Russian ruble' },
|
|
9
|
+
{ code: 'SEK', symbol: 'kr', name: 'Swedish krona' },
|
|
10
|
+
{ code: 'TRY', symbol: '₺', name: 'Turkish lira' },
|
|
11
|
+
{ code: 'CNY', symbol: '¥', name: 'Chinese yuan' },
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
export type InsuranceCurrencyCode = (typeof currencyOptions)[number]['code'];
|
|
15
|
+
|
|
16
|
+
export const defaultCurrencyByLocale: Record<string, InsuranceCurrencyCode> = {
|
|
17
|
+
de: 'EUR', en: 'USD', es: 'EUR', fr: 'EUR', id: 'IDR', it: 'EUR', ja: 'JPY', ko: 'KRW', nl: 'EUR', pl: 'PLN', pt: 'EUR', ru: 'RUB', sv: 'SEK', tr: 'TRY', zh: 'CNY',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const approximateUsdFactors: Record<InsuranceCurrencyCode, number> = {
|
|
21
|
+
USD: 1, EUR: 0.92, IDR: 16000, JPY: 150, KRW: 1380, PLN: 3.95, RUB: 90, SEK: 10.5, TRY: 34, CNY: 7.2,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const isInsuranceCurrencyCode = (value: string): value is InsuranceCurrencyCode => currencyOptions.some(({ code }) => code === value);
|
|
25
|
+
|
|
26
|
+
export const convertInsuranceCurrency = (amount: number, from: InsuranceCurrencyCode, to: InsuranceCurrencyCode): number => Number((amount * approximateUsdFactors[to] / approximateUsdFactors[from]).toFixed(2));
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FinanceToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { InsuranceDeductibleBreakEvenCalculatorUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type InsuranceDeductibleBreakEvenCalculatorLocaleContent = ToolLocaleContent<InsuranceDeductibleBreakEvenCalculatorUI>;
|
|
5
|
+
|
|
6
|
+
export const insuranceDeductibleBreakEvenCalculator: FinanceToolEntry<InsuranceDeductibleBreakEvenCalculatorUI> = {
|
|
7
|
+
id: 'insurance-deductible-break-even-calculator',
|
|
8
|
+
icons: { bg: 'mdi:shield-home', fg: 'mdi:scale-balance' },
|
|
9
|
+
i18n: {
|
|
10
|
+
de: () => import('./i18n/de').then((module) => module.content),
|
|
11
|
+
en: () => import('./i18n/en').then((module) => module.content),
|
|
12
|
+
es: () => import('./i18n/es').then((module) => module.content),
|
|
13
|
+
fr: () => import('./i18n/fr').then((module) => module.content),
|
|
14
|
+
id: () => import('./i18n/id').then((module) => module.content),
|
|
15
|
+
it: () => import('./i18n/it').then((module) => module.content),
|
|
16
|
+
ja: () => import('./i18n/ja').then((module) => module.content),
|
|
17
|
+
ko: () => import('./i18n/ko').then((module) => module.content),
|
|
18
|
+
nl: () => import('./i18n/nl').then((module) => module.content),
|
|
19
|
+
pl: () => import('./i18n/pl').then((module) => module.content),
|
|
20
|
+
pt: () => import('./i18n/pt').then((module) => module.content),
|
|
21
|
+
ru: () => import('./i18n/ru').then((module) => module.content),
|
|
22
|
+
sv: () => import('./i18n/sv').then((module) => module.content),
|
|
23
|
+
tr: () => import('./i18n/tr').then((module) => module.content),
|
|
24
|
+
zh: () => import('./i18n/zh').then((module) => module.content),
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export { bibliography } from './bibliography';
|