Add AI auto-fill, tally, recent views, and cheaper-by-default classification
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>
This commit is contained in:
parent
8b7252dad4
commit
5c21f131fd
21 changed files with 1291 additions and 9 deletions
|
|
@ -38,4 +38,7 @@ CONFIG_PATH=./config.json
|
|||
# Tests never need this: they use a mock endpoint (or the cheapest model when
|
||||
# HSA_CLASSIFY_IT=1 is set explicitly).
|
||||
CLAUDE_API_KEY=
|
||||
CLASSIFY_MODEL=claude-opus-4-8
|
||||
# Cheap model by default — every receipt upload makes one classification call, so
|
||||
# keep this cheap unless you specifically need a stronger model. The active model
|
||||
# is shown as a footnote on the upload page so you always know what a scan costs.
|
||||
CLASSIFY_MODEL=claude-haiku-4-5-20251001
|
||||
|
|
|
|||
|
|
@ -41,6 +41,13 @@ func main() {
|
|||
if err := store.Seed(catalog.CategoryNames(), catalog.PersonLabels()); err != nil {
|
||||
log.Fatalf("seed catalog: %v", err)
|
||||
}
|
||||
// Collapse any stray partial-name people (e.g. "Jude" → "Jude Tremblay") left
|
||||
// over from earlier data, reassigning their receipts to the canonical person.
|
||||
if merged, err := store.ReconcilePeople(catalog.PersonLabels()); err != nil {
|
||||
log.Fatalf("reconcile people: %v", err)
|
||||
} else if merged > 0 {
|
||||
log.Printf("reconciled %d stray person entr(ies) into canonical labels", merged)
|
||||
}
|
||||
|
||||
// Receipt auto-classification is best-effort: only enabled when an API key is set.
|
||||
var classifier *classify.Classifier
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ type Suggestion struct {
|
|||
RawName string `json:"raw_name"`
|
||||
RawDate string `json:"raw_date"`
|
||||
RawAmount string `json:"raw_amount"`
|
||||
|
||||
// Usage is the token accounting for this call, used to compute its cost.
|
||||
Usage Usage
|
||||
}
|
||||
|
||||
// Classifier holds everything needed to classify a receipt.
|
||||
|
|
@ -51,7 +54,7 @@ type Classifier struct {
|
|||
// catalog; pass an empty model to use a sensible default.
|
||||
func New(apiKey, model string, persons []config.Person, categories []config.Category) *Classifier {
|
||||
if model == "" {
|
||||
model = "claude-opus-4-8"
|
||||
model = config.DefaultClassifyModel
|
||||
}
|
||||
return &Classifier{
|
||||
APIKey: apiKey,
|
||||
|
|
@ -111,6 +114,12 @@ type apiResponse struct {
|
|||
Name string `json:"name"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
} `json:"content"`
|
||||
Usage *struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheReadTokens int `json:"cache_read_input_tokens"`
|
||||
CacheCreationTokens int `json:"cache_creation_input_tokens"`
|
||||
} `json:"usage"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
|
|
@ -206,7 +215,16 @@ func (c *Classifier) Classify(ctx context.Context, today time.Time, image []byte
|
|||
if err := json.Unmarshal(blk.Input, &in); err != nil {
|
||||
return Suggestion{}, fmt.Errorf("parse tool input: %w", err)
|
||||
}
|
||||
return c.normalize(in), nil
|
||||
sug := c.normalize(in)
|
||||
if ar.Usage != nil {
|
||||
sug.Usage = Usage{
|
||||
InputTokens: ar.Usage.InputTokens,
|
||||
OutputTokens: ar.Usage.OutputTokens,
|
||||
CacheReadTokens: ar.Usage.CacheReadTokens,
|
||||
CacheCreationTokens: ar.Usage.CacheCreationTokens,
|
||||
}
|
||||
}
|
||||
return sug, nil
|
||||
}
|
||||
}
|
||||
return Suggestion{}, fmt.Errorf("no %s tool_use in response", toolName)
|
||||
|
|
|
|||
47
internal/classify/pricing.go
Normal file
47
internal/classify/pricing.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package classify
|
||||
|
||||
// Pricing for receipt classification. Token counts come straight from each API
|
||||
// response (exact, free); per-token prices are not available from any Anthropic
|
||||
// API, so they live here as constants keyed by exact model id. A model id is
|
||||
// priced once and never re-priced — Anthropic ships price changes as new ids — so
|
||||
// this table only needs a new row when we adopt a new model (i.e. when we'd be
|
||||
// changing CLASSIFY_MODEL anyway). An unknown id yields ok=false, and callers show
|
||||
// the token counts without a (wrong) dollar figure rather than guessing.
|
||||
|
||||
// Usage is the token accounting from one Messages API response.
|
||||
type Usage struct {
|
||||
InputTokens int
|
||||
OutputTokens int
|
||||
CacheReadTokens int
|
||||
CacheCreationTokens int
|
||||
}
|
||||
|
||||
// rate holds a model's price in cents per one million tokens.
|
||||
type rate struct {
|
||||
inPerM float64 // cents per 1M input tokens
|
||||
outPerM float64 // cents per 1M output tokens
|
||||
}
|
||||
|
||||
// modelRates is the hand-maintained price table (cents per 1M tokens).
|
||||
// $1/1M == 100 cents/1M.
|
||||
var modelRates = map[string]rate{
|
||||
"claude-haiku-4-5-20251001": {inPerM: 100, outPerM: 500},
|
||||
"claude-haiku-4-5": {inPerM: 100, outPerM: 500},
|
||||
"claude-sonnet-4-6": {inPerM: 300, outPerM: 1500},
|
||||
"claude-opus-4-8": {inPerM: 500, outPerM: 2500},
|
||||
"claude-opus-4-7": {inPerM: 500, outPerM: 2500},
|
||||
}
|
||||
|
||||
// CostCents returns the cost of a classification call in cents, and whether the
|
||||
// model's price is known. Cache tokens are billed at the input rate (a slight
|
||||
// over-estimate — cache reads actually bill at ~0.1× input — which is fine for a
|
||||
// "what did this scan cost" readout). Returns ok=false for an unpriced model.
|
||||
func CostCents(model string, u Usage) (float64, bool) {
|
||||
r, ok := modelRates[model]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
inTokens := float64(u.InputTokens + u.CacheReadTokens + u.CacheCreationTokens)
|
||||
cents := inTokens*r.inPerM/1_000_000 + float64(u.OutputTokens)*r.outPerM/1_000_000
|
||||
return cents, true
|
||||
}
|
||||
31
internal/classify/pricing_test.go
Normal file
31
internal/classify/pricing_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package classify
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCostCents_Haiku(t *testing.T) {
|
||||
// 1500 input + 100 output on Haiku ($1/1M in, $5/1M out).
|
||||
// input: 1500 * 100c/1e6 = 0.15c ; output: 100 * 500c/1e6 = 0.05c → 0.20c
|
||||
got, ok := CostCents("claude-haiku-4-5-20251001", Usage{InputTokens: 1500, OutputTokens: 100})
|
||||
if !ok {
|
||||
t.Fatal("expected known model")
|
||||
}
|
||||
if math.Abs(got-0.20) > 1e-9 {
|
||||
t.Errorf("cost = %v, want 0.20", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostCents_CacheTokensBilledAtInputRate(t *testing.T) {
|
||||
got, _ := CostCents("claude-haiku-4-5", Usage{CacheReadTokens: 1_000_000})
|
||||
if math.Abs(got-100) > 1e-9 { // 1M input-rate tokens = 100 cents
|
||||
t.Errorf("cost = %v, want 100", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostCents_UnknownModel(t *testing.T) {
|
||||
if _, ok := CostCents("some-future-model", Usage{InputTokens: 1000}); ok {
|
||||
t.Error("expected ok=false for unknown model")
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,11 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// DefaultClassifyModel is the model used for receipt classification unless
|
||||
// CLASSIFY_MODEL overrides it. Deliberately a cheap model so routine uploads
|
||||
// don't burn API credits; override with a stronger model only when needed.
|
||||
const DefaultClassifyModel = "claude-haiku-4-5-20251001"
|
||||
|
||||
// Config holds all runtime configuration, loaded from environment variables.
|
||||
type Config struct {
|
||||
IssuerURL string // OIDC issuer, e.g. https://auth.maisym.com
|
||||
|
|
@ -77,7 +82,7 @@ func Load() (Config, error) {
|
|||
// Domain config + classification (all optional; classification is best-effort).
|
||||
c.ConfigPath = envOr("CONFIG_PATH", "./config.json")
|
||||
c.ClassifyAPIKey = strings.TrimSpace(os.Getenv("CLAUDE_API_KEY"))
|
||||
c.ClassifyModel = envOr("CLASSIFY_MODEL", "claude-opus-4-8")
|
||||
c.ClassifyModel = envOr("CLASSIFY_MODEL", DefaultClassifyModel)
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
|
|
|||
263
internal/storage/query.go
Normal file
263
internal/storage/query.go
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ReceiptRow is a display row for listings and duplicate warnings. It carries the
|
||||
// resolved category/person labels (not ids) and omits the image blob.
|
||||
type ReceiptRow struct {
|
||||
ID string
|
||||
UploadedBy string
|
||||
UploadedAt time.Time
|
||||
ReceiptDate time.Time
|
||||
AmountCents int64
|
||||
Category string
|
||||
Who string // "" when unassigned
|
||||
OriginalFilename string
|
||||
}
|
||||
|
||||
// recentColumns whitelists the sortable columns for ListRecent, so the column
|
||||
// name can be interpolated into SQL safely (it is never user free-text).
|
||||
var recentColumns = map[string]string{
|
||||
"uploaded_at": "r.uploaded_at",
|
||||
"receipt_date": "r.receipt_date",
|
||||
}
|
||||
|
||||
// ListRecent returns up to limit non-deleted receipts ordered by the given column
|
||||
// (descending), skipping offset rows. orderBy must be "uploaded_at" or
|
||||
// "receipt_date". One extra row is fetched so callers can tell if more exist.
|
||||
func (s *Store) ListRecent(orderBy string, limit, offset int) (rows []ReceiptRow, hasMore bool, err error) {
|
||||
col, ok := recentColumns[orderBy]
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("invalid sort column %q", orderBy)
|
||||
}
|
||||
q := `SELECT r.id, r.uploaded_by, r.uploaded_at, r.receipt_date, r.amount_cents,
|
||||
c.label, COALESCE(p.label, ''), r.original_filename
|
||||
FROM receipts r
|
||||
JOIN categories c ON c.id = r.category_id
|
||||
LEFT JOIN people p ON p.id = r.person_id
|
||||
WHERE r.deleted_at IS NULL
|
||||
ORDER BY ` + col + ` DESC, r.id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
// Fetch one extra to detect "has more".
|
||||
res, err := s.db.Query(q, limit+1, offset)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("list recent: %w", err)
|
||||
}
|
||||
defer res.Close()
|
||||
|
||||
for res.Next() {
|
||||
row, err := scanReceiptRow(res)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if err := res.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(rows) > limit {
|
||||
return rows[:limit], true, nil
|
||||
}
|
||||
return rows, false, nil
|
||||
}
|
||||
|
||||
// FindDuplicates returns non-deleted receipts with the same receipt date and
|
||||
// amount — likely the same transaction already posted. Empty means no match.
|
||||
func (s *Store) FindDuplicates(receiptDate time.Time, amountCents int64) ([]ReceiptRow, error) {
|
||||
q := `SELECT r.id, r.uploaded_by, r.uploaded_at, r.receipt_date, r.amount_cents,
|
||||
c.label, COALESCE(p.label, ''), r.original_filename
|
||||
FROM receipts r
|
||||
JOIN categories c ON c.id = r.category_id
|
||||
LEFT JOIN people p ON p.id = r.person_id
|
||||
WHERE r.deleted_at IS NULL AND r.receipt_date = ? AND r.amount_cents = ?
|
||||
ORDER BY r.uploaded_at DESC`
|
||||
res, err := s.db.Query(q, receiptDate.UTC().Format(rfc3339), amountCents)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find duplicates: %w", err)
|
||||
}
|
||||
defer res.Close()
|
||||
|
||||
var rows []ReceiptRow
|
||||
for res.Next() {
|
||||
row, err := scanReceiptRow(res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, res.Err()
|
||||
}
|
||||
|
||||
// rowScanner is satisfied by *sql.Rows.
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanReceiptRow(sc rowScanner) (ReceiptRow, error) {
|
||||
var row ReceiptRow
|
||||
var uploadedAt, receiptDate string
|
||||
if err := sc.Scan(&row.ID, &row.UploadedBy, &uploadedAt, &receiptDate,
|
||||
&row.AmountCents, &row.Category, &row.Who, &row.OriginalFilename); err != nil {
|
||||
return ReceiptRow{}, fmt.Errorf("scan receipt row: %w", err)
|
||||
}
|
||||
row.UploadedAt, _ = time.Parse(rfc3339, uploadedAt)
|
||||
row.ReceiptDate, _ = time.Parse(rfc3339, receiptDate)
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// TallyRow is one person's totals across years (cents).
|
||||
type TallyRow struct {
|
||||
Person string
|
||||
ByYear map[int]int64
|
||||
Total int64
|
||||
}
|
||||
|
||||
// Tally is the person × year totals matrix with margins, for the Tally tab.
|
||||
type Tally struct {
|
||||
Years []int // sorted ascending, only years that have data
|
||||
Rows []TallyRow // one per person ("Unassigned" sorts last)
|
||||
YearTotals map[int]int64 // grand total per year (all people)
|
||||
Grand int64 // overall grand total
|
||||
}
|
||||
|
||||
// Tally sums non-deleted receipt amounts grouped by person and by the year of the
|
||||
// receipt date. Receipts with no "who" are grouped under "Unassigned".
|
||||
func (s *Store) Tally() (Tally, error) {
|
||||
q := `SELECT COALESCE(p.label, 'Unassigned'),
|
||||
CAST(strftime('%Y', r.receipt_date) AS INTEGER),
|
||||
SUM(r.amount_cents)
|
||||
FROM receipts r
|
||||
LEFT JOIN people p ON p.id = r.person_id
|
||||
WHERE r.deleted_at IS NULL
|
||||
GROUP BY COALESCE(p.label, 'Unassigned'), strftime('%Y', r.receipt_date)`
|
||||
res, err := s.db.Query(q)
|
||||
if err != nil {
|
||||
return Tally{}, fmt.Errorf("tally: %w", err)
|
||||
}
|
||||
defer res.Close()
|
||||
|
||||
byPerson := map[string]map[int]int64{}
|
||||
yearSet := map[int]bool{}
|
||||
t := Tally{YearTotals: map[int]int64{}}
|
||||
for res.Next() {
|
||||
var person string
|
||||
var year int
|
||||
var sum int64
|
||||
if err := res.Scan(&person, &year, &sum); err != nil {
|
||||
return Tally{}, fmt.Errorf("scan tally: %w", err)
|
||||
}
|
||||
if byPerson[person] == nil {
|
||||
byPerson[person] = map[int]int64{}
|
||||
}
|
||||
byPerson[person][year] += sum
|
||||
yearSet[year] = true
|
||||
t.YearTotals[year] += sum
|
||||
t.Grand += sum
|
||||
}
|
||||
if err := res.Err(); err != nil {
|
||||
return Tally{}, err
|
||||
}
|
||||
|
||||
for y := range yearSet {
|
||||
t.Years = append(t.Years, y)
|
||||
}
|
||||
sort.Ints(t.Years)
|
||||
|
||||
names := make([]string, 0, len(byPerson))
|
||||
for name := range byPerson {
|
||||
names = append(names, name)
|
||||
}
|
||||
// Alphabetical, but "Unassigned" always last.
|
||||
sort.Slice(names, func(i, j int) bool {
|
||||
if (names[i] == "Unassigned") != (names[j] == "Unassigned") {
|
||||
return names[j] == "Unassigned"
|
||||
}
|
||||
return strings.ToLower(names[i]) < strings.ToLower(names[j])
|
||||
})
|
||||
for _, name := range names {
|
||||
row := TallyRow{Person: name, ByYear: byPerson[name]}
|
||||
for _, v := range byPerson[name] {
|
||||
row.Total += v
|
||||
}
|
||||
t.Rows = append(t.Rows, row)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ReconcilePeople merges stray partial-name person rows into the canonical
|
||||
// config-seeded labels: e.g. a leftover "Jude" is merged into "Jude Tremblay".
|
||||
// For each existing person that is NOT itself a canonical label, if its label
|
||||
// matches the first name of exactly one canonical label (case-insensitive), its
|
||||
// receipts are reassigned to that canonical person and the stray row is deleted.
|
||||
// Ambiguous or unmatched strays are left untouched. Returns the number merged.
|
||||
//
|
||||
// This is conservative on purpose — it only collapses an unambiguous first-name
|
||||
// duplicate, so genuinely custom people added via the manage page survive.
|
||||
func (s *Store) ReconcilePeople(canonical []string) (int, error) {
|
||||
// Map first-name -> canonical label, dropping ambiguous first names.
|
||||
firstToCanon := map[string]string{}
|
||||
ambiguous := map[string]bool{}
|
||||
canonSet := map[string]bool{}
|
||||
for _, label := range canonical {
|
||||
label = strings.TrimSpace(label)
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
canonSet[strings.ToLower(label)] = true
|
||||
first := strings.ToLower(strings.Fields(label)[0])
|
||||
if _, seen := firstToCanon[first]; seen {
|
||||
ambiguous[first] = true
|
||||
}
|
||||
firstToCanon[first] = label
|
||||
}
|
||||
|
||||
people, err := s.ListPeople()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
merged := 0
|
||||
for _, p := range people {
|
||||
lower := strings.ToLower(strings.TrimSpace(p.Label))
|
||||
if canonSet[lower] {
|
||||
continue // already a canonical full name
|
||||
}
|
||||
canon, ok := firstToCanon[lower]
|
||||
if !ok || ambiguous[lower] {
|
||||
continue // no unambiguous canonical match — leave it alone
|
||||
}
|
||||
if err := s.mergePerson(p.ID, canon); err != nil {
|
||||
return merged, err
|
||||
}
|
||||
merged++
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// mergePerson reassigns every receipt pointing at stray personID to the canonical
|
||||
// person (looked up / created by label), then deletes the stray row. Done in a
|
||||
// transaction so a receipt never loses its "who".
|
||||
func (s *Store) mergePerson(strayID int64, canonLabel string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin merge: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var canonID int64
|
||||
if err := tx.QueryRow(`SELECT id FROM people WHERE label = ?`, canonLabel).Scan(&canonID); err != nil {
|
||||
return fmt.Errorf("find canonical %q: %w", canonLabel, err)
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE receipts SET person_id = ? WHERE person_id = ?`, canonID, strayID); err != nil {
|
||||
return fmt.Errorf("reassign receipts: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM people WHERE id = ?`, strayID); err != nil {
|
||||
return fmt.Errorf("delete stray person: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
194
internal/storage/query_test.go
Normal file
194
internal/storage/query_test.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// insertOn inserts a receipt with the given id, person, category, amount and
|
||||
// receipt date (uploaded_at defaults to now).
|
||||
func insertOn(t *testing.T, s *Store, id string, categoryID int64, personID *int64, amountCents int64, date time.Time) {
|
||||
t.Helper()
|
||||
r := sampleReceipt(categoryID)
|
||||
r.ID = id
|
||||
r.PersonID = personID
|
||||
r.AmountCents = amountCents
|
||||
r.ReceiptDate = date
|
||||
if err := s.Insert(r); err != nil {
|
||||
t.Fatalf("Insert %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindDuplicates_MatchesDateAndAmount(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
cid := firstCategoryID(t, s)
|
||||
d := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
insertOn(t, s, "a", cid, nil, 4250, d)
|
||||
insertOn(t, s, "b", cid, nil, 4250, d.AddDate(0, 0, 1)) // different date
|
||||
insertOn(t, s, "c", cid, nil, 9999, d) // different amount
|
||||
|
||||
matches, err := s.FindDuplicates(d, 4250)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 1 || matches[0].ID != "a" {
|
||||
t.Fatalf("matches = %+v, want only id a", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindDuplicates_ExcludesSoftDeleted(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
cid := firstCategoryID(t, s)
|
||||
d := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
insertOn(t, s, "a", cid, nil, 4250, d)
|
||||
if err := s.SoftDelete("a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
matches, err := s.FindDuplicates(d, 4250)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("matches = %+v, want none (soft-deleted)", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRecent_OrderAndPaging(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
cid := firstCategoryID(t, s)
|
||||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
// Insert 12 receipts with ascending receipt dates id00..id11.
|
||||
for i := 0; i < 12; i++ {
|
||||
insertOn(t, s, padID(i), cid, nil, int64(100+i), base.AddDate(0, 0, i))
|
||||
}
|
||||
|
||||
rows, hasMore, err := s.ListRecent("receipt_date", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 10 || !hasMore {
|
||||
t.Fatalf("page1 len=%d hasMore=%v, want 10,true", len(rows), hasMore)
|
||||
}
|
||||
// Newest receipt date first → id11.
|
||||
if rows[0].ID != padID(11) {
|
||||
t.Errorf("first row = %s, want %s", rows[0].ID, padID(11))
|
||||
}
|
||||
|
||||
rows2, hasMore2, err := s.ListRecent("receipt_date", 10, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows2) != 2 || hasMore2 {
|
||||
t.Fatalf("page2 len=%d hasMore=%v, want 2,false", len(rows2), hasMore2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRecent_RejectsBadColumn(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if _, _, err := s.ListRecent("amount_cents; DROP TABLE receipts", 10, 0); err == nil {
|
||||
t.Error("expected error for non-whitelisted column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTally_MatrixAndMargins(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
cid := firstCategoryID(t, s)
|
||||
alice, _ := s.AddPerson("Alice")
|
||||
bob, _ := s.AddPerson("Bob")
|
||||
y2025 := time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||
y2026 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
insertOn(t, s, "1", cid, &alice, 1000, y2025)
|
||||
insertOn(t, s, "2", cid, &alice, 2000, y2026)
|
||||
insertOn(t, s, "3", cid, &bob, 500, y2026)
|
||||
insertOn(t, s, "4", cid, nil, 700, y2026) // unassigned
|
||||
|
||||
tal, err := s.Tally()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tal.Years) != 2 || tal.Years[0] != 2025 || tal.Years[1] != 2026 {
|
||||
t.Fatalf("years = %v, want [2025 2026]", tal.Years)
|
||||
}
|
||||
if tal.Grand != 4200 {
|
||||
t.Errorf("grand = %d, want 4200", tal.Grand)
|
||||
}
|
||||
if tal.YearTotals[2026] != 3200 {
|
||||
t.Errorf("2026 total = %d, want 3200", tal.YearTotals[2026])
|
||||
}
|
||||
// Unassigned must sort last.
|
||||
last := tal.Rows[len(tal.Rows)-1]
|
||||
if last.Person != "Unassigned" || last.Total != 700 {
|
||||
t.Errorf("last row = %+v, want Unassigned/700", last)
|
||||
}
|
||||
// Alice's per-year cells.
|
||||
var aliceRow TallyRow
|
||||
for _, r := range tal.Rows {
|
||||
if r.Person == "Alice" {
|
||||
aliceRow = r
|
||||
}
|
||||
}
|
||||
if aliceRow.ByYear[2025] != 1000 || aliceRow.ByYear[2026] != 2000 || aliceRow.Total != 3000 {
|
||||
t.Errorf("alice row = %+v", aliceRow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcilePeople_MergesStrayFirstName(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
cid := firstCategoryID(t, s)
|
||||
// Canonical seeded person + a stray first-name-only duplicate.
|
||||
if err := s.Seed(nil, []string{"Jude Tremblay"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stray, err := s.AddPerson("Jude")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
insertOn(t, s, "r1", cid, &stray, 1000, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
merged, err := s.ReconcilePeople([]string{"Jude Tremblay"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if merged != 1 {
|
||||
t.Fatalf("merged = %d, want 1", merged)
|
||||
}
|
||||
|
||||
// Stray gone; only the canonical person remains.
|
||||
people, _ := s.ListPeople()
|
||||
if len(people) != 1 || people[0].Label != "Jude Tremblay" {
|
||||
t.Fatalf("people = %+v, want only Jude Tremblay", people)
|
||||
}
|
||||
// The receipt was reassigned to the canonical person (not orphaned).
|
||||
got, _ := s.Get("r1")
|
||||
if got.PersonID == nil || *got.PersonID != people[0].ID {
|
||||
t.Errorf("receipt person = %v, want %d", got.PersonID, people[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcilePeople_LeavesCustomAndCanonicalAlone(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
if err := s.Seed(nil, []string{"Jude Tremblay"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.AddPerson("Grandma"); err != nil { // no canonical first-name match
|
||||
t.Fatal(err)
|
||||
}
|
||||
merged, err := s.ReconcilePeople([]string{"Jude Tremblay"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if merged != 0 {
|
||||
t.Fatalf("merged = %d, want 0", merged)
|
||||
}
|
||||
people, _ := s.ListPeople()
|
||||
if len(people) != 2 {
|
||||
t.Errorf("people = %+v, want 2 (canonical + custom kept)", people)
|
||||
}
|
||||
}
|
||||
|
||||
// padID returns a deterministic 36-char UUID-ish id from an index.
|
||||
func padID(i int) string {
|
||||
const base = "00000000-0000-0000-0000-0000000000"
|
||||
return base + string(rune('0'+i/10)) + string(rune('0'+i%10))
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"maisym.com/hsa/internal/classify"
|
||||
"maisym.com/hsa/internal/storage"
|
||||
)
|
||||
|
||||
|
|
@ -25,6 +26,9 @@ type classifyResponse struct {
|
|||
RawName string `json:"raw_name"`
|
||||
RawDate string `json:"raw_date"`
|
||||
RawAmount string `json:"raw_amount"`
|
||||
|
||||
Model string `json:"model"` // model that ran, for display
|
||||
CostCents *float64 `json:"cost_cents,omitempty"` // nil when the model's price is unknown
|
||||
}
|
||||
|
||||
// handleClassify accepts an uploaded receipt image and returns suggested form
|
||||
|
|
@ -78,6 +82,10 @@ func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
|
|||
RawName: sug.RawName,
|
||||
RawDate: sug.RawDate,
|
||||
RawAmount: sug.RawAmount,
|
||||
Model: s.classifier.Model,
|
||||
}
|
||||
if cents, ok := classify.CostCents(s.classifier.Model, sug.Usage); ok {
|
||||
out.CostCents = ¢s
|
||||
}
|
||||
if id, ok := idForLabel(sug.Category, cats); ok {
|
||||
out.CategoryID = &id
|
||||
|
|
|
|||
|
|
@ -32,6 +32,64 @@ input, select {
|
|||
a { color: #1a73e8; }
|
||||
small { color: #555; }
|
||||
|
||||
/* Footnote under the file picker: which model auto-fills the form. */
|
||||
.footnote {
|
||||
font-size: .8rem;
|
||||
color: #555;
|
||||
margin: .4rem 0 0;
|
||||
}
|
||||
.footnote code {
|
||||
background: #eee;
|
||||
padding: .05rem .3rem;
|
||||
border-radius: .25rem;
|
||||
}
|
||||
.footnote .err-inline { color: #a50e0e; }
|
||||
|
||||
/* Skip-AI / "add anyway" inline checkboxes. */
|
||||
label.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
font-weight: 400;
|
||||
font-size: .9rem;
|
||||
margin: .5rem 0 0;
|
||||
}
|
||||
label.check input { width: auto; }
|
||||
|
||||
/* Possible-duplicate warning panel. */
|
||||
.dup-warn {
|
||||
background: #fef7e0;
|
||||
border: 1px solid #f0c000;
|
||||
color: #7a5a00;
|
||||
padding: .6rem;
|
||||
border-radius: .4rem;
|
||||
margin: 1rem 0;
|
||||
font-size: .9rem;
|
||||
}
|
||||
.dup-warn ul { margin: .4rem 0; padding-left: 1.1rem; }
|
||||
.dup-warn label.check { color: #7a5a00; font-weight: 600; }
|
||||
button.primary:disabled { background: #9aa0a6; }
|
||||
|
||||
/* Tally matrix. */
|
||||
.tally-wrap { overflow-x: auto; }
|
||||
table.tally { border-collapse: collapse; width: 100%; font-size: .95rem; }
|
||||
table.tally th, table.tally td { padding: .4rem .6rem; border-bottom: 1px solid #eee; }
|
||||
table.tally .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
table.tally thead th { border-bottom: 2px solid #ccc; }
|
||||
table.tally tbody th { text-align: left; font-weight: 600; }
|
||||
table.tally .total { font-weight: 600; }
|
||||
table.tally tfoot { border-top: 2px solid #ccc; }
|
||||
table.tally tfoot th, table.tally tfoot td { font-weight: 700; }
|
||||
table.tally .grand { color: #137333; }
|
||||
table.tally .muted { color: #bbb; }
|
||||
|
||||
/* Recent lists + tabs + pager. */
|
||||
p.tabs a.active { font-weight: 700; text-decoration: none; color: #137333; }
|
||||
ul.recent { list-style: none; padding: 0; }
|
||||
ul.recent li { padding: .5rem 0; border-bottom: 1px solid #eee; }
|
||||
ul.recent small { color: #555; }
|
||||
p.pager { margin-top: 1rem; }
|
||||
|
||||
.err {
|
||||
background: #fce8e6;
|
||||
color: #a50e0e;
|
||||
|
|
|
|||
|
|
@ -22,4 +22,6 @@ var (
|
|||
confirmPage = parsePage("confirm.html")
|
||||
managePage = parsePage("manage.html")
|
||||
exportPage = parsePage("export.html")
|
||||
tallyPage = parsePage("tally.html")
|
||||
recentPage = parsePage("recent.html")
|
||||
)
|
||||
|
|
|
|||
30
internal/web/templates/recent.html
Normal file
30
internal/web/templates/recent.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{{define "title"}}{{.Title}}{{end}}
|
||||
{{define "content"}}
|
||||
<header>
|
||||
<span><a href="/">← Add receipt</a></span>
|
||||
<span><a href="/tally">Tally</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a></span>
|
||||
</header>
|
||||
<h1>{{.Title}}</h1>
|
||||
<p class="tabs">
|
||||
<a href="/recent"{{if eq .BasePath "/recent"}} class="active"{{end}}>By upload date</a> ·
|
||||
<a href="/recent/receipts"{{if eq .BasePath "/recent/receipts"}} class="active"{{end}}>By receipt date</a>
|
||||
</p>
|
||||
{{if not .Rows}}
|
||||
<p><small>No receipts to show.</small></p>
|
||||
{{else}}
|
||||
<ul class="recent">
|
||||
{{range .Rows}}
|
||||
<li>
|
||||
<a href="/receipt/{{.ID}}/file" target="_blank">${{.Amount}}</a>
|
||||
· {{.Date}} · {{.Category}}{{if .Who}} · {{.Who}}{{end}}
|
||||
<br><small>{{.Filename}} — uploaded {{.When}}</small>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<p class="pager">
|
||||
{{if .PrevURL}}<a href="{{.PrevURL}}">← Newer</a>{{end}}
|
||||
{{if and .PrevURL .NextURL}} · {{end}}
|
||||
{{if .NextURL}}<a href="{{.NextURL}}">Load next 10 →</a>{{end}}
|
||||
</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
41
internal/web/templates/tally.html
Normal file
41
internal/web/templates/tally.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{{define "title"}}Tally{{end}}
|
||||
{{define "content"}}
|
||||
<header>
|
||||
<span><a href="/">← Add receipt</a></span>
|
||||
<span><a href="/recent">Recent</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a></span>
|
||||
</header>
|
||||
<h1>Tally</h1>
|
||||
{{if .Empty}}
|
||||
<p><small>No receipts yet. Totals will appear here once you add some.</small></p>
|
||||
{{else}}
|
||||
<p><small>Totals by person and receipt-date year, in dollars. Right column is each
|
||||
person's total; bottom row is each year's total.</small></p>
|
||||
<div class="tally-wrap">
|
||||
<table class="tally">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Who</th>
|
||||
{{range .Years}}<th class="num">{{.}}</th>{{end}}
|
||||
<th class="num total">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Rows}}
|
||||
<tr>
|
||||
<th>{{.Person}}</th>
|
||||
{{range .Cells}}<td class="num">{{if .}}{{.}}{{else}}<span class="muted">—</span>{{end}}</td>{{end}}
|
||||
<td class="num total">{{.Total}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th>Total</th>
|
||||
{{range .YearTotals}}<td class="num total">{{.}}</td>{{end}}
|
||||
<td class="num grand">{{.Grand}}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
@ -2,13 +2,19 @@
|
|||
{{define "content"}}
|
||||
<header>
|
||||
<span>{{.Subject}}</span>
|
||||
<span><a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></span>
|
||||
<span><a href="/tally">Tally</a> · <a href="/recent">Recent</a> · <a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></span>
|
||||
</header>
|
||||
<h1>Add receipt</h1>
|
||||
{{range .Errors}}<div class="err">{{.}}</div>{{end}}
|
||||
<form method="post" action="/upload" enctype="multipart/form-data">
|
||||
<form method="post" action="/upload" enctype="multipart/form-data" id="upload-form">
|
||||
<label for="receipt">Receipt photo or PDF</label>
|
||||
<input id="receipt" type="file" name="receipt" accept="image/*,application/pdf" capture="environment" required>
|
||||
{{if .ClassifyEnabled}}
|
||||
<label class="check"><input type="checkbox" id="skip-ai"> Skip AI auto-fill (enter fields by hand)</label>
|
||||
<p class="footnote" id="classify-note">Receipt is read automatically by <code>{{.ClassifyModel}}</code> to pre-fill the fields below — review before submitting.</p>
|
||||
{{else}}
|
||||
<p class="footnote">Auto-fill is off (no API key configured). Enter the fields manually.</p>
|
||||
{{end}}
|
||||
<label for="amount">Amount ($)</label>
|
||||
<input id="amount" type="number" name="amount" step="0.01" min="0.01" inputmode="decimal" value="{{.Amount}}" required>
|
||||
<label for="receipt_date">Date on receipt</label>
|
||||
|
|
@ -24,6 +30,118 @@
|
|||
<option value="">—</option>
|
||||
{{range .People}}<option value="{{.ID}}" {{if eq (printf "%d" .ID) $sp}}selected{{end}}>{{.Label}}</option>{{end}}
|
||||
</select>
|
||||
<button type="submit" class="primary">Submit</button>
|
||||
<div class="dup-warn" id="dup-warn" hidden>
|
||||
<strong>Possible duplicate</strong> — a receipt with this date and amount is already saved:
|
||||
<ul id="dup-list"></ul>
|
||||
<label class="check"><input type="checkbox" id="dup-ack"> Add it anyway</label>
|
||||
</div>
|
||||
<button type="submit" class="primary" id="submit-btn">Submit</button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.getElementById("upload-form");
|
||||
var amount = document.getElementById("amount");
|
||||
var dateEl = document.getElementById("receipt_date");
|
||||
var dupWarn = document.getElementById("dup-warn");
|
||||
var dupList = document.getElementById("dup-list");
|
||||
var dupAck = document.getElementById("dup-ack");
|
||||
var submitBtn = document.getElementById("submit-btn");
|
||||
var dupVisible = false;
|
||||
|
||||
// --- Duplicate check (runs whenever date + amount are both known) ---
|
||||
function updateSubmitState() {
|
||||
submitBtn.disabled = dupVisible && !dupAck.checked;
|
||||
}
|
||||
dupAck.addEventListener("change", updateSubmitState);
|
||||
|
||||
function checkDuplicates() {
|
||||
var a = amount.value.trim(), d = dateEl.value.trim();
|
||||
if (!a || !d) { hideDup(); return; }
|
||||
var url = "/duplicates?date=" + encodeURIComponent(d) + "&amount=" + encodeURIComponent(a);
|
||||
fetch(url)
|
||||
.then(function (r) { return r.ok ? r.json() : { matches: [] }; })
|
||||
.then(function (data) {
|
||||
if (!data.matches || !data.matches.length) { hideDup(); return; }
|
||||
dupList.innerHTML = "";
|
||||
data.matches.forEach(function (m) {
|
||||
var li = document.createElement("li");
|
||||
var who = m.who ? " · " + m.who : "";
|
||||
li.textContent = "$" + m.amount + " · " + m.date + " · " + m.category + who +
|
||||
" — " + m.filename + " (uploaded " + m.uploaded + " by " + m.by + ")";
|
||||
dupList.appendChild(li);
|
||||
});
|
||||
dupWarn.hidden = false;
|
||||
dupVisible = true;
|
||||
dupAck.checked = false;
|
||||
updateSubmitState();
|
||||
})
|
||||
.catch(hideDup);
|
||||
}
|
||||
function hideDup() {
|
||||
dupWarn.hidden = true;
|
||||
dupVisible = false;
|
||||
updateSubmitState();
|
||||
}
|
||||
amount.addEventListener("change", checkDuplicates);
|
||||
dateEl.addEventListener("change", checkDuplicates);
|
||||
checkDuplicates(); // in case the form was pre-filled (e.g. re-render after error)
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{if .ClassifyEnabled}}
|
||||
<script>
|
||||
(function () {
|
||||
var fileInput = document.getElementById("receipt");
|
||||
var skip = document.getElementById("skip-ai");
|
||||
var note = document.getElementById("classify-note");
|
||||
var model = {{.ClassifyModel}};
|
||||
var baseNote = note.innerHTML;
|
||||
|
||||
skip.addEventListener("change", function () {
|
||||
note.style.opacity = skip.checked ? "0.5" : "";
|
||||
if (skip.checked) note.textContent = "AI auto-fill skipped — enter the fields by hand.";
|
||||
else note.innerHTML = baseNote;
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", function () {
|
||||
var file = fileInput.files[0];
|
||||
if (!file || skip.checked) return;
|
||||
note.style.opacity = "";
|
||||
note.textContent = "Reading receipt with " + model + "…";
|
||||
|
||||
var body = new FormData();
|
||||
body.append("receipt", file);
|
||||
|
||||
fetch("/classify", { method: "POST", body: body })
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) throw new Error("classify failed (" + resp.status + ")");
|
||||
return resp.json();
|
||||
})
|
||||
.then(function (d) {
|
||||
if (d.amount) setValue("amount", d.amount);
|
||||
if (d.date) setValue("receipt_date", d.date);
|
||||
if (d.category_id != null) setValue("category_id", String(d.category_id));
|
||||
if (d.person_id != null) setValue("person_id", String(d.person_id));
|
||||
var cost = (d.cost_cents != null) ? " · " + d.cost_cents.toFixed(2) + "¢" : "";
|
||||
note.textContent = "Pre-filled by " + (d.model || model) + cost + " — review and submit.";
|
||||
// Pre-filled date+amount may match an existing receipt — re-check.
|
||||
document.getElementById("receipt_date").dispatchEvent(new Event("change"));
|
||||
})
|
||||
.catch(function (err) {
|
||||
note.innerHTML = baseNote;
|
||||
var e = document.createElement("span");
|
||||
e.className = "err-inline";
|
||||
e.textContent = " Auto-fill failed: " + err.message + ". Enter the fields manually.";
|
||||
note.appendChild(e);
|
||||
});
|
||||
});
|
||||
|
||||
function setValue(id, val) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.value = val;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ type formView struct {
|
|||
CategoryID string
|
||||
PersonID string
|
||||
Errors []string
|
||||
|
||||
// ClassifyModel is the model that will read the receipt to pre-fill the form,
|
||||
// shown as a footnote. ClassifyEnabled is false when no API key is configured.
|
||||
ClassifyModel string
|
||||
ClassifyEnabled bool
|
||||
}
|
||||
|
||||
func (s *Server) handleUploadForm(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -56,6 +61,8 @@ func (s *Server) lookups() (cats, people []storage.Lookup, err error) {
|
|||
}
|
||||
|
||||
func (s *Server) renderForm(w http.ResponseWriter, v formView) {
|
||||
v.ClassifyModel = s.cfg.ClassifyModel
|
||||
v.ClassifyEnabled = s.classifier != nil
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := uploadPage.ExecuteTemplate(w, "base", v); err != nil {
|
||||
s.serverError(w, "render form", err)
|
||||
|
|
|
|||
203
internal/web/views.go
Normal file
203
internal/web/views.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
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)
|
||||
}
|
||||
118
internal/web/views_test.go
Normal file
118
internal/web/views_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"maisym.com/hsa/internal/receipt"
|
||||
)
|
||||
|
||||
// addReceipt inserts a receipt directly via the store for view tests.
|
||||
func addReceipt(t *testing.T, s *Server, id string, amountCents int64, date time.Time) {
|
||||
t.Helper()
|
||||
cats, err := s.store.ListCategories()
|
||||
if err != nil || len(cats) == 0 {
|
||||
t.Fatalf("ListCategories: %v", err)
|
||||
}
|
||||
r := receipt.Receipt{
|
||||
ID: id,
|
||||
UploadedBy: "jm@example.com",
|
||||
UploadedAt: time.Now().UTC(),
|
||||
ReceiptDate: date,
|
||||
AmountCents: amountCents,
|
||||
CategoryID: cats[0].ID,
|
||||
FilePath: id + ".png",
|
||||
ImageData: []byte("\x89PNG\r\n\x1a\nfake"),
|
||||
FileSizeBytes: 12,
|
||||
OriginalFilename: id + ".png",
|
||||
MimeType: "image/png",
|
||||
}
|
||||
if err := s.store.Insert(r); err != nil {
|
||||
t.Fatalf("Insert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func get(t *testing.T, s *Server, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.AddCookie(authCookie(t, s))
|
||||
rec := httptest.NewRecorder()
|
||||
s.Routes().ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestDuplicates_JSONMatch(t *testing.T) {
|
||||
s := testServerWithStore(t)
|
||||
addReceipt(t, s, "dup1", 4250, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
rec := get(t, s, "/duplicates?date=2026-06-01&amount=42.50")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "42.50") || !strings.Contains(body, "dup1.png") {
|
||||
t.Errorf("body missing match: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicates_NoMatchEmpty(t *testing.T) {
|
||||
s := testServerWithStore(t)
|
||||
addReceipt(t, s, "x", 100, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC))
|
||||
rec := get(t, s, "/duplicates?date=2026-06-01&amount=99.99")
|
||||
if !strings.Contains(rec.Body.String(), `"matches":[]`) {
|
||||
t.Errorf("expected empty matches, got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTallyPage_RendersTotals(t *testing.T) {
|
||||
s := testServerWithStore(t)
|
||||
addReceipt(t, s, "t1", 1000, time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC))
|
||||
addReceipt(t, s, "t2", 2500, time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
rec := get(t, s, "/tally")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "2026") || !strings.Contains(body, "35.00") {
|
||||
t.Errorf("tally body missing year/grand: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentPage_ListsAndPages(t *testing.T) {
|
||||
s := testServerWithStore(t)
|
||||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for i := 0; i < 12; i++ {
|
||||
addReceipt(t, s, "rec"+strconv.Itoa(i), int64(100+i), base.AddDate(0, 0, i))
|
||||
}
|
||||
rec := get(t, s, "/recent")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Load next 10") {
|
||||
t.Errorf("expected pager on first page: %s", body)
|
||||
}
|
||||
if strings.Count(body, "/receipt/rec") != 10 {
|
||||
t.Errorf("expected 10 rows, got %d", strings.Count(body, "/receipt/rec"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiptFile_ServesBlob(t *testing.T) {
|
||||
s := testServerWithStore(t)
|
||||
addReceipt(t, s, "file1", 100, time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC))
|
||||
rec := get(t, s, "/receipt/file1/file")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
|
||||
t.Errorf("content-type = %q, want image/png", ct)
|
||||
}
|
||||
if !strings.HasPrefix(rec.Body.String(), "\x89PNG") {
|
||||
t.Errorf("body not the stored blob")
|
||||
}
|
||||
}
|
||||
|
|
@ -69,6 +69,11 @@ func (s *Server) Routes() http.Handler {
|
|||
mux.Handle("GET /{$}", s.requireAuth(http.HandlerFunc(s.handleUploadForm)))
|
||||
mux.Handle("POST /upload", s.requireAuth(http.HandlerFunc(s.handleUpload)))
|
||||
mux.Handle("POST /classify", s.requireAuth(http.HandlerFunc(s.handleClassify)))
|
||||
mux.Handle("GET /duplicates", s.requireAuth(http.HandlerFunc(s.handleDuplicates)))
|
||||
mux.Handle("GET /tally", s.requireAuth(http.HandlerFunc(s.handleTally)))
|
||||
mux.Handle("GET /recent", s.requireAuth(http.HandlerFunc(s.handleRecentUploads)))
|
||||
mux.Handle("GET /recent/receipts", s.requireAuth(http.HandlerFunc(s.handleRecentReceipts)))
|
||||
mux.Handle("GET /receipt/{id}/file", s.requireAuth(http.HandlerFunc(s.handleReceiptFile)))
|
||||
mux.Handle("GET /manage", s.requireAuth(http.HandlerFunc(s.handleManage)))
|
||||
mux.Handle("POST /manage/categories", s.requireAuth(http.HandlerFunc(s.handleAddCategory)))
|
||||
mux.Handle("POST /manage/categories/rename", s.requireAuth(http.HandlerFunc(s.handleRenameCategory)))
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@
|
|||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/go-env.sh
|
||||
go build ./...
|
||||
go build -o hsa ./cmd/hsa
|
||||
echo "Built ./hsa"
|
||||
|
|
|
|||
18
scripts/run.sh
Executable file
18
scripts/run.sh
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build and run the app locally, loading config from .env.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
source scripts/go-env.sh
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
echo "No .env found. Copy .env.example to .env and fill it in." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load .env into the environment (export every assignment).
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
go build -o hsa ./cmd/hsa
|
||||
exec ./hsa "$@"
|
||||
105
spec.md
105
spec.md
|
|
@ -107,3 +107,108 @@ Reimbursement tracking (paid vs pending status)
|
|||
In-place editing of existing receipts
|
||||
Multi-tenancy or per-user data isolation
|
||||
Notification / reminders
|
||||
|
||||
================================================================================
|
||||
v2 — additions
|
||||
================================================================================
|
||||
These supersede the v1 "out of scope" entries for OCR-assisted entry (now present
|
||||
via the classifier) and "Reports, totals, dashboards" (see Tally below). Same two
|
||||
users, same auth model, same storage. Mobile-first still applies.
|
||||
|
||||
1. Skip-AI toggle on upload
|
||||
|
||||
A control on the upload form lets the user opt out of AI parsing for the current
|
||||
receipt — for receipts they know are too hard to read, or to avoid spending an API
|
||||
call on a bad result.
|
||||
- Default: AI parsing ON (auto-fill runs when a file is attached).
|
||||
- When "skip AI" is selected, NO /classify call is made; the user fills amount,
|
||||
date, category, and who by hand.
|
||||
- The model footnote stays visible but reads as disabled when skip is selected,
|
||||
so the user always knows whether a call will happen and which model it uses.
|
||||
- Skipping is per-upload, not a saved preference.
|
||||
|
||||
2. Duplicate-transaction warning before insert
|
||||
|
||||
Once a receipt has both a date and a dollar amount (whether typed or AI-filled),
|
||||
check the DB for an already-posted receipt that looks like the same transaction,
|
||||
and make the user confirm before inserting a possible duplicate.
|
||||
- Match: same receipt_date AND same amount_cents, among non-soft-deleted rows.
|
||||
When a "who" is set on both, prefer/highlight a same-person match; a match with
|
||||
a different or absent person is still shown as a weaker warning.
|
||||
- On a match, show the existing receipt's details: date, amount, category, who,
|
||||
uploaded_by, uploaded_at, original_filename (and a link to view it).
|
||||
- User chooses: Cancel (abort — nothing inserted) or Approve (insert anyway, as a
|
||||
deliberate duplicate). No new schema; this is a pre-insert read + confirm step.
|
||||
- No match → insert proceeds as today with no extra prompt.
|
||||
|
||||
3. Tally tab
|
||||
|
||||
A totals view (read-only). Bucketed by the YEAR of receipt_date.
|
||||
- Matrix: one row per person, one column per year that has data; each cell is the
|
||||
summed amount for that person in that year.
|
||||
- Include an "Unassigned" row for receipts with no "who".
|
||||
- Right margin column: grand total per year (all persons).
|
||||
- Bottom margin row: grand total per person across all years.
|
||||
- Bottom-right cell: overall grand total tracked.
|
||||
- Excludes soft-deleted rows. Amounts shown in dollars (cents / 100).
|
||||
|
||||
4. Recent uploads tab (by upload date)
|
||||
|
||||
A list of the most recently ADDED receipts, ordered by uploaded_at descending.
|
||||
- Show the 10 most recent, with a "Load next 10" control that pages further back
|
||||
(offset or cursor based).
|
||||
- Each row: receipt_date, amount, category, who, original_filename, and a link to
|
||||
view/download. Excludes soft-deleted rows.
|
||||
|
||||
5. Recent receipts tab (by receipt date)
|
||||
|
||||
Identical to #4 but ordered by receipt_date descending instead of uploaded_at —
|
||||
"newest receipts" rather than "newest uploads". Same 10 + "Load next 10" paging.
|
||||
|
||||
6. People catalog integrity (bug)
|
||||
|
||||
The Manage page currently shows partial-name duplicates (e.g. both "Jude" and
|
||||
"Jude Tremblay"). Only the canonical full names seeded from config.json
|
||||
("First Last", per Person.Label) should exist as people.
|
||||
- Remove stray partial entries; keep only the config-seeded canonical labels.
|
||||
- Before deleting a partial entry, reassign any receipts that point at it to the
|
||||
matching canonical person so no receipt loses its "who".
|
||||
- Seeding must be idempotent: re-seeding from config.json must not create a second
|
||||
row for a person who already exists under the canonical label.
|
||||
|
||||
7. Per-parse cost shown in cents (¢)
|
||||
|
||||
After a receipt is classified, show the cost of that single AI call, in cents,
|
||||
using the cent sign (e.g. "0.3¢"). The cost is computed locally from the API
|
||||
response — no extra API call needed.
|
||||
- The Messages API response includes a usage object (input_tokens,
|
||||
output_tokens, and cache_creation/cache_read token counts). The classifier
|
||||
should capture these and return them alongside the suggestion.
|
||||
- Cost = input_tokens × input_price + output_tokens × output_price, using the
|
||||
active model's per-token rates. For the default model (Haiku 4.5): $1 per 1M
|
||||
input tokens and $5 per 1M output tokens — i.e. $0.000001/input-token and
|
||||
$0.000005/output-token. Cache-read tokens bill at ~0.1× input; treat them at
|
||||
the input rate unless we add exact cache pricing later.
|
||||
- Display in cents with the ¢ sign next to where the model footnote shows the
|
||||
model name, so the user sees both which model ran and what the scan cost.
|
||||
|
||||
Where the rates come from (this is the only maintenance cost of the feature):
|
||||
- Token counts are exact and free — they come straight from the response's
|
||||
usage object, no estimation.
|
||||
- Per-token PRICES are not available from any API (the Models API exposes
|
||||
capabilities, not dollars), so they live as hardcoded constants in a small
|
||||
rate table keyed by exact model id:
|
||||
haiku-4-5 → $1/1M in, $5/1M out
|
||||
opus-4-8 → $5/1M in, $25/1M out
|
||||
sonnet-4-6 → $3/1M in, $15/1M out
|
||||
- This table is low-maintenance: Anthropic prices a specific model id once and
|
||||
ships price changes as NEW model ids, so an existing id's rate does not move.
|
||||
A new row is only needed when we adopt a new model — i.e. exactly when we'd be
|
||||
changing CLASSIFY_MODEL anyway.
|
||||
- Unknown model id (not in the table) → show the token counts but omit the ¢
|
||||
figure (or "cost: n/a"), never a guessed number. A stale table degrades
|
||||
gracefully instead of lying.
|
||||
|
||||
(Balance/spend indicator: dropped. The Anthropic API has no remaining-balance
|
||||
endpoint, and a cumulative-spend readout was not wanted. Per-query cost above is
|
||||
the only cost surface.)
|
||||
Loading…
Reference in a new issue