Ctrl K
ComunidadGuías…

REQUIREMENTS.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Calculators — Requirements

This document captures the functionality of every calculator in the app,
focused on inputs, calculation semantics, and the data model that drives each
projection. UI structure is described where it changes the meaning of an
input. The app currently ships:

- **Retirement Planner** — full retirement projection across life expectancy.
- **Goal Planner** — solves for the monthly SIP required to hit a post-tax goal,
  inflation-adjusted to the horizon year.
- **Inflation Projection** — projects an amount forward or backward at a fixed
  inflation rate over a horizon.
- **Investment** — grows regular contributions through an investment phase and a
  subsequent hold phase; reports maturity, net-of-tax, and real value.
- **Loan / EMI** — reducing-balance EMI for a loan, plus a prepayment analysis
  that shows the impact of paying extra (reduce tenure vs reduce EMI).
- **Buy vs Rent** — compares buying a home against renting and investing the
  difference; projects net worth for both paths and reports the break-even year.
- **IRR / XIRR** — computes the annualised internal rate of return across dated
  cashflows (investments and withdrawals), and flags schedules whose rate is not
  unique (multiple roots).
- **Debt Planner** — pays off several debts under a constant monthly budget,
  comparing avalanche vs snowball vs a minimums-only baseline; reports the
  debt-free horizon and the interest and time saved.

The landing route (`/`) shows a tile per calculator, populated automatically
from the `@Menu`-annotated views (no manual registration). Each calculator
owns a route segment (`/retirement`, `/goal`, `/inflation`, `/investment`,
`/loan`, `/buyrent`, `/xirr`, `/debt`).

The Years / Ages / Target-Year horizon selector is shared infrastructure:
`base.common.TimeHorizonMode` + `base.common.TimeHorizon.resolveTotalMonths`,
used by both the Goal Planner and Inflation Projection.

The contribution / prepayment / cashflow frequency selector is likewise shared:
one `base.common.Frequency` enum (`MONTHLY` / `QUARTERLY` / `HALF_YEARLY` /
`YEARLY`, each carrying `monthsPerPeriod`) rendered through the reusable
`base.ui.FrequencyField` dropdown. Every calculator that offers a frequency
(Investment, Loan, Retirement, IRR) uses the same four options.

## Calculator actions

Every calculator carries the same action row beneath its form. Recalculation is
already live — edits recompute as they are typed — so these are the explicit
levers over the *inputs*, not over when the maths runs:

| Action | Effect |
| --- | --- |
| **Calculate** | Recomputes on demand (refreshes the share token, then re-renders the results). Primary button; live recalculation makes it a reassurance rather than a necessity. |
| **Reset** | Restores the current currency's baseline inputs from the classpath defaults, **overwriting** the visitor's own numbers, and persists them as the snapshot. Shown only when sample-data prefill is on (see below). |
| **Clear** | Blanks the form entirely and persists the blank, so the calculator stays empty on reload. Separated to the far side of the row, away from Calculate / Reset. |

Reset and Clear are deliberately distinct: Reset restores the *sample scenario*,
Clear leaves *nothing*. Both write through to the per-currency snapshot, so
neither is undone by a reload.

### Per-section clear

Alongside the row-wide Clear, every **form-section card** carries its own clear
control: an eraser icon in the card header, at the right, beside the card's
validation chip. It empties only that section, so a visitor can start one part
of a long form over without losing the rest. Like every other edit it flows
through the live-recalculation signal, so the results and the persisted snapshot
follow immediately.

What "empty" means is the section's own business. A card of plain inputs blanks
its fields; a card holding a row list drops its rows; and a field whose empty
value would be meaningless — a payoff strategy, a contribution cadence, a
time-horizon mode — returns to the same default the form opens on rather than
to nothing.

## Prefilled sample data

Whether a calculator opens on its shipped sample scenario is a deployment
choice, not a per-user one: `app.calculators.prefill-defaults` (bound to
`base.config.CalculatorSettings`, overridable by the
`CALCULATORS_PREFILL_DEFAULT_VALUES` environment variable), **default `true`**.

It only decides the fallback when nothing else applies. The load order on
opening a calculator is unchanged by it: a `?s=` share token wins, then the
per-currency persisted snapshot, and only then the defaults:

| `prefill-defaults` | Nothing persisted for this currency |
| --- | --- |
| `true` (default) | The form opens on `*-defaults.json` for that currency, and the **Reset** action is offered. |
| `false` | The form opens **blank**, and **Reset is not rendered** — with no defaults on offer the button could only overwrite the visitor's own numbers with a sample scenario. A stale client that posts the click anyway is refused rather than obeyed. |

Turning it off suits a deployment where invented numbers would read as advice.
Switching currency follows the same rule, so a blank-form deployment stays blank
across currencies. `PrefillDefaultsEnabledIT` / `PrefillDefaultsDisabledIT`
cover both settings end-to-end, including the presence of Reset.

## Deployed version indicator

The drawer footer shows the deployed build's identity beneath the Vaadin /
GitHub links: the seven-character commit SHA, linked to that commit via the
configurable `app.links.github-commit-url` template (a `{sha}` placeholder is
substituted with the full commit). The build timestamp is not shown inline — it
appears on hover as the tooltip `Built on <yyyy-MM-dd HH:mm> UTC`.

The values come from Spring's `BuildProperties` (`META-INF/build-info.properties`,
generated by the `spring-boot-maven-plugin` `build-info` goal). The commit is
stamped in at build time via the `build.commit` Maven property, which is
**mandatory**: `maven-enforcer-plugin` fails the build at `validate` if it is
missing or not a git SHA, so an unidentifiable build can never be produced. It
is supplied automatically by every `./run.sh` task (`git rev-parse HEAD`,
including the dev `run`/`preview`), by the `GIT_SHA` Docker build arg in the
container build (the image never needs a `.git` directory), and by `github.sha`
in CI. Because `run.sh` stamps it, the dev preview shows the current `HEAD`
commit. The UI's `dev` fallback only appears if the app somehow runs without
`build-info.properties` at all.

## Shareable links

Every calculator has a **Share** button beside its title that encodes the
current inputs plus the active currency into one opaque query token and copies
an absolute URL (`…/retirement?s=<token>`) to the clipboard. The token is
base64url of a small JSON envelope `{"v":1,"currency":…,"inputs":{…}}`, built by
`base.common.ScenarioCodec` from each store's `toJsonNode`. Opening a `?s=` URL
sets the session currency, loads the decoded inputs (overriding the
persisted/default values), saves them as the per-currency snapshot, recomputes,
and strips the `s` parameter from the address bar. The token is treated as
untrusted: oversized tokens, malformed/garbage payloads, an unknown schema
version or currency, and numerically absurd values are rejected, and an invalid
link falls back to the normal persisted/default load with an "Invalid share
link" notice.

Rejection covers the envelope *and* the `inputs` object: a structurally valid
token whose fields are missing or the wrong type (an absent age, an unparseable
date) takes the same "Invalid share link" path rather than surfacing an error.
`base.common.SharedScenario.parse` is the single boundary where both failure
modes collapse into an empty result, so nothing — including the session currency
— is changed unless the whole token maps cleanly.

