@jjlmoya/utils-finance 1.22.0 → 1.24.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 (37) hide show
  1. package/package.json +2 -2
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +4 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/revolvingCardCalculator/bibliography.astro +14 -0
  8. package/src/tool/revolvingCardCalculator/bibliography.ts +24 -0
  9. package/src/tool/revolvingCardCalculator/component.astro +315 -0
  10. package/src/tool/revolvingCardCalculator/components/AmortizationTable.astro +57 -0
  11. package/src/tool/revolvingCardCalculator/components/CalculatorInputs.astro +117 -0
  12. package/src/tool/revolvingCardCalculator/components/ChartDisplay.astro +35 -0
  13. package/src/tool/revolvingCardCalculator/components/UsuryChecker.astro +32 -0
  14. package/src/tool/revolvingCardCalculator/constants.ts +36 -0
  15. package/src/tool/revolvingCardCalculator/entry.ts +31 -0
  16. package/src/tool/revolvingCardCalculator/i18n/de.ts +84 -0
  17. package/src/tool/revolvingCardCalculator/i18n/en.ts +322 -0
  18. package/src/tool/revolvingCardCalculator/i18n/es.ts +153 -0
  19. package/src/tool/revolvingCardCalculator/i18n/fr.ts +84 -0
  20. package/src/tool/revolvingCardCalculator/i18n/id.ts +84 -0
  21. package/src/tool/revolvingCardCalculator/i18n/it.ts +84 -0
  22. package/src/tool/revolvingCardCalculator/i18n/ja.ts +84 -0
  23. package/src/tool/revolvingCardCalculator/i18n/ko.ts +84 -0
  24. package/src/tool/revolvingCardCalculator/i18n/nl.ts +84 -0
  25. package/src/tool/revolvingCardCalculator/i18n/pl.ts +84 -0
  26. package/src/tool/revolvingCardCalculator/i18n/pt.ts +84 -0
  27. package/src/tool/revolvingCardCalculator/i18n/ru.ts +84 -0
  28. package/src/tool/revolvingCardCalculator/i18n/sv.ts +84 -0
  29. package/src/tool/revolvingCardCalculator/i18n/tr.ts +84 -0
  30. package/src/tool/revolvingCardCalculator/i18n/zh.ts +84 -0
  31. package/src/tool/revolvingCardCalculator/index.ts +10 -0
  32. package/src/tool/revolvingCardCalculator/logic.ts +237 -0
  33. package/src/tool/revolvingCardCalculator/revolving-card-calculator.css +564 -0
  34. package/src/tool/revolvingCardCalculator/seo.astro +15 -0
  35. package/src/tool/revolvingCardCalculator/types.ts +54 -0
  36. package/src/tool/revolvingCardCalculator/ui.ts +58 -0
  37. package/src/tools.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-finance",
3
- "version": "1.22.0",
3
+ "version": "1.24.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -28,7 +28,7 @@
28
28
  "check": "astro check",
29
29
  "type-check": "astro check",
30
30
  "test": "vitest run",
31
- "preversion": "npm run lint && npm run test",
31
+ "preversion": "npm run lint && npm run test && npm run build",
32
32
  "postversion": "git push && git push --tags",
33
33
  "patch": "npm version patch",
34
34
  "minor": "npm version minor",
@@ -13,10 +13,11 @@ import { courtFeeCalculator } from '../tool/courtFeeCalculator/entry';
13
13
  import { legalInterestRate } from '../tool/legalInterestRate/entry';
14
14
  import { fireCalculator } from '../tool/fireCalculator/entry';
15
15
  import { debtSnowball } from '../tool/debtSnowball/entry';
16
+ import { revolvingCardCalculator } from '../tool/revolvingCardCalculator/entry';
16
17
 
