feat(finance): Layer 1 — Property & Loan foundation (#29)

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 <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gonçalo Rodrigues 2026-06-15 22:40:57 +01:00 committed by GitHub
parent 05dd725579
commit 4305a77612
7 changed files with 991 additions and 0 deletions

View File

@ -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)
}

View File

@ -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))
}

View File

@ -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) {

View File

@ -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
}

View File

@ -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
}

View File

@ -578,6 +578,7 @@
<a href="/transactions" class="{{if eq .Route "transactions"}}active{{end}}">Transactions</a>
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
<a href="/property" class="{{if eq .Route "property"}}active{{end}}">Property</a>
{{$analysisActive := or (eq .Route "reports") (eq .Route "projections") (eq .Route "networth") (eq .Route "simulator") (eq .Route "tax")}}
<div class="nav-group">
@ -622,6 +623,7 @@
<a href="/transactions" class="{{if eq .Route "transactions"}}active{{end}}">Transactions</a>
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
<a href="/property" class="{{if eq .Route "property"}}active{{end}}">Property</a>
<a href="/people" class="{{if eq .Route "people"}}active{{end}}">People</a>
<hr>
<span class="nav-drawer-section-label">Analysis</span>

View File

@ -0,0 +1,467 @@
{{template "base.html" .}}
{{define "content"}}
{{$d := .}}
<style>
.prop-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:16px; }
.prop-card {
background:var(--surface); border:1px solid var(--border);
border-radius:var(--radius); padding:22px; position:relative;
transition:border-color 0.18s;
}
.prop-card:hover { border-color:var(--border2); }
.prop-badge {
display:inline-block; font-size:11px; font-weight:600; padding:2px 8px;
border-radius:20px; margin-bottom:12px; text-transform:uppercase; letter-spacing:.5px;
}
.badge-owned { background:rgba(20,184,166,.12); color:#14b8a6; border:1px solid rgba(20,184,166,.25); }
.badge-building { background:rgba(245,158,11,.12); color:#f59e0b; border:1px solid rgba(245,158,11,.25); }
.badge-sold { background:rgba(148,163,184,.1); color:var(--text3); border:1px solid var(--border); }
.prop-name { font-size:17px; font-weight:700; margin:0 0 2px; color:var(--text); }
.prop-addr { font-size:12px; color:var(--text3); margin:0 0 16px; }
.prop-stats { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-bottom:16px; }
.prop-stat { background:var(--surface2); border-radius:8px; padding:10px 12px; }
.prop-stat-label { font-size:11px; color:var(--text3); margin-bottom:3px; }
.prop-stat-value { font-size:15px; font-weight:700; color:var(--text); }
.prop-stat-value.positive { color:var(--green); }
.prop-stat-value.negative { color:var(--red); }
.equity-bar-wrap { margin-bottom:16px; }
.equity-bar-label { display:flex; justify-content:space-between; font-size:12px; color:var(--text3); margin-bottom:5px; }
.equity-bar { height:6px; background:var(--surface2); border-radius:3px; overflow:hidden; }
.equity-bar-fill { height:100%; border-radius:3px; background:linear-gradient(90deg,#14b8a6,#6366f1); transition:width .6s ease; }
.loan-mini {
background:var(--surface2); border-radius:8px; padding:12px 14px;
font-size:12px; color:var(--text2); margin-bottom:12px;
}
.loan-mini-title { font-weight:600; color:var(--text); margin-bottom:6px; font-size:13px; }
.loan-mini-row { display:flex; justify-content:space-between; margin-bottom:3px; }
.loan-progress { height:4px; background:var(--surface); border-radius:2px; margin-top:8px; overflow:hidden; }
.loan-progress-fill { height:100%; background:var(--accent2); border-radius:2px; }
.prop-actions { display:flex; gap:8px; flex-wrap:wrap; }
.loan-card {
background:var(--surface); border:1px solid var(--border);
border-radius:var(--radius); padding:20px; transition:border-color .18s;
}
.loan-card:hover { border-color:var(--border2); }
.loan-type-badge {
display:inline-block; font-size:11px; font-weight:600; padding:2px 8px;
border-radius:20px; margin-bottom:10px; text-transform:capitalize;
background:rgba(99,102,241,.1); color:#6366f1; border:1px solid rgba(99,102,241,.2);
}
/* summary cards */
.summary-row { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-bottom:28px; }
@media(max-width:600px){ .summary-row{grid-template-columns:1fr;} .prop-grid{grid-template-columns:1fr;} }
/* section header */
.section-header { display:flex; align-items:center; justify-content:space-between; margin:28px 0 14px; }
.section-title { font-size:16px; font-weight:700; color:var(--text); }
/* modal */
.modal-overlay {
display:none; position:fixed; inset:0; z-index:1000;
background:rgba(0,0,0,.6); backdrop-filter:blur(4px);
align-items:center; justify-content:center; padding:16px;
}
.modal-box {
background:var(--surface); border:1px solid var(--border2);
border-radius:var(--radius); padding:28px; width:100%; max-width:480px;
max-height:90vh; overflow-y:auto;
}
.modal-title { font-size:17px; font-weight:700; margin:0 0 20px; }
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
@media(max-width:500px){ .form-row{grid-template-columns:1fr;} }
.form-group { display:flex; flex-direction:column; gap:5px; margin-bottom:14px; }
.form-group label { font-size:12px; color:var(--text3); font-weight:500; }
.form-group input, .form-group select, .form-group textarea {
background:var(--surface2); border:1px solid var(--border);
border-radius:8px; padding:9px 12px; color:var(--text);
font-size:14px; outline:none; transition:border-color .18s; width:100%; box-sizing:border-box;
}
.form-group input:focus, .form-group select:focus { border-color:var(--accent2); }
.form-hint { font-size:11px; color:var(--text3); margin-top:2px; }
.modal-footer { display:flex; gap:10px; justify-content:flex-end; margin-top:8px; }
</style>
<!-- ── Summary ── -->
<div class="summary-row">
<div class="card value-card animate-on-scroll">
<h2>Total property value</h2>
<div class="value animate-counter" data-target="{{$d.TotalPropertyValueCents}}" data-prefix="€">€0</div>
<p style="font-size:12px;color:var(--text3);margin-top:4px;">current estimated value</p>
</div>
<div class="card value-card animate-on-scroll">
<h2>Outstanding loans</h2>
<div class="value animate-counter" data-target="{{$d.TotalLoanBalanceCents}}" data-prefix="€" style="color:var(--red);">€0</div>
<p style="font-size:12px;color:var(--text3);margin-top:4px;">remaining balance</p>
</div>
<div class="card value-card animate-on-scroll">
<h2>Net equity</h2>
<div class="value animate-counter" data-target="{{$d.TotalEquityCents}}" data-prefix="€"
style="color:{{if ge $d.TotalEquityCents 0}}var(--green){{else}}var(--red){{end}};">€0</div>
<p style="font-size:12px;color:var(--text3);margin-top:4px;">value loans</p>
</div>
</div>
<!-- ── Properties ── -->
<div class="section-header">
<span class="section-title">Properties</span>
<button class="btn btn-primary btn-sm" onclick="openModal('add-property-modal')">+ Add property</button>
</div>
{{if $d.Properties}}
<div class="prop-grid">
{{range $d.Properties}}
<div class="prop-card animate-on-scroll">
<span class="prop-badge badge-{{.Status}}">{{.StatusLabel}}</span>
<p class="prop-name">{{.Name}}</p>
{{if .Address}}<p class="prop-addr">📍 {{.Address}}</p>{{end}}
<div class="prop-stats">
<div class="prop-stat">
<div class="prop-stat-label">Current value</div>
<div class="prop-stat-value">€{{cents .CurrentValueCents}}</div>
</div>
<div class="prop-stat">
<div class="prop-stat-label">Equity</div>
<div class="prop-stat-value {{if ge .EquityCents 0}}positive{{else}}negative{{end}}">
€{{cents .EquityCents}}
</div>
</div>
<div class="prop-stat">
<div class="prop-stat-label">Purchase price</div>
<div class="prop-stat-value">€{{cents .PurchasePriceCents}}</div>
</div>
<div class="prop-stat">
<div class="prop-stat-label">Gain</div>
<div class="prop-stat-value {{if ge .GainCents 0}}positive{{else}}negative{{end}}">
{{printf "%.1f" .GainPct}}%
</div>
</div>
</div>
{{if .LinkedLoan}}
<div class="equity-bar-wrap">
<div class="equity-bar-label">
<span>Equity {{.EquityPct}}%</span>
<span>Loan {{.LoanPct}}%</span>
</div>
<div class="equity-bar">
<div class="equity-bar-fill" style="width:{{.EquityPct}}%;"></div>
</div>
</div>
<div class="loan-mini">
<div class="loan-mini-title">🏦 {{.LinkedLoan.Name}}</div>
<div class="loan-mini-row"><span>Remaining</span><span style="font-weight:600;color:var(--red);">€{{cents .LinkedLoan.BalanceCents}}</span></div>
<div class="loan-mini-row"><span>Monthly payment</span><span>€{{cents .LinkedLoan.EffectiveMonthlyPaymentCents}}</span></div>
<div class="loan-mini-row"><span>Payoff</span><span>{{.LinkedLoan.PayoffDate.Format "Jan 2006"}}</span></div>
<div class="loan-mini-row"><span>Rate</span><span>{{printf "%.2f" .LinkedLoan.InterestRatePct}}%</span></div>
<div class="loan-progress">
<div class="loan-progress-fill" style="width:{{.LinkedLoan.PaidPct}}%;"></div>
</div>
</div>
{{end}}
<div class="prop-actions">
<button class="btn btn-outline btn-sm"
onclick="openEditProperty('{{.ID}}','{{.Name}}','{{.Address}}',{{.PurchasePriceCents}},{{.CurrentValueCents}},{{printf "%.2f" .AppreciationPct}},'{{.PurchaseDate.Format "2006-01-02"}}','{{.Status}}','{{.Notes}}')">
Edit
</button>
<form method="POST" action="/property" style="display:inline;">
<input type="hidden" name="action" value="delete_property">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--red);border-color:var(--red)33;"
onclick="return confirm('Remove {{.Name}}?')">Remove</button>
</form>
</div>
</div>
{{end}}
</div>
{{else}}
<div class="card empty-state animate-on-scroll">
<div style="font-size:48px;margin-bottom:16px;">🏠</div>
<h3>No properties yet</h3>
<p style="margin-bottom:20px;">Add your home, investment property, or land to track equity and loans in one place.</p>
<button class="btn btn-primary" onclick="openModal('add-property-modal')">Add your first property</button>
</div>
{{end}}
<!-- ── Standalone loans ── -->
{{if or $d.UnlinkedLoans (not $d.Properties)}}
<div class="section-header" style="margin-top:32px;">
<span class="section-title">Loans</span>
<button class="btn btn-outline btn-sm" onclick="openModal('add-loan-modal')">+ Add loan</button>
</div>
{{end}}
{{if $d.UnlinkedLoans}}
<div class="prop-grid">
{{range $d.UnlinkedLoans}}
<div class="loan-card animate-on-scroll">
<span class="loan-type-badge">{{.Type}}</span>
<p class="prop-name" style="margin-bottom:4px;">{{.Name}}</p>
<div class="prop-stats" style="margin-top:12px;">
<div class="prop-stat">
<div class="prop-stat-label">Balance</div>
<div class="prop-stat-value negative">€{{cents .BalanceCents}}</div>
</div>
<div class="prop-stat">
<div class="prop-stat-label">Monthly</div>
<div class="prop-stat-value">€{{cents .EffectiveMonthlyPaymentCents}}</div>
</div>
<div class="prop-stat">
<div class="prop-stat-label">Payoff</div>
<div class="prop-stat-value">{{.PayoffDate.Format "Jan 2006"}}</div>
</div>
<div class="prop-stat">
<div class="prop-stat-label">Rate</div>
<div class="prop-stat-value">{{printf "%.2f" .InterestRatePct}}%</div>
</div>
</div>
<div class="equity-bar-wrap">
<div class="equity-bar-label">
<span>Paid {{.PaidPct}}%</span>
<span>€{{cents .TotalRemainingInterestCents}} interest left</span>
</div>
<div class="equity-bar">
<div class="equity-bar-fill" style="width:{{.PaidPct}}%;"></div>
</div>
</div>
<div class="prop-actions">
<form method="POST" action="/property" style="display:inline;">
<input type="hidden" name="action" value="payoff_loan">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--green);border-color:var(--green)33;"
onclick="return confirm('Mark as paid off?')">Mark paid off</button>
</form>
<form method="POST" action="/property" style="display:inline;">
<input type="hidden" name="action" value="delete_loan">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--red);border-color:var(--red)33;"
onclick="return confirm('Remove {{.Name}}?')">Remove</button>
</form>
</div>
</div>
{{end}}
</div>
{{end}}
{{if $d.Properties}}
<div style="margin-top:24px;">
<button class="btn btn-outline btn-sm" onclick="openModal('add-loan-modal')">+ Add loan</button>
</div>
{{end}}
<!-- ── Add Property Modal ── -->
<div id="add-property-modal" class="modal-overlay" onclick="if(event.target===this)closeModal('add-property-modal')">
<div class="modal-box">
<p class="modal-title">Add property</p>
<form method="POST" action="/property">
<input type="hidden" name="action" value="add_property">
<div class="form-group">
<label>Property name *</label>
<input type="text" name="name" placeholder="e.g. Main House" required>
</div>
<div class="form-group">
<label>Address</label>
<input type="text" name="address" placeholder="Street, city">
</div>
<div class="form-row">
<div class="form-group">
<label>Purchase price (€) *</label>
<input type="number" name="purchase_price" placeholder="220000" step="0.01" required>
</div>
<div class="form-group">
<label>Current value (€)</label>
<input type="number" name="current_value" placeholder="Same as purchase" step="0.01">
<span class="form-hint">Leave blank to use purchase price</span>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Purchase date</label>
<input type="date" name="purchase_date">
</div>
<div class="form-group">
<label>Est. appreciation (%/year)</label>
<input type="number" name="appreciation_pct" placeholder="2.0" step="0.1" value="2.0">
</div>
</div>
<div class="form-group">
<label>Status</label>
<select name="status">
<option value="owned">Owned</option>
<option value="building">Under construction</option>
<option value="sold">Sold</option>
</select>
</div>
<div class="form-group">
<label>Notes</label>
<textarea name="notes" rows="2" placeholder="Optional notes"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" onclick="closeModal('add-property-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Add property</button>
</div>
</form>
</div>
</div>
<!-- ── Edit Property Modal ── -->
<div id="edit-property-modal" class="modal-overlay" onclick="if(event.target===this)closeModal('edit-property-modal')">
<div class="modal-box">
<p class="modal-title">Edit property</p>
<form method="POST" action="/property" id="edit-property-form">
<input type="hidden" name="action" value="update_property">
<input type="hidden" name="id" id="edit-prop-id">
<div class="form-group">
<label>Property name *</label>
<input type="text" name="name" id="edit-prop-name" required>
</div>
<div class="form-group">
<label>Address</label>
<input type="text" name="address" id="edit-prop-addr">
</div>
<div class="form-row">
<div class="form-group">
<label>Purchase price (€)</label>
<input type="number" name="purchase_price" id="edit-prop-purchase" step="0.01">
</div>
<div class="form-group">
<label>Current value (€)</label>
<input type="number" name="current_value" id="edit-prop-value" step="0.01">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Purchase date</label>
<input type="date" name="purchase_date" id="edit-prop-date">
</div>
<div class="form-group">
<label>Appreciation (%/year)</label>
<input type="number" name="appreciation_pct" id="edit-prop-appr" step="0.1">
</div>
</div>
<div class="form-group">
<label>Status</label>
<select name="status" id="edit-prop-status">
<option value="owned">Owned</option>
<option value="building">Under construction</option>
<option value="sold">Sold</option>
</select>
</div>
<div class="form-group">
<label>Notes</label>
<textarea name="notes" id="edit-prop-notes" rows="2"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" onclick="closeModal('edit-property-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Save changes</button>
</div>
</form>
</div>
</div>
<!-- ── Add Loan Modal ── -->
<div id="add-loan-modal" class="modal-overlay" onclick="if(event.target===this)closeModal('add-loan-modal')">
<div class="modal-box">
<p class="modal-title">Add loan</p>
<form method="POST" action="/property">
<input type="hidden" name="action" value="add_loan">
<div class="form-group">
<label>Loan name *</label>
<input type="text" name="name" placeholder="e.g. Home mortgage" required>
</div>
<div class="form-row">
<div class="form-group">
<label>Type</label>
<select name="loan_type">
<option value="mortgage">Mortgage</option>
<option value="construction">Construction loan</option>
<option value="personal">Personal loan</option>
</select>
</div>
<div class="form-group">
<label>Linked property</label>
<select name="property_id">
<option value="">— none —</option>
{{range $d.Properties}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Original principal (€) *</label>
<input type="number" name="principal" placeholder="200000" step="0.01" required>
</div>
<div class="form-group">
<label>Current balance (€)</label>
<input type="number" name="balance" placeholder="Same as principal" step="0.01">
<span class="form-hint">Leave blank if just starting</span>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Interest rate (%/year) *</label>
<input type="number" name="interest_rate" placeholder="3.2" step="0.01" required>
</div>
<div class="form-group">
<label>Term (months) *</label>
<input type="number" name="term_months" placeholder="360" required>
<span class="form-hint">e.g. 360 = 30 years</span>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Monthly payment (€)</label>
<input type="number" name="monthly_payment" placeholder="Auto-computed" step="0.01">
<span class="form-hint">Leave blank to calculate</span>
</div>
<div class="form-group">
<label>Start date</label>
<input type="date" name="start_date">
</div>
</div>
<div class="form-group">
<label>Notes</label>
<textarea name="notes" rows="2" placeholder="Bank name, reference, etc."></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" onclick="closeModal('add-loan-modal')">Cancel</button>
<button type="submit" class="btn btn-primary">Add loan</button>
</div>
</form>
</div>
</div>
<script>
function openModal(id) { document.getElementById(id).style.display = 'flex'; }
function closeModal(id) { document.getElementById(id).style.display = 'none'; }
function openEditProperty(id, name, addr, purchaseCents, valueCents, appr, date, status, notes) {
document.getElementById('edit-prop-id').value = id;
document.getElementById('edit-prop-name').value = name;
document.getElementById('edit-prop-addr').value = addr;
document.getElementById('edit-prop-purchase').value = (purchaseCents / 100).toFixed(2);
document.getElementById('edit-prop-value').value = (valueCents / 100).toFixed(2);
document.getElementById('edit-prop-appr').value = appr;
document.getElementById('edit-prop-date').value = date;
document.getElementById('edit-prop-status').value = status;
document.getElementById('edit-prop-notes').value = notes;
openModal('edit-property-modal');
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
['add-property-modal','edit-property-modal','add-loan-modal'].forEach(closeModal);
}
});
</script>
{{end}}