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
|
# Tests never need this: they use a mock endpoint (or the cheapest model when
|
||||||
# HSA_CLASSIFY_IT=1 is set explicitly).
|
# HSA_CLASSIFY_IT=1 is set explicitly).
|
||||||
CLAUDE_API_KEY=
|
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 {
|
if err := store.Seed(catalog.CategoryNames(), catalog.PersonLabels()); err != nil {
|
||||||
log.Fatalf("seed catalog: %v", err)
|
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.
|
// Receipt auto-classification is best-effort: only enabled when an API key is set.
|
||||||
var classifier *classify.Classifier
|
var classifier *classify.Classifier
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ type Suggestion struct {
|
||||||
RawName string `json:"raw_name"`
|
RawName string `json:"raw_name"`
|
||||||
RawDate string `json:"raw_date"`
|
RawDate string `json:"raw_date"`
|
||||||
RawAmount string `json:"raw_amount"`
|
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.
|
// 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.
|
// catalog; pass an empty model to use a sensible default.
|
||||||
func New(apiKey, model string, persons []config.Person, categories []config.Category) *Classifier {
|
func New(apiKey, model string, persons []config.Person, categories []config.Category) *Classifier {
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = "claude-opus-4-8"
|
model = config.DefaultClassifyModel
|
||||||
}
|
}
|
||||||
return &Classifier{
|
return &Classifier{
|
||||||
APIKey: apiKey,
|
APIKey: apiKey,
|
||||||
|
|
@ -111,6 +114,12 @@ type apiResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Input json.RawMessage `json:"input"`
|
Input json.RawMessage `json:"input"`
|
||||||
} `json:"content"`
|
} `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 {
|
Error *struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Message string `json:"message"`
|
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 {
|
if err := json.Unmarshal(blk.Input, &in); err != nil {
|
||||||
return Suggestion{}, fmt.Errorf("parse tool input: %w", err)
|
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)
|
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"
|
"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.
|
// Config holds all runtime configuration, loaded from environment variables.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
IssuerURL string // OIDC issuer, e.g. https://auth.maisym.com
|
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).
|
// Domain config + classification (all optional; classification is best-effort).
|
||||||
c.ConfigPath = envOr("CONFIG_PATH", "./config.json")
|
c.ConfigPath = envOr("CONFIG_PATH", "./config.json")
|
||||||
c.ClassifyAPIKey = strings.TrimSpace(os.Getenv("CLAUDE_API_KEY"))
|
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
|
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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"maisym.com/hsa/internal/classify"
|
||||||
"maisym.com/hsa/internal/storage"
|
"maisym.com/hsa/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -25,6 +26,9 @@ type classifyResponse struct {
|
||||||
RawName string `json:"raw_name"`
|
RawName string `json:"raw_name"`
|
||||||
RawDate string `json:"raw_date"`
|
RawDate string `json:"raw_date"`
|
||||||
RawAmount string `json:"raw_amount"`
|
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
|
// 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,
|
RawName: sug.RawName,
|
||||||
RawDate: sug.RawDate,
|
RawDate: sug.RawDate,
|
||||||
RawAmount: sug.RawAmount,
|
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 {
|
if id, ok := idForLabel(sug.Category, cats); ok {
|
||||||
out.CategoryID = &id
|
out.CategoryID = &id
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,64 @@ input, select {
|
||||||
a { color: #1a73e8; }
|
a { color: #1a73e8; }
|
||||||
small { color: #555; }
|
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 {
|
.err {
|
||||||
background: #fce8e6;
|
background: #fce8e6;
|
||||||
color: #a50e0e;
|
color: #a50e0e;
|
||||||
|
|
|
||||||
|
|
@ -22,4 +22,6 @@ var (
|
||||||
confirmPage = parsePage("confirm.html")
|
confirmPage = parsePage("confirm.html")
|
||||||
managePage = parsePage("manage.html")
|
managePage = parsePage("manage.html")
|
||||||
exportPage = parsePage("export.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"}}
|
{{define "content"}}
|
||||||
<header>
|
<header>
|
||||||
<span>{{.Subject}}</span>
|
<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>
|
</header>
|
||||||
<h1>Add receipt</h1>
|
<h1>Add receipt</h1>
|
||||||
{{range .Errors}}<div class="err">{{.}}</div>{{end}}
|
{{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>
|
<label for="receipt">Receipt photo or PDF</label>
|
||||||
<input id="receipt" type="file" name="receipt" accept="image/*,application/pdf" capture="environment" required>
|
<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>
|
<label for="amount">Amount ($)</label>
|
||||||
<input id="amount" type="number" name="amount" step="0.01" min="0.01" inputmode="decimal" value="{{.Amount}}" required>
|
<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>
|
<label for="receipt_date">Date on receipt</label>
|
||||||
|
|
@ -24,6 +30,118 @@
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{{range .People}}<option value="{{.ID}}" {{if eq (printf "%d" .ID) $sp}}selected{{end}}>{{.Label}}</option>{{end}}
|
{{range .People}}<option value="{{.ID}}" {{if eq (printf "%d" .ID) $sp}}selected{{end}}>{{.Label}}</option>{{end}}
|
||||||
</select>
|
</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>
|
</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}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ type formView struct {
|
||||||
CategoryID string
|
CategoryID string
|
||||||
PersonID string
|
PersonID string
|
||||||
Errors []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) {
|
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) {
|
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")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := uploadPage.ExecuteTemplate(w, "base", v); err != nil {
|
if err := uploadPage.ExecuteTemplate(w, "base", v); err != nil {
|
||||||
s.serverError(w, "render form", err)
|
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("GET /{$}", s.requireAuth(http.HandlerFunc(s.handleUploadForm)))
|
||||||
mux.Handle("POST /upload", s.requireAuth(http.HandlerFunc(s.handleUpload)))
|
mux.Handle("POST /upload", s.requireAuth(http.HandlerFunc(s.handleUpload)))
|
||||||
mux.Handle("POST /classify", s.requireAuth(http.HandlerFunc(s.handleClassify)))
|
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("GET /manage", s.requireAuth(http.HandlerFunc(s.handleManage)))
|
||||||
mux.Handle("POST /manage/categories", s.requireAuth(http.HandlerFunc(s.handleAddCategory)))
|
mux.Handle("POST /manage/categories", s.requireAuth(http.HandlerFunc(s.handleAddCategory)))
|
||||||
mux.Handle("POST /manage/categories/rename", s.requireAuth(http.HandlerFunc(s.handleRenameCategory)))
|
mux.Handle("POST /manage/categories/rename", s.requireAuth(http.HandlerFunc(s.handleRenameCategory)))
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,5 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
source scripts/go-env.sh
|
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 "$@"
|
||||||
107
spec.md
107
spec.md
|
|
@ -106,4 +106,109 @@ CSV / tax-software export
|
||||||
Reimbursement tracking (paid vs pending status)
|
Reimbursement tracking (paid vs pending status)
|
||||||
In-place editing of existing receipts
|
In-place editing of existing receipts
|
||||||
Multi-tenancy or per-user data isolation
|
Multi-tenancy or per-user data isolation
|
||||||
Notification / reminders
|
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