@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.
Files changed (73) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +5 -1
  4. package/src/tests/locale_completeness.test.ts +2 -2
  5. package/src/tests/mfe_assets_contract.test.ts +48 -0
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/loanAprScenarioCalculator/bibliography.astro +11 -0
  8. package/src/tool/loanAprScenarioCalculator/bibliography.ts +29 -0
  9. package/src/tool/loanAprScenarioCalculator/component.astro +62 -0
  10. package/src/tool/loanAprScenarioCalculator/controller.ts +205 -0
  11. package/src/tool/loanAprScenarioCalculator/currency-dom.ts +107 -0
  12. package/src/tool/loanAprScenarioCalculator/currency.test.ts +57 -0
  13. package/src/tool/loanAprScenarioCalculator/currency.ts +105 -0
  14. package/src/tool/loanAprScenarioCalculator/dom-views.ts +190 -0
  15. package/src/tool/loanAprScenarioCalculator/entry.ts +28 -0
  16. package/src/tool/loanAprScenarioCalculator/evaluator.ts +21 -0
  17. package/src/tool/loanAprScenarioCalculator/i18n/de.ts +194 -0
  18. package/src/tool/loanAprScenarioCalculator/i18n/en.ts +194 -0
  19. package/src/tool/loanAprScenarioCalculator/i18n/es.ts +195 -0
  20. package/src/tool/loanAprScenarioCalculator/i18n/fr.ts +195 -0
  21. package/src/tool/loanAprScenarioCalculator/i18n/id.ts +193 -0
  22. package/src/tool/loanAprScenarioCalculator/i18n/it.ts +193 -0
  23. package/src/tool/loanAprScenarioCalculator/i18n/ja.ts +186 -0
  24. package/src/tool/loanAprScenarioCalculator/i18n/ko.ts +186 -0
  25. package/src/tool/loanAprScenarioCalculator/i18n/nl.ts +194 -0
  26. package/src/tool/loanAprScenarioCalculator/i18n/pl.ts +194 -0
  27. package/src/tool/loanAprScenarioCalculator/i18n/pt.ts +194 -0
  28. package/src/tool/loanAprScenarioCalculator/i18n/ru.ts +193 -0
  29. package/src/tool/loanAprScenarioCalculator/i18n/sv.ts +189 -0
  30. package/src/tool/loanAprScenarioCalculator/i18n/tr.ts +193 -0
  31. package/src/tool/loanAprScenarioCalculator/i18n/zh.ts +181 -0
  32. package/src/tool/loanAprScenarioCalculator/index.ts +11 -0
  33. package/src/tool/loanAprScenarioCalculator/loan-apr-scenario-calculator.css +699 -0
  34. package/src/tool/loanAprScenarioCalculator/logic.test.ts +65 -0
  35. package/src/tool/loanAprScenarioCalculator/logic.ts +259 -0
  36. package/src/tool/loanAprScenarioCalculator/presets.ts +51 -0
  37. package/src/tool/loanAprScenarioCalculator/scenario-dom.ts +101 -0
  38. package/src/tool/loanAprScenarioCalculator/seo.astro +11 -0
  39. package/src/tool/loanAprScenarioCalculator/storage.ts +60 -0
  40. package/src/tool/loanAprScenarioCalculator/ui.ts +55 -0
  41. package/src/tool/loanAprScenarioCalculator/validation.ts +24 -0
  42. package/src/tool/renterInsuranceCoverageGapCalculator/bibliography.astro +11 -0
  43. package/src/tool/renterInsuranceCoverageGapCalculator/bibliography.ts +6 -0
  44. package/src/tool/renterInsuranceCoverageGapCalculator/component.astro +80 -0
  45. package/src/tool/renterInsuranceCoverageGapCalculator/controller.ts +102 -0
  46. package/src/tool/renterInsuranceCoverageGapCalculator/currency.test.ts +17 -0
  47. package/src/tool/renterInsuranceCoverageGapCalculator/currency.ts +69 -0
  48. package/src/tool/renterInsuranceCoverageGapCalculator/dom-views.ts +68 -0
  49. package/src/tool/renterInsuranceCoverageGapCalculator/entry.ts +28 -0
  50. package/src/tool/renterInsuranceCoverageGapCalculator/evaluator.ts +17 -0
  51. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/de.ts +40 -0
  52. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/en.ts +98 -0
  53. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/es.ts +38 -0
  54. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/fr.ts +38 -0
  55. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/id.ts +38 -0
  56. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/it.ts +38 -0
  57. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/ja.ts +38 -0
  58. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/ko.ts +38 -0
  59. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/nl.ts +38 -0
  60. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/pl.ts +38 -0
  61. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/pt.ts +38 -0
  62. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/ru.ts +38 -0
  63. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/sv.ts +38 -0
  64. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/tr.ts +38 -0
  65. package/src/tool/renterInsuranceCoverageGapCalculator/i18n/zh.ts +38 -0
  66. package/src/tool/renterInsuranceCoverageGapCalculator/index.ts +11 -0
  67. package/src/tool/renterInsuranceCoverageGapCalculator/logic.test.ts +52 -0
  68. package/src/tool/renterInsuranceCoverageGapCalculator/logic.ts +108 -0
  69. package/src/tool/renterInsuranceCoverageGapCalculator/renter-insurance-coverage-gap-calculator.css +462 -0
  70. package/src/tool/renterInsuranceCoverageGapCalculator/seo.astro +11 -0
  71. package/src/tool/renterInsuranceCoverageGapCalculator/storage.ts +23 -0
  72. package/src/tool/renterInsuranceCoverageGapCalculator/ui.ts +48 -0
  73. package/src/tools.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-finance",