17
18
  export const financeCategory: FinanceCategoryEntry = {
18
19
  icon: 'mdi:finance',
19
- tools: [compoundInterest, financialFreedom, profitability, mortgage, inflation, percentageCalculator, lateInterest, ibanBicSwift, rentIncrease, lotteryOptimizer, courtFeeCalculator, legalInterestRate, fireCalculator, debtSnowball],
20
+ tools: [compoundInterest, financialFreedom, profitability, mortgage, inflation, percentageCalculator, lateInterest, ibanBicSwift, rentIncrease, lotteryOptimizer, courtFeeCalculator, legalInterestRate, fireCalculator, debtSnowball, revolvingCardCalculator],
20
21
  i18n: {
21
22
  en: () => import('./i18n/en').then((m) => m.content),
22
23
  es: () => import('./i18n/es').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -26,6 +26,8 @@ export { percentageCalculator } from './tool/percentageCalculator/entry';
26
26
  export type { PercentageCalculatorLocaleContent } from './tool/percentageCalculator/entry';
27
27
  export { rentIncrease } from './tool/rentIncreaseCalculator/entry';
28
28
  export type { RentIncreaseLocaleContent } from './tool/rentIncreaseCalculator/entry';
29
+ export { revolvingCardCalculator } from './tool/revolvingCardCalculator/entry';
30
+ export type { RevolvingCardCalculatorLocaleContent } from './tool/revolvingCardCalculator/entry';
29
31
  export { financeCategory } from './category';
30
32
  import { compoundInterest } from './tool/compoundInterest/entry';
31
33
  import { financialFreedom } from './tool/financialFreedom/entry';
@@ -41,4 +43,5 @@ import { lotteryOptimizer } from './tool/lotteryOptimizer/entry';
41
43
  import { mortgage } from './tool/mortgage/entry';
42
44
  import { percentageCalculator } from './tool/percentageCalculator/entry';
43
45
  import { rentIncrease } from './tool/rentIncreaseCalculator/entry';
44
- export const ALL_ENTRIES = [compoundInterest, financialFreedom, profitability, courtFeeCalculator, debtSnowball, fireCalculator, ibanBicSwift, inflation, lateInterest, legalInterestRate, lotteryOptimizer, mortgage, percentageCalculator, rentIncrease];
46
+ import { revolvingCardCalculator } from './tool/revolvingCardCalculator/entry';
47
+ export const ALL_ENTRIES = [compoundInterest, financialFreedom, profitability, courtFeeCalculator, debtSnowball, fireCalculator, ibanBicSwift, inflation, lateInterest, legalInterestRate, lotteryOptimizer, mortgage, percentageCalculator, rentIncrease, revolvingCardCalculator];
package/src/index.ts CHANGED
@@ -29,4 +29,5 @@ export { COURT_FEE_CALCULATOR_TOOL } from './tool/courtFeeCalculator';
29
29
  export { LEGAL_INTEREST_RATE_TOOL } from './tool/legalInterestRate';
30
30
  export { FIRE_CALCULATOR_TOOL } from './tool/fireCalculator';
31
31
  export { DEBT_SNOWBALL_TOOL } from './tool/debtSnowball';
32
+ export { REVOLVING_CARD_CALCULATOR_TOOL } from './tool/revolvingCardCalculator';
32
33
 
@@ -25,8 +25,8 @@ describe('Locale Completeness Validation', () => {
25
25
  });
26
26
  });
27
27
 
28
- it('all 12 tools registered', () => {
29
- expect(ALL_TOOLS.length).toBe(14);
28
+ it('all 15 tools registered', () => {
29
+ expect(ALL_TOOLS.length).toBe(15);
30
30
  });
31
31
  });
32
32
 
@@ -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 14 tools in ALL_TOOLS (compoundInterest, financialFreedom, profitability, mortgage, inflation, percentageCalculator, lateInterest, ibanBicSwift, rentIncrease, lotteryOptimizer, courtFeeCalculator, legalInterestRate, fireCalculator, debtSnowball)', () => {
8
- expect(ALL_TOOLS.length).toBe(14);
7
+ it('should have 15 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(15);
9
9
  });
10
10
 
