mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
feat(retirement): PR4a glide-path chart (D3)
The dashboard centerpiece. Goal::Retirement#glide_payload derives, from the forecast, the active-plan series + a zero-savings (Walletburst) shadow + a ±1pp real-return band + the per-age income breakdown for the hover tooltip + lump markers + the retire/Coast crossover points (three extra deterministic Forecast runs; cheap). retirement_glide_chart_controller (D3, mirrors goal_projection_chart's import / ResizeObserver / theme-observer idiom): portfolio-by-age line + area, accumulation/drawdown phase shading, the ±1pp band cone, the dashed Walletburst shadow, a "Retire · age N / $X" chip on the retire line, a blue Coast crossover ring, purple lump bars, and a hover tooltip (PR #2029 bg-container/rounded-xl/shadow style) showing the monthly State / Workplace / Drawdown breakdown + Total-vs-target with a Covered badge. Wired into the show page above the what-if; container is privacy-sensitive. Browser-verified: renders the band, shading, retire chip ($571K), Coast dot, and shadow against the demo plan. glide_payload + lump_markers unit-tested. Rubocop + erb_lint + biome clean. Remaining for PR4: DS::SelectableCard bucket, "Why this target?" anchor card, skinned DS::Dialog deletes, DE locale, demo seed, system test.
This commit is contained in:
@@ -2,6 +2,7 @@ class RetirementController < ApplicationController
|
||||
include RetirementScoped
|
||||
|
||||
def show
|
||||
@glide = @plan.glide_payload
|
||||
@pension_sources = @plan.pension_sources.order(:start_age)
|
||||
@adjustments = @plan.adjustments.ordered
|
||||
@statements = @plan.statements.chronological.reverse
|
||||
|
||||
262
app/javascript/controllers/retirement_glide_chart_controller.js
Normal file
262
app/javascript/controllers/retirement_glide_chart_controller.js
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import * as d3 from "d3"
|
||||
|
||||
// Glide-path chart for the retirement dashboard: portfolio value across
|
||||
// age, with a ±1pp real-return band, a zero-savings (Walletburst) shadow,
|
||||
// accumulation/drawdown phase shading, retire + Coast crossover markers,
|
||||
// lump markers, and a hover tooltip showing the per-age income breakdown.
|
||||
// Mirrors goal_projection_chart_controller's D3 / resize / theme idiom.
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
data: Object,
|
||||
ariaLabel: { type: String, default: "Retirement glide path" },
|
||||
coveredLabel: { type: String, default: "Covered" },
|
||||
portfolioLabel: { type: String, default: "Portfolio" },
|
||||
stateLabel: { type: String, default: "State pension" },
|
||||
workplaceLabel: { type: String, default: "Workplace" },
|
||||
drawdownLabel: { type: String, default: "Portfolio drawdown" },
|
||||
totalLabel: { type: String, default: "Total / mo" },
|
||||
retireLabel: { type: String, default: "Retire · age" },
|
||||
coastLabel: { type: String, default: "Coast · age" }
|
||||
}
|
||||
|
||||
connect() {
|
||||
this._draw()
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
this._observer = new ResizeObserver(() => this._draw())
|
||||
this._observer.observe(this.element)
|
||||
}
|
||||
if (typeof MutationObserver !== "undefined") {
|
||||
this._themeObserver = new MutationObserver((m) => {
|
||||
if (m.some((x) => x.attributeName === "data-theme")) this._draw()
|
||||
})
|
||||
this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] })
|
||||
}
|
||||
this._onTurbo = () => { if (!this.element.querySelector("svg")) this._draw() }
|
||||
document.addEventListener("turbo:render", this._onTurbo)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._observer?.disconnect()
|
||||
this._themeObserver?.disconnect()
|
||||
document.removeEventListener("turbo:render", this._onTurbo)
|
||||
}
|
||||
|
||||
_css(name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
_symbol() {
|
||||
return this.dataValue.currency_symbol || "$"
|
||||
}
|
||||
|
||||
_short(v) {
|
||||
const abs = Math.abs(v)
|
||||
if (abs >= 1_000_000) { const s = Math.round(v / 100_000) / 10; return `${this._symbol()}${s}M` }
|
||||
if (abs >= 1_000) { const s = Math.round(v / 1_000); return `${this._symbol()}${s}K` }
|
||||
return `${this._symbol()}${Math.round(v)}`
|
||||
}
|
||||
|
||||
_money(v) {
|
||||
return `${this._symbol()}${Math.round(v).toLocaleString()}`
|
||||
}
|
||||
|
||||
_draw() {
|
||||
const data = this.dataValue
|
||||
if (!data || !data.series || data.series.length === 0) return
|
||||
|
||||
const root = this.element
|
||||
root.innerHTML = ""
|
||||
if (getComputedStyle(root).position === "static") root.style.position = "relative"
|
||||
|
||||
const width = root.clientWidth || 640
|
||||
const height = root.clientHeight || 320
|
||||
const yAxisVisible = width >= 360
|
||||
const margin = { top: 24, right: 16, bottom: 28, left: yAxisVisible ? 52 : 12 }
|
||||
const innerW = Math.max(0, width - margin.left - margin.right)
|
||||
const innerH = Math.max(0, height - margin.top - margin.bottom)
|
||||
|
||||
const green = this._css("--color-green-600") || "#16a34a"
|
||||
const blue = this._css("--color-blue-500") || "#3b82f6"
|
||||
const violet = this._css("--color-violet-500") || "#8b5cf6"
|
||||
const border = this._css("--color-gray-300") || "#d1d5db"
|
||||
const secondary = this._css("--color-gray-500") || "#6b7280"
|
||||
const containerBg = this._css("--color-white") || "#ffffff"
|
||||
|
||||
const ages = data.series.map((d) => d.age)
|
||||
const allValues = [
|
||||
...data.series, ...(data.band_high || []), ...(data.shadow_series || [])
|
||||
].map((d) => d.value)
|
||||
const yMax = Math.max(1, d3.max(allValues) || 1)
|
||||
|
||||
const x = d3.scaleLinear().domain([ages[0], ages[ages.length - 1]]).range([margin.left, margin.left + innerW])
|
||||
const y = d3.scaleLinear().domain([0, yMax * 1.05]).range([margin.top + innerH, margin.top])
|
||||
|
||||
const svg = d3.select(root).append("svg")
|
||||
.attr("width", width).attr("height", height)
|
||||
.attr("role", "img").attr("aria-label", this.ariaLabelValue)
|
||||
|
||||
// Phase shading: accumulation (age < retire) tinted, drawdown plain.
|
||||
if (data.retire_age != null) {
|
||||
svg.append("rect")
|
||||
.attr("x", margin.left).attr("y", margin.top)
|
||||
.attr("width", Math.max(0, x(data.retire_age) - margin.left)).attr("height", innerH)
|
||||
.attr("fill", green).attr("opacity", 0.05)
|
||||
}
|
||||
|
||||
// y gridlines + labels
|
||||
if (yAxisVisible) {
|
||||
y.ticks(4).forEach((t) => {
|
||||
svg.append("line")
|
||||
.attr("x1", margin.left).attr("x2", margin.left + innerW)
|
||||
.attr("y1", y(t)).attr("y2", y(t))
|
||||
.attr("stroke", border).attr("stroke-width", 1).attr("opacity", 0.5)
|
||||
svg.append("text")
|
||||
.attr("x", margin.left - 8).attr("y", y(t)).attr("dy", "0.32em")
|
||||
.attr("text-anchor", "end").attr("font-size", 11).attr("fill", secondary)
|
||||
.text(this._short(t))
|
||||
})
|
||||
}
|
||||
|
||||
// x age labels (every ~10y)
|
||||
x.ticks(6).forEach((t) => {
|
||||
svg.append("text")
|
||||
.attr("x", x(t)).attr("y", margin.top + innerH + 18)
|
||||
.attr("text-anchor", "middle").attr("font-size", 11).attr("fill", secondary)
|
||||
.text(`age ${Math.round(t)}`)
|
||||
})
|
||||
|
||||
// ±1pp band (area between band_low and band_high)
|
||||
if (data.band_low && data.band_high) {
|
||||
const byAge = {}
|
||||
data.band_low.forEach((d) => { byAge[d.age] = { lo: d.value } })
|
||||
data.band_high.forEach((d) => {
|
||||
byAge[d.age] = byAge[d.age] || {}
|
||||
byAge[d.age].hi = d.value
|
||||
})
|
||||
const bandData = Object.keys(byAge).map((a) => ({ age: +a, lo: byAge[a].lo, hi: byAge[a].hi }))
|
||||
.filter((d) => d.lo != null && d.hi != null).sort((a, b) => a.age - b.age)
|
||||
const bandArea = d3.area().x((d) => x(d.age)).y0((d) => y(d.lo)).y1((d) => y(d.hi)).curve(d3.curveMonotoneX)
|
||||
svg.append("path").datum(bandData).attr("fill", green).attr("opacity", 0.12).attr("d", bandArea)
|
||||
}
|
||||
|
||||
// Walletburst (zero-savings) shadow — dashed gray
|
||||
if (data.shadow_series) {
|
||||
const line = d3.line().x((d) => x(d.age)).y((d) => y(d.value)).curve(d3.curveMonotoneX)
|
||||
svg.append("path").datum(data.shadow_series)
|
||||
.attr("fill", "none").attr("stroke", secondary).attr("stroke-width", 1.5)
|
||||
.attr("stroke-dasharray", "4 4").attr("opacity", 0.7).attr("d", line)
|
||||
}
|
||||
|
||||
// Active plan — area + line
|
||||
const id = Math.random().toString(36).slice(2)
|
||||
const defs = svg.append("defs")
|
||||
const grad = defs.append("linearGradient").attr("id", `glide-${id}`).attr("x1", 0).attr("y1", 0).attr("x2", 0).attr("y2", 1)
|
||||
grad.append("stop").attr("offset", "0%").attr("stop-color", green).attr("stop-opacity", 0.18)
|
||||
grad.append("stop").attr("offset", "100%").attr("stop-color", green).attr("stop-opacity", 0)
|
||||
const area = d3.area().x((d) => x(d.age)).y0(margin.top + innerH).y1((d) => y(d.value)).curve(d3.curveMonotoneX)
|
||||
const line = d3.line().x((d) => x(d.age)).y((d) => y(d.value)).curve(d3.curveMonotoneX)
|
||||
svg.append("path").datum(data.series).attr("fill", `url(#glide-${id})`).attr("d", area)
|
||||
svg.append("path").datum(data.series).attr("fill", "none").attr("stroke", green)
|
||||
.attr("stroke-width", 2).attr("stroke-linejoin", "round").attr("d", line)
|
||||
|
||||
const valueAt = (age) => {
|
||||
const pt = data.series.find((d) => d.age === age)
|
||||
return pt ? pt.value : null
|
||||
}
|
||||
|
||||
// Retire marker — vertical dashed line + chip
|
||||
if (data.retire_age != null && data.retire_value != null) {
|
||||
svg.append("line")
|
||||
.attr("x1", x(data.retire_age)).attr("x2", x(data.retire_age))
|
||||
.attr("y1", margin.top).attr("y2", margin.top + innerH)
|
||||
.attr("stroke", secondary).attr("stroke-width", 1).attr("stroke-dasharray", "2 4")
|
||||
const label = svg.append("g").attr("transform", `translate(${x(data.retire_age) + 6}, ${margin.top + 6})`)
|
||||
label.append("text").attr("font-size", 10).attr("fill", secondary)
|
||||
.text(`${this.retireLabelValue} ${data.retire_age}`)
|
||||
label.append("text").attr("y", 14).attr("font-size", 12).attr("font-weight", 600)
|
||||
.attr("fill", this._css("--color-gray-900") || "#111").text(this._short(data.retire_value))
|
||||
}
|
||||
|
||||
// Lump markers — purple vertical bars
|
||||
const lumps = data.lumps || []
|
||||
lumps.forEach((lump) => {
|
||||
svg.append("line")
|
||||
.attr("x1", x(lump.age)).attr("x2", x(lump.age))
|
||||
.attr("y1", y(valueAt(lump.age) ?? 0) - 14).attr("y2", y(valueAt(lump.age) ?? 0) + 14)
|
||||
.attr("stroke", violet).attr("stroke-width", 3)
|
||||
})
|
||||
|
||||
// Coast crossover — blue ringed dot
|
||||
if (data.coast_age != null && valueAt(data.coast_age) != null) {
|
||||
svg.append("circle")
|
||||
.attr("cx", x(data.coast_age)).attr("cy", y(valueAt(data.coast_age)))
|
||||
.attr("r", 5).attr("fill", containerBg).attr("stroke", blue).attr("stroke-width", 3)
|
||||
}
|
||||
|
||||
this._attachTooltip(svg, root, x, y, data, { margin, innerW, innerH, green, secondary, blue, violet, containerBg })
|
||||
}
|
||||
|
||||
_attachTooltip(svg, root, x, y, data, opts) {
|
||||
const { margin, innerW, innerH, secondary, containerBg } = opts
|
||||
const incomeByAge = {}
|
||||
const incomeRows = data.income || []
|
||||
incomeRows.forEach((r) => { incomeByAge[r.age] = r })
|
||||
|
||||
const tooltip = document.createElement("div")
|
||||
tooltip.className = "bg-container text-primary text-sm font-sans absolute p-3 rounded-xl shadow-lg shadow-border-xs pointer-events-none z-50 privacy-sensitive"
|
||||
tooltip.style.display = "none"
|
||||
tooltip.style.minWidth = "200px"
|
||||
root.appendChild(tooltip)
|
||||
|
||||
const crosshair = svg.append("line")
|
||||
.attr("y1", margin.top).attr("y2", margin.top + innerH)
|
||||
.attr("stroke", secondary).attr("stroke-width", 1).attr("stroke-dasharray", "2 2")
|
||||
.attr("pointer-events", "none").style("display", "none")
|
||||
const dot = svg.append("circle").attr("r", 4).attr("fill", opts.green)
|
||||
.attr("stroke", containerBg).attr("stroke-width", 2).attr("pointer-events", "none").style("display", "none")
|
||||
|
||||
const row = (label, value, color) => {
|
||||
const swatch = color ? `<span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:${color};margin-right:6px"></span>` : ""
|
||||
return `<div class="flex items-center justify-between gap-4"><span class="text-secondary">${swatch}${label}</span><span class="tabular-nums">${value}</span></div>`
|
||||
}
|
||||
|
||||
const showAt = (mx) => {
|
||||
const age = Math.round(x.invert(mx))
|
||||
const pt = data.series.find((d) => d.age === age)
|
||||
if (!pt) return
|
||||
crosshair.attr("x1", x(age)).attr("x2", x(age)).style("display", null)
|
||||
dot.attr("cx", x(age)).attr("cy", y(pt.value)).style("display", null)
|
||||
|
||||
const inc = incomeByAge[age]
|
||||
const covered = inc ? inc.covered : true
|
||||
const badge = `<span class="text-xs px-1.5 py-0.5 rounded ${covered ? "text-success" : "text-destructive"}">${covered ? `✓ ${this.coveredLabelValue}` : ""}</span>`
|
||||
let html = `<div class="flex items-center justify-between gap-4 mb-2"><span class="font-medium">Age ${age}</span>${badge}</div>`
|
||||
html += row(this.portfolioLabelValue, this._money(pt.value))
|
||||
if (inc) {
|
||||
html += `<div class="border-t border-tertiary my-2"></div>`
|
||||
if (inc.state > 0) html += row(this.stateLabelValue, `${this._money(inc.state / 12)}/mo`, opts.blue)
|
||||
if (inc.workplace > 0) html += row(this.workplaceLabelValue, `${this._money(inc.workplace / 12)}/mo`, opts.violet)
|
||||
if (inc.drawdown > 0) html += row(this.drawdownLabelValue, `${this._money(inc.drawdown / 12)}/mo`, opts.green)
|
||||
const totalMo = (inc.state + inc.workplace + inc.other + inc.drawdown) / 12
|
||||
html += `<div class="border-t border-tertiary my-2"></div>`
|
||||
html += row(this.totalLabelValue, `${this._money(totalMo)} / ${this._money(data.target_monthly)}`)
|
||||
}
|
||||
tooltip.innerHTML = html
|
||||
tooltip.style.display = "block"
|
||||
const tipW = tooltip.getBoundingClientRect().width
|
||||
tooltip.style.left = `${Math.min(root.clientWidth - tipW - 4, Math.max(4, x(age) + 12))}px`
|
||||
tooltip.style.top = `${margin.top + 4}px`
|
||||
}
|
||||
|
||||
svg.append("rect")
|
||||
.attr("x", margin.left).attr("y", margin.top).attr("width", innerW).attr("height", innerH)
|
||||
.attr("fill", "transparent").style("cursor", "crosshair")
|
||||
.on("pointermove", (event) => showAt(d3.pointer(event)[0]))
|
||||
.on("pointerleave", () => {
|
||||
tooltip.style.display = "none"
|
||||
crosshair.style("display", "none")
|
||||
dot.style("display", "none")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,54 @@ class Goal::Retirement < Goal
|
||||
Date.new(birth_year.to_i + age, 1, 1)
|
||||
end
|
||||
|
||||
# One-time lump payouts as chart markers (age + amount).
|
||||
def lump_markers
|
||||
payouts.filter_map do |payout|
|
||||
next unless %w[lump_sum lump_plus_annuity].include?(payout.shape)
|
||||
amount = payout.shape == "lump_sum" ? payout.monthly_amount : payout.lump_amount
|
||||
next if amount.to_d.zero?
|
||||
{ age: payout.start_age, amount: amount.to_i }
|
||||
end
|
||||
end
|
||||
|
||||
# Everything the glide chart needs, pre-derived from the forecast: the
|
||||
# active plan, a zero-savings shadow (Walletburst), a ±1pp real-return
|
||||
# band, the per-age income breakdown for the hover tooltip, lump
|
||||
# markers, and the retire/coast crossover points. nil until projectable.
|
||||
def glide_payload
|
||||
base = forecast
|
||||
return nil if base.nil?
|
||||
|
||||
inputs = forecast_inputs
|
||||
shadow = ::Retirement::Fire::Forecast.new(inputs.with(annual_savings: 0)).call
|
||||
band_low = ::Retirement::Fire::Forecast.new(inputs.with(real_return: inputs.real_return - 0.01)).call
|
||||
band_high = ::Retirement::Fire::Forecast.new(inputs.with(real_return: inputs.real_return + 0.01)).call
|
||||
|
||||
{
|
||||
currency_symbol: Money.new(0, currency).currency.symbol,
|
||||
current_age: current_age,
|
||||
retire_age: effective_retire_age,
|
||||
terminal_age: effective_terminal_age,
|
||||
coast_age: base.coast_age,
|
||||
money_lasts_to_age: base.money_lasts_to_age,
|
||||
lasts_past_terminal: base.lasts_past_terminal?,
|
||||
target_monthly: target_spend_monthly.to_i,
|
||||
retire_value: base.portfolio_at_retirement(effective_retire_age),
|
||||
series: base.glide.map { |age, value| { age: age, value: value } },
|
||||
shadow_series: shadow.glide.map { |age, value| { age: age, value: value } },
|
||||
band_low: band_low.glide.map { |age, value| { age: age, value: value } },
|
||||
band_high: band_high.glide.map { |age, value| { age: age, value: value } },
|
||||
income: base.income_by_year.map do |row|
|
||||
{
|
||||
age: row[:age], state: row[:state], workplace: row[:workplace],
|
||||
other: row[:other], drawdown: row[:drawdown], shortfall: row[:shortfall],
|
||||
covered: row[:shortfall].zero?
|
||||
}
|
||||
end,
|
||||
lumps: lump_markers
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
# Retirement uses RetirementBucketEntry for asset selection, not the
|
||||
# goal_accounts depository join, so the parent validations (which run
|
||||
|
||||
@@ -11,6 +11,28 @@
|
||||
|
||||
<%= render "retirement/kpis", plan: @plan %>
|
||||
|
||||
<%# Glide path chart %>
|
||||
<% if @glide.present? %>
|
||||
<section class="bg-container shadow-border-xs rounded-xl p-5 space-y-3">
|
||||
<div>
|
||||
<h2 class="text-primary font-medium"><%= t("retirement.glide.title") %></h2>
|
||||
<p class="text-secondary text-xs"><%= t("retirement.glide.subtitle") %></p>
|
||||
</div>
|
||||
<div class="h-72 w-full privacy-sensitive"
|
||||
data-controller="retirement-glide-chart"
|
||||
data-retirement-glide-chart-data-value="<%= @glide.to_json %>"
|
||||
data-retirement-glide-chart-aria-label-value="<%= t("retirement.glide.title") %>"
|
||||
data-retirement-glide-chart-covered-label-value="<%= t("retirement.glide.covered") %>"
|
||||
data-retirement-glide-chart-portfolio-label-value="<%= t("retirement.glide.portfolio") %>"
|
||||
data-retirement-glide-chart-state-label-value="<%= t("retirement.glide.state") %>"
|
||||
data-retirement-glide-chart-workplace-label-value="<%= t("retirement.glide.workplace") %>"
|
||||
data-retirement-glide-chart-drawdown-label-value="<%= t("retirement.glide.drawdown") %>"
|
||||
data-retirement-glide-chart-total-label-value="<%= t("retirement.glide.total") %>"
|
||||
data-retirement-glide-chart-retire-label-value="<%= t("retirement.glide.retire") %>"
|
||||
data-retirement-glide-chart-coast-label-value="<%= t("retirement.glide.coast") %>"></div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%# What-if — live recompute on input, persist on save %>
|
||||
<section data-controller="retirement-what-if"
|
||||
data-retirement-what-if-url-value="<%= forecast_retirement_path %>"
|
||||
|
||||
@@ -38,6 +38,17 @@ en:
|
||||
age: "age %{age}"
|
||||
past_age: "past %{age}"
|
||||
terminal: "~%{amount} left at the end"
|
||||
glide:
|
||||
title: Glide path
|
||||
subtitle: Projected portfolio value · today's money · hover for monthly income at that age
|
||||
covered: Covered
|
||||
portfolio: Portfolio
|
||||
state: State pension
|
||||
workplace: Workplace
|
||||
drawdown: Portfolio drawdown
|
||||
total: Total / mo
|
||||
retire: Retire · age
|
||||
coast: Coast · age
|
||||
what_if:
|
||||
title: What-if
|
||||
hint: Change a lever — the projection updates live.
|
||||
|
||||
@@ -140,4 +140,31 @@ class Goal::RetirementTest < ActiveSupport::TestCase
|
||||
assert_instance_of Retirement::Fire::ForecastResult, plan.forecast
|
||||
assert_equal Date.new(Date.current.year - 45 + 60, 1, 1), plan.freedom_date
|
||||
end
|
||||
|
||||
test "glide_payload is nil without a birth year, structured once set" do
|
||||
plan = goals(:retirement_bob)
|
||||
assert_nil plan.glide_payload
|
||||
|
||||
plan.update!(retirement_params: {
|
||||
"birth_year" => Date.current.year - 40, "retire_age" => 60,
|
||||
"monthly_savings" => 1000, "target_spend" => 2000, "real_return_pct" => 5
|
||||
})
|
||||
payload = Goal.find(plan.id).glide_payload
|
||||
|
||||
assert_equal 40, payload[:current_age]
|
||||
assert_equal 60, payload[:retire_age]
|
||||
assert_operator payload[:series].length, :>, 1
|
||||
assert_equal payload[:series].length, payload[:shadow_series].length
|
||||
assert_equal payload[:series].length, payload[:band_low].length
|
||||
assert_kind_of Array, payload[:income]
|
||||
assert_kind_of Array, payload[:lumps]
|
||||
end
|
||||
|
||||
test "lump_markers picks up lump payouts from params" do
|
||||
plan = goals(:retirement_bob)
|
||||
source = plan.pension_sources.find_by(payout_shape: "lump_plus_annuity")
|
||||
source.update!(params: { "lump_amount" => 30_000 })
|
||||
|
||||
assert_equal [ { age: source.start_age, amount: 30_000 } ], plan.reload.lump_markers
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user