diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index f1ed6dd..146a677 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -1793,27 +1793,35 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) { action := r.FormValue("action") if action == "delete" { - id := r.FormValue("id") - h.store.deleteGoal(ctx, id, a.UserID) + h.store.deleteGoal(ctx, r.FormValue("id"), a.UserID) http.Redirect(w, r, "/goals", http.StatusSeeOther) return } if action == "commit" || action == "uncommit" { - id := r.FormValue("id") - h.store.updateGoal(ctx, id, a.UserID, bson.M{"committed": action == "commit"}) + h.store.updateGoal(ctx, r.FormValue("id"), a.UserID, bson.M{"committed": action == "commit"}) http.Redirect(w, r, "/goals", http.StatusSeeOther) return } - // create goal + if action == "adjust_deadline" { + months := parseIntParam(r.FormValue("months"), 0) + if months > 0 { + newDeadline := time.Now().AddDate(0, months, 0) + h.store.updateGoal(ctx, r.FormValue("id"), a.UserID, bson.M{"deadline": newDeadline}) + } + http.Redirect(w, r, "/goals", http.StatusSeeOther) + return + } + + // create goal (from planner tab or direct) name := r.FormValue("name") goalType := GoalType(r.FormValue("type")) - targetStr := r.FormValue("target_euros") - deadlineStr := r.FormValue("deadline") - - targetEuros := parseFloat(targetStr) - deadline, _ := time.Parse("2006-01", deadlineStr) + if goalType == "" { + goalType = GoalTypeOnce + } + targetEuros := parseFloat(r.FormValue("target_euros")) + deadline, _ := time.Parse("2006-01", r.FormValue("deadline")) g := &Goal{ ID: bson.NewObjectID().Hex(), @@ -1938,18 +1946,71 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) { _ = conflictNames } - render(w, goalsTmpl, &GoalsData{ + data := &GoalsData{ UserID: a.UserID, Email: a.Email, Title: "Goals", Route: "goals", + Tab: r.URL.Query().Get("tab"), Goals: plans, AvgMonthlySavings: avgMonthlySavings, DisposableIncome: disposable, CommittedMonthlyCents: committedTotal, RemainingDisposable: remainingDisposable, ConflictWarning: conflictWarning, - }) + } + if data.Tab == "" { + data.Tab = "goals" + } + + // Planner tab: load properties/loans and optionally run simulation + if data.Tab == "planner" { + q := r.URL.Query() + data.PlannerType = q.Get("planner_type") + + props, _ := h.store.getProperties(ctx, a.UserID) + loans, _ := h.store.getLoans(ctx, a.UserID) + for _, p := range props { + data.PlanProperties = append(data.PlanProperties, toPropertyView(p, loans)) + } + for _, l := range loans { + if l.Status == LoanActive { + data.PlanLoans = append(data.PlanLoans, toLoanView(l)) + } + } + + if q.Get("run") == "1" { + switch data.PlannerType { + case "purchase": + data.PurchaseResult = runPurchaseSim( + q.Get("name"), + parseFormCents(q.Get("target")), + parseFormCents(q.Get("monthly_savings")), + q.Get("deadline"), + ) + data.HasPurchaseResult = true + case "transition": + termYears := parseIntParam(q.Get("const_term"), 30) + form := DreamForm{ + PropertyID: q.Get("property_id"), + LoanID: q.Get("loan_id"), + DreamCostCents: parseFormCents(q.Get("dream_cost")), + DownPaymentPct: parseFloatParam(q.Get("down_pct"), 20), + ConstructionRatePct: parseFloatParam(q.Get("const_rate"), 4.0), + ConstructionTermYears: termYears, + ConstructionTermMonths: termYears * 12, + BuildMonths: parseIntParam(q.Get("build_months"), 18), + MonthlySavingsCents: parseFormCents(q.Get("monthly_savings")), + ExpectedSalePriceCents: parseFormCents(q.Get("sale_price")), + } + data.PlanForm = form + data.PlanResult = runDreamSim(form, data.PlanProperties, data.PlanLoans) + data.HasPlanResult = true + } + } + } + + render(w, goalsTmpl, data) } func monthsBetween(from, to time.Time) int { @@ -2763,6 +2824,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /auto-import", h.AutoImport) mux.HandleFunc("GET /property", h.Properties) mux.HandleFunc("POST /property", h.Properties) + mux.HandleFunc("GET /plan", h.Dream) h.RegisterOrgRoutes(mux) } diff --git a/apps/finance/services/api/main/handler_dream.go b/apps/finance/services/api/main/handler_dream.go new file mode 100644 index 0000000..e330bde --- /dev/null +++ b/apps/finance/services/api/main/handler_dream.go @@ -0,0 +1,188 @@ +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 +} diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index f21d2b5..2562035 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -466,10 +466,22 @@ type GoalsData struct { Email string Title string Route string + Tab string // "goals" or "planner" Goals []GoalPlan AvgMonthlySavings int64 DisposableIncome int64 CommittedMonthlyCents int64 // sum of all committed goal contributions RemainingDisposable int64 // disposable after committed goals ConflictWarning string // set when committing would exceed disposable + // Planner tab + PlannerType string // "purchase" or "transition" + PlanProperties []PropertyView + PlanLoans []LoanView + // Transition simulation + HasPlanResult bool + PlanResult *DreamSimResult + PlanForm DreamForm + // Purchase simulation + HasPurchaseResult bool + PurchaseResult *PurchaseSimResult } diff --git a/apps/finance/services/api/main/models_dream.go b/apps/finance/services/api/main/models_dream.go new file mode 100644 index 0000000..da4386d --- /dev/null +++ b/apps/finance/services/api/main/models_dream.go @@ -0,0 +1,99 @@ +package main + +import "time" + +// DreamForm holds the raw user inputs echoed back to the template. +type DreamForm struct { + PropertyID string + LoanID string + DreamCostCents int64 + DownPaymentPct float64 + ConstructionRatePct float64 + ConstructionTermYears int // what the user typed (years); converted to months internally + ConstructionTermMonths int // years * 12 + BuildMonths int + MonthlySavingsCents int64 + ExpectedSalePriceCents int64 +} + +// DreamSimResult is the computed output of one simulation run. +type DreamSimResult struct { + // Echoed inputs + Form DreamForm + + // Current state (from selected property/loan) + CurrentProperty *PropertyView + CurrentLoan *LoanView + CurrentMonthlyCents int64 + + // Construction loan details + ConstructionLoanCents int64 + ConstructionMonthly int64 + + // Phase 1 — save the down payment + DownPaymentCents int64 + AlreadyHaveCents int64 // equity usable today + StillNeededCents int64 + Phase1Months int + Phase1EndDate time.Time + + // Phase 2 — construction (both loans running) + Phase2Months int + Phase2EndDate time.Time + Phase2MonthlyCents int64 // mortgage + construction EMI + ExistingBalanceAtSale int64 // mortgage balance at time of sale + ConstructionBalAtSale int64 // construction loan balance at time of sale + + // Phase 3 — sell current house, pay down construction loan + SalePriceCents int64 + MortgagePayoffCents int64 + NetProceedsCents int64 + RemainingBalanceCents int64 // construction loan after applying proceeds + + // Phase 4 — just the construction loan + Phase4MonthlyCents int64 + Phase4Months int + Phase4EndDate time.Time + + // Totals + TotalMonths int + TotalYears int // TotalMonths / 12 + TotalRemMonths int // TotalMonths % 12 + FinalDate time.Time + TotalInterestCents int64 + + Warning string +} + +// PurchaseSimResult is the computed output for a simple save-for-purchase goal. +type PurchaseSimResult struct { + Name string + TargetCents int64 + MonthlySavingsCents int64 + // At-current-savings projection + MonthsNeeded int + YearsNeeded int + RemMonths int + ReachDate time.Time + // Deadline projection (only set when a deadline was provided) + HasDeadline bool + DeadlineDate time.Time + DeadlineMonths int + MonthlyNeededForDeadline int64 + Feasible bool // monthly savings >= monthly needed for deadline +} + +// DreamData is passed to the dream.html template (kept for compat; redirect goes to /goals). +type DreamData struct { + UserID string + Email string + Title string + Route string + + Properties []PropertyView + Loans []LoanView + + HasResult bool + Result *DreamSimResult + Form DreamForm +} diff --git a/apps/finance/services/api/main/templates/base.html b/apps/finance/services/api/main/templates/base.html index dc48064..1eedd75 100644 --- a/apps/finance/services/api/main/templates/base.html +++ b/apps/finance/services/api/main/templates/base.html @@ -580,6 +580,7 @@ Goals Property + {{$analysisActive := or (eq .Route "reports") (eq .Route "projections") (eq .Route "networth") (eq .Route "simulator") (eq .Route "tax")}}