2026-06-19 02:04:42 +00:00
|
|
|
|
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 {
|
2026-06-19 11:06:15 +00:00
|
|
|
|
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 {
|
2026-06-19 02:04:42 +00:00
|
|
|
|
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 {
|
2026-06-19 11:06:15 +00:00
|
|
|
|
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})
|
|
|
|
|
|
}
|
2026-06-19 02:04:42 +00:00
|
|
|
|
view.Rows = append(view.Rows, recentRow{
|
2026-06-19 11:06:15 +00:00
|
|
|
|
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,
|
2026-06-19 02:04:42 +00:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 11:06:15 +00:00
|
|
|
|
// 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 02:04:42 +00:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|