11
11
  it('financeCategory should be defined', () => {
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { revolvingCardCalculator } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'es' } = Astro.props;
11
+ const content = await revolvingCardCalculator.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,24 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Banco de España - Estadísticas de Tipos de Interés',
6
+ url: 'https://www.bde.es/webbde/es/estadis/infoest/tipos/tipos.html',
7
+ },
8
+ {
9
+ name: 'Sentencia del Tribunal Supremo de España 258/2023 sobre Usura',
10
+ url: 'https://www.poderjudicial.es/',
11
+ },
12
+ {
13
+ name: 'US Consumer Financial Protection Bureau (CFPB) - Credit Cards',
14
+ url: 'https://www.consumerfinance.gov/consumer-tools/credit-cards/',
15
+ },
16
+ {
17
+ name: 'UK Financial Conduct Authority (FCA) - Credit Cards Rules',
18
+ url: 'https://www.fca.org.uk/consumers/credit-cards',
19
+ },
20
+ {
21
+ name: 'Investopedia - Revolving Debt Explained',
22
+ url: 'https://www.investopedia.com/terms/r/revolvingdebt.asp',
23
+ },
24
+ ];
@@ -0,0 +1,315 @@
1
+ ---
2
+ import type { KnownLocale } from '../../types';
3
+ import type { RevolvingCardCalculatorUI } from './ui';
4
+ import CalculatorInputs from './components/CalculatorInputs.astro';
5
+ import UsuryChecker from './components/UsuryChecker.astro';
6
+ import AmortizationTable from './components/AmortizationTable.astro';
7
+ import ChartDisplay from './components/ChartDisplay.astro';
8
+
9
+ import './revolving-card-calculator.css';
10
+
11
+ interface Props {
12
+ locale?: KnownLocale;
13
+ ui?: RevolvingCardCalculatorUI;
14
+ }
15
+
16
+ const { ui = {} as RevolvingCardCalculatorUI } = Astro.props;
17
+ ---
18
+
19
+ <div
20
+ class="rev-container"
21
+ data-currency-symbol={ui.currencySymbol}
22
+ data-percent-symbol={ui.percentSymbol}
23
+ data-copied-label={ui.copiedLabel}
24
+ data-usury-safe={ui.usurySafeStatus}
25
+ data-usury-warning={ui.usuryWarningStatus}
26
+ data-usury-usurious={ui.usuryUsuriousStatus}
27
+ >
28
+ <div class="rev-card fade-in">
29
+ <CalculatorInputs ui={ui} />
30
+
31
+ <UsuryChecker ui={ui} />
32
+
33
+ <ChartDisplay ui={ui} />
34
+
35
+ <AmortizationTable ui={ui} />
36
+
37
+ <div class="action-row">
38
+ <button class="rev-btn-primary" id="rev-copy-report">
39
+ {ui.copyTooltip}
40
+ </button>
41
+ </div>
42
+ </div>
43
+ </div>
44
+
45
+ <script>
46
+ import { calculateAmortization, analyzeUsury } from './logic';
47
+ import { CURRENCY_SYMBOLS, MARKET_CURRENCY_MAP } from './constants';
48
+ import type { RevolvingInputs } from './types';
49
+
50
+ const container = document.querySelector('.rev-container');
51
+ const marketSelect = document.getElementById('rev-market') as HTMLSelectElement | null;
52
+ const currencySelect = document.getElementById('rev-currency') as HTMLSelectElement | null;
53
+ const customThresholdGroup = document.querySelector('.id-custom-threshold-group') as HTMLDivElement | null;
54
+ const customThresholdInput = document.getElementById('rev-custom-threshold') as HTMLInputElement | null;
55
+ const balanceInput = document.getElementById('rev-balance') as HTMLInputElement | null;
56
+ const creditLimitInput = document.getElementById('rev-credit-limit') as HTMLInputElement | null;
57
+ const aprInput = document.getElementById('rev-apr') as HTMLInputElement | null;
58
+ const paymentTypeSelect = document.getElementById('rev-payment-type') as HTMLSelectElement | null;
59
+ const paymentValueInput = document.getElementById('rev-payment-value') as HTMLInputElement | null;
60
+ const paymentValueUnit = document.getElementById('rev-payment-value-unit') as HTMLSpanElement | null;
61
+ const minPaymentGroup = document.getElementById('rev-min-payment-group') as HTMLDivElement | null;
62
+ const minPaymentInput = document.getElementById('rev-min-payment') as HTMLInputElement | null;
63
+
64
+ const usuryBadge = document.getElementById('rev-usury-status-badge');
65
+ const usuryIcon = document.getElementById('rev-status-icon');
66
+ const usuryStatusText = document.getElementById('rev-usury-status-text');
67
+ const usuryStatusDesc = document.getElementById('rev-usury-status-desc');
68
+ const usuryRefVal = document.getElementById('rev-usury-ref-val');
69
+ const usuryThresholdVal = document.getElementById('rev-usury-threshold-val');
70
+
71
+ const barPrincipal = document.getElementById('rev-bar-principal');
72
+ const barInterest = document.getElementById('rev-bar-interest');
73
+ const barPrincipalPct = document.getElementById('rev-bar-principal-pct');
74
+ const barInterestPct = document.getElementById('rev-bar-interest-pct');
75
+ const timelineTrack = document.getElementById('rev-timeline-track');
76
+
77
+ const totalInterestDisplay = document.getElementById('rev-total-interest');
78
+ const totalPaidDisplay = document.getElementById('rev-total-paid');
79
+ const monthsToPayDisplay = document.getElementById('rev-months-to-pay');
80
+ const yearsToPayDisplay = document.getElementById('rev-years-to-pay');
81
+ const infiniteDebtAlert = document.getElementById('rev-infinite-debt-alert');
82
+
83
+ const toggleTableBtn = document.getElementById('rev-toggle-table-btn');
84
+ const tableWrapper = document.getElementById('rev-table-wrapper');
85
+ const tableBody = document.getElementById('rev-table-body');
86
+ const copyReportBtn = document.getElementById('rev-copy-report');
87
+
88
+
89
+ function getCurrencySymbol(): string {
90
+ const val = currencySelect?.value || 'USD';
91
+ return CURRENCY_SYMBOLS[val] || '$';
92
+ }
93
+
94
+ function getPercentSymbol(): string {
95
+ return container?.getAttribute('data-percent-symbol') || '%';
96
+ }
97
+
98
+ function formatCurrency(value: number): string {
99
+ return `${getCurrencySymbol()}${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
100
+ }
101
+
102
+ function formatPercent(value: number): string {
103
+ return `${value.toFixed(2)}${getPercentSymbol()}`;
104
+ }
105
+
106
+ function updateCurrencySymbols() {
107
+ const symbol = getCurrencySymbol();
108
+ document.querySelectorAll('.rev-currency-symbol').forEach((el) => {
109
+ el.textContent = symbol;
110
+ });
111
+ }
112
+
113
+ function updatePaymentUnit() {
114
+ if (!paymentTypeSelect || !paymentValueUnit || !minPaymentGroup) return;
115
+ if (paymentTypeSelect.value === 'percentage') {
116
+ paymentValueUnit.textContent = getPercentSymbol();
117
+ minPaymentGroup.style.display = 'flex';
118
+ } else {
119
+ paymentValueUnit.textContent = getCurrencySymbol();
120
+ minPaymentGroup.style.display = 'none';
121
+ }
122
+ }
123
+
124
+
125
+ function handleMarketChange() {
126
+ if (!marketSelect || !currencySelect) return;
127
+ const market = marketSelect.value;
128
+ const currency = MARKET_CURRENCY_MAP[market];
129
+ if (currency) {
130
+ currencySelect.value = currency;
131
+ }
132
+ }
133
+
134
+ function parseNum(el: HTMLInputElement | null, fallback: string): number {
135
+ return parseFloat(el?.value || fallback);
136
+ }
137
+
138
+ function readRawValues() {
139
+ return {
140
+ market: (marketSelect?.value || 'es') as RevolvingInputs['market'],
141
+ balance: parseNum(balanceInput, '0'),
142
+ creditLimit: parseNum(creditLimitInput, '0'),
143
+ apr: parseNum(aprInput, '0'),
144
+ paymentType: (paymentTypeSelect?.value || 'percentage') as RevolvingInputs['paymentType'],
145
+ paymentValue: parseNum(paymentValueInput, '0'),
146
+ minPaymentRaw: parseNum(minPaymentInput, '0'),
147
+ customThreshold: parseNum(customThresholdInput, '25'),
148
+ };
149
+ }
150
+
151
+ function getInputs(): RevolvingInputs {
152
+ const raw = readRawValues();
153
+ return {
154
+ balance: raw.balance,
155
+ creditLimit: raw.creditLimit,
156
+ apr: raw.apr,
157
+ paymentType: raw.paymentType,
158
+ paymentValue: raw.paymentValue,
159
+ minPaymentValue: raw.paymentType === 'percentage' ? raw.minPaymentRaw : 0,
160
+ market: raw.market,
161
+ customThreshold: raw.customThreshold,
162
+ };
163
+ }
164
+
165
+ function resolveStatusLabel(status: string): string {
166
+ const labels: Record<string, string> = {
167
+ safe: container?.getAttribute('data-usury-safe') || 'Legally Standard (Low Risk)',
168
+ warning: container?.getAttribute('data-usury-warning') || 'High Rate Warning (Medium Risk)',
169
+ usurious: container?.getAttribute('data-usury-usurious') || 'Potentially Usurious (High Risk)',
170
+ };
171
+ return labels[status] ?? labels['usurious']!;
172
+ }
173
+
174
+ function resolveStatusIcon(status: string): string {
175
+ if (status === 'safe') return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>';
176
+ if (status === 'warning') return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm1 14h-2v-2h2v2zm0-4h-2V7h2v5z"/></svg>';
177
+ return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>';
178
+ }
179
+
180
+ function updateUsuryUI(apr: number, market: RevolvingInputs['market'], customThreshold: number) {
181
+ if (!usuryBadge || !usuryIcon || !usuryStatusText || !usuryStatusDesc || !usuryRefVal || !usuryThresholdVal) return;
182
+ const analysis = analyzeUsury(apr, market, customThreshold);
183
+ usuryBadge.className = 'rev-usury-status-container ' + analysis.status;
184
+ usuryIcon.innerHTML = resolveStatusIcon(analysis.status);
185
+ usuryStatusText.textContent = resolveStatusLabel(analysis.status);
186
+ usuryStatusDesc.textContent = analysis.details;
187
+ usuryRefVal.textContent = formatPercent(analysis.referenceRate);
188
+ usuryThresholdVal.textContent = formatPercent(analysis.thresholdRate);
189
+ }
190
+
191
+ function updateSummaryDisplay(result: ReturnType<typeof calculateAmortization>) {
192
+ if (totalInterestDisplay) totalInterestDisplay.textContent = formatCurrency(result.totalInterest);
193
+ if (totalPaidDisplay) totalPaidDisplay.textContent = formatCurrency(result.totalPaid);
194
+ if (infiniteDebtAlert) infiniteDebtAlert.style.display = result.isInfinite ? 'flex' : 'none';
195
+ if (!monthsToPayDisplay || !yearsToPayDisplay) return;
196
+ if (result.isInfinite) {
197
+ monthsToPayDisplay.textContent = '∞';
198
+ yearsToPayDisplay.textContent = 'Never-ending debt';
199
+ } else {
200
+ monthsToPayDisplay.textContent = result.monthsToPay.toString();
201
+ yearsToPayDisplay.textContent = `${(result.monthsToPay / 12).toFixed(1)} years`;
202
+ }
203
+ }
204
+
205
+ function updateBreakdownBar(principal: number, interest: number) {
206
+ if (!barPrincipal || !barInterest || !barPrincipalPct || !barInterestPct) return;
207
+ const total = principal + interest;
208
+ const pp = total > 0 ? (principal / total) * 100 : 100;
209
+ const ip = total > 0 ? (interest / total) * 100 : 0;
210
+ barPrincipal.style.width = `${pp}%`;
211
+ barInterest.style.width = `${ip}%`;
212
+ barPrincipalPct.textContent = `${pp.toFixed(0)}%`;
213
+ barInterestPct.textContent = `${ip.toFixed(0)}%`;
214
+ }
215
+
216
+ function updateTimeline(result: ReturnType<typeof calculateAmortization>) {
217
+ if (!timelineTrack) return;
218
+ timelineTrack.innerHTML = '';
219
+ const fill = document.createElement('div');
220
+ fill.className = 'rev-timeline-fill';
221
+ fill.style.width = '100%';
222
+ if (result.isInfinite || result.schedule.length === 0) {
223
+ fill.style.background = 'var(--rc-chart-interest)';
224
+ timelineTrack.appendChild(fill);
225
+ return;
226
+ }
227
+ const step = Math.max(1, Math.floor(result.schedule.length / 10));
228
+ for (let i = 0; i < result.schedule.length; i += step) {
229
+ const row = result.schedule[i];
230
+ if (!row) continue;
231
+ const marker = document.createElement('div');
232
+ marker.className = 'rev-timeline-marker';
233
+ marker.style.cssText = `position:absolute;left:${(i / result.schedule.length) * 100}%;width:6px;height:12px;background:var(--rc-chart-principal);border-radius:3px`;
234
+ timelineTrack.appendChild(marker);
235
+ }
236
+ timelineTrack.appendChild(fill);
237
+ }
238
+
239
+ function updateTableBody(result: ReturnType<typeof calculateAmortization>) {
240
+ if (!tableBody) return;
241
+ tableBody.innerHTML = '';
242
+ result.schedule.slice(0, 120).forEach((row) => {
243
+ const tr = document.createElement('tr');
244
+ tr.innerHTML = `<td>${row.month}</td><td>${formatCurrency(row.initialBalance)}</td><td>${formatCurrency(row.interestPaid)}</td><td>${formatCurrency(row.principalPaid)}</td><td>${formatCurrency(row.payment)}</td><td>${formatCurrency(row.finalBalance)}</td>`;
245
+ tableBody.appendChild(tr);
246
+ });
247
+ }
248
+
249
+ function updateAmortizationUI(inputs: RevolvingInputs) {
250
+ const result = calculateAmortization(inputs);
251
+ updateSummaryDisplay(result);
252
+ updateBreakdownBar(inputs.balance, result.totalInterest);
253
+ updateTimeline(result);
254
+ updateTableBody(result);
255
+ }
256
+
257
+ function calculate() {
258
+ const inputs = getInputs();
259
+ if (marketSelect && customThresholdGroup) {
260
+ customThresholdGroup.style.display = marketSelect.value === 'custom' ? 'flex' : 'none';
261
+ }
262
+ updateCurrencySymbols();
263
+ updatePaymentUnit();
264
+ updateUsuryUI(inputs.apr, inputs.market, inputs.customThreshold || 25);
265
+ updateAmortizationUI(inputs);
266
+ }
267
+
268
+ function buildReportText(): string {
269
+ const inputs = getInputs();
270
+ const result = calculateAmortization(inputs);
271
+ const usury = analyzeUsury(inputs.apr, inputs.market, inputs.customThreshold || 25);
272
+
273
+ return [
274
+ 'REVOLVING CARD & USURY SIMULATION REPORT',
275
+ '========================================',
276
+ `Outstanding Balance: ${formatCurrency(inputs.balance)}`,
277
+ `Credit Limit: ${formatCurrency(inputs.creditLimit)}`,
278
+ `Interest Rate (APR / TAE): ${formatPercent(inputs.apr)}`,
279
+ `Market context: ${usury.marketName}`,
280
+ `Usury Compliance: ${usury.status.toUpperCase()} - ${usury.details}`,
281
+ '----------------------------------------',
282
+ `Time to Pay Off: ${result.isInfinite ? 'Infinite / Never' : `${result.monthsToPay} months`}`,
283
+ `Total Interest Paid: ${formatCurrency(result.totalInterest)}`,
284
+ `Total Amount Repaid: ${formatCurrency(result.totalPaid)}`,
285
+ '========================================'
286
+ ].join('\n');
287
+ }
288
+
289
+ function handleCopy() {
290
+ const text = buildReportText();
291
+ navigator.clipboard.writeText(text);
292
+ if (copyReportBtn) {
293
+ const originalText = copyReportBtn.textContent;
294
+ copyReportBtn.textContent = container?.getAttribute('data-copied-label') || 'Copied!';
295
+ setTimeout(() => {
296
+ copyReportBtn.textContent = originalText;
297
+ }, 2000);
298
+ }
299
+ }
300
+
301
+ marketSelect?.addEventListener('change', () => { handleMarketChange(); calculate(); });
302
+ marketSelect?.addEventListener('input', calculate);
303
+ [currencySelect, customThresholdInput, balanceInput, creditLimitInput, aprInput, paymentTypeSelect, paymentValueInput, minPaymentInput].forEach((el) => { el?.addEventListener('input', calculate); el?.addEventListener('change', calculate); });
304
+
305
+ toggleTableBtn?.addEventListener('click', () => {
306
+ if (tableWrapper) {
307
+ const isHidden = tableWrapper.style.display === 'none';
308
+ tableWrapper.style.display = isHidden ? 'block' : 'none';
309
+ }
310
+ });
311
+
312
+ copyReportBtn?.addEventListener('click', handleCopy);
313
+
314
+ calculate();
315
+ </script>
@@ -0,0 +1,57 @@
1
+ ---
2
+ import type { RevolvingCardCalculatorUI } from '../ui';
3
+
4
+ interface Props {
5
+ ui: RevolvingCardCalculatorUI;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ ---
10
+
11
+ <div class="rev-amortization-section">
12
+ <div class="rev-summary-grid">
13
+ <div class="rev-summary-card primary-gradient">
14
+ <span class="rev-summary-label">{ui.totalInterestLabel}</span>
15
+ <span class="rev-summary-value" id="rev-total-interest">0.00</span>
16
+ </div>
17
+
18
+ <div class="rev-summary-card">
19
+ <span class="rev-summary-label">{ui.totalPaidLabel}</span>
20
+ <span class="rev-summary-value" id="rev-total-paid">0.00</span>
21
+ </div>
22
+
23
+ <div class="rev-summary-card">
24
+ <span class="rev-summary-label">{ui.monthsToPayLabel}</span>
25
+ <span class="rev-summary-value" id="rev-months-to-pay">0</span>
26
+ <span class="rev-summary-subvalue" id="rev-years-to-pay">0 years</span>
27
+ </div>
28
+ </div>
29
+
30
+ <div class="rev-alert-box error" id="rev-infinite-debt-alert" style="display: none;">
31
+ <div class="rev-alert-icon"></div>
32
+ <div class="rev-alert-content">{ui.infiniteDebtWarning}</div>
33
+ </div>
34
+
35
+ <div class="rev-table-actions">
36
+ <button class="rev-btn-secondary" id="rev-toggle-table-btn">
37
+ {ui.tableTitle}
38
+ </button>
39
+ </div>
40
+
41
+ <div class="rev-table-container" id="rev-table-wrapper" style="display: none;">
42
+ <table class="rev-table">
43
+ <thead>
44
+ <tr>
45
+ <th>{ui.tableHeaderMonth}</th>
46
+ <th>{ui.tableHeaderInitial}</th>
47
+ <th>{ui.tableHeaderInterest}</th>
48
+ <th>{ui.tableHeaderPrincipal}</th>
49
+ <th>{ui.tableHeaderPayment}</th>
50
+ <th>{ui.tableHeaderFinal}</th>
51
+ </tr>
52
+ </thead>
53
+ <tbody id="rev-table-body">
54
+ </tbody>
55
+ </table>
56
+ </div>
57
+ </div>
@@ -0,0 +1,117 @@
1
+ ---
2
+ import type { RevolvingCardCalculatorUI } from '../ui';
3
+
4
+ interface Props {
5
+ ui: RevolvingCardCalculatorUI;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ ---
10
+
11
+ <div class="rev-inputs-grid">
12
+ <div class="rev-input-group">
13
+ <label for="rev-market">{ui.marketLabel}</label>
14
+ <div class="rev-select-wrapper">
15
+ <select id="rev-market">
16
+ <option value="es">{ui.marketES}</option>
17
+ <option value="us">{ui.marketUS}</option>
18
+ <option value="uk">{ui.marketUK}</option>
19
+ <option value="eu">{ui.marketEU}</option>
20
+ <option value="jp">{ui.marketJP}</option>
21
+ <option value="kr">{ui.marketKR}</option>
22
+ <option value="cn">{ui.marketCN}</option>
23
+ <option value="br">{ui.marketBR}</option>
24
+ <option value="mx">{ui.marketMX}</option>
25
+ <option value="pl">{ui.marketPL}</option>
26
+ <option value="id">{ui.marketID}</option>
27
+ <option value="tr">{ui.marketTR}</option>
28
+ <option value="ru">{ui.marketRU}</option>
29
+ <option value="se">{ui.marketSE}</option>
30
+ <option value="au">{ui.marketAU}</option>
31
+ <option value="ca">{ui.marketCA}</option>
32
+ <option value="custom">{ui.marketCustom}</option>
33
+ </select>
34
+ </div>
35
+ </div>
36
+
37
+ <div class="rev-input-group">
38
+ <label for="rev-currency">{ui.currencyLabel}</label>
39
+ <div class="rev-select-wrapper">
40
+ <select id="rev-currency">
41
+ <option value="EUR">EUR (€)</option>
42
+ <option value="USD">USD ($)</option>
43
+ <option value="GBP">GBP (£)</option>
44
+ <option value="JPY">JPY (¥)</option>
45
+ <option value="KRW">KRW (₩)</option>
46
+ <option value="CNY">CNY (¥)</option>
47
+ <option value="BRL">BRL (R$)</option>
48
+ <option value="MXN">MXN ($)</option>
49
+ <option value="PLN">PLN (zł)</option>
50
+ <option value="IDR">IDR (Rp)</option>
51
+ <option value="TRY">TRY (₺)</option>
52
+ <option value="RUB">RUB (₽)</option>
53
+ <option value="SEK">SEK (kr)</option>
54
+ <option value="AUD">AUD (A$)</option>
55
+ <option value="CAD">CAD (C$)</option>
56
+ </select>
57
+ </div>
58
+ </div>
59
+
60
+ <div class="rev-input-group id-custom-threshold-group" style="display: none;">
61
+ <label for="rev-custom-threshold">{ui.customThresholdLabel}</label>
62
+ <div class="rev-input-wrapper">
63
+ <input type="number" id="rev-custom-threshold" min="0" max="1000" value="25" step="0.1" />
64
+ <span class="rev-input-unit">{ui.percentSymbol}</span>
65
+ </div>
66
+ </div>
67
+
68
+ <div class="rev-input-group">
69
+ <label for="rev-balance">{ui.balanceLabel}</label>
70
+ <div class="rev-input-wrapper">
71
+ <input type="number" id="rev-balance" min="0" value="3000" step="10" />
72
+ <span class="rev-input-unit rev-currency-symbol">{ui.currencySymbol}</span>
73
+ </div>
74
+ </div>
75
+
76
+ <div class="rev-input-group">
77
+ <label for="rev-credit-limit">{ui.creditLimitLabel}</label>
78
+ <div class="rev-input-wrapper">
79
+ <input type="number" id="rev-credit-limit" min="0" value="5000" step="100" />
80
+ <span class="rev-input-unit rev-currency-symbol">{ui.currencySymbol}</span>
81
+ </div>
82
+ </div>
83
+
84
+ <div class="rev-input-group">
85
+ <label for="rev-apr">{ui.aprLabel}</label>
86
+ <div class="rev-input-wrapper">
87
+ <input type="number" id="rev-apr" min="0" max="1000" value="24" step="0.05" />
88
+ <span class="rev-input-unit">{ui.percentSymbol}</span>
89
+ </div>
90
+ </div>
91
+
92
+ <div class="rev-input-group">
93
+ <label for="rev-payment-type">{ui.paymentTypeLabel}</label>
94
+ <div class="rev-select-wrapper">
95
+ <select id="rev-payment-type">
96
+ <option value="percentage" selected>{ui.paymentTypePercentage}</option>
97
+ <option value="fixed">{ui.paymentTypeFixed}</option>
98
+ </select>
99
+ </div>
100
+ </div>
101
+
102
+ <div class="rev-input-group">
103
+ <label for="rev-payment-value">{ui.paymentValueLabel}</label>
104
+ <div class="rev-input-wrapper">
105
+ <input type="number" id="rev-payment-value" min="0" value="2.5" step="0.1" />
106
+ <span class="rev-input-unit" id="rev-payment-value-unit">{ui.percentSymbol}</span>
107
+ </div>
108
+ </div>
109
+
110
+ <div class="rev-input-group" id="rev-min-payment-group">
111
+ <label for="rev-min-payment">{ui.minPaymentValueLabel}</label>
112
+ <div class="rev-input-wrapper">
113
+ <input type="number" id="rev-min-payment" min="0" value="30" step="1" />
114
+ <span class="rev-input-unit rev-currency-symbol">{ui.currencySymbol}</span>
115
+ </div>
116
+ </div>
117
+ </div>
@@ -0,0 +1,35 @@
1
+ ---
2
+ import type { RevolvingCardCalculatorUI } from '../ui';
3
+
4
+ interface Props {
5
+ ui: RevolvingCardCalculatorUI;
6
+ }
7
+
8
+ const { ui } = Astro.props;
9
+ ---
10
+
11
+ <div class="rev-chart-container">
12
+ <div class="rev-chart-header">
13
+ <h3 class="rev-chart-title">{ui.payoffYearsLabel}</h3>
14
+ </div>
15
+
16
+ <div class="rev-breakdown-bar-wrapper">
17
+ <div class="rev-bar-labels">
18
+ <span>Original Principal</span>
19
+ <span>Total Interest Paid</span>
20
+ </div>
21
+ <div class="rev-breakdown-bar">
22
+ <div id="rev-bar-principal" class="rev-bar-segment principal" style="width: 50%;">
23
+ <span class="rev-bar-pct" id="rev-bar-principal-pct">50%</span>
24
+ </div>
25
+ <div id="rev-bar-interest" class="rev-bar-segment interest" style="width: 50%;">
26
+ <span class="rev-bar-pct" id="rev-bar-interest-pct">50%</span>
27
+ </div>
28
+ </div>
29
+ </div>
30
+
31
+ <div class="rev-timeline-visual">
32
+ <div class="rev-timeline-track" id="rev-timeline-track">
33
+ </div>
34
+ </div>
35
+ </div>