Features (see spec.md v2): - Wire receipt classification into the upload flow; cheap model (Haiku 4.5) is now the default, shown as a footnote with per-scan cost in cents. - Skip-AI toggle to enter fields by hand. - Duplicate-transaction warning: live check on date+amount, gated submit. - Tally tab: person x year totals with margins and grand total. - Recent uploads / recent receipts tabs with paging and file serving. - People reconcile on startup: merge stray partial names (e.g. "Jude" -> "Jude Tremblay"), reassigning receipts; idempotent seeding. - scripts/build.sh builds the binary; scripts/run.sh builds and runs with .env. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
203 lines
5.7 KiB
Go
203 lines
5.7 KiB
Go
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)
|
||
}
|