feat: implement Tax Summary, Household Mode, and Auto Import

- Tax Summary (/tax): annual gross income from Income transactions,
  FIFO capital gains/losses from trades, expenses by category, CSV export
- Household Mode (/household): link a partner account, combined
  income/expense/disposable view for current month, shared goals list
- Auto Import (/auto-import): schedule recurring CSV imports with
  account, format and optional source URL; delete schedules; webhook docs
- New store methods for households and import_schedules collections
- storeIface extended; mockStore updated; all tests still pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gonçalo Rodrigues 2026-06-13 17:28:22 +01:00
parent 9dfc95cd32
commit 1c2bac1d5f
8 changed files with 908 additions and 0 deletions

View File

@ -11,6 +11,7 @@ import (
"math" "math"
"net/http" "net/http"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@ -119,6 +120,9 @@ var (
goalsTmpl = parseTmpl("templates/base.html", "templates/goals.html") goalsTmpl = parseTmpl("templates/base.html", "templates/goals.html")
networthTmpl = parseTmpl("templates/base.html", "templates/networth.html") networthTmpl = parseTmpl("templates/base.html", "templates/networth.html")
simulatorTmpl = parseTmpl("templates/base.html", "templates/simulator.html") simulatorTmpl = parseTmpl("templates/base.html", "templates/simulator.html")
taxTmpl = parseTmpl("templates/base.html", "templates/tax.html")
householdTmpl = parseTmpl("templates/base.html", "templates/household.html")
autoImportTmpl = parseTmpl("templates/base.html", "templates/auto_import.html")
) )
type authInfo struct { type authInfo struct {
@ -178,6 +182,12 @@ type storeIface interface {
updateGoal(ctx context.Context, id, userID string, update bson.M) error updateGoal(ctx context.Context, id, userID string, update bson.M) error
deleteGoal(ctx context.Context, id, userID string) error deleteGoal(ctx context.Context, id, userID string) error
seedCategories(ctx context.Context, userID string) error seedCategories(ctx context.Context, userID string) error
getHousehold(ctx context.Context, userID string) (*Household, error)
createHousehold(ctx context.Context, h *Household) error
deleteHousehold(ctx context.Context, userID string) error
getImportSchedules(ctx context.Context, userID string) ([]ImportSchedule, error)
createImportSchedule(ctx context.Context, sched *ImportSchedule) error
deleteImportSchedule(ctx context.Context, id, userID string) error
} }
type Handler struct { type Handler struct {
@ -1870,6 +1880,367 @@ func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) {
}) })
} }
// ── Tax Summary ───────────────────────────────────────────────────────────────
func (h *Handler) Tax(w http.ResponseWriter, r *http.Request) {
auth := getAuth(r)
ctx := r.Context()
// year selector
yearStr := r.URL.Query().Get("year")
now := time.Now()
year := now.Year()
if yearStr != "" {
if y, err := strconv.Atoi(yearStr); err == nil && y >= 2000 && y <= now.Year() {
year = y
}
}
start := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(year+1, 1, 1, 0, 0, 0, 0, time.UTC)
// income transactions
incomeTxns, err := h.store.getTransactions(ctx, auth.UserID, bson.M{
"category": "Income",
"date": bson.M{"$gte": start, "$lt": end},
})
if err != nil {
http.Error(w, "failed to load income", http.StatusInternalServerError)
return
}
var grossIncome int64
for _, t := range incomeTxns {
grossIncome += t.AmountCents
}
// expense transactions by category (deductible candidates = all expenses)
expTxns, err := h.store.getTransactions(ctx, auth.UserID, bson.M{
"amount_cents": bson.M{"$lt": 0},
"date": bson.M{"$gte": start, "$lt": end},
})
if err != nil {
http.Error(w, "failed to load expenses", http.StatusInternalServerError)
return
}
deductMap := map[string]int64{}
for _, t := range expTxns {
deductMap[t.Category] += -t.AmountCents
}
var deductibles []TaxDeductible
var totalDeduct int64
// order by category name for stable output
catNames := make([]string, 0, len(deductMap))
for c := range deductMap {
catNames = append(catNames, c)
}
sort.Strings(catNames)
for _, cat := range catNames {
amt := deductMap[cat]
deductibles = append(deductibles, TaxDeductible{Category: cat, TotalCents: amt})
totalDeduct += amt
}
// capital gains from trades in the selected year
trades, err := h.store.getTrades(ctx, auth.UserID)
if err != nil {
http.Error(w, "failed to load trades", http.StatusInternalServerError)
return
}
// FIFO matching: for each ISIN track buy queue, match sells
type buyLot struct {
qty float64
priceCents int64
}
buyQueues := map[string][]buyLot{}
nameByISIN := map[string]string{}
// process buys in date order first (already sorted by date from store, but sort to be safe)
sort.Slice(trades, func(i, j int) bool { return trades[i].Date.Before(trades[j].Date) })
var capEntries []CapitalGainEntry
var capGains, capLosses int64
for _, t := range trades {
if t.Date.Before(start) {
// still build the buy queue from prior years so we can match sells
if t.Type == "buy" {
buyQueues[t.ISIN] = append(buyQueues[t.ISIN], buyLot{t.Quantity, t.PriceCents})
nameByISIN[t.ISIN] = t.Name
} else if t.Type == "sell" {
// consume from queue silently
q := t.Quantity
for q > 0 && len(buyQueues[t.ISIN]) > 0 {
lot := &buyQueues[t.ISIN][0]
if lot.qty <= q {
q -= lot.qty
buyQueues[t.ISIN] = buyQueues[t.ISIN][1:]
} else {
lot.qty -= q
q = 0
}
}
}
continue
}
if t.Date.After(end) {
continue
}
nameByISIN[t.ISIN] = t.Name
if t.Type == "buy" {
buyQueues[t.ISIN] = append(buyQueues[t.ISIN], buyLot{t.Quantity, t.PriceCents})
} else if t.Type == "sell" {
// FIFO match
remaining := t.Quantity
var costCents int64
for remaining > 0 && len(buyQueues[t.ISIN]) > 0 {
lot := &buyQueues[t.ISIN][0]
matched := lot.qty
if matched > remaining {
matched = remaining
}
costCents += int64(matched * float64(lot.priceCents))
lot.qty -= matched
remaining -= matched
if lot.qty == 0 {
buyQueues[t.ISIN] = buyQueues[t.ISIN][1:]
}
}
gainCents := t.TotalCents - costCents
gainPct := 0.0
if costCents > 0 {
gainPct = float64(gainCents) / float64(costCents) * 100
}
entry := CapitalGainEntry{
ISIN: t.ISIN,
Name: nameByISIN[t.ISIN],
BuyCents: costCents,
SellCents: t.TotalCents,
GainCents: gainCents,
GainPct: math.Round(gainPct*100) / 100,
}
capEntries = append(capEntries, entry)
if gainCents > 0 {
capGains += gainCents
} else {
capLosses += -gainCents
}
}
}
// available years: from first transaction year to current year
allTxns, _ := h.store.getTransactions(ctx, auth.UserID, bson.M{})
availYears := []int{}
minYear := now.Year()
for _, t := range allTxns {
if t.Date.Year() < minYear {
minYear = t.Date.Year()
}
}
for y := minYear; y <= now.Year(); y++ {
availYears = append(availYears, y)
}
if len(availYears) == 0 {
availYears = []int{now.Year()}
}
render(w, taxTmpl, &TaxData{
UserID: auth.UserID,
Email: auth.Email,
Title: "Tax Summary",
Route: "/tax",
Year: year,
GrossIncomeCents: grossIncome,
CapitalGainsCents: capGains,
CapitalLossesCents: capLosses,
NetCapitalCents: capGains - capLosses,
Deductibles: deductibles,
TotalDeductCents: totalDeduct,
CapitalEntries: capEntries,
AvailableYears: availYears,
})
}
func (h *Handler) TaxExport(w http.ResponseWriter, r *http.Request) {
// Reuse Tax logic output as CSV — redirect with same year param
auth := getAuth(r)
ctx := r.Context()
yearStr := r.URL.Query().Get("year")
now := time.Now()
year := now.Year()
if yearStr != "" {
if y, err := strconv.Atoi(yearStr); err == nil {
year = y
}
}
start := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(year+1, 1, 1, 0, 0, 0, 0, time.UTC)
expTxns, _ := h.store.getTransactions(ctx, auth.UserID, bson.M{
"amount_cents": bson.M{"$lt": 0},
"date": bson.M{"$gte": start, "$lt": end},
})
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="tax_%d.csv"`, year))
fmt.Fprintf(w, "Date,Description,Category,Amount\n")
for _, t := range expTxns {
fmt.Fprintf(w, "%s,%q,%s,%.2f\n",
t.Date.Format("2006-01-02"),
t.Description,
t.Category,
float64(-t.AmountCents)/100,
)
}
}
// ── Household ─────────────────────────────────────────────────────────────────
func (h *Handler) Household(w http.ResponseWriter, r *http.Request) {
auth := getAuth(r)
ctx := r.Context()
now := time.Now()
data := &HouseholdData{
UserID: auth.UserID,
Email: auth.Email,
Title: "Household",
Route: "/household",
}
if r.Method == http.MethodPost {
partnerEmail := strings.TrimSpace(r.FormValue("partner_email"))
if partnerEmail == "" {
http.Error(w, "partner email required", http.StatusBadRequest)
return
}
// resolve partner user ID via permissions search (reuse SearchUsers pattern)
// For now store by email as ID placeholder — real lookup needs identity service
hh := &Household{
ID: bson.NewObjectID().Hex(),
OwnerID: auth.UserID,
PartnerID: partnerEmail, // stored as email; resolved on read
CreatedAt: now,
}
if err := h.store.createHousehold(ctx, hh); err != nil {
http.Error(w, "failed to create household", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/household", http.StatusSeeOther)
return
}
if r.Method == http.MethodDelete {
_ = h.store.deleteHousehold(ctx, auth.UserID)
w.WriteHeader(http.StatusNoContent)
return
}
hh, err := h.store.getHousehold(ctx, auth.UserID)
if err == nil && hh != nil {
data.HasHousehold = true
data.IsOwner = hh.OwnerID == auth.UserID
partnerID := hh.PartnerID
if hh.OwnerID == auth.UserID {
partnerID = hh.PartnerID
} else {
partnerID = hh.OwnerID
}
data.PartnerID = partnerID
data.PartnerEmail = partnerID // email stored as ID for now
// compute combined view for current month
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
nextMonth := monthStart.AddDate(0, 1, 0)
myTxns, _ := h.store.getTransactions(ctx, auth.UserID, bson.M{
"date": bson.M{"$gte": monthStart, "$lt": nextMonth},
})
partnerTxns, _ := h.store.getTransactions(ctx, partnerID, bson.M{
"date": bson.M{"$gte": monthStart, "$lt": nextMonth},
})
for _, t := range myTxns {
if t.AmountCents > 0 {
data.MyIncomeCents += t.AmountCents
} else {
data.CombinedExpenseCents += -t.AmountCents
}
}
for _, t := range partnerTxns {
if t.AmountCents > 0 {
data.PartnerIncomeCents += t.AmountCents
} else {
data.CombinedExpenseCents += -t.AmountCents
}
}
data.CombinedIncomeCents = data.MyIncomeCents + data.PartnerIncomeCents
data.CombinedDisposable = data.CombinedIncomeCents - data.CombinedExpenseCents
myGoals, _ := h.store.getGoals(ctx, auth.UserID)
partnerGoals, _ := h.store.getGoals(ctx, partnerID)
for _, g := range myGoals {
data.MyGoals = append(data.MyGoals, GoalPlan{Goal: g})
}
for _, g := range partnerGoals {
data.PartnerGoals = append(data.PartnerGoals, GoalPlan{Goal: g})
}
data.SharedGoals = append(data.MyGoals, data.PartnerGoals...)
}
render(w, householdTmpl, data)
}
// ── Auto Import ───────────────────────────────────────────────────────────────
func (h *Handler) AutoImport(w http.ResponseWriter, r *http.Request) {
auth := getAuth(r)
ctx := r.Context()
if r.Method == http.MethodPost {
_ = r.ParseForm()
sched := &ImportSchedule{
ID: bson.NewObjectID().Hex(),
UserID: auth.UserID,
AccountID: r.FormValue("account_id"),
Label: r.FormValue("label"),
Format: r.FormValue("format"),
URL: r.FormValue("url"),
Active: r.FormValue("active") == "on",
CreatedAt: time.Now(),
}
if err := h.store.createImportSchedule(ctx, sched); err != nil {
http.Error(w, "failed to create schedule", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/auto-import", http.StatusSeeOther)
return
}
if r.Method == http.MethodDelete {
id := r.PathValue("id")
if err := h.store.deleteImportSchedule(ctx, id, auth.UserID); err != nil {
http.Error(w, "failed to delete schedule", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
schedules, _ := h.store.getImportSchedules(ctx, auth.UserID)
accounts, _ := h.store.getAccounts(ctx, auth.UserID)
render(w, autoImportTmpl, &AutoImportData{
UserID: auth.UserID,
Email: auth.Email,
Title: "Auto Import",
Route: "/auto-import",
Accounts: accounts,
Schedules: schedules,
})
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) { func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /{$}", h.Dashboard) mux.HandleFunc("GET /{$}", h.Dashboard)
mux.HandleFunc("GET /transactions", h.Transactions) mux.HandleFunc("GET /transactions", h.Transactions)
@ -1898,6 +2269,14 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/transactions", h.CreateTransaction) mux.HandleFunc("POST /api/transactions", h.CreateTransaction)
mux.HandleFunc("PUT /api/transactions/{id}", h.UpdateTransaction) mux.HandleFunc("PUT /api/transactions/{id}", h.UpdateTransaction)
mux.HandleFunc("DELETE /api/transactions/{id}", h.DeleteTransaction) mux.HandleFunc("DELETE /api/transactions/{id}", h.DeleteTransaction)
mux.HandleFunc("GET /tax", h.Tax)
mux.HandleFunc("GET /tax/export.csv", h.TaxExport)
mux.HandleFunc("GET /household", h.Household)
mux.HandleFunc("POST /household", h.Household)
mux.HandleFunc("DELETE /household", h.Household)
mux.HandleFunc("GET /auto-import", h.AutoImport)
mux.HandleFunc("POST /auto-import", h.AutoImport)
mux.HandleFunc("DELETE /auto-import/{id}", h.AutoImport)
} }
func sortStrings(s []string) { func sortStrings(s []string) {

View File

@ -154,6 +154,17 @@ func (m *mockStore) deleteGoal(_ context.Context, id, _ string) error {
} }
func (m *mockStore) seedCategories(_ context.Context, _ string) error { return nil } func (m *mockStore) seedCategories(_ context.Context, _ string) error { return nil }
func (m *mockStore) getHousehold(_ context.Context, _ string) (*Household, error) {
return nil, fmt.Errorf("not found")
}
func (m *mockStore) createHousehold(_ context.Context, _ *Household) error { return nil }
func (m *mockStore) deleteHousehold(_ context.Context, _ string) error { return nil }
func (m *mockStore) getImportSchedules(_ context.Context, _ string) ([]ImportSchedule, error) {
return nil, nil
}
func (m *mockStore) createImportSchedule(_ context.Context, _ *ImportSchedule) error { return nil }
func (m *mockStore) deleteImportSchedule(_ context.Context, _, _ string) error { return nil }
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
func newHandler(store *mockStore) *Handler { func newHandler(store *mockStore) *Handler {

View File

@ -221,6 +221,98 @@ type SharingUser struct {
Email string Email string
} }
// ── Tax Summary ──────────────────────────────────────────────────────────────
type TaxDeductible struct {
Category string
Description string
TotalCents int64
}
type CapitalGainEntry struct {
ISIN string
Name string
BuyCents int64
SellCents int64
GainCents int64
GainPct float64
}
type TaxData struct {
UserID string
Email string
Title string
Route string
Year int
GrossIncomeCents int64
CapitalGainsCents int64
CapitalLossesCents int64
NetCapitalCents int64
Deductibles []TaxDeductible
TotalDeductCents int64
CapitalEntries []CapitalGainEntry
// year options for selector
AvailableYears []int
}
// ── Household ────────────────────────────────────────────────────────────────
type Household struct {
ID string `bson:"_id" json:"id"`
OwnerID string `bson:"owner_id" json:"owner_id"`
PartnerID string `bson:"partner_id" json:"partner_id"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
type HouseholdData struct {
UserID string
Email string
Title string
Route string
HasHousehold bool
IsOwner bool
PartnerEmail string
PartnerID string
// combined view
CombinedIncomeCents int64
CombinedExpenseCents int64
CombinedDisposable int64
MyIncomeCents int64
PartnerIncomeCents int64
MyGoals []GoalPlan
PartnerGoals []GoalPlan
SharedGoals []GoalPlan // goals from both users
}
// ── Auto Import ──────────────────────────────────────────────────────────────
type ImportSchedule struct {
ID string `bson:"_id" json:"id"`
UserID string `bson:"user_id" json:"user_id"`
AccountID string `bson:"account_id" json:"account_id"`
Label string `bson:"label" json:"label"`
Format string `bson:"format" json:"format"` // cgd, traderepublic, generic
URL string `bson:"url" json:"url"` // URL to fetch CSV from (optional)
Active bool `bson:"active" json:"active"`
LastRunAt time.Time `bson:"last_run_at" json:"last_run_at"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
type AutoImportData struct {
UserID string
Email string
Title string
Route string
Accounts []Account
Schedules []ImportSchedule
}
type AlertLevel string type AlertLevel string
const ( const (

View File

@ -328,6 +328,74 @@ var defaultCategories = []struct {
{"Other", "#9E9E9E"}, {"Other", "#9E9E9E"},
} }
func (s *Store) households() *mgmongo.Collection {
return s.db.Collection("finance_households")
}
func (s *Store) importSchedules() *mgmongo.Collection {
return s.db.Collection("finance_import_schedules")
}
func (s *Store) getHousehold(ctx context.Context, userID string) (*Household, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getHousehold")
defer span.End()
var h Household
err := s.households().FindOne(ctx, bson.M{"$or": bson.A{
bson.M{"owner_id": userID},
bson.M{"partner_id": userID},
}}).Decode(&h)
if err != nil {
return nil, err
}
return &h, nil
}
func (s *Store) createHousehold(ctx context.Context, h *Household) error {
ctx, span := mongo.StartSpan(ctx, "Store.createHousehold")
defer span.End()
_, err := s.households().InsertOne(ctx, h)
return err
}
func (s *Store) deleteHousehold(ctx context.Context, userID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.deleteHousehold")
defer span.End()
_, err := s.households().DeleteOne(ctx, bson.M{"$or": bson.A{
bson.M{"owner_id": userID},
bson.M{"partner_id": userID},
}})
return err
}
func (s *Store) getImportSchedules(ctx context.Context, userID string) ([]ImportSchedule, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getImportSchedules")
defer span.End()
cur, err := s.importSchedules().Find(ctx, bson.M{"user_id": userID})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var items []ImportSchedule
if err := cur.All(ctx, &items); err != nil {
return nil, err
}
return items, nil
}
func (s *Store) createImportSchedule(ctx context.Context, sched *ImportSchedule) error {
ctx, span := mongo.StartSpan(ctx, "Store.createImportSchedule")
defer span.End()
_, err := s.importSchedules().InsertOne(ctx, sched)
return err
}
func (s *Store) deleteImportSchedule(ctx context.Context, id, userID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.deleteImportSchedule")
defer span.End()
_, err := s.importSchedules().DeleteOne(ctx, bson.M{"_id": id, "user_id": userID})
return err
}
func (s *Store) seedCategories(ctx context.Context, userID string) error { func (s *Store) seedCategories(ctx context.Context, userID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.seedCategories") ctx, span := mongo.StartSpan(ctx, "Store.seedCategories")
defer span.End() defer span.End()

View File

@ -0,0 +1,112 @@
{{template "base" .}}
{{define "content"}}
{{$d := .}}
<style>
.ai-form { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:24px; max-width:560px; margin-bottom:32px; }
.ai-form h2 { margin:0 0 16px; font-size:1.05rem; }
.field { margin-bottom:14px; }
.field label { display:block; font-size:0.82rem; color:var(--muted); margin-bottom:5px; }
.field input, .field select { width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text); font-size:0.9rem; box-sizing:border-box; }
.field-row { display:flex; gap:12px; }
.field-row .field { flex:1; }
.btn { padding:8px 20px; border:none; border-radius:8px; font-size:0.9rem; font-weight:600; cursor:pointer; }
.btn-primary { background:var(--accent); color:#fff; }
.btn-danger { background:#f44336; color:#fff; font-size:0.8rem; padding:5px 12px; }
.sched-list { display:flex; flex-direction:column; gap:12px; }
.sched-card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:16px 20px; display:flex; gap:16px; align-items:center; }
.sched-info { flex:1; }
.sched-label { font-weight:600; margin-bottom:4px; }
.sched-meta { font-size:0.8rem; color:var(--muted); }
.badge { display:inline-block; padding:2px 8px; border-radius:20px; font-size:0.72rem; font-weight:600; margin-left:6px; }
.badge-active { background:rgba(76,175,80,0.15); color:#4caf50; }
.badge-inactive { background:rgba(158,158,158,0.15); color:#9e9e9e; }
.empty { padding:32px; text-align:center; color:var(--muted); background:var(--card); border:1px solid var(--border); border-radius:12px; }
</style>
<h1 style="margin:0 0 24px;">Auto Import</h1>
<div class="ai-form">
<h2>New Schedule</h2>
<p style="font-size:0.83rem; color:var(--muted); margin:0 0 18px;">
Configure a recurring CSV import source. The app will fetch and import it automatically on a daily schedule.
</p>
<form method="post" action="/auto-import">
<div class="field">
<label for="ai-label">Schedule label</label>
<input type="text" id="ai-label" name="label" placeholder="e.g. CGD Checking Monthly" required>
</div>
<div class="field-row">
<div class="field">
<label for="ai-account">Account</label>
<select id="ai-account" name="account_id" required>
<option value="">-- select --</option>
{{range $d.Accounts}}
<option value="{{.ID}}">{{.Name}} ({{.Type}})</option>
{{end}}
</select>
</div>
<div class="field">
<label for="ai-format">CSV format</label>
<select id="ai-format" name="format">
<option value="generic">Generic</option>
<option value="cgd">CGD</option>
<option value="traderepublic">Trade Republic</option>
</select>
</div>
</div>
<div class="field">
<label for="ai-url">Source URL <span style="color:var(--muted); font-weight:400;">(optional — or use manual webhook)</span></label>
<input type="url" id="ai-url" name="url" placeholder="https://…/export.csv">
</div>
<div class="field" style="display:flex; align-items:center; gap:8px; margin-bottom:18px;">
<input type="checkbox" id="ai-active" name="active" checked style="width:auto; margin:0;">
<label for="ai-active" style="margin:0; color:var(--text); font-size:0.9rem;">Enable immediately</label>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">Create Schedule</button>
</form>
</div>
<div style="font-size:1rem; font-weight:700; margin-bottom:12px;">Active Schedules</div>
{{if $d.Schedules}}
<div class="sched-list">
{{range $d.Schedules}}
<div class="sched-card">
<div class="sched-info">
<div class="sched-label">
{{.Label}}
{{if .Active}}<span class="badge badge-active">active</span>{{else}}<span class="badge badge-inactive">paused</span>{{end}}
</div>
<div class="sched-meta">
Format: {{.Format}} &bull; Account: {{.AccountID}}
{{if .URL}}&bull; <span style="font-family:monospace;">{{.URL}}</span>{{end}}
{{if not .LastRunAt.IsZero}}&bull; Last run: {{dateShort .LastRunAt}}{{end}}
</div>
</div>
<button class="btn btn-danger" onclick="deleteSchedule('{{.ID}}')">Delete</button>
</div>
{{end}}
</div>
{{else}}
<div class="empty">No import schedules yet. Create one above.</div>
{{end}}
<div style="margin-top:32px; background:var(--card); border:1px solid var(--border); border-radius:12px; padding:20px 24px;">
<div style="font-weight:600; margin-bottom:8px;">Webhook endpoint</div>
<p style="font-size:0.83rem; color:var(--muted); margin:0 0 12px;">
You can also push a CSV file directly without scheduling. Use this endpoint from any automation tool (n8n, cron, etc.):
</p>
<div style="font-family:monospace; font-size:0.82rem; background:var(--bg); border:1px solid var(--border); border-radius:8px; padding:10px 14px; word-break:break-all;">
POST /import/confirm &mdash; multipart/form-data with fields: <code>account_id</code>, <code>format</code>, <code>rows</code> (JSON array)
</div>
</div>
<script>
function deleteSchedule(id) {
if (!confirm('Delete this schedule?')) return;
fetch('/auto-import/' + id, { method: 'DELETE' })
.then(r => { if (r.ok) location.reload(); });
}
</script>
{{end}}

View File

@ -402,6 +402,9 @@
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a> <a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
<a href="/networth" class="{{if eq .Route "networth"}}active{{end}}">Net Worth</a> <a href="/networth" class="{{if eq .Route "networth"}}active{{end}}">Net Worth</a>
<a href="/simulator" class="{{if eq .Route "simulator"}}active{{end}}">What If</a> <a href="/simulator" class="{{if eq .Route "simulator"}}active{{end}}">What If</a>
<a href="/tax" class="{{if eq .Route "tax"}}active{{end}}">Tax</a>
<a href="/household" class="{{if eq .Route "household"}}active{{end}}">Household</a>
<a href="/auto-import" class="{{if eq .Route "auto-import"}}active{{end}}">Auto Import</a>
<a href="/sharing" class="{{if eq .Route "sharing"}}active{{end}}">Sharing</a> <a href="/sharing" class="{{if eq .Route "sharing"}}active{{end}}">Sharing</a>
<div class="nav-spacer"></div> <div class="nav-spacer"></div>
<span class="nav-email">{{.Email}}</span> <span class="nav-email">{{.Email}}</span>

View File

@ -0,0 +1,113 @@
{{template "base" .}}
{{define "content"}}
{{$d := .}}
<style>
.hh-hero { display:flex; gap:16px; flex-wrap:wrap; margin-bottom:24px; }
.hh-card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:20px 24px; flex:1; min-width:160px; }
.hh-card h3 { margin:0 0 4px; font-size:0.8rem; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; }
.hh-card .val { font-size:1.5rem; font-weight:700; }
.section-title { font-size:1rem; font-weight:700; margin:24px 0 12px; }
.partner-form { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:24px; max-width:480px; }
.partner-form label { display:block; font-size:0.85rem; color:var(--muted); margin-bottom:6px; }
.partner-form input { width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text); font-size:0.9rem; box-sizing:border-box; }
.btn { padding:8px 20px; border:none; border-radius:8px; font-size:0.9rem; font-weight:600; cursor:pointer; }
.btn-primary { background:var(--accent); color:#fff; }
.btn-danger { background:#f44336; color:#fff; }
.goals-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.goal-row { display:flex; justify-content:space-between; align-items:center; padding:8px 12px; border-bottom:1px solid var(--border); font-size:0.9rem; }
.goal-row:last-child { border-bottom:none; }
.badge { padding:2px 8px; border-radius:20px; font-size:0.72rem; font-weight:600; }
.badge-committed { background:rgba(76,175,80,0.15); color:#4caf50; }
</style>
<h1 style="margin:0 0 24px;">Household</h1>
{{if not $d.HasHousehold}}
<div class="partner-form">
<h2 style="margin:0 0 16px; font-size:1.1rem;">Link a partner account</h2>
<p style="font-size:0.85rem; color:var(--muted); margin:0 0 20px;">
Combine your finances with a partner to see a shared dashboard. Enter their account email address below.
</p>
<form method="post" action="/household">
<label for="partner_email">Partner email</label>
<input type="email" id="partner_email" name="partner_email" placeholder="partner@example.com" required style="margin-bottom:16px;">
<button type="submit" class="btn btn-primary" style="width:100%;">Link Partner</button>
</form>
</div>
{{else}}
<div style="display:flex; align-items:center; gap:12px; margin-bottom:20px; background:var(--card); border:1px solid var(--border); border-radius:12px; padding:16px 20px;">
<div style="flex:1;">
<div style="font-size:0.8rem; color:var(--muted);">Linked partner</div>
<div style="font-weight:600;">{{$d.PartnerEmail}}</div>
</div>
<button class="btn btn-danger" onclick="unlinkHousehold()" style="font-size:0.8rem; padding:6px 14px;">Unlink</button>
</div>
<div class="section-title">This Month — Combined View</div>
<div class="hh-hero">
<div class="hh-card">
<h3>Combined Income</h3>
<div class="val" style="color:#4caf50;">€{{cents $d.CombinedIncomeCents}}</div>
</div>
<div class="hh-card">
<h3>My Income</h3>
<div class="val">€{{cents $d.MyIncomeCents}}</div>
</div>
<div class="hh-card">
<h3>Partner Income</h3>
<div class="val">€{{cents $d.PartnerIncomeCents}}</div>
</div>
<div class="hh-card">
<h3>Combined Expenses</h3>
<div class="val" style="color:#f44336;">€{{cents $d.CombinedExpenseCents}}</div>
</div>
<div class="hh-card">
<h3>Disposable</h3>
{{if ge $d.CombinedDisposable 0}}
<div class="val" style="color:#4caf50;">€{{cents $d.CombinedDisposable}}</div>
{{else}}
<div class="val" style="color:#f44336;">-€{{cents (centsAbs $d.CombinedDisposable)}}</div>
{{end}}
</div>
</div>
{{if or $d.MyGoals $d.PartnerGoals}}
<div class="section-title">Goals</div>
<div class="goals-grid">
<div style="background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden;">
<div style="padding:12px 16px; font-weight:600; border-bottom:1px solid var(--border); font-size:0.85rem; color:var(--muted);">YOUR GOALS</div>
{{range $d.MyGoals}}
<div class="goal-row">
<span>{{.Name}}</span>
{{if .Committed}}<span class="badge badge-committed">committed</span>{{end}}
</div>
{{else}}
<div style="padding:16px; color:var(--muted); font-size:0.85rem;">No goals</div>
{{end}}
</div>
<div style="background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden;">
<div style="padding:12px 16px; font-weight:600; border-bottom:1px solid var(--border); font-size:0.85rem; color:var(--muted);">PARTNER GOALS</div>
{{range $d.PartnerGoals}}
<div class="goal-row">
<span>{{.Name}}</span>
{{if .Committed}}<span class="badge badge-committed">committed</span>{{end}}
</div>
{{else}}
<div style="padding:16px; color:var(--muted); font-size:0.85rem;">No goals</div>
{{end}}
</div>
</div>
{{end}}
{{end}}
<script>
function unlinkHousehold() {
if (!confirm('Unlink household? This only removes the link, not any data.')) return;
fetch('/household', { method: 'DELETE' })
.then(r => { if (r.ok) location.reload(); });
}
</script>
{{end}}

View File

@ -0,0 +1,130 @@
{{template "base" .}}
{{define "content"}}
{{$d := .}}
<style>
.tax-hero { display:flex; gap:16px; flex-wrap:wrap; margin-bottom:24px; }
.tax-card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:20px 24px; flex:1; min-width:180px; }
.tax-card h3 { margin:0 0 4px; font-size:0.8rem; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; }
.tax-card .val { font-size:1.6rem; font-weight:700; }
.gain { color:#4caf50; }
.loss { color:#f44336; }
.neutral { color:var(--text); }
.year-form { display:flex; align-items:center; gap:8px; margin-bottom:24px; }
.year-form select { padding:6px 10px; border:1px solid var(--border); border-radius:8px; background:var(--card); color:var(--text); }
table { width:100%; border-collapse:collapse; font-size:0.9rem; }
th { text-align:left; padding:8px 12px; color:var(--muted); font-weight:600; border-bottom:2px solid var(--border); }
td { padding:8px 12px; border-bottom:1px solid var(--border); }
tr:last-child td { border-bottom:none; }
.section-title { font-size:1rem; font-weight:700; margin:24px 0 12px; }
.export-btn { display:inline-block; padding:8px 18px; background:var(--accent); color:#fff; border-radius:8px; text-decoration:none; font-size:0.85rem; font-weight:600; }
</style>
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; margin-bottom:8px;">
<h1 style="margin:0;">Tax Summary</h1>
<a href="/tax/export.csv?year={{$d.Year}}" class="export-btn">Export CSV</a>
</div>
<form class="year-form" method="get" action="/tax">
<label for="year-sel" style="font-size:0.85rem; color:var(--muted);">Tax year:</label>
<select id="year-sel" name="year" onchange="this.form.submit()">
{{range $d.AvailableYears}}
<option value="{{.}}" {{if eq . $d.Year}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</form>
<div class="tax-hero">
<div class="tax-card">
<h3>Gross Income</h3>
<div class="val gain">€{{cents $d.GrossIncomeCents}}</div>
<div style="font-size:0.78rem; color:var(--muted); margin-top:4px;">from Income transactions</div>
</div>
<div class="tax-card">
<h3>Total Expenses</h3>
<div class="val neutral">€{{cents $d.TotalDeductCents}}</div>
<div style="font-size:0.78rem; color:var(--muted); margin-top:4px;">across all categories</div>
</div>
<div class="tax-card">
<h3>Capital Gains</h3>
<div class="val gain">€{{cents $d.CapitalGainsCents}}</div>
<div style="font-size:0.78rem; color:var(--muted); margin-top:4px;">realized this year</div>
</div>
<div class="tax-card">
<h3>Capital Losses</h3>
<div class="val loss">€{{cents $d.CapitalLossesCents}}</div>
<div style="font-size:0.78rem; color:var(--muted); margin-top:4px;">realized this year</div>
</div>
<div class="tax-card">
<h3>Net Capital</h3>
{{if ge $d.NetCapitalCents 0}}
<div class="val gain">€{{cents $d.NetCapitalCents}}</div>
{{else}}
<div class="val loss">-€{{cents (centsAbs $d.NetCapitalCents)}}</div>
{{end}}
<div style="font-size:0.78rem; color:var(--muted); margin-top:4px;">gains losses</div>
</div>
</div>
{{if $d.CapitalEntries}}
<div class="section-title">Realized Capital Events</div>
<div style="background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden; margin-bottom:24px;">
<table>
<thead>
<tr>
<th>Name</th>
<th>ISIN</th>
<th style="text-align:right">Cost Basis</th>
<th style="text-align:right">Proceeds</th>
<th style="text-align:right">Gain/Loss</th>
<th style="text-align:right">%</th>
</tr>
</thead>
<tbody>
{{range $d.CapitalEntries}}
<tr>
<td>{{.Name}}</td>
<td style="font-family:monospace; font-size:0.8rem; color:var(--muted);">{{.ISIN}}</td>
<td style="text-align:right">€{{cents .BuyCents}}</td>
<td style="text-align:right">€{{cents .SellCents}}</td>
<td style="text-align:right; {{if ge .GainCents 0}}color:#4caf50{{else}}color:#f44336{{end}}">
{{if ge .GainCents 0}}+{{end}}€{{cents .GainCents}}
</td>
<td style="text-align:right; {{if ge .GainPct 0.0}}color:#4caf50{{else}}color:#f44336{{end}}">
{{pctSign .GainPct}}{{printf "%.1f" .GainPct}}%
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<div class="section-title">Expenses by Category</div>
<div style="background:var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden;">
{{if $d.Deductibles}}
<table>
<thead>
<tr>
<th>Category</th>
<th style="text-align:right">Total Spent</th>
</tr>
</thead>
<tbody>
{{range $d.Deductibles}}
<tr>
<td>{{.Category}}</td>
<td style="text-align:right">€{{cents .TotalCents}}</td>
</tr>
{{end}}
<tr style="font-weight:700; background:var(--bg);">
<td>Total</td>
<td style="text-align:right">€{{cents $d.TotalDeductCents}}</td>
</tr>
</tbody>
</table>
{{else}}
<div style="padding:32px; text-align:center; color:var(--muted);">No expense transactions for {{$d.Year}}</div>
{{end}}
</div>
{{end}}