From 4305a776129fe18f6b3656ed3f2a52bd932c0b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= <95761178+GoncaloRodri@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:40:57 +0100 Subject: [PATCH] =?UTF-8?q?feat(finance):=20Layer=201=20=E2=80=94=20Proper?= =?UTF-8?q?ty=20&=20Loan=20foundation=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces properties and loans as first-class financial entities: - models_property.go: Property, Loan, LoanView, PropertyView, PropertyData - store_property.go: full CRUD for finance_properties + finance_loans collections - handler_property.go: GET/POST /property with add/edit/delete for both entities; amortization helpers (EMI, remaining months, total interest) - templates/property.html: summary equity cards, property cards with equity bar and linked loan details, standalone loan cards with payoff progress - base.html: "Property" nav link added to desktop and mobile drawer - storeIface + mockStore updated with 10 new property/loan methods Co-authored-by: Gonçalo Rodrigues Co-authored-by: Claude Sonnet 4.6 --- apps/finance/services/api/main/handler.go | 14 + .../services/api/main/handler_property.go | 298 +++++++++++ .../finance/services/api/main/handler_test.go | 13 + .../services/api/main/models_property.go | 94 ++++ .../services/api/main/store_property.go | 103 ++++ .../services/api/main/templates/base.html | 2 + .../services/api/main/templates/property.html | 467 ++++++++++++++++++ 7 files changed, 991 insertions(+) create mode 100644 apps/finance/services/api/main/handler_property.go create mode 100644 apps/finance/services/api/main/models_property.go create mode 100644 apps/finance/services/api/main/store_property.go create mode 100644 apps/finance/services/api/main/templates/property.html diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index 13b37fe..f0e73c6 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -287,6 +287,18 @@ type storeIface interface { createImportSchedule(ctx context.Context, sched *ImportSchedule) error deleteImportSchedule(ctx context.Context, id, userID string) error + // Property & Loan + getProperties(ctx context.Context, userID string) ([]Property, error) + getProperty(ctx context.Context, id, userID string) (*Property, error) + createProperty(ctx context.Context, p *Property) error + updateProperty(ctx context.Context, id, userID string, update bson.M) error + deleteProperty(ctx context.Context, id, userID string) error + getLoans(ctx context.Context, userID string) ([]Loan, error) + getLoan(ctx context.Context, id, userID string) (*Loan, error) + createLoan(ctx context.Context, l *Loan) error + updateLoan(ctx context.Context, id, userID string, update bson.M) error + deleteLoan(ctx context.Context, id, userID string) error + // Org getOrgsForUser(ctx context.Context, userID string) ([]OrgWithRole, error) getOrg(ctx context.Context, orgID string) (*Org, error) @@ -2691,6 +2703,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /tax", h.Tax) mux.HandleFunc("GET /tax/export.csv", h.TaxExport) mux.HandleFunc("GET /auto-import", h.AutoImport) + mux.HandleFunc("GET /property", h.Properties) + mux.HandleFunc("POST /property", h.Properties) h.RegisterOrgRoutes(mux) } diff --git a/apps/finance/services/api/main/handler_property.go b/apps/finance/services/api/main/handler_property.go new file mode 100644 index 0000000..e4f0afa --- /dev/null +++ b/apps/finance/services/api/main/handler_property.go @@ -0,0 +1,298 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "math" + "net/http" + "strconv" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +var propertyTmpl = parseTmpl("templates/property.html") + +// ── Amortization helpers ────────────────────────────────────────────────────── + +// loanMonthlyPayment computes the fixed EMI for a standard amortising loan. +// annualRatePct is e.g. 3.2 for 3.2%. +func loanMonthlyPayment(principalCents int64, annualRatePct float64, termMonths int) int64 { + if termMonths <= 0 { + return 0 + } + if annualRatePct == 0 { + return principalCents / int64(termMonths) + } + r := annualRatePct / 12 / 100 + factor := math.Pow(1+r, float64(termMonths)) + return int64(math.Round(float64(principalCents) * r * factor / (factor - 1))) +} + +// loanRemainingMonths estimates months to pay off balance at a given monthly payment. +func loanRemainingMonths(balanceCents int64, annualRatePct float64, monthlyPaymentCents int64) int { + if monthlyPaymentCents <= 0 || balanceCents <= 0 { + return 0 + } + if annualRatePct == 0 { + return int(math.Ceil(float64(balanceCents) / float64(monthlyPaymentCents))) + } + r := annualRatePct / 12 / 100 + rB := r * float64(balanceCents) + M := float64(monthlyPaymentCents) + if rB >= M { + return 999 // monthly payment doesn't cover interest + } + return int(math.Ceil(-math.Log(1-rB/M) / math.Log(1+r))) +} + +func toLoanView(l Loan) LoanView { + monthly := l.MonthlyPaymentCents + if monthly == 0 { + monthly = loanMonthlyPayment(l.PrincipalCents, l.InterestRatePct, l.TermMonths) + } + remaining := loanRemainingMonths(l.BalanceCents, l.InterestRatePct, monthly) + payoff := time.Now().AddDate(0, remaining, 0) + + totalRemainingInterest := monthly*int64(remaining) - l.BalanceCents + if totalRemainingInterest < 0 { + totalRemainingInterest = 0 + } + paid := l.PrincipalCents - l.BalanceCents + if paid < 0 { + paid = 0 + } + var paidPct int64 + if l.PrincipalCents > 0 { + paidPct = paid * 100 / l.PrincipalCents + } + return LoanView{ + Loan: l, + EffectiveMonthlyPaymentCents: monthly, + RemainingMonths: remaining, + PayoffDate: payoff, + TotalRemainingInterestCents: totalRemainingInterest, + PaidSoFarCents: paid, + PaidPct: paidPct, + } +} + +func toPropertyView(p Property, allLoans []Loan) PropertyView { + equityCents := p.CurrentValueCents + var linked *LoanView + for _, l := range allLoans { + if l.PropertyID == p.ID && l.Status == LoanActive { + v := toLoanView(l) + linked = &v + equityCents -= l.BalanceCents + } + } + gain := p.CurrentValueCents - p.PurchasePriceCents + gainPct := 0.0 + if p.PurchasePriceCents > 0 { + gainPct = float64(gain) / float64(p.PurchasePriceCents) * 100 + } + var equityPct int64 + if p.CurrentValueCents > 0 && equityCents > 0 { + equityPct = equityCents * 100 / p.CurrentValueCents + } + labels := map[PropertyStatus]string{ + PropertyOwned: "Owned", + PropertyBuilding: "Building", + PropertySold: "Sold", + } + return PropertyView{ + Property: p, + LinkedLoan: linked, + EquityCents: equityCents, + GainCents: gain, + GainPct: gainPct, + EquityPct: equityPct, + LoanPct: 100 - equityPct, + StatusLabel: labels[p.Status], + } +} + +// ── Handler ─────────────────────────────────────────────────────────────────── + +func (h *Handler) Properties(w http.ResponseWriter, r *http.Request) { + auth := h.getAuth(r) + if auth.UserID == "" { + http.Redirect(w, r, "/auth/login?next=/property", http.StatusSeeOther) + return + } + + switch r.Method { + case http.MethodGet: + h.propertiesGET(w, r, auth) + case http.MethodPost: + h.propertiesPOST(w, r, auth) + } +} + +func (h *Handler) propertiesGET(w http.ResponseWriter, r *http.Request, auth authInfo) { + ctx := r.Context() + props, _ := h.store.getProperties(ctx, auth.UserID) + loans, _ := h.store.getLoans(ctx, auth.UserID) + + propIDs := map[string]bool{} + var views []PropertyView + var totalValue, totalLoan int64 + + for _, p := range props { + propIDs[p.ID] = true + v := toPropertyView(p, loans) + views = append(views, v) + if p.Status != PropertySold { + totalValue += p.CurrentValueCents + if v.LinkedLoan != nil { + totalLoan += v.LinkedLoan.BalanceCents + } + } + } + + var unlinked []LoanView + for _, l := range loans { + if l.Status == LoanActive && !propIDs[l.PropertyID] { + v := toLoanView(l) + unlinked = append(unlinked, v) + totalLoan += l.BalanceCents + } + } + + render(w, propertyTmpl, PropertyData{ + UserID: auth.UserID, + Email: auth.Email, + Title: "Property", + Route: "property", + Properties: views, + UnlinkedLoans: unlinked, + TotalPropertyValueCents: totalValue, + TotalLoanBalanceCents: totalLoan, + TotalEquityCents: totalValue - totalLoan, + }) +} + +func (h *Handler) propertiesPOST(w http.ResponseWriter, r *http.Request, auth authInfo) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + ctx := r.Context() + action := r.FormValue("action") + + switch action { + case "add_property": + purchasePrice := parseFormCents(r.FormValue("purchase_price")) + currentValue := parseFormCents(r.FormValue("current_value")) + if currentValue == 0 { + currentValue = purchasePrice + } + appPct, _ := strconv.ParseFloat(r.FormValue("appreciation_pct"), 64) + purchaseDate, _ := time.Parse("2006-01-02", r.FormValue("purchase_date")) + + p := &Property{ + ID: genID(), + UserID: auth.UserID, + Name: strings.TrimSpace(r.FormValue("name")), + Address: strings.TrimSpace(r.FormValue("address")), + PurchasePriceCents: purchasePrice, + CurrentValueCents: currentValue, + AppreciationPct: appPct, + PurchaseDate: purchaseDate, + Status: PropertyStatus(r.FormValue("status")), + Notes: strings.TrimSpace(r.FormValue("notes")), + } + if p.Status == "" { + p.Status = PropertyOwned + } + _ = h.store.createProperty(ctx, p) + + case "update_property": + id := r.FormValue("id") + purchasePrice := parseFormCents(r.FormValue("purchase_price")) + currentValue := parseFormCents(r.FormValue("current_value")) + appPct, _ := strconv.ParseFloat(r.FormValue("appreciation_pct"), 64) + purchaseDate, _ := time.Parse("2006-01-02", r.FormValue("purchase_date")) + _ = h.store.updateProperty(ctx, id, auth.UserID, bson.M{ + "name": strings.TrimSpace(r.FormValue("name")), + "address": strings.TrimSpace(r.FormValue("address")), + "purchase_price_cents": purchasePrice, + "current_value_cents": currentValue, + "appreciation_pct": appPct, + "purchase_date": purchaseDate, + "status": PropertyStatus(r.FormValue("status")), + "notes": strings.TrimSpace(r.FormValue("notes")), + }) + + case "delete_property": + _ = h.store.deleteProperty(ctx, r.FormValue("id"), auth.UserID) + + case "add_loan": + principalCents := parseFormCents(r.FormValue("principal")) + balanceCents := parseFormCents(r.FormValue("balance")) + if balanceCents == 0 { + balanceCents = principalCents + } + ratePct, _ := strconv.ParseFloat(r.FormValue("interest_rate"), 64) + termMonths, _ := strconv.Atoi(r.FormValue("term_months")) + monthlyCents := parseFormCents(r.FormValue("monthly_payment")) + startDate, _ := time.Parse("2006-01-02", r.FormValue("start_date")) + + l := &Loan{ + ID: genID(), + UserID: auth.UserID, + PropertyID: r.FormValue("property_id"), + Name: strings.TrimSpace(r.FormValue("name")), + Type: LoanType(r.FormValue("loan_type")), + PrincipalCents: principalCents, + BalanceCents: balanceCents, + InterestRatePct: ratePct, + TermMonths: termMonths, + StartDate: startDate, + MonthlyPaymentCents: monthlyCents, + Status: LoanActive, + Notes: strings.TrimSpace(r.FormValue("notes")), + } + if l.Type == "" { + l.Type = LoanMortgage + } + _ = h.store.createLoan(ctx, l) + + case "update_loan_balance": + id := r.FormValue("id") + balanceCents := parseFormCents(r.FormValue("balance")) + _ = h.store.updateLoan(ctx, id, auth.UserID, bson.M{ + "balance_cents": balanceCents, + }) + + case "payoff_loan": + _ = h.store.updateLoan(ctx, r.FormValue("id"), auth.UserID, bson.M{ + "status": LoanPaidOff, + }) + + case "delete_loan": + _ = h.store.deleteLoan(ctx, r.FormValue("id"), auth.UserID) + } + + http.Redirect(w, r, "/property", http.StatusSeeOther) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func genID() string { + b := make([]byte, 12) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// parseFormCents converts a plain euro amount string (e.g. "180000" or "180000.50") to cents. +func parseFormCents(s string) int64 { + s = strings.ReplaceAll(s, ",", ".") + f, err := strconv.ParseFloat(strings.TrimSpace(s), 64) + if err != nil { + return 0 + } + return int64(math.Round(f * 100)) +} diff --git a/apps/finance/services/api/main/handler_test.go b/apps/finance/services/api/main/handler_test.go index eb6810d..01cfe3f 100644 --- a/apps/finance/services/api/main/handler_test.go +++ b/apps/finance/services/api/main/handler_test.go @@ -170,6 +170,19 @@ func (m *mockStore) getImportSchedules(_ context.Context, _ string) ([]ImportSch func (m *mockStore) createImportSchedule(_ context.Context, _ *ImportSchedule) error { return nil } func (m *mockStore) deleteImportSchedule(_ context.Context, _, _ string) error { return nil } +// ── Property & Loan stubs ───────────────────────────────────────────────────── + +func (m *mockStore) getProperties(_ context.Context, _ string) ([]Property, error) { return nil, nil } +func (m *mockStore) getProperty(_ context.Context, _, _ string) (*Property, error) { return nil, nil } +func (m *mockStore) createProperty(_ context.Context, _ *Property) error { return nil } +func (m *mockStore) updateProperty(_ context.Context, _, _ string, _ bson.M) error { return nil } +func (m *mockStore) deleteProperty(_ context.Context, _, _ string) error { return nil } +func (m *mockStore) getLoans(_ context.Context, _ string) ([]Loan, error) { return nil, nil } +func (m *mockStore) getLoan(_ context.Context, _, _ string) (*Loan, error) { return nil, nil } +func (m *mockStore) createLoan(_ context.Context, _ *Loan) error { return nil } +func (m *mockStore) updateLoan(_ context.Context, _, _ string, _ bson.M) error { return nil } +func (m *mockStore) deleteLoan(_ context.Context, _, _ string) error { return nil } + // ── Org stubs (not exercised in unit tests) ─────────────────────────────────── func (m *mockStore) getOrgsForUser(_ context.Context, _ string) ([]OrgWithRole, error) { diff --git a/apps/finance/services/api/main/models_property.go b/apps/finance/services/api/main/models_property.go new file mode 100644 index 0000000..090a4da --- /dev/null +++ b/apps/finance/services/api/main/models_property.go @@ -0,0 +1,94 @@ +package main + +import "time" + +type PropertyStatus string + +const ( + PropertyOwned PropertyStatus = "owned" + PropertyBuilding PropertyStatus = "building" + PropertySold PropertyStatus = "sold" +) + +type LoanType string + +const ( + LoanMortgage LoanType = "mortgage" + LoanConstruction LoanType = "construction" + LoanPersonal LoanType = "personal" +) + +type LoanStatus string + +const ( + LoanActive LoanStatus = "active" + LoanPaidOff LoanStatus = "paid_off" +) + +type Property struct { + ID string `bson:"_id" json:"id"` + UserID string `bson:"user_id" json:"user_id"` + Name string `bson:"name" json:"name"` + Address string `bson:"address,omitempty" json:"address,omitempty"` + PurchasePriceCents int64 `bson:"purchase_price_cents" json:"purchase_price_cents"` + CurrentValueCents int64 `bson:"current_value_cents" json:"current_value_cents"` + AppreciationPct float64 `bson:"appreciation_pct" json:"appreciation_pct"` // annual % + PurchaseDate time.Time `bson:"purchase_date" json:"purchase_date"` + Status PropertyStatus `bson:"status" json:"status"` + Notes string `bson:"notes,omitempty" json:"notes,omitempty"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +type Loan struct { + ID string `bson:"_id" json:"id"` + UserID string `bson:"user_id" json:"user_id"` + PropertyID string `bson:"property_id,omitempty" json:"property_id,omitempty"` + Name string `bson:"name" json:"name"` + Type LoanType `bson:"type" json:"type"` + PrincipalCents int64 `bson:"principal_cents" json:"principal_cents"` + BalanceCents int64 `bson:"balance_cents" json:"balance_cents"` + InterestRatePct float64 `bson:"interest_rate_pct" json:"interest_rate_pct"` // annual % + TermMonths int `bson:"term_months" json:"term_months"` + StartDate time.Time `bson:"start_date" json:"start_date"` + MonthlyPaymentCents int64 `bson:"monthly_payment_cents" json:"monthly_payment_cents"` // 0 = computed + Status LoanStatus `bson:"status" json:"status"` + Notes string `bson:"notes,omitempty" json:"notes,omitempty"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} + +// LoanView enriches a Loan with computed amortization fields — never stored. +type LoanView struct { + Loan + EffectiveMonthlyPaymentCents int64 + RemainingMonths int + PayoffDate time.Time + TotalRemainingInterestCents int64 + PaidSoFarCents int64 + PaidPct int64 // int64 so the "sub" template func works +} + +// PropertyView enriches a Property with equity and linked loan — never stored. +type PropertyView struct { + Property + LinkedLoan *LoanView + EquityCents int64 + GainCents int64 + GainPct float64 + EquityPct int64 // int64 so the "sub" template func works + LoanPct int64 // 100 - EquityPct, pre-computed for the template + StatusLabel string +} + +type PropertyData struct { + UserID string + Email string + Title string + Route string + + Properties []PropertyView + UnlinkedLoans []LoanView // active loans not attached to any property + + TotalPropertyValueCents int64 + TotalLoanBalanceCents int64 + TotalEquityCents int64 +} diff --git a/apps/finance/services/api/main/store_property.go b/apps/finance/services/api/main/store_property.go new file mode 100644 index 0000000..8819fe8 --- /dev/null +++ b/apps/finance/services/api/main/store_property.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +func (s *Store) getProperties(ctx context.Context, userID string) ([]Property, error) { + cur, err := s.db.Collection("finance_properties").Find(ctx, + bson.M{"user_id": userID}, + options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}), + ) + if err != nil { + return nil, err + } + var out []Property + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func (s *Store) getProperty(ctx context.Context, id, userID string) (*Property, error) { + var p Property + err := s.db.Collection("finance_properties").FindOne(ctx, + bson.M{"_id": id, "user_id": userID}, + ).Decode(&p) + if err != nil { + return nil, err + } + return &p, nil +} + +func (s *Store) createProperty(ctx context.Context, p *Property) error { + p.CreatedAt = time.Now() + _, err := s.db.Collection("finance_properties").InsertOne(ctx, p) + return err +} + +func (s *Store) updateProperty(ctx context.Context, id, userID string, update bson.M) error { + _, err := s.db.Collection("finance_properties").UpdateOne(ctx, + bson.M{"_id": id, "user_id": userID}, + bson.M{"$set": update}, + ) + return err +} + +func (s *Store) deleteProperty(ctx context.Context, id, userID string) error { + _, err := s.db.Collection("finance_properties").DeleteOne(ctx, + bson.M{"_id": id, "user_id": userID}, + ) + return err +} + +func (s *Store) getLoans(ctx context.Context, userID string) ([]Loan, error) { + cur, err := s.db.Collection("finance_loans").Find(ctx, + bson.M{"user_id": userID}, + options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}), + ) + if err != nil { + return nil, err + } + var out []Loan + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +func (s *Store) getLoan(ctx context.Context, id, userID string) (*Loan, error) { + var l Loan + err := s.db.Collection("finance_loans").FindOne(ctx, + bson.M{"_id": id, "user_id": userID}, + ).Decode(&l) + if err != nil { + return nil, err + } + return &l, nil +} + +func (s *Store) createLoan(ctx context.Context, l *Loan) error { + l.CreatedAt = time.Now() + _, err := s.db.Collection("finance_loans").InsertOne(ctx, l) + return err +} + +func (s *Store) updateLoan(ctx context.Context, id, userID string, update bson.M) error { + _, err := s.db.Collection("finance_loans").UpdateOne(ctx, + bson.M{"_id": id, "user_id": userID}, + bson.M{"$set": update}, + ) + return err +} + +func (s *Store) deleteLoan(ctx context.Context, id, userID string) error { + _, err := s.db.Collection("finance_loans").DeleteOne(ctx, + bson.M{"_id": id, "user_id": userID}, + ) + return err +} diff --git a/apps/finance/services/api/main/templates/base.html b/apps/finance/services/api/main/templates/base.html index de58781..dc48064 100644 --- a/apps/finance/services/api/main/templates/base.html +++ b/apps/finance/services/api/main/templates/base.html @@ -578,6 +578,7 @@ Transactions Portfolio Goals + Property {{$analysisActive := or (eq .Route "reports") (eq .Route "projections") (eq .Route "networth") (eq .Route "simulator") (eq .Route "tax")}}