@jjlmoya/utils-finance 1.41.0 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/entries.ts +4 -1
  3. package/src/tests/locale_completeness.test.ts +2 -2
  4. package/src/tests/tool_validation.test.ts +2 -2
  5. package/src/tool/rentalDepositReturn/bibliography.astro +11 -0
  6. package/src/tool/rentalDepositReturn/bibliography.ts +6 -0
  7. package/src/tool/rentalDepositReturn/component.astro +53 -0
  8. package/src/tool/rentalDepositReturn/controller.ts +156 -0
  9. package/src/tool/rentalDepositReturn/currency.ts +67 -0
  10. package/src/tool/rentalDepositReturn/dom-views.ts +31 -0
  11. package/src/tool/rentalDepositReturn/entry.ts +28 -0
  12. package/src/tool/rentalDepositReturn/i18n/content-factory.ts +36 -0
  13. package/src/tool/rentalDepositReturn/i18n/de.ts +3 -0
  14. package/src/tool/rentalDepositReturn/i18n/en.ts +116 -0
  15. package/src/tool/rentalDepositReturn/i18n/es.ts +3 -0
  16. package/src/tool/rentalDepositReturn/i18n/fr.ts +3 -0
  17. package/src/tool/rentalDepositReturn/i18n/id.ts +3 -0
  18. package/src/tool/rentalDepositReturn/i18n/it.ts +3 -0
  19. package/src/tool/rentalDepositReturn/i18n/ja.ts +3 -0
  20. package/src/tool/rentalDepositReturn/i18n/ko.ts +3 -0
  21. package/src/tool/rentalDepositReturn/i18n/nl.ts +3 -0
  22. package/src/tool/rentalDepositReturn/i18n/pl.ts +3 -0
  23. package/src/tool/rentalDepositReturn/i18n/pt.ts +3 -0
  24. package/src/tool/rentalDepositReturn/i18n/ru.ts +3 -0
  25. package/src/tool/rentalDepositReturn/i18n/sv.ts +3 -0
  26. package/src/tool/rentalDepositReturn/i18n/tr.ts +3 -0
  27. package/src/tool/rentalDepositReturn/i18n/translations.ts +34 -0
  28. package/src/tool/rentalDepositReturn/i18n/zh.ts +3 -0
  29. package/src/tool/rentalDepositReturn/index.ts +11 -0
  30. package/src/tool/rentalDepositReturn/logic.test.ts +53 -0
  31. package/src/tool/rentalDepositReturn/logic.ts +108 -0
  32. package/src/tool/rentalDepositReturn/rental-deposit-return-calculator.css +591 -0
  33. package/src/tool/rentalDepositReturn/seo.astro +11 -0
  34. package/src/tool/rentalDepositReturn/ui.ts +53 -0
  35. 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.41.0",
3
+ "version": "1.42.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
package/src/entries.ts CHANGED
@@ -44,6 +44,8 @@ export { subscriptionCostAnnualizer } from './tool/subscriptionCostAnnualizer/en
44
44
  export type { SubscriptionCostAnnualizerLocaleContent } from './tool/subscriptionCostAnnualizer/entry';
45
45
  export { loanToIncomeRatio } from './tool/loanToIncomeRatio/entry';
46
46
  export type { LoanToIncomeRatioLocaleContent } from './tool/loanToIncomeRatio/entry';
47
+ export { rentalDepositReturn } from './tool/rentalDepositReturn/entry';
48
+ export type { RentalDepositReturnLocaleContent } from './tool/rentalDepositReturn/entry';
47
49
  export { financeCategory } from './category';
48
50
  import { compoundInterest } from './tool/compoundInterest/entry';
49
51
  import { financialFreedom } from './tool/financialFreedom/entry';
@@ -67,4 +69,5 @@ import { mortgageExtraPaymentPlanner } from './tool/mortgageExtraPaymentPlanner/
67
69
  import { inflationPurchasingPower } from './tool/inflationPurchasingPower/entry';
68
70
  import { subscriptionCostAnnualizer } from './tool/subscriptionCostAnnualizer/entry';
69
71
  import { loanToIncomeRatio } from './tool/loanToIncomeRatio/entry';
70
- 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];
72
+ import { rentalDepositReturn } from './tool/rentalDepositReturn/entry';
73
+ 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];
@@ -25,7 +25,7 @@ describe('Locale Completeness Validation', () => {
25
25
  });
26
26
  });
27
27
 
28
- it('all 23 tools registered', () => {
29
- expect(ALL_TOOLS.length).toBe(23);
28
+ it('all 24 tools registered', () => {
29
+ expect(ALL_TOOLS.length).toBe(24);
30
30
  });
31
31
  });
