package web import ( "bytes" "encoding/json" "fmt" "net/http" "strconv" "time" "maisym.com/hsa/internal/receipt" ) const recentPageSize = 10 // dupMatch is one possible-duplicate receipt returned to the upload page. type dupMatch struct { Date string `json:"date"` Amount string `json:"amount"` Category string `json:"category"` Who string `json:"who"` By string `json:"by"` Uploaded string `json:"uploaded"` Filename string `json:"filename"` } // handleDuplicates returns receipts already posted with the same date+amount, so // the upload page can warn before the user submits. Read-only; mutates nothing. func (s *Server) handleDuplicates(w http.ResponseWriter, r *http.Request) { dateStr := r.URL.Query().Get("date") amountStr := r.URL.Query().Get("amount") date, err := time.Parse(dateLayout, dateStr) if err != nil { http.Error(w, "bad date", http.StatusBadRequest) return } amountCents, err := receipt.ParseAmountCents(amountStr) if err != nil { http.Error(w, "bad amount", http.StatusBadRequest) return } matches, err := s.store.FindDuplicates(date, amountCents) if err != nil { s.serverError(w, "find duplicates", err) return } out := make([]dupMatch, 0, len(matches)) for _, m := range matches { out = append(out, dupMatch{ Date: m.ReceiptDate.Format(dateLayout), Amount: dollars(m.AmountCents), Category: m.Category, Who: m.Who, By: m.UploadedBy, Uploaded: m.UploadedAt.Local().Format("2006-01-02 15:04"), Filename: m.OriginalFilename, }) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"matches": out}) } // recentView is the data for the recent-uploads / recent-receipts tab. type recentView struct { Title string // page heading ByLabel string // "upload date" or "receipt date" Rows []recentRow // the receipts on this page PrevURL string // "" when on the first page NextURL string // "" when there are no more rows BasePath string // /recent or /recent/receipts } // recentRow is one listing row with display-formatted fields. type recentRow struct { ID string Date string Amount string Category string Who string When string Filename string } func (s *Server) handleRecentUploads(w http.ResponseWriter, r *http.Request) { s.renderRecent(w, r, "uploaded_at", "Recent uploads", "upload date", "/recent") } func (s *Server) handleRecentReceipts(w http.ResponseWriter, r *http.Request) { s.renderRecent(w, r, "receipt_date", "Recent receipts", "receipt date", "/recent/receipts") } func (s *Server) renderRecent(w http.ResponseWriter, r *http.Request, orderBy, title, byLabel, basePath string) { offset := 0 if v := r.URL.Query().Get("offset"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { offset = n } } rows, hasMore, err := s.store.ListRecent(orderBy, recentPageSize, offset) if err != nil { s.serverError(w, "list recent", err) return } view := recentView{Title: title, ByLabel: byLabel, BasePath: basePath} for _, row := range rows { view.Rows = append(view.Rows, recentRow{ ID: row.ID, Date: row.ReceiptDate.Format(dateLayout), Amount: dollars(row.AmountCents), Category: row.Category, Who: row.Who, When: row.UploadedAt.Local().Format("2006-01-02 15:04"), Filename: row.OriginalFilename, }) } if hasMore { view.NextURL = fmt.Sprintf("%s?offset=%d", basePath, offset+recentPageSize) } if offset > 0 { prev := offset - recentPageSize if prev <= 0 { view.PrevURL = basePath } else { view.PrevURL = fmt.Sprintf("%s?offset=%d", basePath, prev) } } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := recentPage.ExecuteTemplate(w, "base", view); err != nil { s.serverError(w, "render recent", err) } } // tallyView is the rendered person × year matrix. type tallyView struct { Years []int Rows []tallyRowView YearTotals []string // aligned with Years Grand string Empty bool } type tallyRowView struct { Person string Cells []string // aligned with Years (dollars, "" for no data) Total string } func (s *Server) handleTally(w http.ResponseWriter, r *http.Request) { t, err := s.store.Tally() if err != nil { s.serverError(w, "tally", err) return } view := tallyView{Years: t.Years, Empty: len(t.Rows) == 0, Grand: dollars(t.Grand)} for _, y := range t.Years { view.YearTotals = append(view.YearTotals, dollars(t.YearTotals[y])) } for _, row := range t.Rows { rv := tallyRowView{Person: row.Person, Total: dollars(row.Total)} for _, y := range t.Years { if v, ok := row.ByYear[y]; ok { rv.Cells = append(rv.Cells, dollars(v)) } else { rv.Cells = append(rv.Cells, "") } } view.Rows = append(view.Rows, rv) } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tallyPage.ExecuteTemplate(w, "base", view); err != nil { s.serverError(w, "render tally", err) } } // handleReceiptFile serves a stored receipt's original file (from the DB blob, so // it works even if the on-disk copy is missing). Used by the recent-list links. func (s *Server) handleReceiptFile(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") rec, err := s.store.Get(id) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } w.Header().Set("Content-Type", rec.MimeType) w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", rec.OriginalFilename)) http.ServeContent(w, r, rec.OriginalFilename, rec.UploadedAt, bytes.NewReader(rec.ImageData)) } // dollars formats integer cents as a plain dollar string, e.g. 1234 -> "12.34". func dollars(cents int64) string { if cents < 0 { return "-" + dollars(-cents) } return fmt.Sprintf("%d.%02d", cents/100, cents%100) }