## Form validation messages

Every calculator's inputs are grouped into **form-section cards** (`FormCard`).
Validation feedback is shown consistently, in three layers:

- **Field-level** — invalid fields keep their inline Vaadin error (e.g.
  "Required", range messages).
- **Card-level** — each `FormCard` shows a danger **chip at its top-right**
  (the header-suffix slot, shared with the section's clear icon) describing why
  *that card* blocks calculation: a
  generic "Fix the highlighted fields" when any of its own fields are invalid,
  or a card-specific rule (e.g. Goal's "Allocations must sum to 100%"). A
  whole-calculation failure (an exception thrown by the calculator) is attached
  to the form's primary/first card. The chip is hidden when the card is valid.
- **Tab-level** — for tabbed forms, a small dot on each tab marks tabs that hold
  data (primary colour) or contain an invalid field (danger colour).

Every card that hosts inputs participates — including the otherwise card-less
list tabs (e.g. Retirement's Future Expenses / Incomes / **Benefits**), which
are wrapped in a section card so they carry the same top-right message. Summary
/ result cards stay neutral placeholders ("—") while a form is invalid; the
message lives on the input card, never on a result card.

# Retirement Planner

## 1. Inputs

The form is split across five tabs sharing a single `Binder` (so the
projection always sees the full set of fields regardless of which tab is
visible). The shape of each tab is below; persistence and defaults round-trip
through `defaults.json` and browser localStorage (`rc_inputs`).

### 1.1 Basic

| Field | Notes |
| --- | --- |
| Current Age | Integer, ≥ 1, must be `< Retirement Age` |
| Retirement Age | Integer, `> Current Age`, `< Life Expectancy` |
| Life Expectancy | Integer, `> Retirement Age` |
| Current Corpus | Money in today's currency, ≥ 0 |
| Monthly Expenses (today) | Money in today's currency, > 0 |
| Inflation Rate | Annual % — drives annual expense growth and is the fallback rate for recurring expenses |

### 1.2 Investments

**Existing Corpus Returns** (one card):

| Field | Meaning |
| --- | --- |
| Before Retirement (%) | Growth rate applied to the main corpus while `age < retireAge` |
| After Retirement (%) | Growth rate applied to the main corpus while `age ≥ retireAge` |
| Tax Rate (%) | Applied to gains-portion of any withdrawal that comes out of the main corpus |

**Pre-Retirement Contributions** and **Post-Retirement Contributions** (two
sections, each an add/remove list of rows). Pre rows are funded during the
working years (`age < retireAge`); Post rows during retirement
(`age ≥ retireAge`). Each row is an independent accumulating stream:

| Field | Meaning |
| --- | --- |
| Amount | Money in today's currency, per period |
| Frequency | `Monthly` / `Quarterly` / `Half-Yearly` / `Yearly`; the amount is annualised as `amount × periodsPerYear` |
| Growth Percentage (%) | Annual return on this stream while it is accumulating. A **pre** stream derisks to the existing-corpus after-retirement rate once `age ≥ retireAge`; a **post** stream keeps its own rate throughout |
| Step Up Percentage (Yearly) (%) | Annual compounding increment on the contribution amount, counted from the phase start (current age for pre, retirement age for post) |
| Tax Rate (%) | Applied to the gains portion of any withdrawal drawn from this stream |

### 1.3 Future Expenses

Two cards.

**Fixed** — one-off expenses at a specific year (car, knee surgery, wedding):

| Field | Meaning |
| --- | --- |
| Year | Target year |
| Description | Free text |
| Amount (today) | Money in today's currency |
| Inflation (%) | Per-item annual inflation, applied from current year to target year |

**Recurring** — repeating cashflows (rent, school fees, club dues):

| Field | Meaning |
| --- | --- |
| Start Year | First year the cashflow occurs |
| Stop Year | Optional — blank means continue indefinitely |
| Description | Free text |
| Frequency | `Monthly` / `Quarterly` / `Half-Yearly` / `Yearly` |
| Amount (today) | Money in today's currency, per period |
| Inflation (%) | Optional per-item rate — blank ⇒ use overall inflation |

### 1.4 Future Incomes

Two cards mirroring Future Expenses.

**Fixed** — one-off inflows (house sale, business liquidation, inheritance):

| Field | Meaning |
| --- | --- |
| Year | Target year |
| Description | Free text |
| Amount | Nominal value at the target year (not in today's money) |
| Tax Rate (%) | Applied immediately on receipt |

**Recurring** — repeating inflows (rental income, side-gig):

| Field | Meaning |
| --- | --- |
| Start Year | First year the inflow occurs |
| Stop Year | Optional — blank means continue indefinitely |
| Description | Free text |
| Frequency | `Monthly` / `Quarterly` / `Half-Yearly` / `Yearly` |
| Amount | Nominal value per period; no inflation projection |
| Tax Rate (%) | Per-period, applied immediately |

### 1.5 Retirement Benefits

One card with rows for each one-off benefit received in the retirement year
(gratuity, provident fund payout, etc.).

| Field | Meaning |
| --- | --- |
| Description | Free text |
| Amount | Gross amount received in the retirement year |
| Tax Rate (%) | Applied immediately on receipt |

No year field — by definition these are received in the retirement-age year.

## 2. Calculation Model

### 2.1 Buckets

The corpus is modelled as several persistent buckets — the existing corpus
plus one per contribution row. They are *not* merged at retirement.

| Bucket | Seeded with | Growth rate | Tax rate |
| --- | --- | --- | --- |
| **Main** | `Current Corpus` as both balance and principal. Retirement benefits + future / recurring incomes are added here too | `Existing Corpus Returns` (Before / After) | `Existing Corpus Tax Rate` |
| **Contribution stream** (one per row) | 0 / 0, filled by its own contributions | Pre row: its own rate before retirement, then the corpus after-retirement rate; Post row: its own rate | The row's own tax rate |

Each bucket tracks `balance` and `principal`. Gains are derived as
`balance − principal`. Contributions raise both; growth raises only the
balance.

### 2.2 Per-year loop

For each `age` from `currentAge` to `lifeExp`:

1. **Phase indicators** — `isPost = age ≥ retireAge`,
   `isRetireYear = age == retireAge`,
   `yearsFromNow = age − currentAge`,
   `year = currentYear + yearsFromNow`.

2. **Annual expenses** — `annualExp = monthlyExp × 12 × (1 + inflation)^yearsFromNow`.
   Withdrawn only when `isPost`.

3. **Snapshot `investedAtRetirement`** — set to `totalInvested` once, at
   the start of the retirement year, *before* this year's contribution.

4. **Contributions** — for each active stream (pre rows while `!isPost`, post
   rows while `isPost`), deposit into its own bucket:
   ```
   yearsInPhase = isPost ? age − retireAge : age − currentAge
   deposit      = amount × periodsPerYear(frequency) × (1 + stepUp)^yearsInPhase
   ```
   `periodsPerYear` = 12 / monthsPerPeriod (MONTHLY ×12, QUARTERLY ×4,
   HALF_YEARLY ×2, YEARLY ×1). Step-up counts from the phase start, so a post
   stream starts fresh at retirement. `contributionsThisYear` is their sum.

5. **Incomes into the main corpus** (net of immediate tax):
   ```
   benefitsThisYear    = isRetireYear ? Σ (amount × (1 − taxRate))  : 0   (retirement benefits)
   futureIncomeThisYr  = Σ (amount × (1 − taxRate))                       (fixed future incomes whose year == this year)
   recurringIncomeThis = Σ (annualise(amount, frequency) × (1 − taxRate)) (recurring incomes with year ≤ this year ≤ stopYear)
   incomeThisYear      = benefitsThisYear + futureIncomeThisYr + recurringIncomeThis
   ```
   `main.principal += incomeThisYear`, `main.balance += incomeThisYear`;
   `investment = contributionsThisYear + incomeThisYear`;
   `totalInvested += investment`.
   `annualise(x, f) = x × (12 / monthsPerPeriod(f))`.

6. **`startCorpus` snapshot** = `main.balance + Σ stream.balance` *(before*
   this year's growth).

7. **Apply growth** to every bucket at its current-phase rate (a pre stream
   uses its own rate before retirement, the corpus after-retirement rate once
   `isPost`; a post stream always uses its own rate). `returns` is the sum of
   all buckets' earnings.

8. **Withdrawal need**:
   ```
   futureExpenses    = Σ (amount × (1 + itemInflation)^yearsFromNow)             (fixed expenses whose year == this year)
   recurringExpenses = Σ (annualise(amount, frequency) × (1 + effInflation)^yearsFromNow)
   withdrawal        = (isPost ? annualExp : 0) + futureExpenses + recurringExpenses
   ```
   For recurring expenses, `effInflation` is the per-item rate if set
   (positive), otherwise the overall inflation rate.

9. **Drain buckets — lowest-yield-first.** Sort buckets ascending by their
   *current-phase* growth rate; tie-break in favour of main. While
   `remaining > 0`, draw from each bucket in order until either the bucket
   empties or the remaining need is satisfied. For each draw of `X` from a
   bucket with balance `B` and principal `P`:
   ```
   gainsDrawn     = X × (B − P) / B
   principalDrawn = X × P / B
   P             −= principalDrawn
   B             −= X
   taxPaid       += gainsDrawn × bucket.taxRate_currentPhase
   ```
   If the buckets cannot satisfy the withdrawal, the remainder is the
   shortfall.

10. **End-of-year** — `endCorpus = main.balance + Σ stream.balance`
    (or `−remaining` when shortfall).
    `depleted = remaining > 0 ∨ endCorpus < 0`.

11. **Emit `ProjectionRow`** with: `year, age, isRetireYear, isPost,
    annualExp, startCorpus, returns, investment, withdrawal, taxPaid,
    endCorpus, depleted`.

12. **Halt on depletion** — break out of the loop once a year is marked
    depleted.

### 2.3 Semantics worth being explicit about

- **No retirement-year fold.** Each bucket keeps its own principal-vs-gains
  split and tax rate throughout. A **pre** contribution stream's growth rate
  derisks to the corpus after-retirement rate once retired; a **post** stream
  and the main corpus keep their configured rates.
- **Withdrawal column = gross corpus drawdown.** Tax is reported separately
  in `Tax Paid`; the user effectively receives `withdrawal − taxPaid` net of
  tax, but the corpus drops by the gross `withdrawal`.
- **Step-up counts from the phase start.** A post stream's step-up starts
  fresh at retirement; there is no bleed-over between pre and post streams.
- **Benefits and incomes are folded into investment**, *not* subtracted from
  withdrawal. The net (after immediate tax) lands as new principal in the
  **main corpus** and earns at the corpus growth rate from then on.
- **Recurring expenses inflate.** Per-item rate if provided, otherwise the
  overall inflation rate. The entered amount is in today's money.
- **Recurring incomes do not inflate.** The entered amount is the nominal
  cashflow received each period; tax applied per period.
- **Fixed future income amount is at the target year.** No inflation
  projection; it's the realized value in the receipt year.
- **Fixed future expense amount is in today's money.** Inflated per-item
  from current year to the target year.
- **Retirement benefits are received in the retirement-age year** — no
  year input; tax applied immediately on receipt; net lands in the main corpus.
- **`investedAtRetirement` snapshots `totalInvested` at the start of the
  retirement year, before that year's contribution.**
- **Tax rate is applied to the gains portion of withdrawals only**, not to
  principal returns. The gains/principal split per bucket is tracked
  proportionally on every draw.

### 2.4 Validations

- `currentAge < retireAge < lifeExp`.
- `currentCorpus ≥ 0`.
- `monthlyExpenses > 0`.
- Monthly investments (pre and post) ≥ 0.
- Percentages are `0–100`.
- Recurring `stopYear` is inclusive. Blank ⇒ no end.

## 3. Currency

INR, EUR, USD are supported. Currency is a session-level preference
(localStorage `rc_prefs`). Money fields render the selected currency's
symbol and a helper text spelling out the amount (Indian numbering for
INR, Western for the others). Switching currency replaces the form values
with that currency's persisted snapshot or defaults from `defaults.json`.

## 4. Persistence

| Storage | Contents |
| --- | --- |
| `defaults.json` (classpath) | Per-currency baseline inputs, including all rates set to 0 by default for the new fields |
| `rc_prefs` (localStorage) | Selected currency + theme |
| `rc_inputs` (localStorage) | Per-currency snapshot of the user's edited inputs (all fields, including the recurring/future lists) |

`DefaultsJsonTest` enforces the file is parseable, every supported currency
is present, every documented field key exists with a parseable value, and
the loaded defaults produce a valid projection end-to-end.

## 5. Output

### 5.1 Projection grid

Per year: `Year | Age | Phase | Expenses | Corpus (Start) | Returns |
Investment | Withdrawal | Tax Paid | Corpus (End)`. Rows are themed by
status (retirement-year, depleted, low-corpus). Monthly/yearly amounts are
shown for the cashflow columns; hovering exposes the amount in words.

A column-chooser icon (cog) at the top-right of the grid opens a checkable
menu listing every column; toggling an item hides or shows that column
without recomputing the projection.

Because the rows carry status tints, the grid also shows a **row-colour
legend** — an info icon beside the cog opens a popover mapping each row
background colour to its meaning (retirement year, low corpus, depleted),
like a chart legend. This is a general grid affordance (`BaseGrid`):
any grid that registers row-colour entries gets the legend icon automatically;
grids without row tints show only the cog.

### 5.2 Summary cards

`Corpus at Retirement`, `Annual Expenses at Retirement`,
`Corpus Lasts Until` (life-expectancy or depletion age), and
`Final Corpus` (status-coloured by depletion vs. low vs. healthy).

### 5.3 Charts (tabs)

| Tab | Type | What it shows |
| --- | --- | --- |
| Corpus | Area-spline | Yearly corpus with retirement and depletion lines |
| Annual Expenses | Area-spline | Inflation-grown yearly expenses |
| Investments | Donut | Invested principal vs. interest at retirement |
| Return on Investments | Stacked column | Yearly principal + interest until depletion or life expectancy |
| Withdrawal vs Returns | Grouped column | Post-retirement withdrawal vs. returns by year |
| Real Corpus | Split-pane area-spline | Nominal vs. inflation-deflated corpus, on independent y-scales |
| Timeline | Timeline | Major life/financial events along the age axis (see 5.3.1) |

**X-axis convention.** Every per-year value chart (Corpus, Annual Expenses,
Return on Investments, Withdrawal vs Returns, Real Corpus) shares one rule: a row
for the year the person is age *N* is plotted at the **year-end age *N* + 1**, so
reading an age across these tabs always refers to the same year. The Corpus chart
also seeds a starting-corpus point at the current age. The Timeline is the sole
exception — it marks events at the age they actually occur.

#### 5.3.1 Plan Timeline

A timeline chart marking the plan's major events on the age axis. All events
falling in the same year are **clubbed into a single marker** whose inline label
is the event count; the hover tooltip lists every event for that year (with the
amount where relevant). Retirement, drawdown-begins and depletion years are
coloured distinctly from ordinary years.

Events marked:
- **Today** — the current age and starting corpus.
- **Retirement** — the retirement-age year, plus any retirement benefits received.
- **One-off future incomes / expenses** — at their year.
- **Recurring income / expense start and stop** — at the start year and, when set,
  the stop year.
- **Wealth milestones** — the first year the **nominal** corpus crosses each
  threshold. Thresholds are per-currency: INR ₹10L, ₹50L, ₹1cr, ₹5cr, ₹10cr,
  ₹50cr; USD/EUR $1M, $10M, $50M, $100M, $500M, $1B.
- **Drawdown begins** — the first year the corpus shrinks year-on-year
  (`endCorpus < startCorpus`, i.e. outflows exceed returns plus inflows).
- **Corpus depletion** — the year the corpus is exhausted, if it is.


## 6. Test coverage

| Suite | Purpose |
| --- | --- |
| `RetirementCalculatorTest` | Bucket math, step-up behaviour, future-expense inflation, retirement-benefit / future-income contribution, lowest-yield-first drain, depletion detection, validation rejections. |
| `DefaultsJsonTest` | `defaults.json` parses, every currency entry has every required field, values are parseable, projection round-trips |
| `MoneyFormatterTest`, `NumberToWordsTest` | Currency formatting (Indian vs Western groupings, words for amounts) |
| `RetirementCalculatorFormBrowserlessTest` | Form smoke test; catches binding-level regressions in the contribution rows (step-up + frequency round-trip) |

# Goal Planner

## 1. Inputs

A single form across one card for the main inputs and a second card for the
time horizon. A segmented radio toggle (`Years` / `Ages` / `Target Year`)
selects which sub-field drives the deadline; non-selected sub-fields are still
present on the bean so switching modes preserves prior entries. Persistence
keys: `gp_inputs` (browser localStorage, per currency); defaults from
`goal-defaults.json` on the classpath.

**Goal card**

| Field | Notes |
| --- | --- |
| Goal Amount (post-tax) | Money in **today's** currency, > 0. The amount the user wants *in hand*, in today's purchasing power, after taxes on gains. |
| Inflation Rate (%) | Annual, `0–100`. Grows the goal to what that same purchasing power costs in the horizon year. Zero means the entered figure is taken as the nominal target. |

The goal is always inflation-adjusted: a goal entered in today's money would
otherwise be solved against a horizon-year corpus, quietly under-funding the
plan by the whole inflation gap. The resolved target is shown back to the user
as helper text under the rate — `Target at horizon: <amount>` — so the number
being solved for is never hidden.

**Investments card** — repeating rows; user can add or remove buckets.

| Field | Notes |
| --- | --- |
| Label | Free text (e.g. "Equity", "Bonds"). |
| Current | Money, ≥ 0. Current balance of this bucket (treated as 100% principal). |
| Growth (%) | Annual return on this bucket. |
| Tax (%) | Applied to this bucket's gains portion at exit. |
| Step-Up (%) | Optional. Per-bucket annual compounding bump on this bucket's share of the SIP. Blank reads as zero. |
| Allocation (%) | Share of every monthly SIP that flows into this bucket. All rows must sum to 100%; the form shows a live total tinted green/red. |

**Time Horizon card**
| Time-horizon mode | One of `YEARS`, `AGES`, `TARGET_YEAR`. Drives which sub-fields below resolve to *N* total months. |
| ↳ Years + Months | Integer years `0–80` plus integer months `0–11`; their sum must be at least one month (when mode = `YEARS`). |
| ↳ Current Age + Goal Age | Integers, `goalAge > currentAge` (when mode = `AGES`); year resolution only. |
| ↳ Target Year + Target Month | Integer year + month dropdown; must resolve to a future month (when mode = `TARGET_YEAR`). |

## 2. Calculation Model

The required monthly SIP `M` is linear in the goal-vs-corpus gap, so the
calculator solves it in closed form (no iterative root-find). The corpus
compounds monthly; contributions land at the start of each month; step-up
is applied annually and **per bucket** (bucket *i*'s year-2 share becomes
`M · a_i · (1+s_i)`, year 3 becomes `M · a_i · (1+s_i)^2`, and so on).

The entered goal is in today's money, so it is first grown to the horizon year
at the inflation rate `π`. Everything downstream solves against that
inflation-adjusted figure — the target the plan actually has to hit:

```
Goal = Goal_today · (1 + π)^(N_m / 12)
```

The exponent is the fractional horizon, so a partial year inflates
proportionally (`Math.pow`, matching the horizon handling elsewhere). A zero
rate leaves the entered amount untouched. The result carries it as
`inflatedGoal`.

With per-bucket monthly rate `g_m,i = (1 + g_i)^(1/12) − 1`, allocation
fraction `a_i`, exit tax `t_i`, per-bucket step-up `s_i`, and total months `N_m`:

```
corpus_FV_i      = C_i · (1 + g_m,i)^N_m
net_corpus_FV_i  = corpus_FV_i − (corpus_FV_i − C_i) · t_i

α_i              = Σ_{m=0..N_m-1} (1 + s_i)^floor(m/12)                     (principal per unit M·a_i)
β_i              = Σ_{m=0..N_m-1} (1 + s_i)^floor(m/12) · (1 + g_m,i)^(N_m − m)
netPerM_i        = β_i · (1 − t_i) + α_i · t_i

M = max(0, (Goal − Σ_i net_corpus_FV_i) / Σ_i (a_i · netPerM_i))
```

The contribution at each month is `M · a_i · (1+s_i)^floor(m/12)` into bucket
*i*. Each bucket compounds at its own monthly rate and applies its own step-up;
final balance and gains are summed across buckets and each bucket's gains taxed
at its own rate.

The projection emits one row per calendar year (`monthsInPeriod = 12`); a
non-whole-year horizon adds a final row with `monthsInPeriod < 12` covering
the leftover months.

Edges:

- **Goal already covered.** If `net_corpus_FV ≥ Goal`, `M` is set to zero and
  the result flags `goalAlreadyCovered = true`. The UI shows a status banner
  on the summary cards and suppresses the chart and projection grid.
- **Validation.** `N ≥ 1`, `Goal_today > 0` (checked on the entered amount,
  before inflation is applied), `C ≥ 0`. Percentages are clamped to `0–100` at
  the form layer.

## 3. Output

### 3.1 Summary cards

`Monthly Investment`, `First-Year Investment` (`= monthly × 12`),
`Final Corpus (gross)`, `Tax at Exit` (`= gains × tax rate`). The "goal
already covered" banner takes over the first two cards when `M = 0`.

### 3.2 Growth charts

Two tabs:

- **Corpus Build-Up** — stacked column of corpus build-up across the *N*
  projected years (or per month for horizons under 36 months): principal at the
  bottom, gains stacked on top.
- **By Investment** — one line per investment bucket, showing each bucket's
  end-of-period balance over the same timeline. Lines are labelled by the
  bucket's label (falling back to "Investment *n*" when blank). Backed by
  `GoalResult.investmentSeries` (per-bucket balances aligned to the yearly rows
  and, for short horizons, the monthly snapshots).

### 3.3 Projection grid

Per year: `Year | Age? | Yearly Investment | Balance | Principal | Gains`.
The Age column is shown only when the `AGES` horizon mode supplied a current
age; in the other modes the column is hidden.

## 4. Persistence

| Storage | Contents |
| --- | --- |
| `goal-defaults.json` (classpath) | Per-currency baseline inputs for every field, including each horizon sub-field. |
| `gp_inputs` (localStorage) | Per-currency snapshot of the user's edited inputs. |

## 5. Test coverage

| Suite | Purpose |
| --- | --- |
| `GoalCalculatorTest` | Closed-form solve correctness, inflation-adjusted target (net-at-exit reaches the grown goal), monotonicity in growth / step-up / horizon, projection-row reconciliation, validation rejections, edge cases (zero corpus, goal-already-covered, 100% tax). |
| `GoalDefaultsJsonTest` | `goal-defaults.json` parses, every currency has every required field, projection round-trips. |
| `GoalCalculatorFormBrowserlessTest` | Horizon toggle swaps the visible sub-field; binder round-trip preserves values. |

# Inflation Projection

## 1. Inputs

A single card.

| Field | Notes |
| --- | --- |
| Amount | Money, ≥ 0. Interpreted as today's money or a future value per the toggle below. |
| Inflation Rate (%) | Annual rate, 0–100. |
| Inflation Variation (±%) | Optional, 0–20. Uncertainty band around the rate (e.g. 2 ⇒ ±2%). Drives the area-range chart only; does not affect the headline result. |
| Amount is in today's money | Checkbox. Checked ⇒ forward projection; unchecked ⇒ backward (discount to today). |
| Time horizon | Shared Years (+ months) / Ages / Target Year (+ month) selector. |

## 2. Calculation Model

With fractional years `y = totalMonths / 12` and rate `i`:

- **Forward** (amount is today's money): `result = amount · (1 + i)^y`.
- **Backward** (amount is a future value): `result = amount / (1 + i)^y`.

`(1 + i)^y` uses `Math.pow` so partial-year horizons compound proportionally.
Zero inflation leaves the amount unchanged; forward and backward are exact
inverses.

## 3. Output

Two summary cards. Forward: "Amount Today" → "Value at Horizon". Backward:
"Amount at Horizon" → "Value in Today's Money". The projected card is
success-tinted.

Two chart tabs:

- **Value Over Time** — area-spline of the central projection year by year.
- **Variation Range** — area-range band between the `rate − variation` and
  `rate + variation` trajectories, with the central "Expected" line on top.
  Forward: the entered amount is fixed today, so the band starts as a point and
  fans out toward the horizon. Backward: the entered amount is the fixed value at
  the horizon end, so the band converges there and fans out toward today (a higher
  rate discounts to a smaller present value, forming the lower edge). Collapses
  onto the line when the variation is zero. Backed by `InflationResult.band`.

## 4. Persistence

| Storage | Contents |
| --- | --- |
| `inflation-defaults.json` (classpath) | Per-currency baseline inputs. |
| `ip_inputs` (localStorage) | Per-currency snapshot of the user's edited inputs. |

## 5. Test coverage

| Suite | Purpose |
| --- | --- |
| `InflationCalculatorTest` | Forward/backward correctness, inverse round-trip, zero-inflation no-op, fractional-year compounding, horizon-mode resolution, validation rejections, variation band brackets the central line, fans out forward but converges at the fixed horizon end when backward, and collapses at zero variation. |
| `InflationDefaultsJsonTest` | `inflation-defaults.json` parses, every currency has every required field, projection round-trips. |

# Investment

## 1. Inputs

A single card.

| Field | Notes |
| --- | --- |
| Amount | Money, > 0. Contributed each period during the investment phase. |
| Contribution frequency | Monthly / Quarterly / Half-Yearly / Yearly (dropdown). A contribution lands at the start of each period — every `monthsPerPeriod` months (monthly every month, yearly at the start of each 12-month block). |
| Growth Rate (%) | Annual return; compounded monthly. |
| Tax Rate (%) | Applied to the gains portion once, at the end. |
| Inflation Rate (%) | Deflates the net maturity value (and each year's balance) to today's money. |
| Step-Up (%) | Optional annual ramp on the contribution. |
| Investment time | Shared Years (+ months) / Ages / Target-Year selector — how long contributions continue. |
| Hold time | Plain Years + Months duration after contributions stop. No starting corpus; contributions only. |

## 2. Calculation Model

Monthly compounding (`g_m = (1+g)^(1/12) − 1`). For each month:

- **Investment phase** (`month < investmentMonths`): contribute
  `amount · (1 + stepUp)^floor(month/12)` — every month if Monthly, or only at
  each 12-month boundary if Yearly. Both `balance` and `principal` rise.
- **Hold phase**: no contribution.
- Either way, `balance += balance · g_m`.

At the end: `gains = balance − principal`, `taxAtExit = gains · taxRate`,
`netValue = balance − taxAtExit`, and `buyingPowerToday = netValue / (1+inflation)^totalYears`,
where `totalYears = totalMonths / 12` is fractional, so a partial-year horizon deflates proportionally.
Each projection year also carries its balance deflated to today (`realValue`).

## 3. Output

Four summary cards: Total Invested, Maturity Value (gross), Net After Tax,
Buying Power Today. A stacked column chart (principal + gains) where principal
flattens once contributions stop — making the invest/hold split visible. A
year-by-year grid (Year | Phase | Contribution | Balance | Principal | Gains |
Real Value) with a column chooser; the Phase badge marks Investing vs Holding.

## 4. Persistence

| Storage | Contents |
| --- | --- |
| `investment-defaults.json` (classpath) | Per-currency baseline inputs. |
| `iv_inputs` (localStorage) | Per-currency snapshot of the user's edited inputs. |

## 5. Test coverage

| Suite | Purpose |
| --- | --- |
| `InvestmentCalculatorTest` | Invested-total math, monthly vs yearly cadence, hold-phase growth, gains/tax/net reconciliation, buying-power discounting, step-up, phase split across rows, horizon-mode resolution, validation. |
| `InvestmentDefaultsJsonTest` | `investment-defaults.json` parses, every currency has every required field, projection round-trips. |

# Loan / EMI

## 1. Inputs

Two cards. The prepayment levers all default to zero, so the calculator opens as
a plain EMI calculator.

| Field | Notes |
| --- | --- |
| Loan Amount | Money, > 0. |
| Interest Rate (%) | Annual, reducing balance. |
| Tenure | Years + Months (at least one month total). |
| Inflation Rate (%) | Used only to express the cost in today's money. |
| Extra Payment + frequency | Recurring prepayment paid Monthly / Quarterly / Half-Yearly / Yearly on top of the EMI. |
| Extra EMIs / year | Additional full EMIs paid once a year (e.g. 1 → effectively 13 EMIs/year), each valued at the installment then in force (the re-amortized EMI under reduce-EMI). |
| EMI Step-Up (%) | Annual increase of the EMI itself — a "pay more" lever, so it only shortens the tenure. |

## 2. Calculation Model

Reducing balance with the **nominal** monthly rate `r = annual / 12` (the bank
convention, not effective compounding). EMI `= P·r·(1+r)ⁿ / ((1+r)ⁿ − 1)`; a
**zero rate** gives `EMI = P / n`. Each month: `interest = balance · r`,
`principal = EMI − interest`, then any prepayment is applied to the balance.

EMIs are rounded to the minor currency unit, so the **final scheduled
installment settles the remaining balance** (no spill into an extra stub month);
totals are summed from the actual schedule. A loan is rejected if the EMI cannot
cover the monthly interest.

Three scenarios are computed:

- **Baseline** — no prepayments; runs the full tenure.
- **Reduce tenure** — recurring extra + extra-EMIs + step-up are paid on top of a
  fixed EMI, so the loan finishes early. Drives the grid and the chart's
  with-prepayment curve. Headline: interest saved + months saved.
- **Reduce EMI** — recurring extra + extra-EMIs prepay the principal and the loan
  is re-amortized to a lower EMI over the *remaining* original tenure (step-up
  excluded). Because the prepayments keep cutting the balance, the loan may still
  clear before the original tenure ends.
  Headline: interest saved. (The EMI ratchets down a little after every
  prepayment rather than at a single point, so the headline reports the saving,
  not a single lowered installment; the descent is visible in the grid.) An
  extra-EMI prepayment is valued at the re-amortized EMI of that year, so it
  shrinks alongside the installment rather than staying pinned to the original.

`realTotalInterest` discounts each month's interest to today's money at the
inflation rate.

## 3. Output

Six summary cards: Monthly EMI, Total Interest, Total Payment, Interest Saved ·
Reduce Tenure (with months saved), Interest Saved · Reduce EMI, and Interest in
today's money. Two charts in a tab sheet: an **Outstanding Balance** line chart
— baseline vs with-prepayment when prepayments are active — and a **Principal vs
Interest** stacked-column chart of where each year's outgo goes (principal,
interest, and prepayment when active), tracking the reduce-tenure schedule. A
year-by-year amortization grid
(Year | EMI Paid | Principal | Interest | Prepayment | Balance) with a column
chooser, plus a Reduce Tenure / Reduce EMI toggle beside the heading (shown only
when prepayments are active) that switches the grid between the two schedules.

## 4. Persistence

| Storage | Contents |
| --- | --- |
| `loan-defaults.json` (classpath) | Per-currency baseline inputs (prepayments zero). |
| `ln_inputs` (localStorage) | Per-currency snapshot of the user's edited inputs. |

## 5. Test coverage

| Suite | Purpose |
| --- | --- |
| `LoanCalculatorTest` | EMI formula vs known value, zero-interest split, schedule clears to zero, no-prepayment collapses scenarios, recurring/extra-EMI/step-up prepayments save interest & shorten tenure, reduce-EMI lowers the installment, real interest under inflation, validation rejections. |
| `LoanDefaultsJsonTest` | `loan-defaults.json` parses, every currency has every required field, schedule round-trips. |

---

# Buy vs Rent Calculator (`/buyrent`)

Compares the financial outcome of buying a home versus renting over a
user-defined horizon. Both paths are projected month-by-month; net worth is
snapshotted at each year boundary.

## 1. Inputs

| Field | Section | Constraint | Meaning |
| --- | --- | --- | --- |
| Home Price | Home Purchase | > 0, Required | Purchase price of the property. |
| Down Payment | Home Purchase | 0–99 %, Required | Percentage of home price paid upfront. |
| Loan Term | Home Purchase | 1–40 yrs, Required | Mortgage duration in years. |
| Mortgage Rate | Home Purchase | 0–30 %, Required | Annual interest rate on the loan. |
| Property Tax Rate | Costs & Appreciation | 0–5 % | Annual property tax as % of current home value. |
| Maintenance Rate | Costs & Appreciation | 0–5 % | Annual maintenance cost as % of current home value. |
| Home Appreciation | Costs & Appreciation | 0–20 % | Annual rate at which the home gains value. |
| Buying Costs | Costs & Appreciation | 0–15 % | Upfront transaction costs (stamp duty, registration) as % of home price. |
| Selling Costs | Costs & Appreciation | 0–10 % | Sale-time costs (agent fees) as % of sale price. |
| Monthly Rent | Renting | > 0, Required | Starting monthly rent. |
| Annual Rent Increase | Renting | 0–20 % | Step-up applied once per year (rent is flat within a year). |
| Investment Return | Analysis | 0–30 %, Required | Annual return on the rent-path portfolio. |
| Inflation Rate | Analysis | 0–20 % | Deflates the horizon net worth (both buy and rent) to today's money, shown as a second line under the two Net Worth cards. |
| Analysis Horizon | Analysis | 1–50 yrs, Required | How many years to project. Must be ≥ the loan term (a horizon shorter than the mortgage is flagged invalid). |
| Property Capital Gains Tax | Analysis | 0–60 % | Tax rate on the profit when the home is sold (sale proceeds − cost basis). |
| Investment Gains Tax | Analysis | 0–60 % | Tax rate on the investment portfolio profit at exit (portfolio − net contributions). |

## 2. Financial Model

### Buy path
- Down payment + buying costs are paid upfront.
- Monthly EMI is computed using standard reducing-balance amortisation for the
  full loan term; once the loan is paid off there is no more EMI.
- Property tax + maintenance accrue monthly as a fraction of the current home
  value (which appreciates at the stated rate).
- Net worth at year Y (pre-tax) = home value × (1 − selling cost %) − outstanding mortgage balance.
- Property capital-gains tax on exit = max(0, sale proceeds − cost basis) × property CGT rate,
  where cost basis = home price × (1 + buying cost %).
- Net worth after tax = pre-tax equity − property capital-gains tax.

### Rent path
- The down payment + buying costs (capital not spent on the purchase) are
  invested immediately at the investment return rate.
- Each month the **surplus** = max(0, buy monthly cost − rent this month) is
  added to the portfolio. Once rent overtakes the buy cost the renter has
  nothing left to invest, so contributions stop — but there is **no drawdown**;
  the existing corpus keeps compounding. Covering the higher rent from income is
  an affordability question, deliberately outside the buy-vs-rent comparison.
- Rent is flat within each year and steps up once per completed year at the
  rent-increase rate (e.g. 5 % ⇒ year 2's monthly rent = year 1 × 1.05).
- Portfolio net worth (pre-tax) at year Y = investment portfolio balance.
- Investment capital-gains tax on exit = max(0, portfolio − net contributions) × investment CGT rate,
  where net contributions = initial investment + cumulative monthly surpluses (never negative).
- Net worth after tax = portfolio − investment capital-gains tax.

### Cash-flow crossover
The first year the monthly rent ≥ the monthly buy cost (EMI + property tax +
maintenance) — i.e. owning becomes cheaper to *hold* than renting. This usually
lands around loan payoff (when the EMI drops away) and is the meaningful,
near-term signal. Reported as "Not in horizon" if it never happens.

### Break-even
The first year where after-tax buy equity ≥ after-tax rent portfolio — the
*net-worth* crossover. Can sit decades out and be irrelevant to a real horizon,
so it is shown alongside the cash-flow crossover rather than as the headline.
Reported as "Not in horizon" if buy never catches up within the analysis period.

## 3. Outputs

### Summary row (6 cards)
| Card | Value |
| --- | --- |
| Monthly Cost: Buy | EMI + first-month property tax + maintenance |
| Monthly Cost: Rent | First month's rent |
| Cheaper to Own From | First year owning is cheaper to hold (cash-flow crossover), or "Not in horizon" |
| Break-Even | First year buy is ahead on net worth, or "Not in horizon" |
| Net Worth: Buy | After-tax equity at end of horizon, with an "in today's money" line (deflated at the inflation rate) |
| Net Worth: Rent | After-tax portfolio at end of horizon, with an "in today's money" line (deflated at the inflation rate) |

All net-worth values are after capital-gains tax. The winning path at the horizon is highlighted in success green.

### Comparison chart
Line chart showing buy equity (blue) and rent portfolio (green) from year 1 to
the analysis horizon. Where they cross is the break-even year.

### Year-by-year projection grid
| Column | Description |
| --- | --- |
| Year | Year number |
| Home Value | Appreciated home price |
| Mortgage Balance | Outstanding loan (0 after payoff) |
| Buy Net Worth | Equity after sell costs |
| Total Cash Outflow (Buy) | Cumulative cash paid on the buy path: down payment + buying costs + EMI + property tax + maintenance |
| Rent Portfolio | Accumulated investment |
| Total Cash Outflow (Rent) | Cumulative rent paid |
| Difference (Buy − Rent) | Positive = buy ahead |

Two rows are highlighted, explained by the grid's row-colour legend (the info
icon beside the column-chooser cog): the cash-flow crossover row (primary/blue
tint) and the net-worth break-even row (success/green tint). If both fall on the
same year the row carries both parts.

## 4. Persistence & sharing
| Key | Content |
| --- | --- |
| `buyrent-defaults.json` (classpath) | Per-currency baseline inputs. |
| `bvr_inputs` (localStorage) | Per-currency snapshot of the user's edited inputs. |

# IRR / XIRR Calculator (`/xirr`)

Computes the money-weighted annualised return (XIRR) over a set of dated
cashflows, and surfaces the case the calculation cares most about: a
non-conventional schedule whose rate is **not unique**.

## 1. Inputs

The form has two tabs — **Investments** (money paid in) and **Withdrawals**
(money received) — each split into a one-off card and a recurring card. Amounts
are entered as positive magnitudes; the calculator assigns the sign
(investments negative, withdrawals positive). Every row exposes its list as a
signal, folded into one inputs signal for live recalculation.

### 1.1 One-off cashflow (per row)

| Field | Notes |
| --- | --- |
| Date | Required calendar date the money moves. |
| Description | Optional label, carried through to the schedule grid. |
| Amount | Required money in today's currency, > 0. |

### 1.2 Recurring cashflow (per row)

| Field | Notes |
| --- | --- |
| Start Date | Required date of the first payment. |
| Frequency | Monthly / Quarterly / Half-Yearly / Yearly (1 / 3 / 6 / 12 months per period). |
| Payments | Required count ≥ 1; expands to one cashflow per occurrence at `startDate + n × period`. |
| Description | Optional label. |
| Amount | Required per-payment amount, > 0. |

## 2. Calculation Model

`irr.service.XirrCalculator.calculate(inputs)` expands the four lists into a
signed, date-sorted `List<Cashflow>` and delegates the maths to
`irr.service.Xirr`.

### 2.1 NPV and the rate

Time is measured in years from the earliest cashflow on an **Actual/365**
day-count (matching spreadsheet `XIRR`):

```
NPV(r) = Σ CF_i / (1 + r)^((d_i − d_0) / 365)
```

The IRR is any rate `r > −100%` with `NPV(r) = 0`.

### 2.2 Finding every root (not just one)

Roots are found by **scanning** NPV across `[−99.9999%, +10000%]` in fine steps and
**bisecting** each sign-change bracket. Bisection is used deliberately over
Newton-Raphson: it cannot diverge and it returns *all* roots, which is what a
multiple-IRR schedule needs. Roots within `1e-4` of each other are de-duplicated.

### 2.3 Determinacy

The number of **sign changes** in the date-ordered amounts is reported
(Descartes' rule of signs). The result status is:

- **`UNIQUE`** — exactly one root; the XIRR is well defined.
- **`NON_UNIQUE`** — more than one root; no single rate is meaningful. The
  headline XIRR is the root closest to zero, reported **with a warning** that
  lists every root and renders the NPV-vs-rate curve so the crossings are
  visible.

Invalid input is rejected with a message (translation key) shown in the view's
banner: fewer than two cashflows (`needTwoCashflows`), one-directional cashflows
that can never break even (`needBothDirections`), or no root in the searched
range (`noRate`). Blank rows are ignored rather than failing.

### 2.4 Derived figures

| Figure | Meaning |
| --- | --- |
| Total Invested | Sum of outflow magnitudes. |
| Total Withdrawn | Sum of inflows. |
| Net Cashflow | `Total Withdrawn − Total Invested` (undiscounted). |
| Payback date | First date the running (undiscounted) total turns non-negative. |
| NPV curve | NPV sampled across a display range for the chart. |

## 3. Output

- **Summary cards**: XIRR (annualised, tinted as a warning when non-unique),
  Total Invested, Total Withdrawn, Net Cashflow (tinted by sign).
- **Warning banner**: shown for a non-unique rate (lists the roots) or an input
  error.
- **Charts** (tabbed): Cashflow Timeline (columns by date, investments down /
  withdrawals up), NPV vs Rate (curve with the zero baseline and a marked line
  per root; the curve range stretches to enclose every root so each marked line
  lands on a visible zero-crossing), Cumulative Cashflow (running balance with
  the payback date marked).
- **Schedule grid**: every expanded cashflow — date, description, signed amount,
  running cumulative.

## 4. Currency

Amounts and chart axes format via `MoneyFormatter` for the active currency;
switching currency reloads that currency's persisted/default schedule.

## 5. Persistence & sharing

| Key | Content |
| --- | --- |
| `xirr-defaults.json` (classpath) | Per-currency sample schedule (a three-year monthly SIP redeemed at a current value). |
| `xirr_inputs` (localStorage) | Per-currency snapshot of the user's edited cashflows (dates as ISO-8601 strings). |

Both reuse `XirrInputsStore`'s `toJsonNode` / `fromJsonNode`, which also back the
shareable-link codec.

## 6. Test coverage

`Xirr` (NPV, root finding, sign changes, multiple roots), `XirrCalculator`
(expansion, totals, payback, status, validation), `XirrInputsStore` (JSON
round-trip), and `XirrDefaultsJsonTest` (defaults parse and resolve) — all under
the `*/service` 80% coverage gate.

# Debt Payoff Planner (`/debt`)

Given several debts and a fixed extra monthly payment, work out when the user is
debt-free, how much interest they pay, and how much the avalanche/snowball
ordering saves over paying minimums only.

## 1. Inputs

### 1.1 Debts (per row)

| Field | Notes |
| --- | --- |
| Name | Required; labels the debt in the schedule and target column. |
| Balance | Required, > 0. |
| APR | Required, 0–100%; nominal `annual/12` monthly rate (as Loan / EMI). |
| Min payment | Optional fixed minimum. |
| Min % of balance | Optional percentage-of-(statement)-balance minimum. |
| Priority | Optional; at most one debt. Its minimum is covered before any other's, so it is the last to default when the budget is tight (e.g. the car needed for work). |
| Promo APR / Promo months | Optional intro rate applied for the first *N* months (behind an "advanced" disclosure). |

Effective monthly minimum = `max(minimumFloor, minimumPct% × statement balance)`,
capped at the outstanding balance. `minimumFloor` is the row's Min payment when
set, otherwise a per-currency default floor from `debt-defaults.json`. The floor
guarantees the payment eventually exceeds the interest so every debt amortizes.

### 1.2 Plan

| Field | Notes |
| --- | --- |
| Maximum you can pay each month | Required, > 0. The total the user commits; the plan distributes it across the debts. |
| Strategy | Avalanche (highest ongoing APR first) or Snowball (smallest original balance first). Decides where the surplus (beyond minimums) goes. |
| Increase budget yearly | Optional, 0–50%; grows the monthly budget each year. |
| Default fee (per missed minimum) | Optional, ≥ 0; a flat charge added to a debt in any month the budget can't cover its minimum. |
| Inflation Rate | Optional, 0–20%; drives the today's-money interest total. |

### 1.3 One-off windfalls (per row, optional)

| Field | Notes |
| --- | --- |
| Month | Required, 1–600; 1-based from the plan start. |
| Amount | Required, > 0; a lump payment that month, on top of the monthly budget. |

## 2. Calculation Model

Month-by-month simulation, run once per strategy plus a minimums-only baseline.
The budget is a hard cap — the plan never invents money it doesn't have.

- **Budget for month** = `monthlyBudget × (1 + step-up)^yearsElapsed`, plus any
  windfall dated to that month.
- **Fixed, promo-aware order:** rank once — Avalanche by ongoing (post-promo)
  APR descending, Snowball by original balance ascending, tie-break input order.
  The order never re-sorts; only "already cleared" changes.
- Each month: accrue interest (promo rate while `month ≤ promoMonths`, else APR),
  then **distribute the budget** — cover each debt's effective minimum (the
  **priority** debt first, then the rest in strategy order), then funnel the
  remainder to the strategy target, **cascading within the same month** once a
  target clears (so one month can retire several debts).
- **Defaults:** if the budget can't cover every minimum, the debts it can't reach
  that month get less than their minimum and are flagged as defaulting; the flat
  default fee (if set) is added to each defaulting balance. Nothing is topped up —
  the plan shows what actually happens.
- Stop when all balances hit zero, or at a 1200-month cap. A plan that never
  clears is not an error — it comes back with `fullyPaid` false (the debt-free
  card shows "Over 100 years").
- **Baseline:** each debt pays exactly its minimum, ignoring the budget (no
  distribution, no defaults) — the "bare minimum" yardstick. `interestSaved` and
  `monthsSaved` are the primary strategy's advantage over it; today's-money
  interest deflates each month's interest by `(1+inflation)^(m/12)`.

The result carries the chosen strategy's schedule as `primary` (the same object
as the avalanche or snowball run) plus the baseline. The only hard error is
having no valid debts.

## 3. Output

### 3.1 Summary cards (5)

Debt-free horizon (primary; "Over 100 years" when it never clears), total
interest (with a today's-money subtitle), interest saved and time saved vs
minimums-only, and the interest delta against the other strategy (label names it,
e.g. "vs Snowball").

### 3.2 Comparison chart

Total outstanding balance over time — Avalanche, Snowball, and minimums-only
(dashed).

### 3.3 Schedule grids (tabbed)

The year-by-year and monthly grids share one card, switched by a "Year by year" /
"Monthly schedule" tab.

- **Year by year** — for the chosen strategy: Total Balance, Interest Paid,
  Principal Paid, and Cumulative Interest. The debt-free year (last row) is
  highlighted with a legend.
- **Monthly schedule** — month-by-month: Month, one column per debt showing
  exactly what to pay into it, and a Total. When the budget can't cover a debt's
  minimum, the unpaid **shortfall** is shown in red beneath that payment. If any
  month falls short, a **Shortfall** total column and a legend explaining the red
  appear (both hidden when nothing ever defaults). Columns are built from the
  current debts; the tab carries its own column-chooser and legend controls.

## 4. Persistence & sharing

| Key | Content |
| --- | --- |
| `debt-defaults.json` (classpath) | Per-currency sample debts, monthly budget, budget step-up, default fee, windfalls, strategy, inflation, and the default minimum floor. |
| `dbt_inputs` (localStorage) | Per-currency snapshot of the edited debts, windfalls, and plan settings. |

`DebtInputsStore`'s `toJsonNode` / `fromJsonNode` (debts and windfalls round-trip
as JSON arrays; the debt order is preserved because both strategies rank with it
as the tie-break, and the minimums-only baseline pays in it) also back the
shareable-link codec. An unrecognised strategy name — including the retired
`CUSTOM` still sitting in an old snapshot or share link — is dropped rather than
failing the load, leaving the plan on the Avalanche default.

## 5. Test coverage

`DebtCalculator` (strategy ordering, fixed promo-aware order, same-month cascade,
percentage-minimum shrink, promo window, floor termination, budget-below-minimums
defaults, default fee, priority protection, single-debt and zero-APR cases, plus
budget step-up and windfalls), `DebtInputsStore` (JSON round-trip incl. the debts
and windfalls lists and the priority flag; unknown strategy falls back), and
`DebtDefaultsJsonTest` (defaults parse and clear the debts) — under the
`*/service` 80% coverage gate — plus a browserless form round-trip and Playwright
reactivity/strategy ITs.