Skip to content

Latest commit

 

History

History
1151 lines (902 loc) · 56.2 KB

File metadata and controls

1151 lines (902 loc) · 56.2 KB

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.

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) 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 indicatorsisPost = age ≥ retireAge, isRetireYear = age == retireAge, yearsFromNow = age − currentAge, year = currentYear + yearsFromNow.

  2. Annual expensesannualExp = 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-yearendCorpus = 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.