3
- "version": "1.45.0",
3
+ "version": "1.47.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -23,10 +23,11 @@ import { subscriptionCostAnnualizer } from '../tool/subscriptionCostAnnualizer/e
23
23
  import { loanToIncomeRatio } from '../tool/loanToIncomeRatio/entry';
24
24
  import { capitalGainsTaxScenarioCalculator } from '../tool/capitalGainsTaxScenarioCalculator/entry';
25
25
  import { insuranceDeductibleBreakEvenCalculator } from '../tool/insuranceDeductibleBreakEvenCalculator/entry';
26
+ import { loanAprScenarioCalculator } from '../tool/loanAprScenarioCalculator/entry';
26
27
 
27
28
  export const financeCategory: FinanceCategoryEntry = {
28
29
  icon: 'mdi:finance',
29
- tools: [compoundInterest, financialFreedom, savingsRate, profitability, mortgage, inflation, percentageCalculator, lateInterest, ibanBicSwift, rentIncrease, lotteryOptimizer, courtFeeCalculator, legalInterestRate, fireCalculator, debtSnowball, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, inflationPurchasingPower, subscriptionCostAnnualizer, loanToIncomeRatio, capitalGainsTaxScenarioCalculator, insuranceDeductibleBreakEvenCalculator],
30
+ tools: [compoundInterest, financialFreedom, savingsRate, profitability, mortgage, inflation, percentageCalculator, lateInterest, ibanBicSwift, rentIncrease, lotteryOptimizer, courtFeeCalculator, legalInterestRate, fireCalculator, debtSnowball, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, inflationPurchasingPower, subscriptionCostAnnualizer, loanToIncomeRatio, capitalGainsTaxScenarioCalculator, insuranceDeductibleBreakEvenCalculator, loanAprScenarioCalculator],
30
31
  i18n: {
31
32
  en: () => import('./i18n/en').then((m) => m.content),
32
33
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -48,6 +48,8 @@ export { rentalDepositReturn } from './tool/rentalDepositReturn/entry';
48
48
  export type { RentalDepositReturnLocaleContent } from './tool/rentalDepositReturn/entry';
49
49
  export { capitalGainsTaxScenarioCalculator } from './tool/capitalGainsTaxScenarioCalculator/entry';
50
50
  export type { CapitalGainsTaxScenarioCalculatorLocaleContent } from './tool/capitalGainsTaxScenarioCalculator/entry';
51
+ export { loanAprScenarioCalculator } from './tool/loanAprScenarioCalculator/entry';
52
+ export type { LoanAprScenarioCalculatorLocaleContent } from './tool/loanAprScenarioCalculator/entry';
51
53
  export { insuranceDeductibleBreakEvenCalculator } from './tool/insuranceDeductibleBreakEvenCalculator/entry';
52
54
  export type { InsuranceDeductibleBreakEvenCalculatorLocaleContent } from './tool/insuranceDeductibleBreakEvenCalculator/entry';
53
55
  export { financeCategory } from './category';
@@ -76,4 +78,6 @@ import { loanToIncomeRatio } from './tool/loanToIncomeRatio/entry';
76
78
  import { rentalDepositReturn } from './tool/rentalDepositReturn/entry';
77
79
  import { capitalGainsTaxScenarioCalculator } from './tool/capitalGainsTaxScenarioCalculator/entry';
78
80
  import { insuranceDeductibleBreakEvenCalculator } from './tool/insuranceDeductibleBreakEvenCalculator/entry';
79
- export const ALL_ENTRIES = [compoundInterest, financialFreedom, savingsRate, profitability, courtFeeCalculator, debtSnowball, fireCalculator, ibanBicSwift, inflation, lateInterest, legalInterestRate, lotteryOptimizer, mortgage, percentageCalculator, rentIncrease, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, mortgageExtraPaymentPlanner, inflationPurchasingPower, subscriptionCostAnnualizer, loanToIncomeRatio, rentalDepositReturn, capitalGainsTaxScenarioCalculator, insuranceDeductibleBreakEvenCalculator];
81
+ import { renterInsuranceCoverageGapCalculator } from './tool/renterInsuranceCoverageGapCalculator/entry';
82
+ import { loanAprScenarioCalculator } from './tool/loanAprScenarioCalculator/entry';
83
+ export const ALL_ENTRIES = [compoundInterest, financialFreedom, savingsRate, profitability, courtFeeCalculator, debtSnowball, fireCalculator, ibanBicSwift, inflation, lateInterest, legalInterestRate, lotteryOptimizer, mortgage, percentageCalculator, rentIncrease, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, mortgageExtraPaymentPlanner, inflationPurchasingPower, subscriptionCostAnnualizer, loanToIncomeRatio, rentalDepositReturn, capitalGainsTaxScenarioCalculator, insuranceDeductibleBreakEvenCalculator, renterInsuranceCoverageGapCalculator, loanAprScenarioCalculator];
@@ -25,7 +25,7 @@ describe('Locale Completeness Validation', () => {
25
25
  });
26
26
  });
