homelab/apps/finance/services/api/main/handler_dream.go
Gonçalo Rodrigues 2ab3acdce2 feat(goals): Goal Planner — type-driven planner merged into /goals tab
* feat(property): Layer 3 — Dream House Simulator

Add /dream page with a four-phase simulation engine:

  Phase 1 — Save the down payment (uses current property equity)
  Phase 2 — Construction period (both loans running simultaneously)
  Phase 3 — Sell current house, apply proceeds to construction loan
  Phase 4 — Final state: just the construction loan remaining

Inputs: dream cost, down payment %, construction loan rate/term,
build duration, monthly savings, expected sale price. All pre-filled
from existing property/loan data when available.

Output: per-phase timeline cards, monthly cost bar chart, total
interest, final payoff date, and a key levers section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(plan): rename Dream House to Goal Planner at /plan

- Route /dream → /plan
- Nav label "Dream House" → "Goal Planner"
- Template dream.html → plan.html
- All user-facing labels generalised (construction loan → new loan,
  build duration → acquisition/build period, current property →
  current asset, dream house cost → new goal cost, etc.)
- Empty state updated with generic copy and 🎯 icon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(goals): merge Goal Planner into /goals as a second tab

- /goals now has two tabs: "Committed goals" and "Goal Planner"
- Goal creation only happens from the Planner tab (simulate first,
  then "Save as goal" → creates an uncommitted goal)
- Commitment, deadline adjustment, and deletion stay on the Goals tab
- Off-track goals show an "Adjust deadline →" button that pushes the
  deadline to the realistic date based on current savings rate
- /plan and /dream both redirect to /goals?tab=planner (301)
- "Goal Planner" nav link removed; plan.html kept for redirect compat
- GoalsData gains Tab, PlanProperties, PlanLoans, HasPlanResult,
  PlanResult, PlanForm fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(goals): type-driven planner — Save for a purchase vs Sell & upgrade

Goal Planner tab now opens with two goal type cards:

  🛒 Save for a purchase — name, target, monthly savings, optional
     deadline. Shows time-to-reach at current rate, monthly needed
     to hit the deadline, and a feasibility banner.

  🔄 Sell & upgrade — the full four-phase transition simulator
     (existing asset + loan → acquire new → sell old → payoff).

Each type has its own focused form and result section. Selecting a
type highlights the card and loads the matching form. Results include
a "Save as goal" action that drops an uncommitted goal into the
Goals tab.

Also adds runPurchaseSim() and PurchaseSimResult model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 22:02:41 +01:00

189 lines
6.5 KiB
Go