@@ -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 23 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(23);
7
+ it('should have 24 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(24);
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 { rentalDepositReturn } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props { locale?: KnownLocale; }
7
+ const { locale = 'en' } = Astro.props;
8
+ const content = await rentalDepositReturn.i18n[locale]?.() ?? await rentalDepositReturn.i18n.en?.();
9
+ ---
10
+
11
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,6 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ { name: 'Boletín Oficial del Estado, Ley 29/1994 de Arrendamientos Urbanos, artículo 36', url: 'https://www.boe.es/buscar/act.php?id=BOE-A-1994-26003' },
5
+ { name: 'GOV.UK, Tenancy deposit protection: Overview', url: 'https://www.gov.uk/tenancy-deposit-protection' },
6
+ ];
@@ -0,0 +1,53 @@
1
+ ---
2
+ import type { RentalDepositReturnUI } from './ui';
3
+ import { CURRENCIES, getDefaultCurrency, type CurrencyCode } from './currency';
4
+ import type { RentalDepositInput } from './logic';
5
+ import { calculateRentalDepositReturn } from './logic';
6
+ import { renderDepositResults, renderDepositScene } from './dom-views';
7
+ import './rental-deposit-return-calculator.css';
8
+
9
+ interface Props { ui: RentalDepositReturnUI; locale?: string; }
10
+
11
+ const { ui, locale = 'en' } = Astro.props;
12
+ const initialCurrency = getDefaultCurrency(locale);
13
+ const initial: RentalDepositInput = {
14
+ currency: initialCurrency,
15
+ deposit: 1200,
16
+ deductions: [
17
+ { id: 'final-bills', description: 'Final utility bills', amount: 84, status: 'confirmed' },
18
+ { id: 'wall-repair', description: 'Wall repair', amount: 165, status: 'disputed' },
19
+ { id: 'cleaning', description: 'Cleaning charge', amount: 60, status: 'disputed' },
20
+ ],
21
+ };
22
+ const initialResult = calculateRentalDepositReturn(initial);
23
+ const config = JSON.stringify({ ui, initial, locale });
24
+ const currencyOptions = Object.values(CURRENCIES) as Array<{ code: CurrencyCode; symbol: string }>;
25
+ ---
26
+
27
+ <div class="rdr-tool" data-rdr-tool>
28
+ <form class="rdr-controls" aria-label={ui.deductionsTitle}>
29
+ <div class="rdr-top-controls">
30
+ <label class="rdr-deposit-field"><span>{ui.depositLabel}</span><input data-rdr-deposit type="number" min="0" step="0.01" inputmode="decimal" required /><small>{ui.depositHelp}</small></label>
31
+ <div class="rdr-currency-field"><span class="rdr-field-label">{ui.currencyLabel}</span><div class="rdr-currency-select" data-rdr-currency-select data-value={initialCurrency}>
32
+ <button type="button" class="rdr-currency-trigger" id="rdr-currency-trigger" data-rdr-currency-trigger aria-haspopup="listbox" aria-expanded="false" aria-controls="rdr-currency-menu" aria-label={`${ui.currencyLabel}: ${initialCurrency}`}><span class="rdr-currency-trigger-symbol" data-rdr-currency-symbol aria-hidden="true">{CURRENCIES[initialCurrency].symbol}</span><span class="rdr-currency-trigger-arrow" aria-hidden="true"></span></button>
33
+ <div class="rdr-currency-menu" id="rdr-currency-menu" data-rdr-currency-menu role="listbox" hidden aria-label={ui.currencyLabel}>
34
+ {currencyOptions.map((option) => <button type="button" class="rdr-currency-option" data-rdr-currency-option data-value={option.code} role="option" aria-label={`${option.code}: ${option.symbol}`} aria-selected={option.code === initialCurrency}><span aria-hidden="true">{option.symbol}</span></button>)}
35
+ </div>
36
+ </div><small>{ui.currencyHelp}</small></div>
37
+ </div>
38
+ <div class="rdr-list-heading"><div><h2>{ui.deductionsTitle}</h2><p>{ui.deductionsHelp}</p></div><button type="button" class="rdr-secondary-button" data-rdr-reset>{ui.resetButton}</button></div>
39
+ <div class="rdr-list" data-rdr-list></div>
40
+ <div class="rdr-actions"><button type="button" class="rdr-primary-button" data-rdr-add>{ui.addButton}</button><button type="button" class="rdr-copy-button" data-rdr-copy><span data-rdr-copy-label>{ui.copyButton}</span></button></div>
41
+ </form>
42
+ <section class="rdr-output" aria-live="polite"><div class="rdr-scene-wrap" data-rdr-scene set:html={initialResult ? renderDepositScene(initialResult, ui, locale) : ''}></div><div class="rdr-results-wrap" data-rdr-results set:html={initialResult ? renderDepositResults(initialResult, ui, locale) : ''}></div><p class="rdr-error" data-rdr-error hidden>{ui.emptyMessage}</p></section>
43
+ <p class="rdr-privacy">{ui.privacyNote}</p>
44
+ <details class="rdr-method"><summary>{ui.methodTitle}</summary><p>{ui.methodText}</p><p>{ui.limitationText}</p></details>
45
+ </div>
46
+
47
+ <script is:inline type="application/json" data-rdr-config set:html={config}></script>
48
+ <script>
49
+ import { initRentalDepositReturn } from './controller';
50
+ const root = document.querySelector<HTMLElement>('[data-rdr-tool]');
51
+ const configElement = document.querySelector<HTMLScriptElement>('[data-rdr-config]');
52
+ if (root && configElement?.textContent) initRentalDepositReturn(root, JSON.parse(configElement.textContent));
53
+ </script>
@@ -0,0 +1,156 @@
1
+ import { calculateRentalDepositReturn, type DeductionInput, type DeductionStatus, type RentalDepositInput } from './logic';
2
+ import { CURRENCIES, type CurrencyCode } from './currency';
3
+ import { renderDepositResults, renderDepositScene } from './dom-views';
4
+ import type { RentalDepositReturnUI } from './ui';
5
+
6
+ interface Config { ui: RentalDepositReturnUI; initial: RentalDepositInput; locale: string; }
7
+
8
+ function createId(): string {
9
+ return `deduction-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
10
+ }
11
+
12
+ function escapeAttribute(value: string): string {
13
+ return value.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character] ?? character);
14
+ }
15
+
16
+ function fieldValue(row: HTMLElement, selector: string): string {
17
+ return row.querySelector<HTMLInputElement | HTMLSelectElement>(selector)?.value.trim() ?? '';
18
+ }
19
+
20
+ function readDeductions(root: HTMLElement): DeductionInput[] {
21
+ return Array.from(root.querySelectorAll<HTMLElement>('[data-rdr-row]')).map((row) => ({
22
+ id: row.dataset.rdrRow ?? createId(),
23
+ description: fieldValue(row, '[data-rdr-field="description"]'),
24
+ amount: Number(fieldValue(row, '[data-rdr-field="amount"]')),
25
+ status: (fieldValue(row, '[data-rdr-field="status"]') || 'confirmed') as DeductionStatus,
26
+ }));
27
+ }
28
+
29
+ function collectState(root: HTMLElement): RentalDepositInput {
30
+ return {
31
+ currency: root.querySelector<HTMLElement>('[data-rdr-currency-select]')?.dataset.value ?? 'EUR',
32
+ deposit: Number(root.querySelector<HTMLInputElement>('[data-rdr-deposit]')?.value ?? 0),
33
+ deductions: readDeductions(root),
34
+ };
35
+ }
36
+
37
+ function render(root: HTMLElement, state: RentalDepositInput, ui: RentalDepositReturnUI, locale: string): void {
38
+ const result = calculateRentalDepositReturn(state);
39
+ const scene = root.querySelector<HTMLElement>('[data-rdr-scene]');
40
+ const results = root.querySelector<HTMLElement>('[data-rdr-results]');
41
+ const error = root.querySelector<HTMLElement>('[data-rdr-error]');
42
+ if (!scene || !results || !error) return;
43
+ if (!result) {
44
+ scene.innerHTML = '';
45
+ results.innerHTML = '';
46
+ error.hidden = false;
47
+ return;
48
+ }
49
+ error.hidden = true;
50
+ scene.innerHTML = renderDepositScene(result, ui, locale);
51
+ results.innerHTML = renderDepositResults(result, ui, locale);
52
+ }
53
+
54
+ function rowMarkup(item: DeductionInput, ui: RentalDepositReturnUI): string {
55
+ return `<div class="rdr-row" data-rdr-row="${escapeAttribute(item.id)}"><label><span>${escapeAttribute(ui.descriptionLabel)}</span><input data-rdr-field="description" value="${escapeAttribute(item.description)}" placeholder="${escapeAttribute(ui.descriptionPlaceholder)}" required /></label><label><span>${escapeAttribute(ui.amountLabel)}</span><input data-rdr-field="amount" type="number" min="0" step="0.01" value="${item.amount}" inputmode="decimal" required /></label><label><span>${escapeAttribute(ui.statusLabel)}</span><span class="rdr-select-wrap"><select data-rdr-field="status"><option value="confirmed" ${item.status === 'confirmed' ? 'selected' : ''}>${escapeAttribute(ui.confirmedOption)}</option><option value="disputed" ${item.status === 'disputed' ? 'selected' : ''}>${escapeAttribute(ui.disputedOption)}</option></select><span class="rdr-select-arrow" aria-hidden="true"></span></span></label><button type="button" class="rdr-remove" data-rdr-remove aria-label="${escapeAttribute(ui.removeButton)}">×</button></div>`;
56
+ }
57
+
58
+ function report(result: ReturnType<typeof calculateRentalDepositReturn>, ui: RentalDepositReturnUI, locale: string): string {
59
+ if (!result) return '';
60
+ const format = (value: number): string => new Intl.NumberFormat(locale, { style: 'currency', currency: result.currency }).format(value);
61
+ const lines = result.deductions.map((deduction) => `- ${deduction.description}: ${format(deduction.amount)} (${deduction.status === 'confirmed' ? ui.confirmedOption : ui.disputedOption})`);
62
+ return [ui.reportTitle, `${ui.reportDeposit}: ${format(result.deposit)}`, `${ui.reportDeductions}:`, ...lines, `${ui.reportConfirmed}: ${format(result.confirmedDeductions)}`, `${ui.reportDisputed}: ${format(result.disputedDeductions)}`, `${ui.reportEstimatedReturn}: ${format(result.estimatedReturn)}`, `${ui.reportPotentialReturn}: ${format(result.undisputedReturn)}`, ui.reportNote].join('\n');
63
+ }
64
+
65
+ // eslint-disable-next-line max-lines-per-function, complexity
66
+ export function initRentalDepositReturn(root: HTMLElement, config: Config): void {
67
+ const deposit = root.querySelector<HTMLInputElement>('[data-rdr-deposit]');
68
+ const currency = root.querySelector<HTMLElement>('[data-rdr-currency-select]');
69
+ const currencyTrigger = currency?.querySelector<HTMLButtonElement>('[data-rdr-currency-trigger]');
70
+ const currencyMenu = currency?.querySelector<HTMLElement>('[data-rdr-currency-menu]');
71
+ const list = root.querySelector<HTMLElement>('[data-rdr-list]');
72
+ const copy = root.querySelector<HTMLButtonElement>('[data-rdr-copy]');
73
+ if (!deposit || !currency || !currencyTrigger || !currencyMenu || !list || !copy) return;
74
+ const initialState = config.initial;
75
+ deposit.value = String(initialState.deposit);
76
+ currency.dataset.value = initialState.currency;
77
+ list.innerHTML = initialState.deductions.map((item) => rowMarkup(item, config.ui)).join('');
78
+ render(root, initialState, config.ui, config.locale);
79
+ root.addEventListener('input', () => render(root, collectState(root), config.ui, config.locale));
80
+ root.addEventListener('change', () => render(root, collectState(root), config.ui, config.locale));
81
+ const closeCurrencyMenu = (): void => {
82
+ currencyMenu.hidden = true;
83
+ currencyTrigger.setAttribute('aria-expanded', 'false');
84
+ };
85
+ const syncCurrency = (value: CurrencyCode): void => {
86
+ currency.dataset.value = value;
87
+ const definition = CURRENCIES[value];
88
+ const symbol = currency.querySelector<HTMLElement>('[data-rdr-currency-symbol]');
89
+ if (symbol) symbol.textContent = definition.symbol;
90
+ currencyTrigger.setAttribute('aria-label', `${config.ui.currencyLabel}: ${value}`);
91
+ currency.querySelectorAll<HTMLButtonElement>('[data-rdr-currency-option]').forEach((option) => {
92
+ option.classList.toggle('is-active', option.dataset.value === value);
93
+ option.setAttribute('aria-selected', `${option.dataset.value === value}`);
94
+ });
95
+ };
96
+ syncCurrency(initialState.currency as CurrencyCode);
97
+ const openCurrencyMenu = (focusActive = false): void => {
98
+ currencyMenu.hidden = false;
99
+ currencyTrigger.setAttribute('aria-expanded', 'true');
100
+ if (focusActive) currency.querySelector<HTMLButtonElement>('.rdr-currency-option.is-active')?.focus();
101
+ };
102
+ currencyTrigger.addEventListener('click', () => {
103
+ const shouldOpen = currencyMenu.hidden;
104
+ closeCurrencyMenu();
105
+ if (shouldOpen) openCurrencyMenu();
106
+ });
107
+ currencyTrigger.addEventListener('keydown', (event) => {
108
+ if (event.key === 'Escape') closeCurrencyMenu();
109
+ if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
110
+ event.preventDefault();
111
+ openCurrencyMenu(true);
112
+ }
113
+ });
114
+ currencyMenu.querySelectorAll<HTMLButtonElement>('[data-rdr-currency-option]').forEach((option) => {
115
+ option.addEventListener('click', () => {
116
+ const value = option.dataset.value as CurrencyCode;
117
+ syncCurrency(value);
118
+ closeCurrencyMenu();
119
+ currencyTrigger.focus();
120
+ render(root, collectState(root), config.ui, config.locale);
121
+ });
122
+ });
123
+ document.addEventListener('click', (event) => {
124
+ if (!currency.contains(event.target as Node)) closeCurrencyMenu();
125
+ });
126
+ root.addEventListener('click', (event) => {
127
+ const target = event.target as HTMLElement;
128
+ const remove = target.closest<HTMLButtonElement>('[data-rdr-remove]');
129
+ if (remove) {
130
+ remove.closest<HTMLElement>('[data-rdr-row]')?.remove();
131
+ render(root, collectState(root), config.ui, config.locale);
132
+ return;
133
+ }
134
+ if (target.closest('[data-rdr-add]')) {
135
+ const next: DeductionInput = { id: createId(), description: config.ui.newDeductionLabel, amount: 0, status: 'confirmed' };
136
+ list.insertAdjacentHTML('beforeend', rowMarkup(next, config.ui));
137
+ list.querySelector<HTMLElement>('[data-rdr-row]:last-child [data-rdr-field="description"]')?.focus();
138
+ render(root, collectState(root), config.ui, config.locale);
139
+ return;
140
+ }
141
+ if (target.closest('[data-rdr-reset]')) {
142
+ deposit.value = String(initialState.deposit);
143
+ syncCurrency(initialState.currency as CurrencyCode);
144
+ list.innerHTML = initialState.deductions.map((item) => rowMarkup(item, config.ui)).join('');
145
+ render(root, initialState, config.ui, config.locale);
146
+ return;
147
+ }
148
+ if (target.closest('[data-rdr-copy]')) {
149
+ const result = calculateRentalDepositReturn(collectState(root));
150
+ navigator.clipboard.writeText(report(result, config.ui, config.locale));
151
+ const label = copy.querySelector('[data-rdr-copy-label]');
152
+ if (label) label.textContent = config.ui.copiedButton;
153
+ window.setTimeout(() => { if (label) label.textContent = config.ui.copyButton; }, 2000);
154
+ }
155
+ });
156
+ }
@@ -0,0 +1,67 @@
1
+ export type CurrencyCode =
2
+ | 'AUD' | 'BRL' | 'CAD' | 'CHF' | 'CNY' | 'CZK' | 'DKK'
3
+ | 'EUR' | 'GBP' | 'IDR' | 'INR' | 'JPY' | 'KRW' | 'MXN' | 'NOK'
4
+ | 'PLN' | 'RUB' | 'SEK' | 'TRY' | 'USD';
5
+
6
+ export interface CurrencyDefinition {
7
+ code: CurrencyCode;
8
+ symbol: string;
9
+ }
10
+
11
+ export const CURRENCIES = {
12
+ AUD: { code: 'AUD', symbol: 'A$' },
13
+ BRL: { code: 'BRL', symbol: 'R$' },
14
+ CAD: { code: 'CAD', symbol: 'C$' },
15
+ CHF: { code: 'CHF', symbol: 'CHF' },
16
+ CNY: { code: 'CNY', symbol: '¥' },
17
+ CZK: { code: 'CZK', symbol: 'Kč' },
18
+ DKK: { code: 'DKK', symbol: 'kr' },
19
+ EUR: { code: 'EUR', symbol: '€' },
20
+ GBP: { code: 'GBP', symbol: '£' },
21
+ IDR: { code: 'IDR', symbol: 'Rp' },
22
+ INR: { code: 'INR', symbol: '₹' },
23
+ JPY: { code: 'JPY', symbol: '¥' },
24
+ KRW: { code: 'KRW', symbol: '₩' },
25
+ MXN: { code: 'MXN', symbol: 'MX$' },
26
+ NOK: { code: 'NOK', symbol: 'kr' },
27
+ PLN: { code: 'PLN', symbol: 'zł' },
28
+ RUB: { code: 'RUB', symbol: '₽' },
29
+ SEK: { code: 'SEK', symbol: 'kr' },
30
+ TRY: { code: 'TRY', symbol: '₺' },
31
+ USD: { code: 'USD', symbol: '$' },
32
+ } as const satisfies Record<CurrencyCode, CurrencyDefinition>;
33
+
34
+ const DEFAULT_CURRENCY_BY_LOCALE: Record<string, CurrencyCode> = {
35
+ 'en-au': 'AUD',
36
+ 'en-ca': 'CAD',
37
+ 'en-gb': 'GBP',
38
+ 'en-in': 'INR',
39
+ 'fr-ca': 'CAD',
40
+ 'pt-br': 'BRL',
41
+ de: 'EUR',
42
+ en: 'USD',
43
+ es: 'EUR',
44
+ fr: 'EUR',
45
+ id: 'IDR',
46
+ it: 'EUR',
47
+ ja: 'JPY',
48
+ ko: 'KRW',
49
+ nl: 'EUR',
50
+ pl: 'PLN',
51
+ pt: 'EUR',
52
+ ru: 'RUB',
53
+ sv: 'SEK',
54
+ tr: 'TRY',
55
+ zh: 'CNY',
56
+ };
57
+
58
+ export function getDefaultCurrency(locale: string): CurrencyCode {
59
+ const normalizedLocale = locale.toLowerCase().replace('_', '-');
60
+ return DEFAULT_CURRENCY_BY_LOCALE[normalizedLocale]
61
+ ?? DEFAULT_CURRENCY_BY_LOCALE[normalizedLocale.split('-')[0] ?? '']
62
+ ?? 'EUR';
63
+ }
64
+
65
+ export function isCurrencyCode(value: string): value is CurrencyCode {
66
+ return Object.prototype.hasOwnProperty.call(CURRENCIES, value);
67
+ }
@@ -0,0 +1,31 @@
1
+ import type { RentalDepositResult } from './logic';
2
+ import type { RentalDepositReturnUI } from './ui';
3
+
4
+ function escapeHtml(value: string): string {
5
+ return value.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character] ?? character);
6
+ }
7
+
8
+ function formatMoney(value: number, currency: string, locale: string): string {
9
+ return new Intl.NumberFormat(locale, { style: 'currency', currency, maximumFractionDigits: 2 }).format(value);
10
+ }
11
+
12
+ function metric(label: string, value: string, accent = ''): string {
13
+ return `<div class="rdr-metric ${accent}"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
14
+ }
15
+
16
+ export function renderDepositScene(result: RentalDepositResult, ui: RentalDepositReturnUI, locale = 'en-US'): string {
17
+ const used = result.deposit > 0 ? Math.min(100, (result.totalDeductions / result.deposit) * 100) : 0;
18
+ const returnWidth = result.deposit > 0 ? Math.max(0, 100 - used) : 0;
19
+ const rows = result.deductions.length === 0
20
+ ? `<p class="rdr-scene-empty">${escapeHtml(ui.emptyMessage)}</p>`
21
+ : result.deductions.map((deduction) => `<div class="rdr-ledger-row ${deduction.status}"><span>${escapeHtml(deduction.description)}</span><i class="rdr-ledger-line"><b style="width:${Math.min(100, Math.max(0, deduction.shareOfDeposit)).toFixed(1)}%"></b></i><strong>${escapeHtml(formatMoney(deduction.amount, result.currency, locale))}</strong></div>`).join('');
22
+ return `<div class="rdr-scene" aria-label="${escapeHtml(ui.returnFlowTitle)}"><div class="rdr-scene-heading"><span class="rdr-key-mark" aria-hidden="true">⌁</span><div><strong>${escapeHtml(ui.returnFlowTitle)}</strong><p>${escapeHtml(ui.returnFlowText)}</p></div></div><div class="rdr-flow"><div class="rdr-flow-node rdr-flow-deposit"><span aria-hidden="true">●</span><strong>${escapeHtml(formatMoney(result.deposit, result.currency, locale))}</strong><small>${escapeHtml(ui.depositNode)}</small></div><div class="rdr-flow-arrow" aria-hidden="true">→</div><div class="rdr-flow-node rdr-flow-return" style="--return-width:${returnWidth.toFixed(1)}%"><span aria-hidden="true">↗</span><strong>${escapeHtml(formatMoney(result.estimatedReturn, result.currency, locale))}</strong><small>${escapeHtml(ui.returnNode)}</small></div></div><div class="rdr-meter" aria-label="${escapeHtml(ui.deductionNode)}"><span style="width:${used.toFixed(1)}%"></span></div><div class="rdr-meter-labels"><span>${escapeHtml(ui.deductionNode)} ${escapeHtml(formatMoney(result.totalDeductions, result.currency, locale))}</span><span>${escapeHtml(ui.returnNode)} ${escapeHtml(formatMoney(result.estimatedReturn, result.currency, locale))}</span></div><div class="rdr-ledger"><div class="rdr-ledger-title"><span>${escapeHtml(ui.deductionBreakdownTitle)}</span><span>${escapeHtml(ui.amountLabel)}</span></div>${rows}</div></div>`;
23
+ }
24
+
25
+ export function renderDepositResults(result: RentalDepositResult, ui: RentalDepositReturnUI, locale = 'en-US'): string {
26
+ const scenarioRows = result.scenarios.length === 0
27
+ ? `<p class="rdr-muted">${escapeHtml(ui.noDisputed)}</p>`
28
+ : `<div class="rdr-table-wrap"><table><thead><tr><th scope="col">${escapeHtml(ui.scenarioColumn)}</th><th scope="col">${escapeHtml(ui.scenarioReturnColumn)}</th><th scope="col">${escapeHtml(ui.scenarioIncreaseColumn)}</th></tr></thead><tbody>${result.scenarios.map((scenario) => `<tr><th scope="row">${escapeHtml(scenario.description)}</th><td>${escapeHtml(formatMoney(scenario.returnAmount, result.currency, locale))}</td><td>+${escapeHtml(formatMoney(scenario.additionalReturn, result.currency, locale))}</td></tr>`).join('')}</tbody></table></div>`;
29
+ const warning = result.overDeducted > 0 ? `<div class="rdr-warning"><strong>${escapeHtml(ui.overDeductedTitle)}</strong><p>${escapeHtml(ui.overDeductedText)} ${escapeHtml(formatMoney(result.overDeducted, result.currency, locale))}</p></div>` : '';
30
+ return `<div class="rdr-results"><div class="rdr-metrics">${metric(ui.estimatedReturnLabel, formatMoney(result.estimatedReturn, result.currency, locale), 'rdr-accent')}${metric(ui.undisputedReturnLabel, formatMoney(result.undisputedReturn, result.currency, locale))}${metric(ui.totalDeductionsLabel, formatMoney(result.totalDeductions, result.currency, locale))}</div>${warning}<section class="rdr-panel"><h3>${escapeHtml(ui.scenariosTitle)}</h3><p>${escapeHtml(ui.scenariosText)}</p>${scenarioRows}</section><section class="rdr-panel rdr-summary"><h3>${escapeHtml(ui.summaryTitle)}</h3><dl><div><dt>${escapeHtml(ui.confirmedLabel)}</dt><dd>${escapeHtml(formatMoney(result.confirmedDeductions, result.currency, locale))}</dd></div><div><dt>${escapeHtml(ui.disputedLabel)}</dt><dd>${escapeHtml(formatMoney(result.disputedDeductions, result.currency, locale))}</dd></div></dl></section></div>`;
31
+ }
@@ -0,0 +1,28 @@
1
+ import type { FinanceToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { RentalDepositReturnUI } from './ui';
3
+
4
+ export type RentalDepositReturnLocaleContent = ToolLocaleContent<RentalDepositReturnUI>;
5
+
6
+ export const rentalDepositReturn: FinanceToolEntry<RentalDepositReturnUI> = {
7
+ id: 'rental-deposit-return-calculator',
8
+ icons: { bg: 'mdi:home-lock-open', fg: 'mdi:cash-refund' },
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';
@@ -0,0 +1,36 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
2
+ import type { SEOSection, ToolLocaleContent } from '../../../types';
3
+ import { bibliography } from '../bibliography';
4
+ import type { RentalDepositReturnUI } from '../ui';
5
+
6
+ const baseUi: RentalDepositReturnUI = {
7
+ depositLabel: 'Deposit paid', depositHelp: 'Use the amount actually paid as the deposit, in the same currency as every line below.',
8
+ deductionsTitle: 'Items kept from the deposit', deductionsHelp: 'Enter the landlord or agent own breakdown. Mark a line disputed when you want to test the amount without it.',
9
+ descriptionLabel: 'Description', descriptionPlaceholder: 'e.g. Final water bill', amountLabel: 'Amount', statusLabel: 'Deduction status', confirmedOption: 'Confirmed', disputedOption: 'Disputed',
10
+ addButton: 'Add deduction', resetButton: 'Restore example', newDeductionLabel: 'New deduction', removeButton: 'Remove this deduction', currencyLabel: 'Currency', currencyHelp: 'All figures stay in this currency. No exchange rates are used.',
11
+ summaryTitle: 'Confirmed vs disputed', estimatedReturnLabel: 'Estimated return', undisputedReturnLabel: 'If disputed items are removed', totalDeductionsLabel: 'Total deductions', deductionBreakdownTitle: 'The ledger behind the result', confirmedLabel: 'Confirmed items', disputedLabel: 'Disputed items',
12
+ returnFlowTitle: 'Deposit to return', returnFlowText: 'A clear split between what is being kept and what could come back.', depositNode: 'Deposit in', deductionNode: 'Kept for deductions', returnNode: 'Estimated back', overDeductedTitle: 'The deductions exceed the deposit', overDeductedText: 'The entered breakdown is above the deposit by',
13
+ scenariosTitle: 'One dispute at a time', scenariosText: 'These scenarios show the return if one disputed line is removed while all other lines stay in place.', scenarioColumn: 'Removed line', scenarioReturnColumn: 'Return if removed', scenarioIncreaseColumn: 'Increase in return', noDisputed: 'Mark a line as disputed to see a comparison scenario.',
14
+ emptyMessage: 'Add a deposit and use a description with a zero or positive amount for every deduction.', copyButton: 'Copy claim summary', copiedButton: 'Summary copied', privacyNote: 'Your figures stay in this browser. Nothing is sent to a server.', methodTitle: 'How this estimate works',
15
+ methodText: 'The tool adds every deduction, subtracts that total from the deposit and floors the estimated return at zero. A disputed scenario removes one disputed line from the total so you can compare the effect of that line.', limitationText: 'This is a calculation of your own figures, not a legal decision. Deposit rules, allowable deductions, evidence requirements and deadlines vary by jurisdiction and agreement.',
16
+ reportTitle: 'Rental deposit return summary', reportDeposit: 'Deposit paid', reportDeductions: 'Deductions entered', reportConfirmed: 'Confirmed deductions', reportDisputed: 'Disputed deductions', reportEstimatedReturn: 'Estimated return', reportPotentialReturn: 'Return if disputed items are removed', reportNote: 'Check the agreement, invoices, condition record and local process before making a claim.',
17
+ };
18
+
19
+ const nativeUiPatches: Record<string, Partial<RentalDepositReturnUI>> = {
20
+ ja: { depositHelp: '\u5b9f\u969b\u306b\u652f\u6255\u3063\u305f\u4fdd\u8a3c\u91d1\u3068\u540c\u3058\u901a\u8ca8\u3067\u5165\u529b\u3057\u307e\u3059\u3002', deductionsHelp: '\u660e\u7d30\u306e\u5404\u9805\u76ee\u3092\u5165\u529b\u3057\u3001\u78ba\u8a8d\u3057\u305f\u3044\u9805\u76ee\u3092\u4e89\u8b70\u4e2d\u306b\u3057\u307e\u3059\u3002', descriptionPlaceholder: '\u4f8b: \u6700\u7d42\u6c34\u9053\u6599\u91d1', newDeductionLabel: '\u65b0\u3057\u3044\u63a7\u9664', removeButton: '\u3053\u306e\u63a7\u9664\u3092\u524a\u9664', currencyHelp: '\u3059\u3079\u3066\u306e\u91d1\u984d\u306b\u3053\u306e\u901a\u8ca8\u3092\u4f7f\u3044\u307e\u3059\u3002\u70ba\u66ff\u30ec\u30fc\u30c8\u306f\u4f7f\u3044\u307e\u305b\u3093\u3002', deductionBreakdownTitle: '\u7d50\u679c\u306e\u660e\u7d30', confirmedLabel: '\u78ba\u5b9a\u3057\u305f\u63a7\u9664', disputedLabel: '\u4e89\u8b70\u4e2d\u306e\u63a7\u9664', returnFlowText: '\u4fdd\u6301\u3055\u308c\u308b\u91d1\u984d\u3068\u8fd4\u9084\u3055\u308c\u308b\u53ef\u80fd\u6027\u306e\u3042\u308b\u91d1\u984d\u3092\u5206\u3051\u3066\u8868\u793a\u3057\u307e\u3059\u3002', depositNode: '\u5165\u91d1\u4fdd\u8a3c\u91d1', deductionNode: '\u63a7\u9664\u3068\u3057\u3066\u4fdd\u6301', overDeductedTitle: '\u63a7\u9664\u984d\u304c\u4fdd\u8a3c\u91d1\u3092\u8d85\u3048\u3066\u3044\u307e\u3059', overDeductedText: '\u5165\u529b\u3057\u305f\u660e\u7d30\u306f\u4fdd\u8a3c\u91d1\u3092\u6b21\u306e\u91d1\u984d\u3060\u3051\u8d85\u3048\u3066\u3044\u307e\u3059:', scenariosTitle: '\u4e00\u5ea6\u306b\u4e00\u3064\u306e\u4e89\u3044\u3092\u78ba\u8a8d', scenariosText: '\u4ed6\u306e\u9805\u76ee\u3092\u6b8b\u3057\u305f\u307e\u307e\u3001\u4e89\u8b70\u4e2d\u306e\u9805\u76ee\u3092\u4e00\u3064\u9664\u3044\u305f\u5834\u5408\u306e\u8fd4\u9084\u984d\u3067\u3059\u3002', scenarioColumn: '\u9664\u5916\u3059\u308b\u9805\u76ee', scenarioReturnColumn: '\u9664\u5916\u5f8c\u306e\u8fd4\u9084', scenarioIncreaseColumn: '\u8fd4\u9084\u306e\u5897\u52a0', noDisputed: '\u6bd4\u8f03\u3059\u308b\u306b\u306f\u9805\u76ee\u3092\u4e89\u8b70\u4e2d\u306b\u3057\u3066\u304f\u3060\u3055\u3044\u3002', emptyMessage: '\u4fdd\u8a3c\u91d1\u3068\u3001\u5404\u63a7\u9664\u306e\u8aac\u660e\u304a\u3088\u30730\u4ee5\u4e0a\u306e\u91d1\u984d\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002', privacyNote: '\u5165\u529b\u3057\u305f\u6570\u5024\u306f\u3053\u306e\u30d6\u30e9\u30a6\u30b6\u30fc\u5185\u306b\u6b8b\u308a\u307e\u3059\u3002\u30b5\u30fc\u30d0\u30fc\u306b\u306f\u9001\u4fe1\u3055\u308c\u307e\u305b\u3093\u3002', methodText: '\u3059\u3079\u3066\u306e\u63a7\u9664\u3092\u5408\u8a08\u3057\u3066\u4fdd\u8a3c\u91d1\u304b\u3089\u5dee\u3057\u5f15\u304d\u3001\u8fd4\u9084\u984d\u304c\u8ca0\u306b\u306a\u3089\u306a\u3044\u3088\u3046\u306b\u3057\u307e\u3059\u3002', limitationText: '\u3053\u308c\u306f\u5165\u529b\u3057\u305f\u6570\u5024\u306e\u8a08\u7b97\u3067\u3042\u308a\u3001\u6cd5\u7684\u5224\u65ad\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002' },
21
+ ko: { depositHelp: '\uc2e4\uc81c\ub85c \ub0a9\ubd80\ud55c \ubcf4\uc99d\uae08\uc744 \ubaa8\ub4e0 \ud56d\ubaa9\uacfc \uac19\uc740 \ud1b5\ud654\ub85c \uc785\ub825\ud558\uc138\uc694.', deductionsHelp: '\uba85\uc138\uc11c\uc758 \uac01 \ud56d\ubaa9\uc744 \uc785\ub825\ud558\uace0 \ud655\uc778\ud560 \ud56d\ubaa9\uc740 \uc774\uc758 \uc81c\uae30\ub85c \ud45c\uc2dc\ud558\uc138\uc694.', descriptionPlaceholder: '\uc608: \ucd5c\uc885 \uc218\ub3c4 \uc694\uae08', newDeductionLabel: '\uc0c8 \uacf5\uc81c \ud56d\ubaa9', removeButton: '\uc774 \uacf5\uc81c \ud56d\ubaa9 \uc0ad\uc81c', currencyHelp: '\ubaa8\ub4e0 \uae08\uc561\uc740 \uc774 \ud1b5\ud654\ub85c \ud45c\uc2dc\ub429\ub2c8\ub2e4. \ud658\uc728\uc740 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.', deductionBreakdownTitle: '\uacb0\uacfc\uc758 \uc138\ubd80 \ub0b4\uc5ed', confirmedLabel: '\ud655\uc815\ub41c \uacf5\uc81c', disputedLabel: '\uc774\uc758 \uc81c\uae30 \uacf5\uc81c', returnFlowText: '\ubcf4\uc99d\uae08\uc5d0\uc11c \ubcf4\ub958\ub418\ub294 \uae08\uc561\uacfc \ub3cc\uc544\uc62c \uc218 \uc788\ub294 \uae08\uc561\uc744 \ub098\ub204\uc5b4 \ubcf4\uc5ec\uc90d\ub2c8\ub2e4.', depositNode: '\ub0a9\ubd80\ud55c \ubcf4\uc99d\uae08', deductionNode: '\uacf5\uc81c\ub85c \ubcf4\ub958', overDeductedTitle: '\uacf5\uc81c\uc561\uc774 \ubcf4\uc99d\uae08\uc744 \ucd08\uacfc\ud569\ub2c8\ub2e4', overDeductedText: '\uc785\ub825\ud55c \ub0b4\uc5ed\uc774 \ubcf4\uc99d\uae08\uc744 \ucd08\uacfc\ud55c \uae08\uc561:', scenariosTitle: '\ud55c \ubc88\uc5d0 \ud558\ub098\uc529 \uc774\uc758 \uc81c\uae30', scenariosText: '\ub2e4\ub978 \ud56d\ubaa9\uc740 \uadf8\ub300\ub85c \ub450\uace0 \uc774\uc758 \uc81c\uae30 \ud56d\ubaa9 \ud558\ub098\ub97c \ube80 \ubc18\ud658\uc561\uc744 \ubcf4\uc5ec\uc90d\ub2c8\ub2e4.', scenarioColumn: '\uc81c\uc678\ud55c \ud56d\ubaa9', scenarioReturnColumn: '\uc81c\uc678 \ud6c4 \ubc18\ud658\uc561', scenarioIncreaseColumn: '\ubc18\ud658\uc561 \uc99d\uac00', noDisputed: '\ube44\uad50\ud558\ub824\uba74 \ud56d\ubaa9\uc744 \uc774\uc758 \uc81c\uae30\ub85c \ud45c\uc2dc\ud558\uc138\uc694.', emptyMessage: '\ubcf4\uc99d\uae08\uacfc \uac01 \uacf5\uc81c\uc758 \uc124\uba85 \ubc0f 0 \uc774\uc0c1\uc758 \uae08\uc561\uc744 \uc785\ub825\ud558\uc138\uc694.', privacyNote: '\uc785\ub825\ud55c \uc218\uce58\ub294 \uc774 \ube0c\ub77c\uc6b0\uc800\uc5d0\ub9cc \ub0a8\uc2b5\ub2c8\ub2e4. \uc11c\ubc84\ub85c \uc804\uc1a1\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.', methodText: '\ubaa8\ub4e0 \uacf5\uc81c\uc561\uc744 \ub354\ud574 \ubcf4\uc99d\uae08\uc5d0\uc11c \ube7c\uace0 \ubc18\ud658\uc561\uc774 \uc74c\uc218\uac00 \ub418\uc9c0 \uc54a\uac8c \ud569\ub2c8\ub2e4.', limitationText: '\uc785\ub825\ud55c \uae08\uc561\uc5d0 \ub300\ud55c \uacc4\uc0b0\uc77c \ubfd0 \ubc95\uc801 \ud310\ub2e8\uc774 \uc544\ub2d9\ub2c8\ub2e4.' },
22
+ ru: { depositHelp: '\u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u0444\u0430\u043a\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u0432\u043d\u0435\u0441\u0451\u043d\u043d\u044b\u0439 \u0437\u0430\u043b\u043e\u0433 \u0432 \u0442\u043e\u0439 \u0436\u0435 \u0432\u0430\u043b\u044e\u0442\u0435, \u0447\u0442\u043e \u0438 \u0432\u0441\u0435 \u0441\u0442\u0440\u043e\u043a\u0438 \u043d\u0438\u0436\u0435.', deductionsHelp: '\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0441\u0435 \u0441\u0442\u0440\u043e\u043a\u0438 \u0438\u0437 \u0440\u0430\u0441\u0447\u0451\u0442\u0430 \u0438 \u043e\u0442\u043c\u0435\u0442\u044c\u0442\u0435 \u0441\u043f\u043e\u0440\u043d\u044b\u0435 \u043f\u043e\u0437\u0438\u0446\u0438\u0438.', descriptionPlaceholder: '\u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440, \u0438\u0442\u043e\u0433\u043e\u0432\u044b\u0439 \u0441\u0447\u0451\u0442 \u0437\u0430 \u0432\u043e\u0434\u0443', newDeductionLabel: '\u041d\u043e\u0432\u043e\u0435 \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435', removeButton: '\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u044d\u0442\u043e \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435', currencyHelp: '\u0412\u0441\u0435 \u0441\u0443\u043c\u043c\u044b \u0443\u043a\u0430\u0437\u0430\u043d\u044b \u0432 \u044d\u0442\u043e\u0439 \u0432\u0430\u043b\u044e\u0442\u0435. \u041a\u0443\u0440\u0441\u044b \u043e\u0431\u043c\u0435\u043d\u0430 \u043d\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e\u0442\u0441\u044f.', deductionBreakdownTitle: '\u0420\u0430\u0437\u0431\u043e\u0440 \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430', confirmedLabel: '\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0451\u043d\u043d\u044b\u0435 \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u044f', disputedLabel: '\u041e\u0441\u043f\u0430\u0440\u0438\u0432\u0430\u0435\u043c\u044b\u0435 \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u044f', returnFlowText: '\u041f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0435\u0442 \u0440\u0430\u0437\u043d\u0438\u0446\u0443 \u043c\u0435\u0436\u0434\u0443 \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u043d\u043e\u0439 \u0441\u0443\u043c\u043c\u043e\u0439 \u0438 \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u044b\u043c \u0432\u043e\u0437\u0432\u0440\u0430\u0442\u043e\u043c.', depositNode: '\u0412\u043d\u0435\u0441\u0451\u043d\u043d\u044b\u0439 \u0437\u0430\u043b\u043e\u0433', deductionNode: '\u0423\u0434\u0435\u0440\u0436\u0430\u043d\u043e', overDeductedTitle: '\u0423\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u044f \u043f\u0440\u0435\u0432\u044b\u0448\u0430\u044e\u0442 \u0437\u0430\u043b\u043e\u0433', overDeductedText: '\u0423\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0435 \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u044f \u043f\u0440\u0435\u0432\u044b\u0448\u0430\u044e\u0442 \u0437\u0430\u043b\u043e\u0433 \u043d\u0430', scenariosTitle: '\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043e\u0434\u043d\u043e\u0433\u043e \u0441\u043f\u043e\u0440\u0430', scenariosText: '\u0412\u043e\u0437\u0432\u0440\u0430\u0442, \u0435\u0441\u043b\u0438 \u0443\u0431\u0440\u0430\u0442\u044c \u043e\u0434\u043d\u0443 \u043e\u0441\u043f\u0430\u0440\u0438\u0432\u0430\u0435\u043c\u0443\u044e \u0441\u0442\u0440\u043e\u043a\u0443 \u0438 \u043e\u0441\u0442\u0430\u0432\u0438\u0442\u044c \u043e\u0441\u0442\u0430\u043b\u044c\u043d\u044b\u0435.', scenarioColumn: '\u0423\u0431\u0440\u0430\u043d\u043d\u0430\u044f \u0441\u0442\u0440\u043e\u043a\u0430', scenarioReturnColumn: '\u0412\u043e\u0437\u0432\u0440\u0430\u0442 \u043f\u043e\u0441\u043b\u0435 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u044f', scenarioIncreaseColumn: '\u0423\u0432\u0435\u043b\u0438\u0447\u0435\u043d\u0438\u0435 \u0432\u043e\u0437\u0432\u0440\u0430\u0442\u0430', noDisputed: '\u041e\u0442\u043c\u0435\u0442\u044c\u0442\u0435 \u0441\u0442\u0440\u043e\u043a\u0443 \u043a\u0430\u043a \u043e\u0441\u043f\u0430\u0440\u0438\u0432\u0430\u0435\u043c\u0443\u044e, \u0447\u0442\u043e\u0431\u044b \u0443\u0432\u0438\u0434\u0435\u0442\u044c \u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u0435.', emptyMessage: '\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u0430\u043b\u043e\u0433, \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435 \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u044f \u0438 \u0441\u0443\u043c\u043c\u0443 \u043d\u0435 \u043c\u0435\u043d\u044c\u0448\u0435 \u043d\u0443\u043b\u044f.', privacyNote: '\u0412\u0430\u0448\u0438 \u0446\u0438\u0444\u0440\u044b \u043e\u0441\u0442\u0430\u044e\u0442\u0441\u044f \u0432 \u044d\u0442\u043e\u043c \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u0435 \u0438 \u043d\u0435 \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u044e\u0442\u0441\u044f \u043d\u0430 \u0441\u0435\u0440\u0432\u0435\u0440.', methodText: '\u0418\u043d\u0441\u0442\u0440\u0443\u043c\u0435\u043d\u0442 \u0441\u043a\u043b\u0430\u0434\u044b\u0432\u0430\u0435\u0442 \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u044f, \u0432\u044b\u0447\u0438\u0442\u0430\u0435\u0442 \u0438\u0445 \u0438\u0437 \u0437\u0430\u043b\u043e\u0433\u0430 \u0438 \u043d\u0435 \u0434\u043e\u043f\u0443\u0441\u043a\u0430\u0435\u0442 \u043e\u0442\u0440\u0438\u0446\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u0432\u043e\u0437\u0432\u0440\u0430\u0442.', limitationText: '\u042d\u0442\u043e \u0440\u0430\u0441\u0447\u0451\u0442 \u0432\u0430\u0448\u0438\u0445 \u0441\u0443\u043c\u043c, \u0430 \u043d\u0435 \u044e\u0440\u0438\u0434\u0438\u0447\u0435\u0441\u043a\u043e\u0435 \u0440\u0435\u0448\u0435\u043d\u0438\u0435.' },
23
+ zh: { depositHelp: '\u8bf7\u8f93\u5165\u5b9e\u9645\u652f\u4ed8\u7684\u62bc\u91d1\uff0c\u6240\u6709\u9879\u76ee\u4f7f\u7528\u540c\u4e00\u8d27\u5e01\u3002', deductionsHelp: '\u8f93\u5165\u7ed3\u7b97\u5355\u4e2d\u7684\u6bcf\u4e2a\u6263\u6b3e\u9879\u76ee\uff0c\u5e76\u6807\u8bb0\u60f3\u8981\u6838\u67e5\u7684\u4e89\u8bae\u9879\u76ee\u3002', descriptionPlaceholder: '\u4f8b\u5982\uff1a\u6700\u7ec8\u6c34\u8d39', newDeductionLabel: '\u65b0\u6263\u6b3e', removeButton: '\u5220\u9664\u6b64\u6263\u6b3e', currencyHelp: '\u6240\u6709\u91d1\u989d\u90fd\u4f7f\u7528\u6b64\u8d27\u5e01\uff0c\u4e0d\u4f7f\u7528\u6c47\u7387\u6362\u7b97\u3002', deductionBreakdownTitle: '\u7ed3\u679c\u660e\u7ec6', confirmedLabel: '\u5df2\u786e\u8ba4\u6263\u6b3e', disputedLabel: '\u6709\u4e89\u8bae\u6263\u6b3e', returnFlowText: '\u6e05\u695a\u533a\u5206\u4fdd\u7559\u91d1\u989d\u548c\u53ef\u80fd\u9000\u8fd8\u7684\u91d1\u989d\u3002', depositNode: '\u62bc\u91d1\u91d1\u989d', deductionNode: '\u6263\u9664\u5e76\u4fdd\u7559', overDeductedTitle: '\u6263\u6b3e\u8d85\u8fc7\u62bc\u91d1', overDeductedText: '\u8f93\u5165\u7684\u6263\u6b3e\u8d85\u8fc7\u62bc\u91d1', scenariosTitle: '\u9010\u9879\u67e5\u770b\u4e89\u8bae', scenariosText: '\u4fdd\u7559\u5176\u4ed6\u9879\u76ee\u4e0d\u53d8\uff0c\u79fb\u9664\u4e00\u4e2a\u6709\u4e89\u8bae\u9879\u76ee\u540e\u53ef\u9000\u8fd8\u7684\u91d1\u989d\u3002', scenarioColumn: '\u79fb\u9664\u9879\u76ee', scenarioReturnColumn: '\u79fb\u9664\u540e\u7684\u9000\u8fd8', scenarioIncreaseColumn: '\u9000\u8fd8\u589e\u52a0', noDisputed: '\u5c06\u9879\u76ee\u6807\u8bb0\u4e3a\u6709\u4e89\u8bae\u5373\u53ef\u67e5\u770b\u6bd4\u8f83\u3002', emptyMessage: '\u8bf7\u8f93\u5165\u62bc\u91d1\u3001\u6bcf\u9879\u6263\u6b3e\u7684\u8bf4\u660e\u4ee5\u53ca\u4e0d\u5c0f\u4e8e\u96f6\u7684\u91d1\u989d\u3002', privacyNote: '\u4f60\u7684\u6570\u5b57\u53ea\u4fdd\u7559\u5728\u6b64\u6d4f\u89c8\u5668\u4e2d\uff0c\u4e0d\u4f1a\u53d1\u9001\u5230\u670d\u52a1\u5668\u3002', methodText: '\u8ba1\u7b97\u5668\u6c47\u603b\u6240\u6709\u6263\u6b3e\u5e76\u4ece\u62bc\u91d1\u4e2d\u51cf\u53bb\uff0c\u9000\u8fd8\u91d1\u989d\u4e0d\u4f1a\u4f4e\u4e8e\u96f6\u3002', limitationText: '\u8fd9\u662f\u5bf9\u4f60\u8f93\u5165\u91d1\u989d\u7684\u8ba1\u7b97\uff0c\u4e0d\u662f\u6cd5\u5f8b\u5224\u65ad\u3002' },
24
+ };
25
+
26
+ export interface RentalDepositLocaleCopy { title: string; description: string; ui: Partial<RentalDepositReturnUI>; faq: Array<{ question: string; answer: string }>; howTo: Array<{ name: string; text: string }>; seo: SEOSection[]; }
27
+
28
+ export function createRentalDepositContent(locale: string, copy: RentalDepositLocaleCopy): ToolLocaleContent<RentalDepositReturnUI> {
29
+ const ui: RentalDepositReturnUI = { ...baseUi, ...(nativeUiPatches[locale] ?? {}), ...copy.ui };
30
+ const localizedSlugs: Record<string, string> = { de: 'mietkaution-rueckzahlung-rechner', es: 'calculadora-devolucion-fianza-alquiler', fr: 'calculateur-restitution-depot-garantie', id: 'kalkulator-pengembalian-deposit-sewa', it: 'calcolatore-restituzione-deposito-cauzionale', nl: 'rekenhulp-terugbetaling-huurwaarborg', pl: 'kalkulator-zwrotu-kaucji-wynajem', pt: 'calculadora-devolucao-deposito-arrendamento', ru: 'kalkulyator-vozvrata-arendnogo-zaloga', sv: 'kalkylator-aterbetalning-hyresdeposition', tr: 'kira-depozitosu-iade-hesaplayici' };
31
+ const faqSchema: WithContext<FAQPage> = { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: copy.faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
32
+ const howToSchema: WithContext<HowTo> = { '@context': 'https://schema.org', '@type': 'HowTo', name: copy.howTo[0]?.name ?? copy.title, description: copy.description, step: copy.howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })) };
33
+ const appSchema: WithContext<SoftwareApplication> = { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: copy.title, description: copy.description, applicationCategory: 'FinanceApplication', operatingSystem: 'All', offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' }, inLanguage: locale };
34
+ const seo: SEOSection[] = [...copy.seo, { type: 'title', text: ui.scenariosTitle, level: 3 }, { type: 'paragraph', html: `<p>${copy.description} ${ui.methodText} ${ui.limitationText}</p>` }, { type: 'list', items: [ui.depositLabel, ui.deductionsTitle, ui.estimatedReturnLabel, ui.copyButton] }];
35
+ return { slug: localizedSlugs[locale] ?? 'rental-deposit-return-calculator', title: copy.title, description: copy.description, ui, faq: copy.faq, bibliography, howTo: copy.howTo, schemas: [faqSchema, howToSchema, appSchema], seo };
36
+ }
@@ -0,0 +1,3 @@
1
+ import { createRentalDepositContent } from './content-factory';
2
+ import { localizedCopies } from './translations';
3
+ export const content = createRentalDepositContent('de', localizedCopies.de!);
@@ -0,0 +1,116 @@
1
+ import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
2
+ import type { ToolLocaleContent } from '../../../types';
3
+ import { bibliography } from '../bibliography';
4
+ import type { RentalDepositReturnUI } from '../ui';
5
+
6
+ const ui: RentalDepositReturnUI = {
7
+ depositLabel: 'Deposit paid',
8
+ depositHelp: 'Use the amount actually paid as the deposit, in the same currency as every line below.',
9
+ deductionsTitle: 'Items kept from the deposit',
10
+ deductionsHelp: 'Enter the landlord or agent\'s own breakdown. Mark a line disputed when you want to test the amount without it.',
11
+ descriptionLabel: 'Description',
12
+ descriptionPlaceholder: 'e.g. Final water bill',
13
+ amountLabel: 'Amount',
14
+ statusLabel: 'Deduction status',
15
+ confirmedOption: 'Confirmed',
16
+ disputedOption: 'Disputed',
17
+ addButton: 'Add deduction',
18
+ resetButton: 'Restore example',
19
+ newDeductionLabel: 'New deduction',
20
+ removeButton: 'Remove this deduction',
21
+ currencyLabel: 'Currency',
22
+ currencyHelp: 'All figures stay in this currency. No exchange rates are used.',
23
+ summaryTitle: 'Confirmed vs disputed',
24
+ estimatedReturnLabel: 'Estimated return',
25
+ undisputedReturnLabel: 'If disputed items are removed',
26
+ totalDeductionsLabel: 'Total deductions',
27
+ deductionBreakdownTitle: 'The ledger behind the result',
28
+ confirmedLabel: 'Confirmed items',
29
+ disputedLabel: 'Disputed items',
30
+ returnFlowTitle: 'Deposit to return',
31
+ returnFlowText: 'A clear split between what is being kept and what could come back.',
32
+ depositNode: 'Deposit in',
33
+ deductionNode: 'Kept for deductions',
34
+ returnNode: 'Estimated back',
35
+ overDeductedTitle: 'The deductions exceed the deposit',
36
+ overDeductedText: 'The entered breakdown is above the deposit by',
37
+ scenariosTitle: 'One dispute at a time',
38
+ scenariosText: 'These scenarios show the return if one disputed line is removed while all other lines stay in place.',
39
+ scenarioColumn: 'Removed line',
40
+ scenarioReturnColumn: 'Return if removed',
41
+ scenarioIncreaseColumn: 'Increase in return',
42
+ noDisputed: 'Mark a line as disputed to see a comparison scenario.',
43
+ emptyMessage: 'Add a deposit and use a description with a zero or positive amount for every deduction.',
44
+ copyButton: 'Copy claim summary',
45
+ copiedButton: 'Summary copied',
46
+ privacyNote: 'Your figures stay in this browser. Nothing is sent to a server.',
47
+ methodTitle: 'How this estimate works',
48
+ methodText: 'The tool adds every deduction, subtracts that total from the deposit and floors the estimated return at zero. A disputed scenario removes one disputed line from the total so you can compare the effect of that line.',
49
+ limitationText: 'This is a calculation of your own figures, not a legal decision. Deposit rules, allowable deductions, evidence requirements and deadlines vary by jurisdiction and agreement.',
50
+ reportTitle: 'Rental deposit return summary',
51
+ reportDeposit: 'Deposit paid',
52
+ reportDeductions: 'Deductions entered',
53
+ reportConfirmed: 'Confirmed deductions',
54
+ reportDisputed: 'Disputed deductions',
55
+ reportEstimatedReturn: 'Estimated return',
56
+ reportPotentialReturn: 'Return if disputed items are removed',
57
+ reportNote: 'Check the agreement, invoices, condition record and local process before making a claim.',
58
+ };
59
+
60
+ const faq = [
61
+ { question: 'What does the rental deposit return calculator estimate?', answer: 'It totals the deduction amounts you enter and subtracts them from the deposit. It also shows the amount that would remain if disputed lines were removed one at a time.' },
62
+ { question: 'Does the calculator decide whether a deduction is legal?', answer: 'No. It only transforms your own figures. The rules, evidence and deadlines for a deposit depend on the tenancy agreement and the jurisdiction.' },
63
+ { question: 'What should I enter as a disputed item?', answer: 'Use a line for a charge you want to question, such as a repair, cleaning fee, unpaid bill or rent balance. The scenario shows how much the return changes if that line is left out.' },
64
+ ];
65
+
66
+ const howTo = [
67
+ { name: 'Enter the deposit', text: 'Type the deposit amount that was actually paid and choose its currency.' },
68
+ { name: 'Add the breakdown', text: 'Enter each deduction from the statement or settlement, with its amount and a confirmed or disputed status.' },
69
+ { name: 'Compare the returns', text: 'Read the estimated return, the return without disputed items and the one-line dispute scenarios.' },
70
+ { name: 'Copy the summary', text: 'Copy the figures into your own message or notes, then check the agreement and supporting documents before claiming.' },
71
+ ];
72
+
73
+ const faqSchema: WithContext<FAQPage> = {
74
+ '@context': 'https://schema.org',
75
+ '@type': 'FAQPage',
76
+ mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })),
77
+ };
78
+
79
+ const howToSchema: WithContext<HowTo> = {
80
+ '@context': 'https://schema.org',
81
+ '@type': 'HowTo',
82
+ name: 'Estimate a rental deposit return',
83
+ description: 'Compare a rental deposit with the deductions in your own settlement.',
84
+ step: howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })),
85
+ };
86
+
87
+ const appSchema: WithContext<SoftwareApplication> = {
88
+ '@context': 'https://schema.org',
89
+ '@type': 'SoftwareApplication',
90
+ name: 'Rental Deposit Return Calculator',
91
+ description: 'Estimate a rental deposit return from your own deductions and compare disputed items.',
92
+ applicationCategory: 'FinanceApplication',
93
+ operatingSystem: 'All',
94
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
95
+ inLanguage: 'en',
96
+ };
97
+
98
+ export const content: ToolLocaleContent<RentalDepositReturnUI> = {
99
+ slug: 'rental-deposit-return-calculator',
100
+ title: 'Rental Deposit Return Calculator',
101
+ description: 'Estimate how much of your rental deposit could come back after itemising confirmed and disputed deductions.',
102
+ ui,
103
+ faq,
104
+ bibliography,
105
+ howTo,
106
+ schemas: [faqSchema, howToSchema, appSchema],
107
+ seo: [
108
+ { type: 'title', text: 'Turn a deposit statement into a return estimate', level: 2 },
109
+ { type: 'paragraph', html: '<p>When a tenancy ends, a settlement can mix the deposit, unpaid bills, cleaning, repairs and other charges in one opaque figure. This browser-only calculator lets you enter that statement and see the arithmetic behind the estimated return.</p>' },
110
+ { type: 'title', text: 'Separate confirmed and disputed deductions', level: 3 },
111
+ { type: 'paragraph', html: '<p>Mark each line as confirmed or disputed. The main estimate includes every line, while the comparison shows what would come back if a disputed line were removed. That turns a vague disagreement into a specific amount to check against invoices, the condition record and the tenancy agreement.</p>' },
112
+ { type: 'list', items: ['Deposit paid and total deductions', 'Return estimate with the full breakdown', 'Return if disputed items are removed', 'A copyable summary for your own claim notes'] },
113
+ { type: 'title', text: 'A calculation, not a legal ruling', level: 3 },
114
+ { type: 'paragraph', html: '<p>Deposit protection and deduction rules differ across countries, regions and agreements. The tool does not decide whether a charge is lawful, reasonable or evidenced, and it does not apply a universal deadline or percentage.</p>' },
115
+ ],
116
+ };
@@ -0,0 +1,3 @@
1
+ import { createRentalDepositContent } from './content-factory';
2
+ import { localizedCopies } from './translations';
3
+ export const content = createRentalDepositContent('es', localizedCopies.es!);