27
27
 
28
- it('all 26 tools registered', () => {
29
- expect(ALL_TOOLS.length).toBe(26);
28
+ it('all 28 tools registered', () => {
29
+ expect(ALL_TOOLS.length).toBe(28);
30
30
  });
31
31
  });
@@ -0,0 +1,48 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import { CATEGORY_OG_IMAGE, getUtilityOgImage } from '../mfe/assets';
6
+
7
+ const categoryImageMatch = CATEGORY_OG_IMAGE.match(
8
+ /^(\/_utilities\/[^/]+\/images)\/([^/]+\.webp)\?version=(.+)$/,
9
+ );
10
+
11
+ if (!categoryImageMatch) {
12
+ throw new Error(`Unexpected CATEGORY_OG_IMAGE format: ${CATEGORY_OG_IMAGE}`);
13
+ }
14
+
15
+ const [, imageUrlRoot, categoryImage, assetVersion] = categoryImageMatch;
16
+ const assetRoot = join(process.cwd(), 'public', imageUrlRoot.slice(1));
17
+ const categorySlug = basename(categoryImage, '.webp');
18
+
19
+ describe('MFE asset contract', () => {
20
+ it('has one non-empty English-slug OG image per category and registered tool', async () => {
21
+ const expectedSlugs = new Set([categorySlug]);
22
+
23
+ for (const { entry } of ALL_TOOLS) {
24
+ const englishLoader = entry.i18n.en;
25
+ if (!englishLoader) throw new Error(`Missing English locale for ${entry.id}`);
26
+
27
+ const englishContent = await englishLoader();
28
+ expectedSlugs.add(englishContent.slug);
29
+ }
30
+
31
+ const actualSlugs = new Set(
32
+ readdirSync(assetRoot)
33
+ .filter((filename) => filename.endsWith('.webp'))
34
+ .map((filename) => filename.slice(0, -'.webp'.length)),
35
+ );
36
+
37
+ expect(actualSlugs).toEqual(expectedSlugs);
38
+
39
+ for (const slug of expectedSlugs) {
40
+ const imagePath = join(assetRoot, `${slug}.webp`);
41
+ expect(existsSync(imagePath), `${imagePath} should exist`).toBe(true);
42
+ expect(statSync(imagePath).size, `${imagePath} should not be empty`).toBeGreaterThan(0);
43
+ expect(getUtilityOgImage(slug)).toBe(
44
+ `${imageUrlRoot}/${slug}.webp?version=${assetVersion}`,
45
+ );
46
+ }
47
+ }, 30000);
48
+ });
@@ -4,8 +4,8 @@ import { financeCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 26 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(26);
7
+ it('should have 28 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(28);
9
9
  });
10
10
 