package main
import (
"math"
"net/http"
"strconv"
"time"
)
// Dream redirects /plan to the Planner tab inside /goals.
func (h *Handler) Dream(w http.ResponseWriter, r *http.Request) {
target := "/goals?tab=planner"
if q := r.URL.RawQuery; q != "" {
target += "&" + q
}
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
// runDreamSim computes the four-phase goal plan.
func runDreamSim(form DreamForm, props []PropertyView, loans []LoanView) *DreamSimResult {
res := &DreamSimResult{Form: form}
for i := range props {
if props[i].ID == form.PropertyID {
res.CurrentProperty = &props[i]
break
}
}
for i := range loans {
if loans[i].ID == form.LoanID {
res.CurrentLoan = &loans[i]
break
}
}
if res.CurrentLoan != nil {
res.CurrentMonthlyCents = res.CurrentLoan.EffectiveMonthlyPaymentCents
}
// ── Phase 1: save the down payment ───────────────────────────────────────
res.DownPaymentCents = int64(math.Round(float64(form.DreamCostCents) * form.DownPaymentPct / 100))
if res.CurrentProperty != nil {
res.AlreadyHaveCents = res.CurrentProperty.EquityCents
}
res.StillNeededCents = res.DownPaymentCents - res.AlreadyHaveCents
if res.StillNeededCents < 0 {
res.StillNeededCents = 0
}
if res.StillNeededCents > 0 && form.MonthlySavingsCents > 0 {
res.Phase1Months = int(math.Ceil(float64(res.StillNeededCents) / float64(form.MonthlySavingsCents)))
}
res.Phase1EndDate = time.Now().AddDate(0, res.Phase1Months, 0)
// ── Construction loan details ─────────────────────────────────────────────
res.ConstructionLoanCents = form.DreamCostCents - res.DownPaymentCents
res.ConstructionMonthly = loanMonthlyPayment(res.ConstructionLoanCents, form.ConstructionRatePct, form.ConstructionTermMonths)
// ── Phase 2: acquisition period (both loans running) ─────────────────────
res.Phase2Months = form.BuildMonths
res.Phase2EndDate = res.Phase1EndDate.AddDate(0, res.Phase2Months, 0)
res.Phase2MonthlyCents = res.CurrentMonthlyCents + res.ConstructionMonthly
if res.CurrentLoan != nil {
totalElapsed := res.Phase1Months + res.Phase2Months
res.ExistingBalanceAtSale = loanBalanceAt(
res.CurrentLoan.PrincipalCents,
res.CurrentLoan.InterestRatePct,
res.CurrentLoan.EffectiveMonthlyPaymentCents,
totalElapsed,
)
}
res.ConstructionBalAtSale = loanBalanceAt(
res.ConstructionLoanCents,
form.ConstructionRatePct,
res.ConstructionMonthly,
res.Phase2Months,
)
// ── Phase 3: sell current asset ───────────────────────────────────────────
res.SalePriceCents = form.ExpectedSalePriceCents
if res.SalePriceCents == 0 && res.CurrentProperty != nil {
res.SalePriceCents = res.CurrentProperty.CurrentValueCents
}
res.MortgagePayoffCents = res.ExistingBalanceAtSale
res.NetProceedsCents = res.SalePriceCents - res.MortgagePayoffCents
if res.NetProceedsCents < 0 {
res.NetProceedsCents = 0
res.Warning = "Sale proceeds don't cover the remaining loan — you'll need to cover the gap."
}
res.RemainingBalanceCents = res.ConstructionBalAtSale - res.NetProceedsCents
if res.RemainingBalanceCents < 0 {
res.RemainingBalanceCents = 0
}
// ── Phase 4: goal achieved ────────────────────────────────────────────────
if res.RemainingBalanceCents > 0 {
res.Phase4Months = loanRemainingMonths(res.RemainingBalanceCents, form.ConstructionRatePct, res.ConstructionMonthly)
res.Phase4MonthlyCents = res.ConstructionMonthly
remainingTerm := form.ConstructionTermMonths - res.Phase2Months
if remainingTerm > 0 && remainingTerm < res.Phase4Months {
res.Phase4Months = remainingTerm
res.Phase4MonthlyCents = loanMonthlyPayment(res.RemainingBalanceCents, form.ConstructionRatePct, remainingTerm)
}
}
res.Phase4EndDate = res.Phase2EndDate.AddDate(0, res.Phase4Months, 0)
// ── Totals ────────────────────────────────────────────────────────────────
res.TotalMonths = res.Phase1Months + res.Phase2Months + res.Phase4Months
res.TotalYears = res.TotalMonths / 12
res.TotalRemMonths = res.TotalMonths % 12
res.FinalDate = res.Phase4EndDate
existingInterest := int64(0)
if res.CurrentLoan != nil {
totalPaid := res.CurrentMonthlyCents * int64(res.Phase1Months+res.Phase2Months)
principal := res.CurrentLoan.BalanceCents - res.ExistingBalanceAtSale
existingInterest = totalPaid - principal
if existingInterest < 0 {
existingInterest = 0
}
}
constructionTotalPaid := res.ConstructionMonthly*int64(res.Phase2Months) + res.Phase4MonthlyCents*int64(res.Phase4Months)
constructionInterest := constructionTotalPaid - res.ConstructionLoanCents
if constructionInterest < 0 {
constructionInterest = 0
}
res.TotalInterestCents = existingInterest + constructionInterest
return res
}
// runPurchaseSim computes a simple save-for-purchase projection.
func runPurchaseSim(name string, targetCents, monthlyCents int64, deadlineStr string) *PurchaseSimResult {
res := &PurchaseSimResult{
Name: name,
TargetCents: targetCents,
MonthlySavingsCents: monthlyCents,
}
if targetCents <= 0 {
return res
}
// At-current-savings: how long to reach target
if monthlyCents > 0 {
res.MonthsNeeded = int(math.Ceil(float64(targetCents) / float64(monthlyCents)))
res.YearsNeeded = res.MonthsNeeded / 12
res.RemMonths = res.MonthsNeeded % 12
res.ReachDate = time.Now().AddDate(0, res.MonthsNeeded, 0)
}
// Deadline projection
if deadlineStr != "" {
dl, err := time.Parse("2006-01", deadlineStr)
if err == nil && !dl.IsZero() {
res.HasDeadline = true
res.DeadlineDate = dl
res.DeadlineMonths = monthsBetween(time.Now(), dl)
if res.DeadlineMonths < 1 {
res.DeadlineMonths = 1
}
res.MonthlyNeededForDeadline = int64(math.Ceil(float64(targetCents) / float64(res.DeadlineMonths)))
res.Feasible = monthlyCents >= res.MonthlyNeededForDeadline
}
}
return res
}
func parseFloatParam(s string, def float64) float64 {
if s == "" {
return def
}
v, err := strconv.ParseFloat(s, 64)
if err != nil || v < 0 {
return def
}
return v
}
func parseIntParam(s string, def int) int {
if s == "" {
return def
}
v, err := strconv.Atoi(s)
if err != nil || v < 0 {
return def
}
return v
}