hsa-app/internal/web/views.go
Jean-Michel Tremblay 04d6e8177d Add receipt attachments captured at upload time (spec item 10)
- New attachments table (FK to receipts), dual-write: file on disk + DB blob.
- On-disk layout split: receipts under <ROOT>/receipts/<YYYY>/..., attachments
  under <ROOT>/attachments/<YYYY>/..., attachments named from the parent
  receipt's date+amount stem (_att, _att_1, ...).
- Upload form gains an optional multi-file "Additional files" field; the files
  ride along with POST /upload, saved after the receipt row exists. No AI runs
  on attachments; primary-image auto-fill unchanged.
- GET /attachment/{id}/file serves blobs; confirm page lists them; recent list
  links them. Adding attachments to an already-saved receipt is not yet supported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:06:15 -04:00

233 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
Attachments []attachLink
}
// attachLink is a viewable attachment reference shown under a recent row.
type attachLink struct {
ID 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 {
atts, err := s.store.ListAttachmentMeta(row.ID)
if err != nil {
s.serverError(w, "list attachments", err)
return
}
var links []attachLink
for _, a := range atts {
links = append(links, attachLink{ID: a.ID, Filename: a.OriginalFilename})
}
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,
Attachments: links,
})
}
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))
}
// handleAttachmentFile serves a receipt attachment's bytes from the DB blob.
func (s *Server) handleAttachmentFile(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
a, err := s.store.GetAttachment(id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", a.MimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", a.OriginalFilename))
http.ServeContent(w, r, a.OriginalFilename, a.UploadedAt, bytes.NewReader(a.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)
}