11
11
  it('financeCategory should be defined', () => {
@@ -0,0 +1,11 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { loanAprScenarioCalculator } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props { locale?: KnownLocale; }
7
+ const { locale = 'en' } = Astro.props;
8
+ const content = await loanAprScenarioCalculator.i18n[locale]?.() ?? await loanAprScenarioCalculator.i18n.en?.();
9
+ ---
10
+
11
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,29 @@
1
+ import type { BibliographyEntry } from "../../types";
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: "Banco de España: TAE de un préstamo o producto de financiación",
6
+ url: "https://clientebancario.bde.es/pcb/es/menu-horizontal/productosservici/relacionados/tiposinteres/guia-textual/latae/tae_prestamo.html",
7
+ },
8
+ {
9
+ name: "Consumer Financial Protection Bureau: What is the difference between a loan interest rate and the APR?",
10
+ url: "https://www.consumerfinance.gov/ask-cfpb/what-is-the-difference-between-a-loan-interest-rate-and-the-apr-en-733/",
11
+ },
12
+ ];
13
+
14
+ export const bibliographyTraceability = [
15
+ {
16
+ country: "Spain",
17
+ language: "Spanish",
18
+ claim:
19
+ "TAE includes the nominal rate, payment frequency, bank fees and some transaction expenses.",
20
+ url: bibliography[0].url,
21
+ },
22
+ {
23
+ country: "United States",
24
+ language: "English",
25
+ claim:
26
+ "APR is a broader borrowing-cost measure than the interest rate because it includes additional fees.",
27
+ url: bibliography[1].url,
28
+ },
29
+ ];
@@ -0,0 +1,62 @@
1
+ ---
2
+ import type { LoanAprScenarioCalculatorUI } from './ui';
3
+ import type { KnownLocale } from '../../types';
4
+ import { currencyOptions, defaultCurrencyByLocale } from './currency';
5
+
6
+ interface Props { ui: LoanAprScenarioCalculatorUI; locale?: KnownLocale; }
7
+ const { ui, locale = 'en' } = Astro.props;
8
+ const defaultCurrency = defaultCurrencyByLocale[locale] ?? 'USD';
9
+ const defaultCurrencyOption = currencyOptions.find(({ code }) => code === defaultCurrency);
10
+ const defaultCurrencySymbol = defaultCurrencyOption?.symbol ?? defaultCurrency;
11
+ const defaultCurrencyName = defaultCurrencyOption?.name ?? defaultCurrency;
12
+ const data = JSON.stringify({ ui, locale, currency: defaultCurrency, currencies: currencyOptions }).replace(/</g, '\\u003c');
13
+ ---
14
+
15
+ <div class="loan-apr-tool" data-loan-apr-tool>
16
+ <form class="loan-apr-input-ribbon" data-loan-apr-form>
17
+ <div class="loan-apr-ribbon-head">
18
+ <div class="loan-apr-currency-copy"><span class="loan-apr-currency-label">{ui.labelCurrency}</span><small>{ui.labelApproximateRates}</small></div>
19
+ <div class="loan-apr-header-controls">
20
+ <div class="loan-apr-actions"><button type="button" class="loan-apr-text-button" data-reset>{ui.labelReset}</button><button type="button" class="loan-apr-copy-button" data-copy>{ui.labelCopy}</button><span class="loan-apr-copy-status" data-copy-status aria-live="polite"></span></div>
21
+ <div class="loan-apr-currency-select"><button type="button" class="loan-apr-currency-trigger" data-currency-trigger aria-expanded="false" aria-haspopup="listbox" aria-label={ui.labelCurrency}><span class="loan-apr-currency-trigger-main"><strong data-selected-currency-symbol>{defaultCurrencySymbol}</strong><span data-selected-currency-name>{defaultCurrencyName}</span></span><span class="loan-apr-currency-arrow" aria-hidden="true"></span></button><div class="loan-apr-currency-menu" data-currency-menu role="listbox" aria-label={ui.labelCurrency} hidden>{currencyOptions.map(({ code, symbol, name }) => <button type="button" class="loan-apr-currency-option" data-currency={code} role="option" aria-selected={code === defaultCurrency} aria-label={`${symbol} ${name}`}><strong>{symbol}</strong><span>{name}</span></button>)}</div></div>
22
+ </div>
23
+ </div>
24
+ <div class="loan-apr-presets" role="group" aria-label={ui.labelPreset}><button type="button" data-preset="balanced">{ui.labelPresetBalanced}</button><button type="button" data-preset="low-fee">{ui.labelPresetLowFee}</button><button type="button" data-preset="long-term">{ui.labelPresetLongTerm}</button></div>
25
+ <div class="loan-apr-scenarios">
26
+ <fieldset class="loan-apr-scenario" data-scenario="a">
27
+ <legend><span class="loan-apr-scenario-index">A</span><input data-field="name" type="text" value={ui.labelOfferNameA} aria-label={ui.labelScenarioName} /></legend>
28
+ <div class="loan-apr-field-grid"><label><span>{ui.labelPrincipal}</span><span class="loan-apr-input-with-unit"><input data-field="principal" type="number" min="1" step="100" value="10000" required /><b data-currency-unit>{defaultCurrencySymbol}</b></span></label><label><span>{ui.labelTerm}</span><span class="loan-apr-input-with-unit"><input data-field="term" type="number" min="1" max="600" step="1" value="36" required /><b>{ui.labelMonths}</b></span></label></div>
29
+ <div class="loan-apr-mode-row" role="group" aria-label={ui.labelInputBasis}><button type="button" data-mode-side="a" data-mode="rate" aria-pressed="true">{ui.labelRateMode}</button><button type="button" data-mode-side="a" data-mode="payment" aria-pressed="false">{ui.labelPaymentMode}</button></div>
30
+ <label data-mode-field="rate"><span>{ui.labelAnnualRate}</span><span class="loan-apr-input-with-unit"><input data-field="rate" type="number" min="0" max="100" step="0.01" value="6" required /><b>%</b></span></label>
31
+ <label data-mode-field="payment" hidden><span>{ui.labelMonthlyPayment}</span><span class="loan-apr-input-with-unit"><input data-field="payment" type="number" min="0" step="0.01" value="304.22" required /><b data-currency-unit>{defaultCurrencySymbol}</b></span></label>
32
+ <div class="loan-apr-fees"><label><span>{ui.labelUpfrontFees}</span><span class="loan-apr-input-with-unit"><input data-field="upfrontFees" type="number" min="0" step="10" value="0" /><b data-currency-unit>{defaultCurrencySymbol}</b></span></label><label><span>{ui.labelMonthlyFees}</span><span class="loan-apr-input-with-unit"><input data-field="monthlyFees" type="number" min="0" step="1" value="0" /><b data-currency-unit>{defaultCurrencySymbol}</b></span></label><label><span>{ui.labelFinalFees}</span><span class="loan-apr-input-with-unit"><input data-field="finalFees" type="number" min="0" step="10" value="0" /><b data-currency-unit>{defaultCurrencySymbol}</b></span></label></div>
33
+ </fieldset>
34
+ <div class="loan-apr-divider" aria-hidden="true"><span>VS</span></div>
35
+ <fieldset class="loan-apr-scenario" data-scenario="b">
36
+ <legend><span class="loan-apr-scenario-index">B</span><input data-field="name" type="text" value={ui.labelOfferNameB} aria-label={ui.labelScenarioName} /></legend>
37
+ <div class="loan-apr-field-grid"><label><span>{ui.labelPrincipal}</span><span class="loan-apr-input-with-unit"><input data-field="principal" type="number" min="1" step="100" value="10000" required /><b data-currency-unit>{defaultCurrencySymbol}</b></span></label><label><span>{ui.labelTerm}</span><span class="loan-apr-input-with-unit"><input data-field="term" type="number" min="1" max="600" step="1" value="36" required /><b>{ui.labelMonths}</b></span></label></div>
38
+ <div class="loan-apr-mode-row" role="group" aria-label={ui.labelInputBasis}><button type="button" data-mode-side="b" data-mode="rate" aria-pressed="true">{ui.labelRateMode}</button><button type="button" data-mode-side="b" data-mode="payment" aria-pressed="false">{ui.labelPaymentMode}</button></div>
39
+ <label data-mode-field="rate"><span>{ui.labelAnnualRate}</span><span class="loan-apr-input-with-unit"><input data-field="rate" type="number" min="0" max="100" step="0.01" value="7.25" required /><b>%</b></span></label>
40
+ <label data-mode-field="payment" hidden><span>{ui.labelMonthlyPayment}</span><span class="loan-apr-input-with-unit"><input data-field="payment" type="number" min="0" step="0.01" value="309.88" required /><b data-currency-unit>{defaultCurrencySymbol}</b></span></label>
41
+ <div class="loan-apr-fees"><label><span>{ui.labelUpfrontFees}</span><span class="loan-apr-input-with-unit"><input data-field="upfrontFees" type="number" min="0" step="10" value="150" /><b data-currency-unit>{defaultCurrencySymbol}</b></span></label><label><span>{ui.labelMonthlyFees}</span><span class="loan-apr-input-with-unit"><input data-field="monthlyFees" type="number" min="0" step="1" value="0" /><b data-currency-unit>{defaultCurrencySymbol}</b></span></label><label><span>{ui.labelFinalFees}</span><span class="loan-apr-input-with-unit"><input data-field="finalFees" type="number" min="0" step="10" value="0" /><b data-currency-unit>{defaultCurrencySymbol}</b></span></label></div>
42
+ </fieldset>
43
+ </div>
44
+ </form>
45
+ <section class="loan-apr-result" data-result aria-live="polite"></section>
46
+ <script is:inline type="application/json" data-loan-apr-data set:html={data}></script>
47
+ <script>
48
+ import { setupLoanAprScenarioCalculator } from './controller';
49
+ import type { LoanAprScenarioCalculatorUI } from './ui';
50
+ import type { LoanAprCurrencyCode } from './currency';
51
+ const root = document.querySelector<HTMLElement>('[data-loan-apr-tool]');
52
+ const dataNode = root?.querySelector<HTMLScriptElement>('[data-loan-apr-data]');
53
+ if (root && dataNode?.textContent) {
54
+ const data = JSON.parse(dataNode.textContent) as { ui: LoanAprScenarioCalculatorUI; locale: string; currency: LoanAprCurrencyCode };
55
+ setupLoanAprScenarioCalculator(root, data.ui, data.locale, data.currency);
56
+ }
57
+ </script>
58
+ </div>
59
+
60
+ <style is:global>
61
+ @import './loan-apr-scenario-calculator.css';
62
+ </style>
@@ -0,0 +1,205 @@
1
+ import { calculateScenario, compareScenarios } from "./logic";
2
+ import type { LoanAprCurrencyCode } from "./currency";
3
+ import {
4
+ defaultState,
5
+ loadStoredState,
6
+ saveStoredState,
7
+ type LoanAprStoredState,
8
+ } from "./storage";
9
+ import { formatResultForCopy, renderComparison } from "./dom-views";
10
+ import type { LoanAprScenarioCalculatorUI } from "./ui";
11
+ import {
12
+ closeCurrencyMenu,
13
+ convertMoneyFields,
14
+ setCurrencyState,
15
+ toggleCurrencyMenu,
16
+ } from "./currency-dom";
17
+ import { applyPreset } from "./presets";
18
+ import {
19
+ readScenario,
20
+ setMode,
21
+ writeScenario,
22
+ type ScenarioSide,
23
+ } from "./scenario-dom";
24
+
25
+ function readState(
26
+ root: HTMLElement,
27
+ currency: LoanAprCurrencyCode,
28
+ ): LoanAprStoredState {
29
+ return {
30
+ scenarioA: readScenario(root, "a"),
31
+ scenarioB: readScenario(root, "b"),
32
+ currency,
33
+ };
34
+ }
35
+
36
+ function render(
37
+ root: HTMLElement,
38
+ ui: LoanAprScenarioCalculatorUI,
39
+ locale: string,
40
+ currency: LoanAprCurrencyCode,
41
+ ): { copy: string } {
42
+ const state = readState(root, currency);
43
+ const first = calculateScenario(state.scenarioA);
44
+ const second = calculateScenario(state.scenarioB);
45
+ const comparison = compareScenarios(first, second);
46
+ const result = root.querySelector<HTMLElement>("[data-result]");
47
+ if (result)
48
+ renderComparison({
49
+ root: result,
50
+ first,
51
+ second,
52
+ comparison,
53
+ context: { ui, locale, currency },
54
+ });
55
+ saveStoredState(state);
56
+ return {
57
+ copy: formatResultForCopy({
58
+ first,
59
+ second,
60
+ comparison,
61
+ context: { ui, locale, currency },
62
+ }),
63
+ };
64
+ }
65
+
66
+ export function setupLoanAprScenarioCalculator(
67
+ root: HTMLElement,
68
+ ui: LoanAprScenarioCalculatorUI,
69
+ locale: string,
70
+ defaultCurrency: LoanAprCurrencyCode,
71
+ ): void {
72
+ const stored = loadStoredState();
73
+ const initialState = stored ?? defaultState();
74
+ let selectedCurrency = stored?.currency ?? defaultCurrency;
75
+ writeScenario(root, "a", initialState.scenarioA);
76
+ writeScenario(root, "b", initialState.scenarioB);
77
+ setCurrencyState(root, selectedCurrency);
78
+ const copyStatus = root.querySelector<HTMLElement>("[data-copy-status]");
79
+ render(root, ui, locale, selectedCurrency);
80
+ const context: ControllerContext = {
81
+ ui,
82
+ locale,
83
+ defaultCurrency,
84
+ status: copyStatus,
85
+ getCurrency: () => selectedCurrency,
86
+ setCurrency: (currency) => {
87
+ selectedCurrency = currency;
88
+ },
89
+ };
90
+ bindEvents(root, context);
91
+ }
92
+
93
+ function bindEvents(root: HTMLElement, context: ControllerContext): void {
94
+ root.addEventListener("input", () =>
95
+ render(root, context.ui, context.locale, context.getCurrency()),
96
+ );
97
+ root.addEventListener("change", () =>
98
+ render(root, context.ui, context.locale, context.getCurrency()),
99
+ );
100
+ root.addEventListener("click", (event) => handleClick(root, event, context));
101
+ }
102
+
103
+ interface ControllerContext {
104
+ ui: LoanAprScenarioCalculatorUI;
105
+ locale: string;
106
+ defaultCurrency: LoanAprCurrencyCode;
107
+ status: HTMLElement | null;
108
+ getCurrency: () => LoanAprCurrencyCode;
109
+ setCurrency: (currency: LoanAprCurrencyCode) => void;
110
+ }
111
+
112
+ function resetTool(root: HTMLElement, context: ControllerContext): void {
113
+ const reset = defaultState();
114
+ writeScenario(root, "a", reset.scenarioA);
115
+ writeScenario(root, "b", reset.scenarioB);
116
+ context.setCurrency(context.defaultCurrency);
117
+ setCurrencyState(root, context.defaultCurrency);
118
+ closeCurrencyMenu(root);
119
+ render(root, context.ui, context.locale, context.defaultCurrency);
120
+ if (context.status) context.status.textContent = "";
121
+ }
122
+
123
+ function handleCurrencyClick(
124
+ root: HTMLElement,
125
+ target: HTMLElement,
126
+ context: ControllerContext,
127
+ ): boolean {
128
+ const trigger = target.closest<HTMLButtonElement>("[data-currency-trigger]");
129
+ if (trigger) {
130
+ toggleCurrencyMenu(root);
131
+ return true;
132
+ }
133
+ const option = target.closest<HTMLButtonElement>("[data-currency]");
134
+ if (!option) return false;
135
+ const nextCurrency = option.dataset.currency as LoanAprCurrencyCode;
136
+ convertMoneyFields(root, context.getCurrency(), nextCurrency);
137
+ context.setCurrency(nextCurrency);
138
+ setCurrencyState(root, nextCurrency);
139
+ closeCurrencyMenu(root);
140
+ render(root, context.ui, context.locale, nextCurrency);
141
+ return true;
142
+ }
143
+
144
+ function handleScenarioClick(
145
+ root: HTMLElement,
146
+ target: HTMLElement,
147
+ context: ControllerContext,
148
+ ): boolean {
149
+ const modeButton = target.closest<HTMLButtonElement>(
150
+ "[data-mode-side][data-mode]",
151
+ );
152
+ if (modeButton) {
153
+ setMode(
154
+ root,
155
+ modeButton.dataset.modeSide as ScenarioSide,
156
+ modeButton.dataset.mode as LoanMode,
157
+ );
158
+ render(root, context.ui, context.locale, context.getCurrency());
159
+ return true;
160
+ }
161
+ const preset = target.closest<HTMLButtonElement>("[data-preset]");
162
+ if (!preset) return false;
163
+ applyPreset(root, preset.dataset.preset ?? "balanced");
164
+ render(root, context.ui, context.locale, context.getCurrency());
165
+ return true;
166
+ }
167
+
168
+ function handleActionClick(
169
+ root: HTMLElement,
170
+ target: HTMLElement,
171
+ context: ControllerContext,
172
+ ): void {
173
+ if (target.closest("[data-reset]")) {
174
+ resetTool(root, context);
175
+ return;
176
+ }
177
+ if (target.closest("[data-copy]"))
178
+ void copyText(
179
+ render(root, context.ui, context.locale, context.getCurrency()).copy,
180
+ context.status,
181
+ context.ui.labelCopied,
182
+ );
183
+ }
184
+
185
+ function handleClick(
186
+ root: HTMLElement,
187
+ event: Event,
188
+ context: ControllerContext,
189
+ ): void {
190
+ const target = event.target as HTMLElement;
191
+ if (handleCurrencyClick(root, target, context)) return;
192
+ if (handleScenarioClick(root, target, context)) return;
193
+ handleActionClick(root, target, context);
194
+ }
195
+
196
+ async function copyText(
197
+ text: string,
198
+ status: HTMLElement | null,
199
+ copiedLabel: string,
200
+ ): Promise<void> {
201
+ try {
202
+ await navigator.clipboard.writeText(text);
203
+ if (status) status.textContent = copiedLabel;
204
+ } catch {}
205
+ }
@@ -0,0 +1,107 @@
1
+ import {
2
+ convertLoanCurrency,
3
+ currencyOptions,
4
+ type LoanAprCurrencyCode,
5
+ } from "./currency";
6
+
7
+ const moneyFields = [
8
+ "principal",
9
+ "payment",
10
+ "upfrontFees",
11
+ "monthlyFees",
12
+ "finalFees",
13
+ ] as const;
14
+
15
+ function currencyDisplay(
16
+ currency: LoanAprCurrencyCode,
17
+ selected: HTMLButtonElement | null,
18
+ ): { symbol: string; name: string } {
19
+ const option = currencyOptions.find(({ code }) => code === currency);
20
+ return {
21
+ symbol:
22
+ option?.symbol ??
23
+ selected?.querySelector("strong")?.textContent ??
24
+ currency,
25
+ name: option?.name ?? currency,
26
+ };
27
+ }
28
+
29
+ function markCurrencyOptions(
30
+ root: HTMLElement,
31
+ currency: LoanAprCurrencyCode,
32
+ ): void {
33
+ root
34
+ .querySelectorAll<HTMLButtonElement>("[data-currency]")
35
+ .forEach((button) =>
36
+ button.setAttribute(
37
+ "aria-selected",
38
+ String(button.dataset.currency === currency),
39
+ ),
40
+ );
41
+ }
42
+
43
+ function updateCurrencyLabels(
44
+ root: HTMLElement,
45
+ display: { symbol: string; name: string },
46
+ ): void {
47
+ root
48
+ .querySelector<HTMLElement>("[data-selected-currency-symbol]")
49
+ ?.replaceChildren(display.symbol);
50
+ root
51
+ .querySelector<HTMLElement>("[data-selected-currency-name]")
52
+ ?.replaceChildren(display.name);
53
+ root
54
+ .querySelectorAll<HTMLElement>("[data-currency-unit]")
55
+ .forEach((unit) => (unit.textContent = display.symbol));
56
+ }
57
+
58
+ export function setCurrencyState(
59
+ root: HTMLElement,
60
+ currency: LoanAprCurrencyCode,
61
+ ): void {
62
+ const selected = root.querySelector<HTMLButtonElement>(
63
+ `[data-currency="${currency}"]`,
64
+ );
65
+ markCurrencyOptions(root, currency);
66
+ updateCurrencyLabels(root, currencyDisplay(currency, selected));
67
+ }
68
+
69
+ export function closeCurrencyMenu(root: HTMLElement): void {
70
+ const menu = root.querySelector<HTMLElement>("[data-currency-menu]");
71
+ const trigger = root.querySelector<HTMLButtonElement>(
72
+ "[data-currency-trigger]",
73
+ );
74
+ if (menu && trigger) {
75
+ menu.hidden = true;
76
+ trigger.setAttribute("aria-expanded", "false");
77
+ }
78
+ }
79
+
80
+ export function toggleCurrencyMenu(root: HTMLElement): void {
81
+ const menu = root.querySelector<HTMLElement>("[data-currency-menu]");
82
+ const trigger = root.querySelector<HTMLButtonElement>(
83
+ "[data-currency-trigger]",
84
+ );
85
+ if (menu && trigger) {
86
+ menu.hidden = !menu.hidden;
87
+ trigger.setAttribute("aria-expanded", String(!menu.hidden));
88
+ }
89
+ }
90
+
91
+ export function convertMoneyFields(
92
+ root: HTMLElement,
93
+ from: LoanAprCurrencyCode,
94
+ to: LoanAprCurrencyCode,
95
+ ): void {
96
+ if (from === to) return;
97
+ (["a", "b"] as const).forEach((side) =>
98
+ moneyFields.forEach((field) => {
99
+ const input = root.querySelector<HTMLInputElement>(
100
+ `[data-scenario="${side}"] [data-field="${field}"]`,
101
+ );
102
+ const value = Number(input?.value);
103
+ if (input && Number.isFinite(value))
104
+ input.value = String(convertLoanCurrency(value, from, to));
105
+ }),
106
+ );
107
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ convertLoanCurrency,
4
+ currencyOptions,
5
+ defaultCurrencyByLocale,
6
+ } from "./currency";
7
+
8
+ describe("loan APR currency support", () => {
9
+ it("offers the international currency set used by the other scenario tools", () => {
10
+ expect(currencyOptions.map(({ code }) => code)).toEqual([
11
+ "AUD",
12
+ "BRL",
13
+ "CAD",
14
+ "CHF",
15
+ "CNY",
16
+ "CZK",
17
+ "DKK",
18
+ "EUR",
19
+ "GBP",
20
+ "IDR",
21
+ "INR",
22
+ "JPY",
23
+ "KRW",
24
+ "MXN",
25
+ "NOK",
26
+ "PLN",
27
+ "RUB",
28
+ "SEK",
29
+ "TRY",
30
+ "USD",
31
+ ]);
32
+ });
33
+
34
+ it("uses a symbol and name for every currency option", () => {
35
+ expect(currencyOptions.find(({ code }) => code === "RUB")).toMatchObject({
36
+ symbol: "₽",
37
+ name: "Russian ruble",
38
+ });
39
+ expect(currencyOptions.every(({ symbol, name }) => symbol && name)).toBe(
40
+ true,
41
+ );
42
+ });
43
+
44
+ it("selects a useful default for each supported locale family", () => {
45
+ expect(defaultCurrencyByLocale).toMatchObject({
46
+ en: "USD",
47
+ es: "EUR",
48
+ ja: "JPY",
49
+ zh: "CNY",
50
+ });
51
+ });
52
+
53
+ it("converts monetary inputs with reversible planning factors", () => {
54
+ expect(convertLoanCurrency(100, "EUR", "USD")).toBe(108.7);
55
+ expect(convertLoanCurrency(108.7, "USD", "EUR")).toBe(100);
56
+ });
57
+ });