commit 8b7252dad411fa111d7ed96cf7db6e68832fd168 Author: Jean-Michel Tremblay Date: Wed Jun 17 21:40:12 2026 -0400 Initial commit: HSA receipt tracker Go app for capturing and archiving HSA-eligible receipts: OIDC/PKCE auth against Authelia, SQLite storage with dual-write (filesystem + DB blob), mobile-first upload, and DB export. Adds AI receipt classification: a config.json catalog of people and categories (seeded into the DB on startup), a prompt builder that derives name-order/initial variants from the data (with same-surname ambiguity handling), and an Anthropic tool-use client behind POST /classify. Tests run against a mock endpoint; a live integration test is env-gated to the cheapest model. Co-Authored-By: Claude Opus 4.8 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f16ebe9 --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +# Copy to .env and fill in. .env is gitignored. +# Local-dev values shown; for production use the https URLs. + +# OIDC issuer (Authelia) +ISSUER_URL=https://auth.maisym.com + +# OIDC client — must match the client registered in Authelia +OIDC_CLIENT_ID=hsa-tracker +OIDC_CLIENT_SECRET= + +# Where Authelia redirects back. Must be registered in the client's redirect_uris. +# Local dev: +REDIRECT_URL=http://localhost:8080/callback +# Production would be: https://hsa.maisym.com/callback + +# Group that gates access (403 if the user isn't in it) +REQUIRED_GROUP=hsa-users + +# Any random string; used to derive the session-cookie encryption key. +# Generate one with: openssl rand -base64 32 +SESSION_SECRET= + +# Listen address +LISTEN_ADDR=:8080 + +# Storage (defaults shown; relative paths are resolved from the app's working dir). +# For the LXC deployment use absolute paths under /var/lib/hsa. +DB_PATH=./data/hsa.db +STORAGE_DIR=./data/receipts + +# Max upload size in megabytes (reject larger). Camera photos can be ~10MB. +MAX_UPLOAD_MB=32 + +# People + categories catalog (seeded into the DB on startup). +CONFIG_PATH=./config.json + +# Receipt auto-classification (Anthropic). Leave CLAUDE_API_KEY empty to disable. +# 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5d03de --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Compiled binary (root only; not the cmd/hsa/ package dir) +/hsa + +# Env files (keep the example) +*.env +.env +!.env.example + +# Local data: SQLite DB + uploaded receipt files +data/ +*.db +*.db-shm +*.db-wal + +# Generated output from the call_claude.sh prototype +response.json + +# Go build/test artifacts +*.test +*.out +coverage.* diff --git a/call_claude.sh b/call_claude.sh new file mode 100644 index 0000000..f162fbc --- /dev/null +++ b/call_claude.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMG=/home/jm/Downloads/receipt.jpeg +B64=$(base64 -w0 "$IMG") + +OUT=response.json + +curl -s https://api.anthropic.com/v1/messages \ + -H "x-api-key: $CLAUDE_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "content-type: application/json" \ + -o "$OUT" \ + -d @- <<EOF +{ + "model": "claude-opus-4-8", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { "type": "base64", "media_type": "image/jpeg", "data": "$B64" } + }, + { + "type": "text", + "text": "Analyze this receipt and return ONLY a JSON object, no prose, with these fields:\n- \"person\": one of exactly \"jean-michel tremblay\", \"lynna nguyen\", or \"could not fetch\" if the name is absent or matches neither. The first and last name may appear in any order (e.g. \"Tremblay Jean-Michel\" or \"Nguyen, Lynna\"), possibly with a middle initial or accents; match on the name regardless of order or formatting.\n- \"category\": one of exactly \"pharmacy\", \"dental\", \"medical\", or \"other\".\n- \"date\": the receipt date as YYYY-MM-DD, or \"could not fetch\". Today is 2026-06-12, and the receipt date is usually within the last year or two of that (it may be a backfilled older receipt). The year is the last component; if it is only two digits, expand it to a year near the present (20YY within about 2 years of today), never far in the past or future. This is a US receipt, so read ambiguous numeric dates as MM-DD-YY.\n- \"amount\": the total as a plain number string (e.g. \"42.50\"), or \"could not fetch\"." + } + ] + } + ] +} +EOF + +echo "Raw response saved to $OUT" +jq -r '.content[0].text' "$OUT" + +# Cost — Opus 4.8: $5/M input, $25/M output +jq -r ' + .usage as $u + | ($u.input_tokens // 0) as $in + | ($u.output_tokens // 0) as $out + | ($in * 5 / 1000000) as $cin + | ($out * 25 / 1000000) as $cout + | "\n--- usage ---\ninput: \($in) tok $\($cin * 10000 | round / 10000)\noutput: \($out) tok $\($cout * 10000 | round / 10000)\ntotal: $\(($cin + $cout) * 10000 | round / 10000)" +' "$OUT" diff --git a/cmd/hsa/main.go b/cmd/hsa/main.go new file mode 100644 index 0000000..de60ee7 --- /dev/null +++ b/cmd/hsa/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "path/filepath" + + "maisym.com/hsa/internal/classify" + "maisym.com/hsa/internal/config" + "maisym.com/hsa/internal/storage" + "maisym.com/hsa/internal/web" +) + +func main() { + // Best-effort local dev convenience: load .env if present. + if err := config.LoadDotEnv(".env"); err != nil { + log.Fatalf("loading .env: %v", err) + } + + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0o700); err != nil { + log.Fatalf("create db dir: %v", err) + } + store, err := storage.Open(cfg.DBPath) + if err != nil { + log.Fatalf("open store: %v", err) + } + defer store.Close() + + // Load the people/categories catalog and seed it into the DB (insert-if-absent). + catalog, err := config.LoadCatalog(cfg.ConfigPath) + if err != nil { + log.Fatalf("config catalog: %v", err) + } + if err := store.Seed(catalog.CategoryNames(), catalog.PersonLabels()); err != nil { + log.Fatalf("seed catalog: %v", err) + } + + // Receipt auto-classification is best-effort: only enabled when an API key is set. + var classifier *classify.Classifier + if cfg.ClassifyAPIKey != "" { + classifier = classify.New(cfg.ClassifyAPIKey, cfg.ClassifyModel, catalog.Persons, catalog.Categories) + log.Printf("receipt classification enabled (model %s)", cfg.ClassifyModel) + } else { + log.Printf("receipt classification disabled (CLAUDE_API_KEY not set)") + } + + srv, err := web.NewServer(context.Background(), cfg, store, classifier) + if err != nil { + log.Fatalf("server init: %v", err) + } + + log.Printf("HSA listening on %s (issuer %s)", cfg.ListenAddr, cfg.IssuerURL) + if err := http.ListenAndServe(cfg.ListenAddr, srv.Routes()); err != nil { + log.Fatalf("listen: %v", err) + } +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..91fe0ad --- /dev/null +++ b/config.json @@ -0,0 +1,31 @@ +{ + "persons": [ + { "last": "Tremblay", "first": "Jean-Michel", "middle": "" }, + { "last": "Nguyen", "first": "Lynna", "middle": "" }, + { "last": "Tremblay", "first": "Jude", "middle": "" }, + { "last": "Tremblay", "first": "Alex", "middle": "" }, + { "last": "Tremblay", "first": "Evan", "middle": "" } + ], + "categories": [ + { + "name": "Medical", + "examples": ["doctor", "clinic", "hospital", "urgent care", "lab work", "copay", "physician"] + }, + { + "name": "Dental", + "examples": ["dentist", "orthodontist", "cleaning", "filling", "crown", "braces"] + }, + { + "name": "Vision", + "examples": ["optometrist", "eye exam", "glasses", "contact lenses", "frames"] + }, + { + "name": "Pharmacy", + "examples": ["CVS", "Walgreens", "Rite Aid", "Rockville Pharmacy", "prescription", "Rx", "pharmacy copay"] + }, + { + "name": "Other", + "examples": ["anything HSA-eligible that does not fit the buckets above"] + } + ] +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..70e6bb4 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module maisym.com/hsa + +go 1.26.4 + +require ( + github.com/coreos/go-oidc/v3 v3.18.0 + golang.org/x/oauth2 v0.36.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.52.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ad19f6d --- /dev/null +++ b/go.sum @@ -0,0 +1,27 @@ +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= +modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..8832b06 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1 @@ +package auth diff --git a/internal/auth/authorize.go b/internal/auth/authorize.go new file mode 100644 index 0000000..ff68682 --- /dev/null +++ b/internal/auth/authorize.go @@ -0,0 +1,14 @@ +package auth + +import "golang.org/x/oauth2" + +// AuthorizeURL builds the Authelia authorization redirect URL with PKCE and state. +// challenge must be the pre-computed S256 value from ChallengeS256(verifier). +// Extra options (e.g. the OIDC nonce) may be passed via opts. +func AuthorizeURL(cfg *oauth2.Config, state, challenge string, opts ...oauth2.AuthCodeOption) string { + args := append([]oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("code_challenge", challenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + }, opts...) + return cfg.AuthCodeURL(state, args...) +} diff --git a/internal/auth/authorize_test.go b/internal/auth/authorize_test.go new file mode 100644 index 0000000..064bc7c --- /dev/null +++ b/internal/auth/authorize_test.go @@ -0,0 +1,52 @@ +package auth + +import ( + "net/url" + "strings" + "testing" + + "golang.org/x/oauth2" +) + +func TestAuthorizeURL(t *testing.T) { + cfg := &oauth2.Config{ + ClientID: "hsa-tracker", + RedirectURL: "https://hsa.maisym.com/callback", + Scopes: []string{"openid", "profile", "email", "groups"}, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://auth.maisym.com/api/oidc/authorization", + TokenURL: "https://auth.maisym.com/api/oidc/token", + }, + } + + state := "test-state-value" + verifier := "test-verifier-value" + challenge := ChallengeS256(verifier) + + rawURL := AuthorizeURL(cfg, state, challenge) + + u, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("AuthorizeURL returned invalid URL: %v", err) + } + q := u.Query() + + if q.Get("state") != state { + t.Errorf("state = %q, want %q", q.Get("state"), state) + } + if q.Get("code_challenge") != challenge { + t.Errorf("code_challenge = %q, want %q", q.Get("code_challenge"), challenge) + } + if q.Get("code_challenge_method") != "S256" { + t.Errorf("code_challenge_method = %q, want S256", q.Get("code_challenge_method")) + } + if q.Get("response_type") != "code" { + t.Errorf("response_type = %q, want code", q.Get("response_type")) + } + scopes := q.Get("scope") + for _, s := range []string{"openid", "profile", "email", "groups"} { + if !strings.Contains(scopes, s) { + t.Errorf("scope %q missing from %q", s, scopes) + } + } +} diff --git a/internal/auth/authz.go b/internal/auth/authz.go new file mode 100644 index 0000000..03dab8c --- /dev/null +++ b/internal/auth/authz.go @@ -0,0 +1,11 @@ +package auth + +// IsAuthorized returns true if required is present in groups (exact, case-sensitive match). +func IsAuthorized(groups []string, required string) bool { + for _, g := range groups { + if g == required { + return true + } + } + return false +} diff --git a/internal/auth/authz_test.go b/internal/auth/authz_test.go new file mode 100644 index 0000000..6119b96 --- /dev/null +++ b/internal/auth/authz_test.go @@ -0,0 +1,28 @@ +package auth + +import "testing" + +func TestIsAuthorized(t *testing.T) { + cases := []struct { + name string + groups []string + required string + want bool + }{ + {"in group", []string{"hsa-users"}, "hsa-users", true}, + {"in group among others", []string{"admins", "hsa-users", "devs"}, "hsa-users", true}, + {"empty groups", []string{}, "hsa-users", false}, + {"nil groups", nil, "hsa-users", false}, + {"other group only", []string{"admins"}, "hsa-users", false}, + {"wrong case", []string{"HSA-USERS"}, "hsa-users", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := IsAuthorized(tc.groups, tc.required) + if got != tc.want { + t.Errorf("IsAuthorized(%v, %q) = %v, want %v", tc.groups, tc.required, got, tc.want) + } + }) + } +} diff --git a/internal/auth/loginstate.go b/internal/auth/loginstate.go new file mode 100644 index 0000000..08f6fae --- /dev/null +++ b/internal/auth/loginstate.go @@ -0,0 +1,23 @@ +package auth + +// LoginState is the per-login data we stash in a short-lived encrypted cookie +// during /login, to validate the response at /callback. +type LoginState struct { + State string // CSRF token echoed back in the callback + Verifier string // PKCE code verifier + Nonce string // OIDC nonce echoed back in the ID token +} + +// EncodeLoginState encrypts the login state into a cookie value. +func EncodeLoginState(ls LoginState, key [32]byte) (string, error) { + return seal(key, ls) +} + +// DecodeLoginState decrypts a cookie value produced by EncodeLoginState. +func DecodeLoginState(encoded string, key [32]byte) (LoginState, error) { + var ls LoginState + if err := open(key, encoded, &ls); err != nil { + return LoginState{}, err + } + return ls, nil +} diff --git a/internal/auth/pkce.go b/internal/auth/pkce.go new file mode 100644 index 0000000..b781c9d --- /dev/null +++ b/internal/auth/pkce.go @@ -0,0 +1,24 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" +) + +// GenerateVerifier returns a cryptographically random PKCE verifier (RFC 7636). +// Length is 64 URL-safe characters, within the required [43, 128] range. +func GenerateVerifier() (string, error) { + b := make([]byte, 48) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// ChallengeS256 returns the S256 PKCE challenge for the given verifier. +// challenge = BASE64URL(SHA256(ASCII(verifier))) +func ChallengeS256(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} diff --git a/internal/auth/pkce_test.go b/internal/auth/pkce_test.go new file mode 100644 index 0000000..3d08652 --- /dev/null +++ b/internal/auth/pkce_test.go @@ -0,0 +1,54 @@ +package auth + +import ( + "strings" + "testing" +) + +// RFC 7636 Appendix B known vector. +// verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" +// expected challenge (S256) = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" +const ( + rfcVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + rfcChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" +) + +func TestChallengeS256_RFCVector(t *testing.T) { + got := ChallengeS256(rfcVerifier) + if got != rfcChallenge { + t.Errorf("ChallengeS256(%q) = %q, want %q", rfcVerifier, got, rfcChallenge) + } +} + +func TestGenerateVerifier_Length(t *testing.T) { + v, err := GenerateVerifier() + if err != nil { + t.Fatal(err) + } + if len(v) < 43 || len(v) > 128 { + t.Errorf("verifier length %d outside [43,128]", len(v)) + } +} + +func TestGenerateVerifier_URLSafe(t *testing.T) { + for range 20 { + v, err := GenerateVerifier() + if err != nil { + t.Fatal(err) + } + for _, c := range v { + if !strings.ContainsRune("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~", c) { + t.Errorf("verifier %q contains non-URL-safe char %q", v, c) + break + } + } + } +} + +func TestGenerateVerifier_Unique(t *testing.T) { + a, _ := GenerateVerifier() + b, _ := GenerateVerifier() + if a == b { + t.Error("two calls returned the same verifier") + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..f2dc713 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,89 @@ +package auth + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/gob" + "fmt" + "io" + "time" +) + +// Session holds the claims we care about after a successful OIDC login. +type Session struct { + Subject string // email or sub claim + Groups []string // groups claim from Authelia + IssuedAt time.Time // when we issued this session cookie +} + +// seal gob-encodes v and AES-256-GCM encrypts it into a base64url string. +func seal(key [32]byte, v any) (string, error) { + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode(v); err != nil { + return "", fmt.Errorf("seal gob: %w", err) + } + + block, err := aes.NewCipher(key[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + // nonce || ciphertext+tag + ciphertext := gcm.Seal(nonce, nonce, buf.Bytes(), nil) + return base64.RawURLEncoding.EncodeToString(ciphertext), nil +} + +// open decrypts and gob-decodes a value produced by seal into v. +// Returns an error if the value was tampered with or sealed with a different key. +func open(key [32]byte, encoded string, v any) error { + data, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return fmt.Errorf("open base64: %w", err) + } + + block, err := aes.NewCipher(key[:]) + if err != nil { + return err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return err + } + if len(data) < gcm.NonceSize() { + return fmt.Errorf("sealed value too short") + } + nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return fmt.Errorf("open decrypt: %w", err) + } + + if err := gob.NewDecoder(bytes.NewReader(plaintext)).Decode(v); err != nil { + return fmt.Errorf("open gob: %w", err) + } + return nil +} + +// EncodeSession serialises and encrypts a Session into a cookie value. +func EncodeSession(s Session, key [32]byte) (string, error) { + return seal(key, s) +} + +// DecodeSession decrypts and deserialises a cookie value produced by EncodeSession. +func DecodeSession(encoded string, key [32]byte) (Session, error) { + var s Session + if err := open(key, encoded, &s); err != nil { + return Session{}, err + } + return s, nil +} diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go new file mode 100644 index 0000000..9eb54e8 --- /dev/null +++ b/internal/auth/session_test.go @@ -0,0 +1,77 @@ +package auth + +import ( + "testing" + "time" +) + +func testKey() [32]byte { + var k [32]byte + copy(k[:], "test-key-32-bytes-exactly-padded") + return k +} + +func TestSessionRoundTrip(t *testing.T) { + key := testKey() + sess := Session{ + Subject: "jeanmi@example.com", + Groups: []string{"hsa-users", "admins"}, + IssuedAt: time.Now().UTC().Truncate(time.Second), + } + + encoded, err := EncodeSession(sess, key) + if err != nil { + t.Fatalf("encode: %v", err) + } + + got, err := DecodeSession(encoded, key) + if err != nil { + t.Fatalf("decode: %v", err) + } + + if got.Subject != sess.Subject { + t.Errorf("subject: got %q want %q", got.Subject, sess.Subject) + } + if len(got.Groups) != len(sess.Groups) || got.Groups[0] != sess.Groups[0] { + t.Errorf("groups: got %v want %v", got.Groups, sess.Groups) + } + if !got.IssuedAt.Equal(sess.IssuedAt) { + t.Errorf("issuedAt: got %v want %v", got.IssuedAt, sess.IssuedAt) + } +} + +func TestSessionTamperedRejected(t *testing.T) { + key := testKey() + sess := Session{Subject: "someone", Groups: []string{"hsa-users"}, IssuedAt: time.Now()} + + encoded, err := EncodeSession(sess, key) + if err != nil { + t.Fatal(err) + } + + // Flip a byte near the end of the ciphertext. + b := []byte(encoded) + b[len(b)-4] ^= 0xFF + tampered := string(b) + + if _, err := DecodeSession(tampered, key); err == nil { + t.Error("expected error for tampered cookie, got nil") + } +} + +func TestSessionWrongKeyRejected(t *testing.T) { + key := testKey() + sess := Session{Subject: "someone", Groups: []string{"hsa-users"}, IssuedAt: time.Now()} + + encoded, err := EncodeSession(sess, key) + if err != nil { + t.Fatal(err) + } + + var otherKey [32]byte + copy(otherKey[:], "different-key-32-bytes-padded!!!") + + if _, err := DecodeSession(encoded, otherKey); err == nil { + t.Error("expected error for wrong key, got nil") + } +} diff --git a/internal/auth/state.go b/internal/auth/state.go new file mode 100644 index 0000000..0e15b31 --- /dev/null +++ b/internal/auth/state.go @@ -0,0 +1,16 @@ +package auth + +import ( + "crypto/rand" + "encoding/base64" +) + +// GenerateState returns a cryptographically random, URL-safe state value +// for use as a CSRF token in the OIDC authorization flow. +func GenerateState() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/internal/auth/state_test.go b/internal/auth/state_test.go new file mode 100644 index 0000000..8c16241 --- /dev/null +++ b/internal/auth/state_test.go @@ -0,0 +1,22 @@ +package auth + +import "testing" + +func TestGenerateState_Length(t *testing.T) { + s, err := GenerateState() + if err != nil { + t.Fatal(err) + } + // 32 random bytes → 43 base64url chars (no padding) + if len(s) < 32 { + t.Errorf("state too short: %d chars", len(s)) + } +} + +func TestGenerateState_Unique(t *testing.T) { + a, _ := GenerateState() + b, _ := GenerateState() + if a == b { + t.Error("two calls returned the same state") + } +} diff --git a/internal/classify/classify.go b/internal/classify/classify.go new file mode 100644 index 0000000..478bec3 --- /dev/null +++ b/internal/classify/classify.go @@ -0,0 +1,310 @@ +// Package classify calls the Anthropic API to read an HSA receipt image and +// suggest its patient, category, date, and amount. It builds the prompt from the +// configured people and categories (see prompt.go) and forces a structured answer +// via tool-use so the result is always valid JSON. The HTTP endpoint is injectable +// so tests can run against a mock (or the cheapest model) instead of paying for Opus. +package classify + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "maisym.com/hsa/internal/config" +) + +// DefaultEndpoint is the live Anthropic messages API. +const DefaultEndpoint = "https://api.anthropic.com/v1/messages" + +// Suggestion is the classifier's reading of a receipt. The normalized fields are +// nil when the model could not determine them; Category falls back to the last +// (most general) configured category rather than nil, since it is a closed set. +// Raw* hold the literal text the model saw, for auditing misreads. +type Suggestion struct { + Person *string `json:"person"` // canonical person label, or nil + Category string `json:"category"` // one of the configured category names + Date *string `json:"date"` // YYYY-MM-DD, or nil + Amount *string `json:"amount"` // plain number string e.g. "42.50", or nil + + RawName string `json:"raw_name"` + RawDate string `json:"raw_date"` + RawAmount string `json:"raw_amount"` +} + +// Classifier holds everything needed to classify a receipt. +type Classifier struct { + APIKey string + Model string + Endpoint string + HTTP *http.Client + Persons []config.Person + Categories []config.Category +} + +// New builds a Classifier for the live API. Persons and categories come from the +// 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" + } + return &Classifier{ + APIKey: apiKey, + Model: model, + Endpoint: DefaultEndpoint, + HTTP: &http.Client{Timeout: 60 * time.Second}, + Persons: persons, + Categories: categories, + } +} + +const toolName = "record_receipt" + +// --- request / response shapes --- + +type apiRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + Temperature float64 `json:"temperature"` + System string `json:"system"` + Tools []apiTool `json:"tools"` + ToolChoice apiToolPick `json:"tool_choice"` + Messages []apiMessage `json:"messages"` +} + +type apiTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"input_schema"` +} + +type apiToolPick struct { + Type string `json:"type"` + Name string `json:"name"` +} + +type apiMessage struct { + Role string `json:"role"` + Content []apiBlock `json:"content"` +} + +type apiBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Source *apiSource `json:"source,omitempty"` +} + +type apiSource struct { + Type string `json:"type"` + MediaType string `json:"media_type"` + Data string `json:"data"` +} + +type apiResponse struct { + Content []struct { + Type string `json:"type"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` + } `json:"content"` + Error *struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` +} + +type toolInput struct { + Person *string `json:"person"` + Category *string `json:"category"` + Date *string `json:"date"` + Amount *string `json:"amount"` + RawName string `json:"raw_name"` + RawDate string `json:"raw_date"` + RawAmount string `json:"raw_amount"` +} + +// Classify sends the receipt to the model and returns its normalized reading. +// today is used so the date heuristics ("closest to today, never future") are +// testable; pass time.Now(). +func (c *Classifier) Classify(ctx context.Context, today time.Time, image []byte, mimeType string) (Suggestion, error) { + system := BuildSystemPrompt(c.Persons, c.Categories, today.Format("2006-01-02")) + + imgBlock := apiBlock{ + Type: "image", + Source: &apiSource{Type: "base64", MediaType: mimeType, Data: base64.StdEncoding.EncodeToString(image)}, + } + if mimeType == "application/pdf" { + imgBlock.Type = "document" + } + + reqBody := apiRequest{ + Model: c.Model, + MaxTokens: 1024, + Temperature: 0, + System: system, + Tools: []apiTool{{Name: toolName, Description: "Record the extracted receipt fields.", InputSchema: c.inputSchema()}}, + ToolChoice: apiToolPick{Type: "tool", Name: toolName}, + Messages: []apiMessage{{ + Role: "user", + Content: []apiBlock{ + imgBlock, + {Type: "text", Text: "Read this receipt and call record_receipt."}, + }, + }}, + } + + payload, err := json.Marshal(reqBody) + if err != nil { + return Suggestion{}, fmt.Errorf("marshal request: %w", err) + } + + endpoint := c.Endpoint + if endpoint == "" { + endpoint = DefaultEndpoint + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return Suggestion{}, fmt.Errorf("build request: %w", err) + } + req.Header.Set("x-api-key", c.APIKey) + req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("content-type", "application/json") + + httpClient := c.HTTP + if httpClient == nil { + httpClient = http.DefaultClient + } + resp, err := httpClient.Do(req) + if err != nil { + return Suggestion{}, fmt.Errorf("call anthropic: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return Suggestion{}, fmt.Errorf("read response: %w", err) + } + + var ar apiResponse + if err := json.Unmarshal(body, &ar); err != nil { + return Suggestion{}, fmt.Errorf("parse response (status %d): %w", resp.StatusCode, err) + } + if ar.Error != nil { + return Suggestion{}, fmt.Errorf("anthropic error: %s: %s", ar.Error.Type, ar.Error.Message) + } + if resp.StatusCode != http.StatusOK { + return Suggestion{}, fmt.Errorf("anthropic status %d: %s", resp.StatusCode, string(body)) + } + + for _, blk := range ar.Content { + if blk.Type == "tool_use" && blk.Name == toolName { + var in toolInput + if err := json.Unmarshal(blk.Input, &in); err != nil { + return Suggestion{}, fmt.Errorf("parse tool input: %w", err) + } + return c.normalize(in), nil + } + } + return Suggestion{}, fmt.Errorf("no %s tool_use in response", toolName) +} + +// inputSchema is the JSON Schema the model must fill. Enums constrain person and +// category to the configured sets; null is allowed where a field may be unknown. +func (c *Classifier) inputSchema() map[string]any { + personEnum := append(allowedLabelsAny(c.Persons), nil) + catEnum := make([]any, 0, len(c.Categories)) + for _, cat := range c.Categories { + catEnum = append(catEnum, cat.Name) + } + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "person": map[string]any{"type": []string{"string", "null"}, "enum": personEnum, "description": "Exact patient name, or null if absent/ambiguous."}, + "category": map[string]any{"type": "string", "enum": catEnum, "description": "Best-fit category name."}, + "date": map[string]any{"type": []string{"string", "null"}, "description": "Service date as YYYY-MM-DD, or null."}, + "amount": map[string]any{"type": []string{"string", "null"}, "description": "Total paid as a plain number, or null."}, + "raw_name": map[string]any{"type": "string", "description": "Literal name text read, or empty string."}, + "raw_date": map[string]any{"type": "string", "description": "Literal date text read, or empty string."}, + "raw_amount": map[string]any{"type": "string", "description": "Literal amount text read, or empty string."}, + }, + "required": []string{"person", "category", "date", "amount", "raw_name", "raw_date", "raw_amount"}, + } +} + +// allowedLabels here returns []any for the schema enum (string labels). +func allowedLabelsAny(persons []config.Person) []any { + labels := allowedLabels(persons) + out := make([]any, len(labels)) + for i, l := range labels { + out[i] = l + } + return out +} + +// normalize validates and cleans the raw tool output into a Suggestion. It guards +// against the model returning a non-canonical person or category despite the enum. +func (c *Classifier) normalize(in toolInput) Suggestion { + s := Suggestion{ + RawName: strings.TrimSpace(in.RawName), + RawDate: strings.TrimSpace(in.RawDate), + RawAmount: strings.TrimSpace(in.RawAmount), + } + + // Person: keep only if it matches a canonical label exactly. + if in.Person != nil { + if p := strings.TrimSpace(*in.Person); p != "" && !isNullish(p) { + for _, want := range c.Persons { + if strings.EqualFold(p, want.Label()) { + label := want.Label() + s.Person = &label + break + } + } + } + } + + // Category: must be a configured name; otherwise fall back to the last + // (most general) category. + s.Category = c.fallbackCategory() + if in.Category != nil { + got := strings.TrimSpace(*in.Category) + for _, cat := range c.Categories { + if strings.EqualFold(got, cat.Name) { + s.Category = cat.Name + break + } + } + } + + if in.Date != nil { + if d := strings.TrimSpace(*in.Date); d != "" && !isNullish(d) { + s.Date = &d + } + } + if in.Amount != nil { + if a := strings.TrimSpace(*in.Amount); a != "" && !isNullish(a) { + s.Amount = &a + } + } + return s +} + +func (c *Classifier) fallbackCategory() string { + if len(c.Categories) == 0 { + return "" + } + return c.Categories[len(c.Categories)-1].Name +} + +// isNullish catches stringified nulls the model may emit despite the schema. +func isNullish(s string) bool { + switch strings.ToLower(s) { + case "null", "none", "n/a", "na", "could not fetch", "unknown": + return true + } + return false +} diff --git a/internal/classify/classify_test.go b/internal/classify/classify_test.go new file mode 100644 index 0000000..2c89bdf --- /dev/null +++ b/internal/classify/classify_test.go @@ -0,0 +1,156 @@ +package classify + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "maisym.com/hsa/internal/config" +) + +func testCatalog() ([]config.Person, []config.Category) { + persons := []config.Person{ + {First: "Jean-Michel", Last: "Tremblay"}, + {First: "Lynna", Last: "Nguyen"}, + {First: "Jude", Last: "Tremblay"}, + } + categories := []config.Category{ + {Name: "Medical", Examples: []string{"clinic"}}, + {Name: "Pharmacy", Examples: []string{"CVS"}}, + {Name: "Other"}, + } + return persons, categories +} + +// mockServer returns an httptest server that replies with a tool_use block whose +// input is the given map, and the Classifier pointed at it. +func mockServer(t *testing.T, input map[string]any) (*Classifier, *httptest.Server) { + t.Helper() + persons, categories := testCatalog() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Sanity: the request must force our tool. + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), toolName) { + t.Errorf("request missing tool %q: %s", toolName, body) + } + resp := map[string]any{ + "content": []map[string]any{ + {"type": "tool_use", "name": toolName, "input": input}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + t.Cleanup(srv.Close) + + c := New("test-key", "claude-haiku-4-5-20251001", persons, categories) + c.Endpoint = srv.URL + c.HTTP = srv.Client() + return c, srv +} + +func TestClassifyHappyPath(t *testing.T) { + c, _ := mockServer(t, map[string]any{ + "person": "Lynna Nguyen", + "category": "Pharmacy", + "date": "2025-11-03", + "amount": "42.50", + "raw_name": "Nguyen, Lynna", + "raw_date": "11/03/25", + "raw_amount": "$42.50", + }) + + got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg") + if err != nil { + t.Fatalf("Classify: %v", err) + } + if got.Person == nil || *got.Person != "Lynna Nguyen" { + t.Errorf("person = %v, want Lynna Nguyen", got.Person) + } + if got.Category != "Pharmacy" { + t.Errorf("category = %q, want Pharmacy", got.Category) + } + if got.Date == nil || *got.Date != "2025-11-03" { + t.Errorf("date = %v, want 2025-11-03", got.Date) + } + if got.Amount == nil || *got.Amount != "42.50" { + t.Errorf("amount = %v, want 42.50", got.Amount) + } + if got.RawName != "Nguyen, Lynna" { + t.Errorf("raw_name = %q", got.RawName) + } +} + +func TestClassifyNullsAndFallback(t *testing.T) { + c, _ := mockServer(t, map[string]any{ + "person": nil, + "category": "definitely-not-a-real-category", + "date": nil, + "amount": "null", // stringified null + "raw_name": "", + "raw_date": "", + "raw_amount": "", + }) + + got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg") + if err != nil { + t.Fatalf("Classify: %v", err) + } + if got.Person != nil { + t.Errorf("person = %v, want nil", *got.Person) + } + if got.Date != nil { + t.Errorf("date = %v, want nil", *got.Date) + } + if got.Amount != nil { + t.Errorf("amount = %v, want nil (stringified null)", *got.Amount) + } + if got.Category != "Other" { // fallback to most general + t.Errorf("category = %q, want Other fallback", got.Category) + } +} + +func TestClassifyRejectsNonCanonicalPerson(t *testing.T) { + c, _ := mockServer(t, map[string]any{ + "person": "Dr. Emily Smith", // a provider, not a configured patient + "category": "Medical", + }) + got, err := c.Classify(context.Background(), time.Now(), []byte("img"), "image/jpeg") + if err != nil { + t.Fatalf("Classify: %v", err) + } + if got.Person != nil { + t.Errorf("person = %v, want nil (non-canonical)", *got.Person) + } +} + +// TestClassifyIntegration hits the real API with the cheapest model. Skipped unless +// HSA_CLASSIFY_IT=1 and CLAUDE_API_KEY are set, so normal test runs cost nothing. +func TestClassifyIntegration(t *testing.T) { + if os.Getenv("HSA_CLASSIFY_IT") != "1" { + t.Skip("set HSA_CLASSIFY_IT=1 (and CLAUDE_API_KEY) to run the live integration test") + } + key := os.Getenv("CLAUDE_API_KEY") + if key == "" { + t.Skip("CLAUDE_API_KEY not set") + } + persons, categories := testCatalog() + c := New(key, "claude-haiku-4-5-20251001", persons, categories) + + img, err := os.ReadFile(os.Getenv("HSA_CLASSIFY_IMG")) + if err != nil { + t.Skipf("set HSA_CLASSIFY_IMG to a receipt image: %v", err) + } + got, err := c.Classify(context.Background(), time.Now(), img, "image/jpeg") + if err != nil { + t.Fatalf("Classify: %v", err) + } + t.Logf("suggestion: %+v", got) +} diff --git a/internal/classify/prompt.go b/internal/classify/prompt.go new file mode 100644 index 0000000..5565e2d --- /dev/null +++ b/internal/classify/prompt.go @@ -0,0 +1,192 @@ +package classify + +import ( + "fmt" + "sort" + "strings" + + "maisym.com/hsa/internal/config" +) + +// NameVariants returns example renderings of one person's name as it might appear +// on a receipt, derived from the structured (first/middle/last) data rather than +// hand-written. `all` is the full people list, used to suppress initial-based +// variants that would be ambiguous: with several people sharing a surname (e.g. +// the Tremblays), "J. Tremblay" could be Jean-Michel or Jude, so neither gets it, +// while "A. Tremblay" (unique to Alex) is kept. +func NameVariants(p config.Person, all []config.Person) []string { + first := strings.TrimSpace(p.First) + last := strings.TrimSpace(p.Last) + mid := strings.TrimSpace(p.Middle) + + var out []string + add := func(s string) { + s = strings.Join(strings.Fields(s), " ") // collapse whitespace + if s == "" { + return + } + for _, e := range out { + if e == s { + return + } + } + out = append(out, s) + } + + // Order and punctuation variants are always unambiguous (full first name present). + add(first + " " + last) + add(last + " " + first) + add(last + ", " + first) + if mid != "" { + mi := initial(mid) + add(first + " " + mi + ". " + last) + add(last + ", " + first + " " + mi + ".") + } + + // Compound first names ("Jean-Michel") yield specific multi-letter initials + // ("J-M", "J.M.") that are far less likely to collide — keep them if unique. + if parts := firstParts(first); len(parts) > 1 { + comp := compoundInitial(parts, "-") // J-M + compDot := compoundInitial(parts, ".") + "." + if uniqueCompound(p, all) { + add(comp + " " + last) + add(compDot + " " + last) + } + } + + // A single first-initial ("J.") is only safe when no one else shares the + // surname with the same initial. + if fi := initial(first); fi != "" && uniqueSingleInitial(p, all) { + add(fi + ". " + last) + add(last + ", " + fi + ".") + } + + return out +} + +func initial(s string) string { + for _, r := range strings.TrimSpace(s) { + return strings.ToUpper(string(r)) + } + return "" +} + +// firstParts splits a first name on spaces and hyphens ("Jean-Michel" -> Jean, Michel). +func firstParts(first string) []string { + return strings.FieldsFunc(first, func(r rune) bool { return r == '-' || r == ' ' }) +} + +// compoundInitial joins the initial of each part with sep: ["Jean","Michel"] -> "J-M" / "J.M". +func compoundInitial(parts []string, sep string) string { + var b strings.Builder + for i, p := range parts { + if i > 0 { + b.WriteString(sep) + } + b.WriteString(initial(p)) + } + return b.String() +} + +// sameSurname reports whether q is a different person sharing p's surname. +func sameSurname(p, q config.Person) bool { + return !strings.EqualFold(strings.TrimSpace(p.First), strings.TrimSpace(q.First)) && + strings.EqualFold(strings.TrimSpace(p.Last), strings.TrimSpace(q.Last)) +} + +// uniqueSingleInitial reports whether p's first initial is unique among people +// sharing p's surname. +func uniqueSingleInitial(p config.Person, all []config.Person) bool { + fi := initial(p.First) + for _, q := range all { + if sameSurname(p, q) && initial(q.First) == fi { + return false + } + } + return true +} + +// uniqueCompound reports whether p's compound first-initial is unique among people +// sharing p's surname. +func uniqueCompound(p config.Person, all []config.Person) bool { + pc := compoundInitial(firstParts(p.First), "-") + for _, q := range all { + if sameSurname(p, q) && compoundInitial(firstParts(q.First), "-") == pc { + return false + } + } + return true +} + +// BuildSystemPrompt assembles the full instruction text for the classifier from the +// catalog and today's date (YYYY-MM-DD). The catalog's canonical labels are the only +// allowed outputs; variants and examples are presented as illustrations, never as +// data to extract. +func BuildSystemPrompt(persons []config.Person, categories []config.Category, today string) string { + var b strings.Builder + + b.WriteString("You extract data from a US health-care receipt for HSA reimbursement. ") + b.WriteString("Call the record_receipt tool exactly once with your best reading. ") + b.WriteString("Never invent values; when a field is genuinely unreadable or absent, pass null for it.\n\n") + + // People. + b.WriteString("PATIENT — who the receipt is FOR. Output one of these EXACT names, or null:\n") + for _, p := range persons { + variants := NameVariants(p, persons) + // Drop the canonical form itself from the "also appears as" list. + var also []string + for _, v := range variants { + if v != p.Label() { + also = append(also, v) + } + } + if len(also) > 0 { + fmt.Fprintf(&b, " - %q (may also appear as: %s)\n", p.Label(), strings.Join(also, "; ")) + } else { + fmt.Fprintf(&b, " - %q\n", p.Label()) + } + } + b.WriteString("Rules for the patient:\n") + b.WriteString(" - The variants above are formatting illustrations of the SAME person, not separate people.\n") + b.WriteString(" - Match on first AND last name together, in any order, with or without a comma, accents, or a middle name/initial.\n") + b.WriteString(" - A lone surname, or an initial that fits more than one person above (e.g. \"J. Tremblay\" could be Jean-Michel or Jude), is AMBIGUOUS — output null.\n") + b.WriteString(" - IGNORE everyone who is not the patient: doctors, dentists, pharmacists, nurses, providers, billing/account reps, the store manager, the cashier. Titles like Dr., MD, DDS, RPh, NP mark a provider, not a patient. A clinic, pharmacy, or business name is not a patient.\n\n") + + // Categories. + b.WriteString("CATEGORY — output exactly one of these names (default to the most general bucket if unsure, never invent one):\n") + for _, c := range categories { + if len(c.Examples) > 0 { + fmt.Fprintf(&b, " - %q: %s\n", c.Name, strings.Join(c.Examples, ", ")) + } else { + fmt.Fprintf(&b, " - %q\n", c.Name) + } + } + b.WriteString("\n") + + // Date. + fmt.Fprintf(&b, "DATE — the date of SERVICE or purchase as YYYY-MM-DD, or null. Today is %s.\n", today) + b.WriteString(" - Receipts are contemporary: almost always within the last year or two, occasionally an older backfilled receipt, but NEVER in the future.\n") + b.WriteString(" - If several dates appear (service, print, due, date of birth), use the service/transaction date — not a print/due date or DOB.\n") + b.WriteString(" - Formats are usually numeric MM-DD-YY or MM-DD-YYYY (US order), sometimes a written month (\"Jun 5, 2025\"), and ISO YYYY-MM-DD when the first part is a 4-digit year. YY-MM-DD also occurs but is rare.\n") + b.WriteString(" - Expand a 2-digit year to 20YY. When a date is ambiguous, pick the interpretation CLOSEST to today without going into the future.\n\n") + + // Amount. + b.WriteString("AMOUNT — the total the patient actually PAID (grand total / amount due / amount paid / patient responsibility) as a plain number string like \"42.50\", or null.\n") + b.WriteString(" - Strip currency symbols and thousands separators. Do not return a subtotal, list price, insurance-covered portion, or a single line item when a final total exists.\n\n") + + // Raw echoes. + b.WriteString("RAW — also pass back the literal text you read for the name, date, and amount (raw_name, raw_date, raw_amount), exactly as printed, for auditing. Use \"\" if nothing was found.\n") + b.WriteString("If the image holds more than one receipt, read the primary (largest/topmost) one.\n") + + return b.String() +} + +// allowedLabels returns the canonical labels of a lookup, sorted for stable output. +func allowedLabels(persons []config.Person) []string { + out := make([]string, 0, len(persons)) + for _, p := range persons { + out = append(out, p.Label()) + } + sort.Strings(out) + return out +} diff --git a/internal/classify/prompt_test.go b/internal/classify/prompt_test.go new file mode 100644 index 0000000..04e0f30 --- /dev/null +++ b/internal/classify/prompt_test.go @@ -0,0 +1,90 @@ +package classify + +import ( + "strings" + "testing" + + "maisym.com/hsa/internal/config" +) + +func contains(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} + +// The Tremblay family: Jean-Michel and Jude share the initial "J", so neither may +// get a bare "J. Tremblay" variant; Alex ("A") and Evan ("E") are unique and may. +func TestNameVariantsAmbiguousInitial(t *testing.T) { + all := []config.Person{ + {First: "Jean-Michel", Last: "Tremblay"}, + {First: "Jude", Last: "Tremblay"}, + {First: "Alex", Last: "Tremblay"}, + {First: "Evan", Last: "Tremblay"}, + {First: "Lynna", Last: "Nguyen"}, + } + + jm := NameVariants(all[0], all) + if contains(jm, "J. Tremblay") || contains(jm, "Tremblay, J.") { + t.Errorf("Jean-Michel must not get an ambiguous single-initial variant: %v", jm) + } + // Compound initials are unique to Jean-Michel and should appear. + if !contains(jm, "J-M Tremblay") { + t.Errorf("Jean-Michel should get compound initial variant J-M Tremblay: %v", jm) + } + + jude := NameVariants(all[1], all) + if contains(jude, "J. Tremblay") { + t.Errorf("Jude must not get an ambiguous single-initial variant: %v", jude) + } + + alex := NameVariants(all[2], all) + if !contains(alex, "A. Tremblay") { + t.Errorf("Alex (unique initial) should get A. Tremblay: %v", alex) + } + + // Lynna's surname is unique, so a single initial is fine. + lynna := NameVariants(all[4], all) + if !contains(lynna, "L. Nguyen") { + t.Errorf("Lynna (unique surname) should get L. Nguyen: %v", lynna) + } + + // Order/comma variants are always present and unambiguous. + for _, want := range []string{"Jean-Michel Tremblay", "Tremblay Jean-Michel", "Tremblay, Jean-Michel"} { + if !contains(jm, want) { + t.Errorf("expected variant %q in %v", want, jm) + } + } +} + +func TestBuildSystemPrompt(t *testing.T) { + persons := []config.Person{ + {First: "Jean-Michel", Last: "Tremblay"}, + {First: "Lynna", Last: "Nguyen"}, + } + categories := []config.Category{ + {Name: "Pharmacy", Examples: []string{"CVS", "Rx"}}, + {Name: "Other"}, + } + p := BuildSystemPrompt(persons, categories, "2026-06-17") + + for _, want := range []string{ + "Jean-Michel Tremblay", // canonical label + "Lynna Nguyen", + "Pharmacy", + "CVS", // example injected + "2026-06-17", // today + "AMBIGUOUS", // the ambiguity rule + "IGNORE everyone", // ignore-other-people rule + "null", // unknown handling + "raw_name", // raw echo + "CLOSEST to today", // date tie-breaker + } { + if !strings.Contains(p, want) { + t.Errorf("system prompt missing %q", want) + } + } +} diff --git a/internal/config/catalog.go b/internal/config/catalog.go new file mode 100644 index 0000000..30109fe --- /dev/null +++ b/internal/config/catalog.go @@ -0,0 +1,92 @@ +package config + +// This file loads the editable, non-secret lists from config.json (path set by +// CONFIG_PATH) into Go structs: the people a receipt can belong to, and the +// categories it can fall into. It is separate from config.go, which only reads +// environment variables (secrets, ports, paths). On startup these lists are +// seeded into the database; the per-category "examples" are used to build the +// receipt-classification prompt and deliberately do NOT live in the DB, so they +// can be tuned without a schema change. + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// Catalog is the parsed content of config.json. +type Catalog struct { + Persons []Person `json:"persons"` + Categories []Category `json:"categories"` +} + +// Person is one possible "Who" for a receipt, stored structurally (last/first/ +// middle) so the classifier can derive the many ways a name may appear on a +// receipt — different order, a comma, or initials. +type Person struct { + Last string `json:"last"` + First string `json:"first"` + Middle string `json:"middle"` +} + +// Category is one classification bucket plus authored examples (store names, +// typical line items) that help the classifier decide. Examples are semantic and +// cannot be derived from the name, so they are authored here by hand. +type Category struct { + Name string `json:"name"` + Examples []string `json:"examples"` +} + +// Label is the canonical display name ("First Last"), used both as the DB label +// and as the exact string the classifier must emit. The middle name is carried +// only to disambiguate; it is not part of the canonical label. +func (p Person) Label() string { + return strings.TrimSpace(strings.TrimSpace(p.First) + " " + strings.TrimSpace(p.Last)) +} + +// LoadCatalog reads and validates config.json at path. A missing file is an error: +// the app needs people and categories to function. +func LoadCatalog(path string) (Catalog, error) { + f, err := os.Open(path) + if err != nil { + return Catalog{}, fmt.Errorf("read config file %q: %w", path, err) + } + defer f.Close() + + var c Catalog + dec := json.NewDecoder(f) + dec.DisallowUnknownFields() + if err := dec.Decode(&c); err != nil { + return Catalog{}, fmt.Errorf("parse config file %q: %w", path, err) + } + for i, p := range c.Persons { + if strings.TrimSpace(p.First) == "" || strings.TrimSpace(p.Last) == "" { + return Catalog{}, fmt.Errorf("person %d: first and last are required", i) + } + } + for i, cat := range c.Categories { + if strings.TrimSpace(cat.Name) == "" { + return Catalog{}, fmt.Errorf("category %d: name is required", i) + } + } + return c, nil +} + +// CategoryNames returns the category labels in config order. +func (c Catalog) CategoryNames() []string { + out := make([]string, 0, len(c.Categories)) + for _, cat := range c.Categories { + out = append(out, cat.Name) + } + return out +} + +// PersonLabels returns the canonical person labels in config order. +func (c Catalog) PersonLabels() []string { + out := make([]string, 0, len(c.Persons)) + for _, p := range c.Persons { + out = append(out, p.Label()) + } + return out +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..83d9ebe --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,122 @@ +package config + +import ( + "bufio" + "crypto/sha256" + "fmt" + "os" + "strconv" + "strings" +) + +// Config holds all runtime configuration, loaded from environment variables. +type Config struct { + IssuerURL string // OIDC issuer, e.g. https://auth.maisym.com + ClientID string // OIDC client_id + ClientSecret string // OIDC client secret (plaintext) + RedirectURL string // e.g. http://localhost:8080/callback + RequiredGroup string // group that gates access, e.g. hsa-users + SessionKey [32]byte // AES-256 key derived from SESSION_SECRET + ListenAddr string // e.g. :8080 + SecureCookies bool // true when serving over https + + DBPath string // SQLite file path + StorageDir string // directory for receipt files on disk + MaxUploadBytes int64 // reject uploads larger than this + + ConfigPath string // path to config.json (people + categories) + ClassifyAPIKey string // Anthropic API key; empty disables auto-classification + ClassifyModel string // model id for classification +} + +// Load reads configuration from the environment, validating required values. +func Load() (Config, error) { + var c Config + var missing []string + + get := func(key string) string { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + missing = append(missing, key) + } + return v + } + + c.IssuerURL = get("ISSUER_URL") + c.ClientID = get("OIDC_CLIENT_ID") + c.ClientSecret = get("OIDC_CLIENT_SECRET") + c.RedirectURL = get("REDIRECT_URL") + c.RequiredGroup = get("REQUIRED_GROUP") + sessionSecret := get("SESSION_SECRET") + + if len(missing) > 0 { + return Config{}, fmt.Errorf("missing required env vars: %s", strings.Join(missing, ", ")) + } + + // Derive a fixed 32-byte AES key from whatever secret string was provided. + c.SessionKey = sha256.Sum256([]byte(sessionSecret)) + + c.ListenAddr = strings.TrimSpace(os.Getenv("LISTEN_ADDR")) + if c.ListenAddr == "" { + c.ListenAddr = ":8080" + } + + c.SecureCookies = strings.HasPrefix(c.RedirectURL, "https://") + + c.DBPath = envOr("DB_PATH", "./data/hsa.db") + c.StorageDir = envOr("STORAGE_DIR", "./data/receipts") + + maxMB := 32 + if v := strings.TrimSpace(os.Getenv("MAX_UPLOAD_MB")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + maxMB = n + } + } + c.MaxUploadBytes = int64(maxMB) * 1024 * 1024 + + // 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") + + return c, nil +} + +func envOr(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +// LoadDotEnv is a best-effort loader that reads KEY=VALUE lines from path into +// the process environment if the file exists. Existing env vars are not overwritten. +// Intended for local development convenience only. +func LoadDotEnv(path string) error { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = strings.Trim(strings.TrimSpace(val), `"'`) + if _, exists := os.LookupEnv(key); !exists { + os.Setenv(key, val) + } + } + return scanner.Err() +} diff --git a/internal/receipt/receipt.go b/internal/receipt/receipt.go new file mode 100644 index 0000000..f9f3023 --- /dev/null +++ b/internal/receipt/receipt.go @@ -0,0 +1,75 @@ +// Package receipt holds the receipt domain model and pure validation logic. +package receipt + +import ( + "fmt" + "strings" + "time" +) + +// Receipt is a stored receipt record. Category and Who are referenced by id into +// the categories/people lookup tables (PersonID is nil when "Who" is unset). +type Receipt struct { + ID string + UploadedBy string + UploadedAt time.Time + ReceiptDate time.Time + AmountCents int64 + CategoryID int64 + PersonID *int64 + FilePath string + ImageData []byte + FileSizeBytes int64 + OriginalFilename string + MimeType string + DeletedAt *time.Time +} + +// ParseAmountCents parses a positive dollar amount into integer cents without +// using floating point. Accepts an optional leading "$", surrounding spaces, and +// thousands separators. Rejects empty, non-numeric, more than two decimal places, +// zero, and negative values. +func ParseAmountCents(s string) (int64, error) { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "$") + s = strings.ReplaceAll(s, ",", "") + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("empty amount") + } + if strings.HasPrefix(s, "-") { + return 0, fmt.Errorf("amount must be positive") + } + + whole, frac, hasDot := strings.Cut(s, ".") + if hasDot && strings.Contains(frac, ".") { + return 0, fmt.Errorf("invalid amount %q", s) + } + if whole == "" && frac == "" { + return 0, fmt.Errorf("invalid amount %q", s) + } + + // Normalise the fractional part to exactly two digits. + switch len(frac) { + case 0: + frac = "00" + case 1: + frac = frac + "0" + case 2: + // ok + default: + return 0, fmt.Errorf("amount has more than two decimal places: %q", s) + } + + var cents int64 + for _, r := range whole + frac { + if r < '0' || r > '9' { + return 0, fmt.Errorf("invalid amount %q", s) + } + cents = cents*10 + int64(r-'0') + } + if cents <= 0 { + return 0, fmt.Errorf("amount must be greater than zero") + } + return cents, nil +} diff --git a/internal/receipt/receipt_test.go b/internal/receipt/receipt_test.go new file mode 100644 index 0000000..5f3b296 --- /dev/null +++ b/internal/receipt/receipt_test.go @@ -0,0 +1,37 @@ +package receipt + +import "testing" + +func TestParseAmountCents(t *testing.T) { + ok := []struct { + in string + want int64 + }{ + {"12.34", 1234}, + {"0.99", 99}, + {"100", 10000}, + {"12.3", 1230}, + {"12", 1200}, + {"1234.00", 123400}, + {"$12.34", 1234}, + {" 12.34 ", 1234}, + {"1,234.56", 123456}, + } + for _, tc := range ok { + got, err := ParseAmountCents(tc.in) + if err != nil { + t.Errorf("ParseAmountCents(%q) unexpected error: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("ParseAmountCents(%q) = %d, want %d", tc.in, got, tc.want) + } + } + + bad := []string{"", "abc", "12.345", "-5.00", "0", "0.00", "12.3.4", "."} + for _, in := range bad { + if _, err := ParseAmountCents(in); err == nil { + t.Errorf("ParseAmountCents(%q) expected error, got nil", in) + } + } +} diff --git a/internal/storage/lookup.go b/internal/storage/lookup.go new file mode 100644 index 0000000..27256b1 --- /dev/null +++ b/internal/storage/lookup.go @@ -0,0 +1,70 @@ +package storage + +import ( + "fmt" + "strings" +) + +// ListCategories returns all categories ordered by label. +func (s *Store) ListCategories() ([]Lookup, error) { return s.listLookup("categories") } + +// ListPeople returns all people ("Who") ordered by label. +func (s *Store) ListPeople() ([]Lookup, error) { return s.listLookup("people") } + +// AddCategory inserts a new category label and returns its id. +func (s *Store) AddCategory(label string) (int64, error) { return s.addLookup("categories", label) } + +// AddPerson inserts a new person label and returns its id. +func (s *Store) AddPerson(label string) (int64, error) { return s.addLookup("people", label) } + +// RenameCategory updates a category's label (the change propagates to every +// receipt referencing it, since receipts hold the id, not the text). +func (s *Store) RenameCategory(id int64, label string) error { + return s.renameLookup("categories", id, label) +} + +// RenamePerson updates a person's label. +func (s *Store) RenamePerson(id int64, label string) error { + return s.renameLookup("people", id, label) +} + +func (s *Store) listLookup(table string) ([]Lookup, error) { + rows, err := s.db.Query(`SELECT id, label FROM ` + table + ` ORDER BY label COLLATE NOCASE`) + if err != nil { + return nil, fmt.Errorf("list %s: %w", table, err) + } + defer rows.Close() + + var out []Lookup + for rows.Next() { + var l Lookup + if err := rows.Scan(&l.ID, &l.Label); err != nil { + return nil, fmt.Errorf("scan %s: %w", table, err) + } + out = append(out, l) + } + return out, rows.Err() +} + +func (s *Store) addLookup(table, label string) (int64, error) { + label = strings.TrimSpace(label) + if label == "" { + return 0, fmt.Errorf("label cannot be empty") + } + res, err := s.db.Exec(`INSERT INTO `+table+`(label) VALUES (?)`, label) + if err != nil { + return 0, fmt.Errorf("add %s: %w", table, err) + } + return res.LastInsertId() +} + +func (s *Store) renameLookup(table string, id int64, label string) error { + label = strings.TrimSpace(label) + if label == "" { + return fmt.Errorf("label cannot be empty") + } + if _, err := s.db.Exec(`UPDATE `+table+` SET label = ? WHERE id = ?`, label, id); err != nil { + return fmt.Errorf("rename %s: %w", table, err) + } + return nil +} diff --git a/internal/storage/snapshot.go b/internal/storage/snapshot.go new file mode 100644 index 0000000..d6b4d0c --- /dev/null +++ b/internal/storage/snapshot.go @@ -0,0 +1,41 @@ +package storage + +import ( + "database/sql" + "fmt" + "strings" +) + +// SnapshotTo writes a consistent point-in-time copy of the database to destPath +// using SQLite's VACUUM INTO (never touching/locking the live file). When +// includeBlobs is false, the image_data blobs are stripped from the copy, +// leaving only metadata — which is ~99% smaller. +// +// destPath must not already exist (VACUUM INTO requires this). +func (s *Store) SnapshotTo(destPath string, includeBlobs bool) error { + if _, err := s.db.Exec(`VACUUM INTO '` + escapeSQLiteString(destPath) + `'`); err != nil { + return fmt.Errorf("vacuum into snapshot: %w", err) + } + if includeBlobs { + return nil + } + + // Strip blobs from the copy only. image_data is NOT NULL, so use an empty blob. + snap, err := sql.Open("sqlite", destPath) + if err != nil { + return fmt.Errorf("open snapshot: %w", err) + } + defer snap.Close() + if _, err := snap.Exec(`UPDATE receipts SET image_data = X''`); err != nil { + return fmt.Errorf("strip blobs: %w", err) + } + if _, err := snap.Exec(`VACUUM`); err != nil { + return fmt.Errorf("vacuum stripped snapshot: %w", err) + } + return nil +} + +// escapeSQLiteString escapes a string for use inside single quotes in SQL. +func escapeSQLiteString(s string) string { + return strings.ReplaceAll(s, "'", "''") +} diff --git a/internal/storage/snapshot_test.go b/internal/storage/snapshot_test.go new file mode 100644 index 0000000..fb3f55c --- /dev/null +++ b/internal/storage/snapshot_test.go @@ -0,0 +1,71 @@ +package storage + +import ( + "path/filepath" + "testing" +) + +func TestSnapshotWithBlobs(t *testing.T) { + s := newTestStore(t) + r := sampleReceipt(firstCategoryID(t, s)) + if err := s.Insert(r); err != nil { + t.Fatal(err) + } + + dest := filepath.Join(t.TempDir(), "snap.db") + if err := s.SnapshotTo(dest, true); err != nil { + t.Fatalf("SnapshotTo: %v", err) + } + + snap, err := Open(dest) + if err != nil { + t.Fatal(err) + } + defer snap.Close() + + got, err := snap.Get(r.ID) + if err != nil { + t.Fatal(err) + } + if string(got.ImageData) != string(r.ImageData) { + t.Errorf("blob not preserved: got %q", got.ImageData) + } +} + +func TestSnapshotWithoutBlobs(t *testing.T) { + s := newTestStore(t) + r := sampleReceipt(firstCategoryID(t, s)) + if err := s.Insert(r); err != nil { + t.Fatal(err) + } + + dest := filepath.Join(t.TempDir(), "snap-noblob.db") + if err := s.SnapshotTo(dest, false); err != nil { + t.Fatalf("SnapshotTo: %v", err) + } + + snap, err := Open(dest) + if err != nil { + t.Fatal(err) + } + defer snap.Close() + + // Metadata row survives, but the blob is stripped. + n, err := snap.CountActive() + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("CountActive = %d, want 1 (metadata should survive)", n) + } + got, err := snap.Get(r.ID) + if err != nil { + t.Fatal(err) + } + if len(got.ImageData) != 0 { + t.Errorf("blob not stripped: got %d bytes", len(got.ImageData)) + } + if got.AmountCents != r.AmountCents { + t.Errorf("metadata lost: amount = %d, want %d", got.AmountCents, r.AmountCents) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..a52bd88 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,179 @@ +// Package storage is the SQLite persistence layer for receipts. +package storage + +import ( + "database/sql" + "fmt" + "strings" + "time" + + _ "modernc.org/sqlite" + + "maisym.com/hsa/internal/receipt" +) + +// Store wraps the SQLite database. +type Store struct { + db *sql.DB +} + +// Lookup is an editable label referenced by receipts via foreign key. +type Lookup struct { + ID int64 + Label string +} + +// seedCategories are inserted on first open. They can be renamed (and more added) +// from the manage portal; receipts reference them by id, so renames propagate. +var seedCategories = []string{"Medical", "Dental", "Vision", "Pharmacy", "Other"} + +const schema = ` +CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL UNIQUE +); +CREATE TABLE IF NOT EXISTS people ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL UNIQUE +); +CREATE TABLE IF NOT EXISTS receipts ( + id TEXT PRIMARY KEY, + uploaded_by TEXT NOT NULL, + uploaded_at TEXT NOT NULL, + receipt_date TEXT NOT NULL, + amount_cents INTEGER NOT NULL, + category_id INTEGER NOT NULL REFERENCES categories(id), + person_id INTEGER REFERENCES people(id), + file_path TEXT NOT NULL, + image_data BLOB NOT NULL, + file_size_bytes INTEGER NOT NULL, + original_filename TEXT NOT NULL, + mime_type TEXT NOT NULL, + deleted_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_receipts_active ON receipts(deleted_at); +` + +// Open opens (creating if needed) the SQLite database at path, applies the schema, +// and seeds the category list on first creation. +func Open(path string) (*Store, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;`); err != nil { + db.Close() + return nil, fmt.Errorf("set pragmas: %w", err) + } + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, fmt.Errorf("apply schema: %w", err) + } + for _, label := range seedCategories { + if _, err := db.Exec(`INSERT OR IGNORE INTO categories(label) VALUES (?)`, label); err != nil { + db.Close() + return nil, fmt.Errorf("seed categories: %w", err) + } + } + return &Store{db: db}, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +// Seed inserts the given category and person labels if they are not already +// present (INSERT OR IGNORE), so renames made in the manage portal are preserved +// across restarts. Called on startup from the config catalog. +func (s *Store) Seed(categories, people []string) error { + insert := func(table string, labels []string) error { + for _, label := range labels { + label = strings.TrimSpace(label) + if label == "" { + continue + } + if _, err := s.db.Exec(`INSERT OR IGNORE INTO `+table+`(label) VALUES (?)`, label); err != nil { + return fmt.Errorf("seed %s: %w", table, err) + } + } + return nil + } + if err := insert("categories", categories); err != nil { + return err + } + return insert("people", people) +} + +const rfc3339 = time.RFC3339 + +// Insert stores a receipt (metadata + image blob) in a single statement. +func (s *Store) Insert(r receipt.Receipt) error { + var personID any + if r.PersonID != nil { + personID = *r.PersonID + } + _, err := s.db.Exec( + `INSERT INTO receipts + (id, uploaded_by, uploaded_at, receipt_date, amount_cents, category_id, person_id, + file_path, image_data, file_size_bytes, original_filename, mime_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + r.ID, r.UploadedBy, r.UploadedAt.UTC().Format(rfc3339), + r.ReceiptDate.UTC().Format(rfc3339), r.AmountCents, r.CategoryID, personID, + r.FilePath, r.ImageData, r.FileSizeBytes, r.OriginalFilename, r.MimeType, + ) + if err != nil { + return fmt.Errorf("insert receipt: %w", err) + } + return nil +} + +// Get returns the receipt with the given id (including soft-deleted rows). +func (s *Store) Get(id string) (receipt.Receipt, error) { + row := s.db.QueryRow( + `SELECT id, uploaded_by, uploaded_at, receipt_date, amount_cents, category_id, person_id, + file_path, image_data, file_size_bytes, original_filename, mime_type, deleted_at + FROM receipts WHERE id = ?`, id) + + var r receipt.Receipt + var uploadedAt, receiptDate string + var personID sql.NullInt64 + var deletedAt sql.NullString + if err := row.Scan( + &r.ID, &r.UploadedBy, &uploadedAt, &receiptDate, &r.AmountCents, &r.CategoryID, &personID, + &r.FilePath, &r.ImageData, &r.FileSizeBytes, &r.OriginalFilename, &r.MimeType, &deletedAt, + ); err != nil { + return receipt.Receipt{}, fmt.Errorf("get receipt: %w", err) + } + r.UploadedAt, _ = time.Parse(rfc3339, uploadedAt) + r.ReceiptDate, _ = time.Parse(rfc3339, receiptDate) + if personID.Valid { + r.PersonID = &personID.Int64 + } + if deletedAt.Valid { + if t, err := time.Parse(rfc3339, deletedAt.String); err == nil { + r.DeletedAt = &t + } + } + return r, nil +} + +// SoftDelete marks a receipt deleted without removing the row or its blob. +func (s *Store) SoftDelete(id string) error { + _, err := s.db.Exec( + `UPDATE receipts SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`, + time.Now().UTC().Format(rfc3339), id) + if err != nil { + return fmt.Errorf("soft delete: %w", err) + } + return nil +} + +// CountActive returns the number of non-deleted receipts. +func (s *Store) CountActive() (int, error) { + var n int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM receipts WHERE deleted_at IS NULL`).Scan(&n); err != nil { + return 0, fmt.Errorf("count active: %w", err) + } + return n, nil +} + +// DB exposes the underlying handle for the export snapshot. +func (s *Store) DB() *sql.DB { return s.db } diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go new file mode 100644 index 0000000..62f2e4e --- /dev/null +++ b/internal/storage/storage_test.go @@ -0,0 +1,175 @@ +package storage + +import ( + "path/filepath" + "testing" + "time" + + "maisym.com/hsa/internal/receipt" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + s, err := Open(dbPath) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func sampleReceipt(categoryID int64) receipt.Receipt { + return receipt.Receipt{ + ID: "11111111-1111-1111-1111-111111111111", + UploadedBy: "jm@example.com", + UploadedAt: time.Now().UTC().Truncate(time.Second), + ReceiptDate: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), + AmountCents: 1234, + CategoryID: categoryID, + FilePath: "ab/abcd.jpg", + ImageData: []byte("fake-image-bytes"), + FileSizeBytes: 16, + OriginalFilename: "receipt.jpg", + MimeType: "image/jpeg", + } +} + +// firstCategoryID returns the id of a seeded category for use in tests. +func firstCategoryID(t *testing.T, s *Store) int64 { + t.Helper() + cats, err := s.ListCategories() + if err != nil || len(cats) == 0 { + t.Fatalf("ListCategories: %v (len %d)", err, len(cats)) + } + return cats[0].ID +} + +func TestInsertAndCount(t *testing.T) { + s := newTestStore(t) + if err := s.Insert(sampleReceipt(firstCategoryID(t, s))); err != nil { + t.Fatalf("Insert: %v", err) + } + + n, err := s.CountActive() + if err != nil { + t.Fatalf("CountActive: %v", err) + } + if n != 1 { + t.Errorf("CountActive = %d, want 1", n) + } +} + +func TestInsertPreservesBlobAndFields(t *testing.T) { + s := newTestStore(t) + r := sampleReceipt(firstCategoryID(t, s)) + if err := s.Insert(r); err != nil { + t.Fatalf("Insert: %v", err) + } + + got, err := s.Get(r.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.AmountCents != r.AmountCents { + t.Errorf("AmountCents = %d, want %d", got.AmountCents, r.AmountCents) + } + if string(got.ImageData) != string(r.ImageData) { + t.Errorf("ImageData = %q, want %q", got.ImageData, r.ImageData) + } + if got.CategoryID != r.CategoryID || got.OriginalFilename != r.OriginalFilename { + t.Errorf("field mismatch: %+v", got) + } +} + +func TestInsertWithPerson(t *testing.T) { + s := newTestStore(t) + pid, err := s.AddPerson("John") + if err != nil { + t.Fatal(err) + } + r := sampleReceipt(firstCategoryID(t, s)) + r.PersonID = &pid + if err := s.Insert(r); err != nil { + t.Fatalf("Insert: %v", err) + } + got, err := s.Get(r.ID) + if err != nil { + t.Fatal(err) + } + if got.PersonID == nil || *got.PersonID != pid { + t.Errorf("PersonID = %v, want %d", got.PersonID, pid) + } +} + +func TestInsertRejectsUnknownCategoryFK(t *testing.T) { + s := newTestStore(t) + r := sampleReceipt(99999) // no such category + if err := s.Insert(r); err == nil { + t.Error("expected FK violation for unknown category_id, got nil") + } +} + +func TestRenameCategoryPropagatesViaID(t *testing.T) { + s := newTestStore(t) + cid := firstCategoryID(t, s) + r := sampleReceipt(cid) + if err := s.Insert(r); err != nil { + t.Fatal(err) + } + if err := s.RenameCategory(cid, "Medical (fixed)"); err != nil { + t.Fatal(err) + } + // The receipt still points at the same id; the label change is automatic. + got, _ := s.Get(r.ID) + if got.CategoryID != cid { + t.Errorf("CategoryID changed after rename: %d", got.CategoryID) + } + cats, _ := s.ListCategories() + var found bool + for _, c := range cats { + if c.ID == cid && c.Label == "Medical (fixed)" { + found = true + } + } + if !found { + t.Errorf("rename not reflected in ListCategories: %+v", cats) + } +} + +func TestSoftDeleteHidesFromCount(t *testing.T) { + s := newTestStore(t) + r := sampleReceipt(firstCategoryID(t, s)) + if err := s.Insert(r); err != nil { + t.Fatalf("Insert: %v", err) + } + if err := s.SoftDelete(r.ID); err != nil { + t.Fatalf("SoftDelete: %v", err) + } + + n, err := s.CountActive() + if err != nil { + t.Fatalf("CountActive: %v", err) + } + if n != 0 { + t.Errorf("CountActive after soft delete = %d, want 0", n) + } +} + +func TestSeededCategories(t *testing.T) { + s := newTestStore(t) + cats, err := s.ListCategories() + if err != nil { + t.Fatal(err) + } + if len(cats) != 5 { + t.Errorf("seeded categories = %d, want 5", len(cats)) + } + people, err := s.ListPeople() + if err != nil { + t.Fatal(err) + } + if len(people) != 0 { + t.Errorf("people should start empty, got %d", len(people)) + } +} diff --git a/internal/web/export.go b/internal/web/export.go new file mode 100644 index 0000000..b617087 --- /dev/null +++ b/internal/web/export.go @@ -0,0 +1,54 @@ +package web + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "time" +) + +func (s *Server) handleExportPage(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := exportPage.ExecuteTemplate(w, "base", nil); err != nil { + s.serverError(w, "render export page", err) + } +} + +func (s *Server) handleExportDB(w http.ResponseWriter, r *http.Request) { + includeBlobs := r.URL.Query().Get("blobs") != "false" // default: include + + // Snapshot to a fresh temp path (VACUUM INTO requires the file not exist). + tmpDir, err := os.MkdirTemp("", "hsa-export-") + if err != nil { + s.serverError(w, "export tempdir", err) + return + } + defer os.RemoveAll(tmpDir) + snapPath := filepath.Join(tmpDir, "snapshot.db") + + if err := s.store.SnapshotTo(snapPath, includeBlobs); err != nil { + s.serverError(w, "export snapshot", err) + return + } + + f, err := os.Open(snapPath) + if err != nil { + s.serverError(w, "open snapshot", err) + return + } + defer f.Close() + + suffix := "" + if !includeBlobs { + suffix = "-metadata" + } + filename := fmt.Sprintf("hsa-export-%s%s.db", time.Now().Format(dateLayout), suffix) + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + if fi, err := f.Stat(); err == nil { + w.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size())) + } + http.ServeContent(w, r, filename, time.Now(), f) +} diff --git a/internal/web/manage.go b/internal/web/manage.go new file mode 100644 index 0000000..e0ddedd --- /dev/null +++ b/internal/web/manage.go @@ -0,0 +1,85 @@ +package web + +import ( + "net/http" + "net/url" + "strconv" + "strings" + + "maisym.com/hsa/internal/storage" +) + +type manageView struct { + Categories []storage.Lookup + People []storage.Lookup + Error string +} + +func (s *Server) handleManage(w http.ResponseWriter, r *http.Request) { + cats, people, err := s.lookups() + if err != nil { + s.serverError(w, "load lookups", err) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = managePage.ExecuteTemplate(w, "base", manageView{ + Categories: cats, + People: people, + Error: r.URL.Query().Get("error"), + }) +} + +func (s *Server) handleAddCategory(w http.ResponseWriter, r *http.Request) { + s.addLookup(w, r, s.store.AddCategory) +} + +func (s *Server) handleAddPerson(w http.ResponseWriter, r *http.Request) { + s.addLookup(w, r, s.store.AddPerson) +} + +func (s *Server) handleRenameCategory(w http.ResponseWriter, r *http.Request) { + s.renameLookup(w, r, s.store.RenameCategory) +} + +func (s *Server) handleRenamePerson(w http.ResponseWriter, r *http.Request) { + s.renameLookup(w, r, s.store.RenamePerson) +} + +func (s *Server) addLookup(w http.ResponseWriter, r *http.Request, add func(string) (int64, error)) { + label := strings.TrimSpace(r.FormValue("label")) + if label == "" { + redirectManage(w, r, "Label cannot be empty.") + return + } + if _, err := add(label); err != nil { + redirectManage(w, r, "Could not add — maybe that label already exists.") + return + } + redirectManage(w, r, "") +} + +func (s *Server) renameLookup(w http.ResponseWriter, r *http.Request, rename func(int64, string) error) { + id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64) + if err != nil { + redirectManage(w, r, "Invalid id.") + return + } + label := strings.TrimSpace(r.FormValue("label")) + if label == "" { + redirectManage(w, r, "Label cannot be empty.") + return + } + if err := rename(id, label); err != nil { + redirectManage(w, r, "Could not rename — maybe that label already exists.") + return + } + redirectManage(w, r, "") +} + +func redirectManage(w http.ResponseWriter, r *http.Request, errMsg string) { + target := "/manage" + if errMsg != "" { + target += "?error=" + url.QueryEscape(errMsg) + } + http.Redirect(w, r, target, http.StatusSeeOther) +} diff --git a/internal/web/manage_test.go b/internal/web/manage_test.go new file mode 100644 index 0000000..8f7ff53 --- /dev/null +++ b/internal/web/manage_test.go @@ -0,0 +1,110 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" +) + +func postForm(t *testing.T, s *Server, path string, form url.Values) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(authCookie(t, s)) + rec := httptest.NewRecorder() + s.Routes().ServeHTTP(rec, req) + return rec +} + +func TestManage_AddAndRenamePerson(t *testing.T) { + s := testServerWithStore(t) + + // Add "johnn" (with the typo). + rec := postForm(t, s, "/manage/people", url.Values{"label": {"johnn"}}) + if rec.Code != http.StatusSeeOther { + t.Fatalf("add person status = %d, want 303", rec.Code) + } + people, _ := s.store.ListPeople() + if len(people) != 1 || people[0].Label != "johnn" { + t.Fatalf("after add: %+v", people) + } + id := people[0].ID + + // Rename to fix the typo — same id. + rec = postForm(t, s, "/manage/people/rename", + url.Values{"id": {strconv.FormatInt(id, 10)}, "label": {"John"}}) + if rec.Code != http.StatusSeeOther { + t.Fatalf("rename status = %d, want 303", rec.Code) + } + people, _ = s.store.ListPeople() + if len(people) != 1 || people[0].ID != id || people[0].Label != "John" { + t.Errorf("after rename: %+v (id should be unchanged)", people) + } +} + +func TestManage_AddCategory(t *testing.T) { + s := testServerWithStore(t) + postForm(t, s, "/manage/categories", url.Values{"label": {"Chiropractic"}}) + cats, _ := s.store.ListCategories() + if len(cats) != 6 { // 5 seeded + 1 + t.Errorf("categories = %d, want 6", len(cats)) + } +} + +func TestManage_EmptyLabelRejected(t *testing.T) { + s := testServerWithStore(t) + rec := postForm(t, s, "/manage/people", url.Values{"label": {" "}}) + loc := rec.Header().Get("Location") + if !strings.Contains(loc, "error=") { + t.Errorf("expected error redirect, got %q", loc) + } + people, _ := s.store.ListPeople() + if len(people) != 0 { + t.Errorf("empty label should not create a person, got %d", len(people)) + } +} + +func TestManage_PageListsCategories(t *testing.T) { + s := testServerWithStore(t) + req := httptest.NewRequest(http.MethodGet, "/manage", nil) + req.AddCookie(authCookie(t, s)) + rec := httptest.NewRecorder() + s.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{"Medical", "Categories", "Who"} { + if !strings.Contains(body, want) { + t.Errorf("manage page missing %q", want) + } + } +} + +func TestUpload_WithWho_StoresPersonID(t *testing.T) { + s := testServerWithStore(t) + pid, err := s.store.AddPerson("John") + if err != nil { + t.Fatal(err) + } + + body, ct := multipartUpload(t, + map[string]string{ + "amount": "20.00", "receipt_date": "2026-06-01", + "category_id": aCategoryID(t, s), "person_id": strconv.FormatInt(pid, 10), + }, + "receipt", "r.png", fakePNG()) + req := httptest.NewRequest(http.MethodPost, "/upload", body) + req.Header.Set("Content-Type", ct) + req.AddCookie(authCookie(t, s)) + rec := httptest.NewRecorder() + s.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "John") { + t.Errorf("expected confirmation mentioning John; status=%d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/web/middleware.go b/internal/web/middleware.go new file mode 100644 index 0000000..feb6faa --- /dev/null +++ b/internal/web/middleware.go @@ -0,0 +1,63 @@ +package web + +import ( + "context" + "net/http" + "time" + + "maisym.com/hsa/internal/auth" +) + +type ctxKey int + +const sessionKey ctxKey = 0 + +// requireAuth rejects requests without a valid session cookie by redirecting to /login. +// On success the decoded session is placed in the request context. +func (s *Server) requireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(sessionCookie) + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + sess, err := auth.DecodeSession(c.Value, s.cfg.SessionKey) + if err != nil { + s.clearCookie(w, sessionCookie) + http.Redirect(w, r, "/login", http.StatusFound) + return + } + ctx := context.WithValue(r.Context(), sessionKey, sess) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// sessionFrom returns the session stored in the context by requireAuth. +func sessionFrom(ctx context.Context) auth.Session { + sess, _ := ctx.Value(sessionKey).(auth.Session) + return sess +} + +func (s *Server) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + MaxAge: int(ttl.Seconds()), + HttpOnly: true, + Secure: s.cfg.SecureCookies, + SameSite: http.SameSiteLaxMode, + }) +} + +func (s *Server) clearCookie(w http.ResponseWriter, name string) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: s.cfg.SecureCookies, + SameSite: http.SameSiteLaxMode, + }) +} diff --git a/internal/web/scan.go b/internal/web/scan.go new file mode 100644 index 0000000..d54f50c --- /dev/null +++ b/internal/web/scan.go @@ -0,0 +1,105 @@ +package web + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "maisym.com/hsa/internal/storage" +) + +// classifyResponse is the JSON returned to the upload page to pre-fill the form. +// IDs map the classifier's label suggestions onto the live lookup rows; they are +// null when the classifier returned nothing or the label has no matching row. +type classifyResponse struct { + Amount *string `json:"amount"` + Date *string `json:"date"` + CategoryID *int64 `json:"category_id"` + PersonID *int64 `json:"person_id"` + + Category string `json:"category"` // label, for display + Person string `json:"person"` // label, for display + + RawName string `json:"raw_name"` + RawDate string `json:"raw_date"` + RawAmount string `json:"raw_amount"` +} + +// handleClassify accepts an uploaded receipt image and returns suggested form +// values from the AI classifier. It mutates no state — the user still reviews and +// submits via POST /upload. +func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) { + if s.classifier == nil { + http.Error(w, "classification not configured", http.StatusServiceUnavailable) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes) + if err := r.ParseMultipartForm(10 << 20); err != nil { + http.Error(w, "upload too large or malformed", http.StatusBadRequest) + return + } + + file, _, err := r.FormFile("receipt") + if err != nil { + http.Error(w, "attach a receipt image or PDF", http.StatusBadRequest) + return + } + defer file.Close() + data, err := io.ReadAll(file) + if err != nil { + http.Error(w, "could not read the uploaded file", http.StatusBadRequest) + return + } + mimeType := detectMime(data) + if !allowedMime(mimeType) { + http.Error(w, "only images and PDFs are allowed", http.StatusUnsupportedMediaType) + return + } + + sug, err := s.classifier.Classify(r.Context(), time.Now(), data, mimeType) + if err != nil { + s.serverError(w, "classify receipt", err) + return + } + + cats, people, err := s.lookups() + if err != nil { + s.serverError(w, "load lookups", err) + return + } + + out := classifyResponse{ + Amount: sug.Amount, + Date: sug.Date, + Category: sug.Category, + RawName: sug.RawName, + RawDate: sug.RawDate, + RawAmount: sug.RawAmount, + } + if id, ok := idForLabel(sug.Category, cats); ok { + out.CategoryID = &id + } + if sug.Person != nil { + if id, ok := idForLabel(*sug.Person, people); ok { + out.PersonID = &id + out.Person = *sug.Person + } + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) +} + +// idForLabel finds a lookup row by case-insensitive label match. +func idForLabel(label string, set []storage.Lookup) (int64, bool) { + label = strings.TrimSpace(label) + for _, l := range set { + if strings.EqualFold(l.Label, label) { + return l.ID, true + } + } + return 0, false +} diff --git a/internal/web/static/style.css b/internal/web/static/style.css new file mode 100644 index 0000000..d4b682a --- /dev/null +++ b/internal/web/static/style.css @@ -0,0 +1,83 @@ +body { + font-family: system-ui, sans-serif; + max-width: 30rem; + margin: 0 auto; + padding: 1rem; + line-height: 1.5; +} + +header { + display: flex; + justify-content: space-between; + font-size: .85rem; + color: #555; + margin-bottom: 1rem; +} + +h2 { margin-top: 1.5rem; } + +label { + display: block; + margin: 1rem 0 .25rem; + font-weight: 600; +} + +input, select { + font-size: 1.1rem; + width: 100%; + padding: .6rem; + box-sizing: border-box; +} + +a { color: #1a73e8; } +small { color: #555; } + +.err { + background: #fce8e6; + color: #a50e0e; + padding: .6rem; + border-radius: .4rem; + margin: .5rem 0; +} + +.ok { + color: #137333; + font-weight: 600; + font-size: 1.3rem; +} + +/* Full-width primary submit button (upload form). */ +button.primary { + font-size: 1.1rem; + width: 100%; + padding: .6rem; + margin-top: 1.5rem; + background: #137333; + color: #fff; + border: 0; + border-radius: .4rem; + font-weight: 600; +} + +/* Links styled as buttons. */ +a.button { + display: block; + text-align: center; + color: #fff; + padding: .7rem; + border-radius: .4rem; + text-decoration: none; + margin: .75rem 0; +} +a.button.green { background: #137333; } +a.button.blue { background: #1a73e8; } + +/* Inline rename/add rows on the manage page. */ +form.row { + display: flex; + gap: .4rem; + margin: .3rem 0; +} +form.row input { flex: 1; padding: .4rem; } +form.row button { padding: .4rem .7rem; } +.add input { border: 1px solid #137333; } diff --git a/internal/web/templates.go b/internal/web/templates.go new file mode 100644 index 0000000..8a3e51f --- /dev/null +++ b/internal/web/templates.go @@ -0,0 +1,25 @@ +package web + +import ( + "embed" + "html/template" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +//go:embed static/* +var staticFS embed.FS + +// parsePage parses the shared base layout together with a single page template, +// yielding a template set whose "base" definition renders the full document. +func parsePage(name string) *template.Template { + return template.Must(template.ParseFS(templatesFS, "templates/base.html", "templates/"+name)) +} + +var ( + uploadPage = parsePage("upload.html") + confirmPage = parsePage("confirm.html") + managePage = parsePage("manage.html") + exportPage = parsePage("export.html") +) diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html new file mode 100644 index 0000000..6c61e33 --- /dev/null +++ b/internal/web/templates/base.html @@ -0,0 +1,12 @@ +{{define "base"}}<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{block "title" .}}HSA Receipt Tracker{{end}}</title> +<link rel="stylesheet" href="/static/style.css"> +</head> +<body> +{{block "content" .}}{{end}} +</body> +</html>{{end}} diff --git a/internal/web/templates/confirm.html b/internal/web/templates/confirm.html new file mode 100644 index 0000000..a3fbe12 --- /dev/null +++ b/internal/web/templates/confirm.html @@ -0,0 +1,7 @@ +{{define "title"}}Saved{{end}} +{{define "content"}} +<p class="ok">✓ Receipt saved</p> +<p>{{.Category}}{{if .Who}} · {{.Who}}{{end}} — ${{.Amount}} — {{.Date}}<br>{{.Filename}}</p> +<a class="button green" href="/">Add another</a> +<p style="text-align:center;margin-top:1rem"><a href="/manage">Manage</a> · <a href="/export">Export</a> · <a href="/logout">Log out</a></p> +{{end}} diff --git a/internal/web/templates/export.html b/internal/web/templates/export.html new file mode 100644 index 0000000..c57d9c5 --- /dev/null +++ b/internal/web/templates/export.html @@ -0,0 +1,9 @@ +{{define "title"}}Export{{end}} +{{define "content"}} +<h1>Export database</h1> +<a class="button blue" href="/export/db?blobs=true">Download full DB (with images)</a> +<a class="button blue" href="/export/db?blobs=false">Download metadata only (no images)</a> +<p><small>The full DB is a complete, self-contained snapshot — metadata and every +receipt image in one SQLite file. Metadata-only omits the image blobs (~99% of the size).</small></p> +<p style="text-align:center"><a href="/">Back</a></p> +{{end}} diff --git a/internal/web/templates/manage.html b/internal/web/templates/manage.html new file mode 100644 index 0000000..cc0e700 --- /dev/null +++ b/internal/web/templates/manage.html @@ -0,0 +1,34 @@ +{{define "title"}}Manage{{end}} +{{define "content"}} +<p><a href="/">← Back to upload</a></p> +<h1>Manage lists</h1> +<p><small>Rename fixes the label everywhere it's used (receipts reference these by id). Adding a new entry creates a fresh id.</small></p> +{{if .Error}}<div class="err">{{.Error}}</div>{{end}} + +<h2>Categories</h2> +{{range .Categories}} +<form class="row" method="post" action="/manage/categories/rename"> + <input type="hidden" name="id" value="{{.ID}}"> + <input type="text" name="label" value="{{.Label}}"> + <button type="submit">Save</button> +</form> +{{end}} +<form class="row add" method="post" action="/manage/categories"> + <input type="text" name="label" placeholder="New category"> + <button type="submit">Add</button> +</form> + +<h2>Who</h2> +{{if not .People}}<p><small>No entries yet — add one below.</small></p>{{end}} +{{range .People}} +<form class="row" method="post" action="/manage/people/rename"> + <input type="hidden" name="id" value="{{.ID}}"> + <input type="text" name="label" value="{{.Label}}"> + <button type="submit">Save</button> +</form> +{{end}} +<form class="row add" method="post" action="/manage/people"> + <input type="text" name="label" placeholder="New person"> + <button type="submit">Add</button> +</form> +{{end}} diff --git a/internal/web/templates/upload.html b/internal/web/templates/upload.html new file mode 100644 index 0000000..a5630bb --- /dev/null +++ b/internal/web/templates/upload.html @@ -0,0 +1,29 @@ +{{define "title"}}Add receipt{{end}} +{{define "content"}} +<header> + <span>{{.Subject}}</span> + <span><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"> + <label for="receipt">Receipt photo or PDF</label> + <input id="receipt" type="file" name="receipt" accept="image/*,application/pdf" capture="environment" required> + <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> + <input id="receipt_date" type="date" name="receipt_date" value="{{.Date}}" required> + <label for="category_id">Category</label> + <select id="category_id" name="category_id"> + {{$sc := .CategoryID}} + {{range .Categories}}<option value="{{.ID}}" {{if eq (printf "%d" .ID) $sc}}selected{{end}}>{{.Label}}</option>{{end}} + </select> + <label for="person_id">Who</label> + <select id="person_id" name="person_id"> + {{$sp := .PersonID}} + <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> +</form> +{{end}} diff --git a/internal/web/upload.go b/internal/web/upload.go new file mode 100644 index 0000000..16f9ef1 --- /dev/null +++ b/internal/web/upload.go @@ -0,0 +1,259 @@ +package web + +import ( + "crypto/rand" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "maisym.com/hsa/internal/receipt" + "maisym.com/hsa/internal/storage" +) + +const dateLayout = "2006-01-02" + +type formView struct { + Subject string + Categories []storage.Lookup + People []storage.Lookup + Amount string + Date string + CategoryID string + PersonID string + Errors []string +} + +func (s *Server) handleUploadForm(w http.ResponseWriter, r *http.Request) { + sess := sessionFrom(r.Context()) + cats, people, err := s.lookups() + if err != nil { + s.serverError(w, "load lookups", err) + return + } + s.renderForm(w, formView{ + Subject: sess.Subject, + Categories: cats, + People: people, + Date: time.Now().Format(dateLayout), + }) +} + +func (s *Server) lookups() (cats, people []storage.Lookup, err error) { + cats, err = s.store.ListCategories() + if err != nil { + return nil, nil, err + } + people, err = s.store.ListPeople() + if err != nil { + return nil, nil, err + } + return cats, people, nil +} + +func (s *Server) renderForm(w http.ResponseWriter, v formView) { + 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) + } +} + +func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { + sess := sessionFrom(r.Context()) + cats, people, err := s.lookups() + if err != nil { + s.serverError(w, "load lookups", err) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes) + if err := r.ParseMultipartForm(10 << 20); err != nil { + s.renderForm(w, formView{ + Subject: sess.Subject, Categories: cats, People: people, + Errors: []string{"Upload too large or malformed."}, + }) + return + } + + amountStr := strings.TrimSpace(r.FormValue("amount")) + dateStr := strings.TrimSpace(r.FormValue("receipt_date")) + categoryStr := strings.TrimSpace(r.FormValue("category_id")) + personStr := strings.TrimSpace(r.FormValue("person_id")) + + view := formView{ + Subject: sess.Subject, Categories: cats, People: people, + Amount: amountStr, Date: dateStr, CategoryID: categoryStr, PersonID: personStr, + } + + var errs []string + amountCents, err := receipt.ParseAmountCents(amountStr) + if err != nil { + errs = append(errs, "Enter a valid amount, e.g. 12.34") + } + receiptDate, err := time.Parse(dateLayout, dateStr) + if err != nil { + errs = append(errs, "Enter a valid date.") + } + + categoryID, ok := parseLookupID(categoryStr, cats) + if !ok { + errs = append(errs, "Choose a category.") + } + + var personID *int64 + if personStr != "" { + pid, ok := parseLookupID(personStr, people) + if !ok { + errs = append(errs, "Choose a valid \"Who\" or leave it blank.") + } else { + personID = &pid + } + } + + file, header, err := r.FormFile("receipt") + if err != nil { + errs = append(errs, "Attach a receipt photo or PDF.") + } + var data []byte + if file != nil { + defer file.Close() + data, err = io.ReadAll(file) + if err != nil { + errs = append(errs, "Could not read the uploaded file.") + } + } + + mimeType := "" + if len(data) > 0 { + mimeType = detectMime(data) + if !allowedMime(mimeType) { + errs = append(errs, "Only images and PDFs are allowed.") + } + } + + if len(errs) > 0 { + view.Errors = errs + s.renderForm(w, view) + return + } + + // Dual-write: file on disk (UUID name) + blob in DB. + id := newID() + ext := extFor(header.Filename, mimeType) + relPath := id + ext + if err := s.saveFile(relPath, data); err != nil { + s.serverError(w, "save file", err) + return + } + + rec := receipt.Receipt{ + ID: id, + UploadedBy: sess.Subject, + UploadedAt: time.Now().UTC(), + ReceiptDate: receiptDate, + AmountCents: amountCents, + CategoryID: categoryID, + PersonID: personID, + FilePath: relPath, + ImageData: data, + FileSizeBytes: int64(len(data)), + OriginalFilename: header.Filename, + MimeType: mimeType, + } + if err := s.store.Insert(rec); err != nil { + s.serverError(w, "insert receipt", err) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = confirmPage.ExecuteTemplate(w, "base", map[string]string{ + "Category": labelFor(categoryID, cats), + "Who": whoLabel(personID, people), + "Amount": fmt.Sprintf("%d.%02d", amountCents/100, amountCents%100), + "Date": dateStr, + "Filename": header.Filename, + }) +} + +// parseLookupID parses s as an int64 and confirms it exists in the given set. +func parseLookupID(s string, set []storage.Lookup) (int64, bool) { + id, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, false + } + for _, l := range set { + if l.ID == id { + return id, true + } + } + return 0, false +} + +func labelFor(id int64, set []storage.Lookup) string { + for _, l := range set { + if l.ID == id { + return l.Label + } + } + return "" +} + +func whoLabel(id *int64, set []storage.Lookup) string { + if id == nil { + return "" + } + return labelFor(*id, set) +} + +func (s *Server) saveFile(relPath string, data []byte) error { + if err := os.MkdirAll(s.cfg.StorageDir, 0o700); err != nil { + return err + } + return os.WriteFile(filepath.Join(s.cfg.StorageDir, relPath), data, 0o600) +} + +func detectMime(data []byte) string { + n := min(len(data), 512) + ct := http.DetectContentType(data[:n]) + if i := strings.IndexByte(ct, ';'); i >= 0 { + ct = ct[:i] + } + return strings.TrimSpace(ct) +} + +func allowedMime(mime string) bool { + return strings.HasPrefix(mime, "image/") || mime == "application/pdf" +} + +func extFor(filename, mimeType string) string { + if ext := strings.ToLower(filepath.Ext(filename)); ext != "" && len(ext) <= 5 { + return ext + } + switch mimeType { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + case "application/pdf": + return ".pdf" + default: + return ".bin" + } +} + +// newID returns a random UUIDv4 string. +func newID() string { + var b [16]byte + _, _ = rand.Read(b[:]) + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/internal/web/upload_test.go b/internal/web/upload_test.go new file mode 100644 index 0000000..493641d --- /dev/null +++ b/internal/web/upload_test.go @@ -0,0 +1,195 @@ +package web + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "maisym.com/hsa/internal/auth" + "maisym.com/hsa/internal/config" + "maisym.com/hsa/internal/storage" +) + +func testServerWithStore(t *testing.T) *Server { + t.Helper() + var key [32]byte + copy(key[:], "web-test-key-32-bytes-padded!!!!") + store, err := storage.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + return &Server{ + cfg: config.Config{ + SessionKey: key, + RequiredGroup: "hsa-users", + StorageDir: filepath.Join(t.TempDir(), "files"), + MaxUploadBytes: 32 << 20, + }, + store: store, + } +} + +func authCookie(t *testing.T, s *Server) *http.Cookie { + t.Helper() + sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}} + v, err := auth.EncodeSession(sess, s.cfg.SessionKey) + if err != nil { + t.Fatal(err) + } + return &http.Cookie{Name: sessionCookie, Value: v} +} + +// aCategoryID returns the id of a seeded category as a string. +func aCategoryID(t *testing.T, s *Server) string { + t.Helper() + cats, err := s.store.ListCategories() + if err != nil || len(cats) == 0 { + t.Fatalf("ListCategories: %v", err) + } + return strconv.FormatInt(cats[0].ID, 10) +} + +// fakePNG returns bytes that http.DetectContentType recognises as image/png. +func fakePNG() []byte { + return append([]byte("\x89PNG\r\n\x1a\n"), []byte("fake-image-content")...) +} + +func multipartUpload(t *testing.T, fields map[string]string, fileField, fileName string, fileData []byte) (*bytes.Buffer, string) { + t.Helper() + var body bytes.Buffer + mw := multipart.NewWriter(&body) + for k, v := range fields { + _ = mw.WriteField(k, v) + } + if fileData != nil { + fw, err := mw.CreateFormFile(fileField, fileName) + if err != nil { + t.Fatal(err) + } + fw.Write(fileData) + } + mw.Close() + return &body, mw.FormDataContentType() +} + +func TestUpload_HappyPath_WritesFileAndRow(t *testing.T) { + s := testServerWithStore(t) + body, ct := multipartUpload(t, + map[string]string{"amount": "12.34", "receipt_date": "2026-06-01", "category_id": aCategoryID(t, s)}, + "receipt", "receipt.png", fakePNG()) + + req := httptest.NewRequest(http.MethodPost, "/upload", body) + req.Header.Set("Content-Type", ct) + req.AddCookie(authCookie(t, s)) + rec := httptest.NewRecorder() + s.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "Receipt saved") { + t.Errorf("missing confirmation: %s", rec.Body.String()) + } + + // Row written. + n, err := s.store.CountActive() + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("CountActive = %d, want 1", n) + } + + // File written to disk (dual-write). + entries, err := os.ReadDir(s.cfg.StorageDir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("storage dir has %d files, want 1", len(entries)) + } + if !strings.HasSuffix(entries[0].Name(), ".png") { + t.Errorf("stored file name = %q, want .png suffix", entries[0].Name()) + } +} + +func TestUpload_BadAmount_RerendersWithErrorNoRow(t *testing.T) { + s := testServerWithStore(t) + body, ct := multipartUpload(t, + map[string]string{"amount": "abc", "receipt_date": "2026-06-01", "category_id": aCategoryID(t, s)}, + "receipt", "receipt.png", fakePNG()) + + req := httptest.NewRequest(http.MethodPost, "/upload", body) + req.Header.Set("Content-Type", ct) + req.AddCookie(authCookie(t, s)) + rec := httptest.NewRecorder() + s.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (re-render)", rec.Code) + } + if !strings.Contains(rec.Body.String(), "valid amount") { + t.Errorf("missing amount error: %s", rec.Body.String()) + } + n, _ := s.store.CountActive() + if n != 0 { + t.Errorf("CountActive = %d, want 0 (nothing stored on validation error)", n) + } +} + +func TestUpload_RejectsNonImageNonPDF(t *testing.T) { + s := testServerWithStore(t) + body, ct := multipartUpload(t, + map[string]string{"amount": "5.00", "receipt_date": "2026-06-01", "category_id": aCategoryID(t, s)}, + "receipt", "notes.txt", []byte("just plain text, not an image or pdf")) + + req := httptest.NewRequest(http.MethodPost, "/upload", body) + req.Header.Set("Content-Type", ct) + req.AddCookie(authCookie(t, s)) + rec := httptest.NewRecorder() + s.Routes().ServeHTTP(rec, req) + + if !strings.Contains(rec.Body.String(), "images and PDFs") { + t.Errorf("expected mime rejection: %s", rec.Body.String()) + } + n, _ := s.store.CountActive() + if n != 0 { + t.Errorf("CountActive = %d, want 0", n) + } +} + +func TestExportDB_StreamsSQLiteFile(t *testing.T) { + s := testServerWithStore(t) + // Seed one receipt via the upload handler. + body, ct := multipartUpload(t, + map[string]string{"amount": "9.99", "receipt_date": "2026-06-01", "category_id": aCategoryID(t, s)}, + "receipt", "r.png", fakePNG()) + req := httptest.NewRequest(http.MethodPost, "/upload", body) + req.Header.Set("Content-Type", ct) + req.AddCookie(authCookie(t, s)) + s.Routes().ServeHTTP(httptest.NewRecorder(), req) + + for _, blobs := range []string{"true", "false"} { + req := httptest.NewRequest(http.MethodGet, "/export/db?blobs="+blobs, nil) + req.AddCookie(authCookie(t, s)) + rec := httptest.NewRecorder() + s.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("blobs=%s: status = %d, want 200", blobs, rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/octet-stream" { + t.Errorf("blobs=%s: content-type = %q", blobs, ct) + } + if !strings.HasPrefix(rec.Body.String(), "SQLite format 3") { + t.Errorf("blobs=%s: body is not a SQLite file", blobs) + } + } +} diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..88f1bf3 --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,227 @@ +package web + +import ( + "context" + "fmt" + "log" + "net/http" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "maisym.com/hsa/internal/auth" + "maisym.com/hsa/internal/classify" + "maisym.com/hsa/internal/config" + "maisym.com/hsa/internal/storage" +) + +const ( + sessionCookie = "hsa_session" + loginCookie = "hsa_login" +) + +// Server holds the wired dependencies for the HTTP handlers. +type Server struct { + cfg config.Config + provider *oidc.Provider + oauth *oauth2.Config + verifier *oidc.IDTokenVerifier + store *storage.Store + classifier *classify.Classifier // nil when classification is disabled +} + +// NewServer performs OIDC discovery against the issuer and builds the server. +// This makes a network call to the issuer's /.well-known/openid-configuration. +// classifier may be nil to disable receipt auto-classification. +func NewServer(ctx context.Context, cfg config.Config, store *storage.Store, classifier *classify.Classifier) (*Server, error) { + provider, err := oidc.NewProvider(ctx, cfg.IssuerURL) + if err != nil { + return nil, fmt.Errorf("oidc discovery against %s: %w", cfg.IssuerURL, err) + } + + oauthCfg := &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURL: cfg.RedirectURL, + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"}, + } + + return &Server{ + cfg: cfg, + provider: provider, + oauth: oauthCfg, + verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}), + store: store, + classifier: classifier, + }, nil +} + +// Routes returns the configured HTTP handler. +func (s *Server) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", s.handleHealthz) + mux.Handle("GET /static/", http.FileServerFS(staticFS)) + mux.HandleFunc("GET /login", s.handleLogin) + mux.HandleFunc("GET /callback", s.handleCallback) + mux.HandleFunc("GET /logout", s.handleLogout) + 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 /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))) + mux.Handle("POST /manage/people", s.requireAuth(http.HandlerFunc(s.handleAddPerson))) + mux.Handle("POST /manage/people/rename", s.requireAuth(http.HandlerFunc(s.handleRenamePerson))) + mux.Handle("GET /export", s.requireAuth(http.HandlerFunc(s.handleExportPage))) + mux.Handle("GET /export/db", s.requireAuth(http.HandlerFunc(s.handleExportDB))) + return mux +} + +func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "ok") +} + +// handleLogin starts the OIDC flow: generate PKCE + state + nonce, stash them in +// a short-lived encrypted cookie, and redirect to Authelia's authorize endpoint. +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + verifier, err := auth.GenerateVerifier() + if err != nil { + s.serverError(w, "generate verifier", err) + return + } + state, err := auth.GenerateState() + if err != nil { + s.serverError(w, "generate state", err) + return + } + nonce, err := auth.GenerateState() // reuse the random generator for the nonce + if err != nil { + s.serverError(w, "generate nonce", err) + return + } + + ls := auth.LoginState{State: state, Verifier: verifier, Nonce: nonce} + encoded, err := auth.EncodeLoginState(ls, s.cfg.SessionKey) + if err != nil { + s.serverError(w, "encode login state", err) + return + } + s.setCookie(w, loginCookie, encoded, 10*time.Minute) + + challenge := auth.ChallengeS256(verifier) + url := auth.AuthorizeURL(s.oauth, state, challenge, oidc.Nonce(nonce)) + http.Redirect(w, r, url, http.StatusFound) +} + +// handleCallback completes the OIDC flow: validate state, exchange the code with +// PKCE, verify the ID token, check the group, and set the session cookie. +func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) { + // Recover the login state from the cookie. + c, err := r.Cookie(loginCookie) + if err != nil { + http.Error(w, "missing login state; start at /login", http.StatusBadRequest) + return + } + ls, err := auth.DecodeLoginState(c.Value, s.cfg.SessionKey) + if err != nil { + http.Error(w, "invalid login state", http.StatusBadRequest) + return + } + s.clearCookie(w, loginCookie) + + // CSRF: the state we issued must match the one returned. + if r.URL.Query().Get("state") != ls.State { + http.Error(w, "state mismatch", http.StatusBadRequest) + return + } + + // Exchange the authorization code, sending the PKCE verifier. + code := r.URL.Query().Get("code") + token, err := s.oauth.Exchange(r.Context(), code, oauth2.VerifierOption(ls.Verifier)) + if err != nil { + s.serverError(w, "token exchange", err) + return + } + + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + http.Error(w, "no id_token in response", http.StatusBadGateway) + return + } + idToken, err := s.verifier.Verify(r.Context(), rawIDToken) + if err != nil { + s.serverError(w, "verify id_token", err) + return + } + if idToken.Nonce != ls.Nonce { + http.Error(w, "nonce mismatch", http.StatusBadRequest) + return + } + + // Authelia serves groups/email from the UserInfo endpoint rather than the ID + // token by default, so read claims from the ID token and merge in UserInfo. + type oidcClaims struct { + Email string `json:"email"` + PreferredUsername string `json:"preferred_username"` + Groups []string `json:"groups"` + } + var idClaims oidcClaims + if err := idToken.Claims(&idClaims); err != nil { + s.serverError(w, "parse id token claims", err) + return + } + + var uiClaims oidcClaims + if userInfo, err := s.provider.UserInfo(r.Context(), oauth2.StaticTokenSource(token)); err == nil { + _ = userInfo.Claims(&uiClaims) + } else { + log.Printf("warning: userinfo fetch failed: %v", err) + } + + groups := idClaims.Groups + if len(groups) == 0 { + groups = uiClaims.Groups + } + email := idClaims.Email + if email == "" { + email = uiClaims.Email + } + username := idClaims.PreferredUsername + if username == "" { + username = uiClaims.PreferredUsername + } + + subject := email + if subject == "" { + subject = username + } + log.Printf("login attempt: subject=%q groups=%v", subject, groups) + + // Authorization gate: must be in the required group. + if !auth.IsAuthorized(groups, s.cfg.RequiredGroup) { + http.Error(w, fmt.Sprintf("403: not a member of %q", s.cfg.RequiredGroup), http.StatusForbidden) + return + } + + sess := auth.Session{Subject: subject, Groups: groups, IssuedAt: time.Now().UTC()} + encoded, err := auth.EncodeSession(sess, s.cfg.SessionKey) + if err != nil { + s.serverError(w, "encode session", err) + return + } + s.setCookie(w, sessionCookie, encoded, 12*time.Hour) + http.Redirect(w, r, "/", http.StatusFound) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + s.clearCookie(w, sessionCookie) + http.Redirect(w, r, "/login", http.StatusFound) +} + +func (s *Server) serverError(w http.ResponseWriter, what string, err error) { + log.Printf("error: %s: %v", what, err) + http.Error(w, "internal server error", http.StatusInternalServerError) +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..e972600 --- /dev/null +++ b/internal/web/web_test.go @@ -0,0 +1,128 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "maisym.com/hsa/internal/auth" + "maisym.com/hsa/internal/config" +) + +func testServer() *Server { + var key [32]byte + copy(key[:], "web-test-key-32-bytes-padded!!!!") + return &Server{cfg: config.Config{ + SessionKey: key, + RequiredGroup: "hsa-users", + SecureCookies: false, + }} +} + +func TestStaticCSS(t *testing.T) { + s := testServer() + req := httptest.NewRequest(http.MethodGet, "/static/style.css", nil) + rec := httptest.NewRecorder() + s.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "font-family") { + t.Errorf("CSS not served: %q", rec.Body.String()) + } +} + +func TestHealthz(t *testing.T) { + s := testServer() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + s.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "ok") { + t.Errorf("body = %q, want to contain ok", rec.Body.String()) + } +} + +func TestHome_NoSession_RedirectsToLogin(t *testing.T) { + s := testServer() + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + s.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/login" { + t.Errorf("Location = %q, want /login", loc) + } +} + +func TestHome_ValidSession_ShowsUploadForm(t *testing.T) { + s := testServerWithStore(t) + sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}} + cookie, err := auth.EncodeSession(sess, s.cfg.SessionKey) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: cookie}) + rec := httptest.NewRecorder() + s.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "jm@example.com") { + t.Errorf("body missing subject: %q", body) + } + if !strings.Contains(body, "Add receipt") { + t.Errorf("body missing upload form: %q", body) + } +} + +func TestHome_TamperedSession_RedirectsToLogin(t *testing.T) { + s := testServer() + sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}} + cookie, _ := auth.EncodeSession(sess, s.cfg.SessionKey) + tampered := cookie[:len(cookie)-3] + "AAA" + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: tampered}) + rec := httptest.NewRecorder() + s.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/login" { + t.Errorf("Location = %q, want /login", loc) + } +} + +func TestLogout_ClearsCookieAndRedirects(t *testing.T) { + s := testServer() + req := httptest.NewRequest(http.MethodGet, "/logout", nil) + rec := httptest.NewRecorder() + s.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Fatalf("status = %d, want 302", rec.Code) + } + // The session cookie should be expired (MaxAge < 0). + var cleared bool + for _, c := range rec.Result().Cookies() { + if c.Name == sessionCookie && c.MaxAge < 0 { + cleared = true + } + } + if !cleared { + t.Error("logout did not clear the session cookie") + } +} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..b3f63b3 --- /dev/null +++ b/plan.md @@ -0,0 +1,157 @@ +# HSA Receipt Tracker — Implementation Plan (v1) + +Companion to `spec.md`. This is the *how* and the *order*; `spec.md` is the *what*. +Strategy: red/green (write a failing test, make it pass) wherever the logic is pure +and deterministic. Where behaviour depends on the live world (the real OIDC +round-trip against Authelia, a camera, a browser), we verify manually instead and +say so explicitly. + +--- + +## Decisions locked (from consultation) + +| Topic | Decision | +|------------------|-----------------------------------------------------------------| +| Stack | Go — single static binary, systemd in LXC, SQLite | +| App domain | `hsa.maisym.com` | +| Auth issuer | `auth.maisym.com` | +| OIDC client | Confidential + PKCE | +| Auth group | `hsa-users` (gates access; not in group → 403) | +| Session | Encrypted, signed cookie (stateless, no server-side store) | +| Edit | Designed-for, **not built** in v1 | +| Export | `/export/db` only, with include-blobs **yes/no** toggle | +| Files | PDF + images, accepted as-is; client compression is **phase 2** | +| View list/detail | Out of scope for v1 | +| Auth testing | Unit-test pure pieces; verify live round-trip manually | + +--- + +## Build order (milestones) + +- **M0 — Auth flow only.** App authenticates against live Authelia and shows a + "you're logged in" result, including the group gate. *This is the first thing JM + wants to see running.* Detailed below. +- **M1 — Storage layer.** SQLite schema + receipts data access (insert, soft-delete, + fetch). Dual-write (filesystem + BLOB) in one transaction. +- **M2 — Upload flow.** Mobile-first capture/upload form (amount, date, category), + PDF+image handling, validation, store both copies, confirmation page. +- **M3 — Export.** `GET /export/db` with include-blobs toggle, via point-in-time + snapshot (never the live DB file). +- **Phase 2 / later.** Client-side image compression; edit functionality. + +Only **M0 is planned in detail** below — we plan the next milestone when we get +there, so the plan stays honest. + +--- + +## Milestone 0 — Auth flow + +**Goal / acceptance:** Open `hsa.maisym.com` in a phone/browser → redirected to +`auth.maisym.com` → log in → land back on a page that reads roughly: + +> Logged in as `jeanmi.tremblay@gmail.com` · groups: `[hsa-users]` · access: **GRANTED** + +…and a user who is **not** in `hsa-users` gets a **403**. That single screen proves +the entire chain: OIDC+PKCE round-trip, identity claims, group claim, and the +authorization gate. + +### Step 0.1 — Prerequisites (setup, not tested) + +- Install Go toolchain (not currently present on this machine). +- `git init` the repo. +- `go mod init` — **module path TBD** (see Open Questions). Default proposal: + `maisym.com/hsa`. +- Project skeleton: `cmd/hsa/main.go`, `internal/auth/`, `internal/web/`, + `internal/config/`. + +### Step 0.2 — JM's Authelia-side config (your action; app can't do this) + +Enumerated here so the app and Authelia agree on every value: + +- Create group `hsa-users`; add both users. +- Register a **confidential** OIDC client in `identity_providers.oidc.clients`: + - `client_id`: e.g. `hsa-tracker` + - `client_secret`: random, stored **hashed** (via `authelia crypto hash generate`) + - `redirect_uris`: `https://hsa.maisym.com/callback` + (+ a dev redirect, e.g. `http://localhost:8080/callback` — see Open Questions) + - `scopes`: `openid`, `profile`, `email`, `groups` + - `response_types`: `code`; `grant_types`: `authorization_code` + - PKCE: require `S256` + - token endpoint auth method: `client_secret_basic` (confirm at execution) +- Reload Authelia. + +### Step 0.3 — App config (env vars) + +`ISSUER_URL`, `CLIENT_ID`, `CLIENT_SECRET`, `REDIRECT_URL`, `REQUIRED_GROUP` +(=`hsa-users`), `SESSION_KEY` (cookie encryption key), `LISTEN_ADDR`. Loaded and +validated at startup (fail fast if any missing). + +### Step 0.4 — Red/green units (pure logic) + +Each is a failing test first, then the implementation: + +1. **PKCE** — `GenerateVerifier()` (43–128 URL-safe chars) and + `ChallengeS256(verifier)`. Test with the **RFC 7636 Appendix B** known vector + (fixed verifier → known challenge) so the encoding is provably correct. +2. **State / nonce** — sufficient length, two calls differ (CSRF + replay defense). +3. **Session codec** — `Encode(session)`/`Decode(cookie)` round-trips; a **tampered** + value is rejected; a value signed with the **wrong key** is rejected. +4. **Authorization decision** — `IsAuthorized(groups, required) bool`. Table tests: + in-group → true; empty groups → false; other-group-only → false. *This is the + 403 rule, tested in isolation.* +5. **Authorize-URL builder** — construct `oauth2.Config` with fixed endpoints (no + network/discovery) and assert `AuthCodeURL` carries `state`, `code_challenge`, + `code_challenge_method=S256`, and the right scopes. + +### Step 0.5 — Handlers tested via `httptest` (no live Authelia) + +- `GET /healthz` → 200. +- `GET /` (protected) behind `RequireAuth`: forged **valid** session cookie → 200 and + the page shows identity + groups; **no/invalid** cookie → 302 to `/login`. +- `GET /logout` → clears cookie, 302. + +### Step 0.6 — Live round-trip (manual verification — the M0 acceptance) + +Not unit-testable (needs real Authelia + a human clicking login): + +- `GET /login` → builds PKCE+state, stashes verifier/state in a short-lived cookie, + 302 to Authelia's authorize endpoint. +- `GET /callback` → validate `state`, exchange `code` (with PKCE verifier), verify + the ID token, extract claims, run `IsAuthorized`, set the session cookie, redirect + to `/`. (The pure sub-parts — claim extraction, group gate — are already + red/green from 0.4.) + +**Manual test script:** +1. Run the app with real config (locally with the dev redirect, or deployed in the + LXC behind Caddy). +2. Browser → app → redirected to `auth.maisym.com` → log in → returned to success + page showing identity + `[hsa-users]` + **GRANTED**. +3. Negative path: an account **not** in `hsa-users` → **403**. + +### M0 done = + +All red/green units green, `httptest` handler tests green, and the manual +round-trip (both positive and 403 paths) confirmed against live Authelia. + +--- + +## Open questions (revisit before/at execution — not guessing) + +1. **Module path / repo name** — `maisym.com/hsa`? a GitHub path? Will there be a + GitHub remote, or local-only for now? +2. **Dev redirect URI** — do you want to test M0 *locally first* (needs a + `http://localhost:8080/callback` redirect added to the Authelia client), or + **deploy-to-LXC-first** and test only at `https://hsa.maisym.com`? +3. **Token endpoint auth method** — `client_secret_basic` vs `_post`. Pick when we + wire it; basic is the default assumption. +4. **Infra ownership** — Caddy vhost + systemd unit: you handle, or you want me to + draft the unit file / Caddyfile snippet as part of M0? + +## Deferred to later milestones (noted so we don't design against them) + +- SQLite driver choice — lean **`modernc.org/sqlite`** (pure Go, keeps the static + binary, no cgo). Decide at M1. +- Edit (v1: schema/storage must not preclude it — on a future edit, update FS file + and BLOB in one transaction, keep `file_path` UUID stable). +- Client-side image compression (phase 2): re-encode camera images to JPEG/WebP at a + quality factor before upload; PDFs pass through untouched. diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..1eab91e --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +source scripts/go-env.sh +go build ./... diff --git a/scripts/go-env.sh b/scripts/go-env.sh new file mode 100755 index 0000000..7c98af7 --- /dev/null +++ b/scripts/go-env.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Shared helper: put the Go toolchain on PATH. Source this from other scripts. +# The toolchain isn't on the default PATH on this machine. +if ! command -v go >/dev/null 2>&1; then + export PATH="$PATH:/home/jm/go-toolchain/go/bin" +fi diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..bf148c5 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +source scripts/go-env.sh +go test ./... "$@" diff --git a/secret.md b/secret.md new file mode 100644 index 0000000..d812911 --- /dev/null +++ b/secret.md @@ -0,0 +1,192 @@ +# Authelia OIDC Client Setup + +One-time setup. Do this before running the app for the first time. + +--- + +## Step 1 — Generate the OIDC client secret + +Run `secret.sh` on your **Authelia host** (needs `openssl` + `authelia` in PATH): + +```bash +bash secret.sh +``` + +Output looks like: + +``` +=== Plaintext secret (app .env → OIDC_CLIENT_SECRET) === +xK9mP2...64chars... + +=== Authelia hash (configuration.yml → client_secret) === +$argon2id$v=19$m=65536,t=3,p=4$... +``` + +--- + +## Step 2 — Save the plaintext secret + +The plaintext secret is **only shown once**. Save it immediately to the app's env file on the LXC where the app will run: + +``` +# /etc/hsa/hsa.env (create this file, chmod 600, owned by the service user) +OIDC_CLIENT_SECRET=xK9mP2...64chars... +``` + +Full env file contents are documented in the app's README once we get there. +For now just save `OIDC_CLIENT_SECRET=<value>` somewhere safe. + +**Never commit this file to git.** `.gitignore` already excludes `.env` files. + +--- + +## Step 3 — Generate the OIDC signing key (RSA / RS256) + +Authelia needs a signing key for the JWTs it issues. **It must be RSA (RS256)** — OIDC +mandates RS256 as the baseline and Authelia rejects Ed25519/EdDSA for the OIDC JWKS. + +Generate it and place it in the secrets dir, owned by the Authelia service user so the +process can read it (it runs non-root; a root-owned `chmod 600` key gives +`permission denied`): + +```bash +sudo mkdir -p /tmp/oidc-rsa +sudo authelia crypto pair rsa generate --bits 2048 --directory /tmp/oidc-rsa +sudo install -o authelia -g authelia -m 600 /tmp/oidc-rsa/private.pem /etc/authelia/secrets/oidc_jwks.pem +sudo rm -rf /tmp/oidc-rsa +``` + +(Confirm the owner matches your other secrets: `ls -l /etc/authelia/secrets/`. Substitute +if it's not `authelia:authelia`.) + +--- + +## Step 4 — Add the jwks + client block to configuration.yml + +The key is referenced from the .pem file via Authelia's template filter, so it never gets +copied into the config. This requires enabling the template filter (see the note after the +block — it's a one-line systemd env var). + +In `configuration.yml`, the full `identity_providers.oidc` section should look like this +(append the `hsa-tracker` entry if you already have other clients): + +```yaml +identity_providers: + oidc: + jwks: + - key_id: 'main' + algorithm: 'RS256' + use: 'sig' + key: {{ secret "/etc/authelia/secrets/oidc_jwks.pem" | mindent 10 "|" | msquote }} + clients: + - client_id: 'hsa-tracker' + client_name: 'HSA Receipt Tracker' + client_secret: '$argon2id$v=19$...' # the hash from secret.sh + public: false + authorization_policy: 'two_factor' + require_pkce: true + pkce_challenge_method: 'S256' + redirect_uris: + - 'https://hsa.maisym.com/callback' + - 'http://localhost:8080/callback' # for local dev testing + scopes: + - 'openid' + - 'profile' + - 'email' + - 'groups' + response_types: + - 'code' + grant_types: + - 'authorization_code' + token_endpoint_auth_method: 'client_secret_basic' + userinfo_signed_response_alg: 'none' +``` + +### Required: enable the template config filter + +The `{{ secret ... }}` syntax above only works when Authelia's **template config filter** +is enabled — it's OFF by default. If you skip this, the `{{ ... }}` is passed to the YAML +parser raw and breaks the *entire* config file (symptom: `yaml: invalid map key` plus a +cascade of "X not configured" errors — those cascade errors are a red herring caused by +the file failing to parse). + +Enable the filter on the service: + +```bash +sudo systemctl edit authelia +``` + +Add, save, exit: + +``` +[Service] +Environment=X_AUTHELIA_CONFIG_FILTERS=template +``` + +Confirm it registered before restarting: + +```bash +systemctl cat authelia | grep -i filter # should show your Environment line +``` + +Then restart (env changes need a full restart, not reload): + +```bash +sudo systemctl restart authelia +``` + +### Gotchas worth knowing + +- **The key must be RSA and readable by the Authelia service user.** Ed25519 is rejected + by the OIDC JWKS (RS256 is mandatory). A root-owned `chmod 600` key gives the process + `permission denied` — keep it in `/etc/authelia/secrets/` owned by `authelia`. +- **`authelia config validate` run manually shows false positives** for `jwt_secret` and + `storage encryption_key`, because the `*_FILE` secrets are injected by systemd and aren't + present in a manual shell. Under the running service they're fine — only trust the + `journalctl` output after a real restart for those two. + +--- + +## Step 5 — Create the hsa-users group and add both users + +In your Authelia users file (typically `users_database.yml`), add `hsa-users` to the `groups` list for each user: + +```yaml +users: + jm: + disabled: false + displayname: 'JM' + password: '$argon2id$...' + email: 'jm@jmopines.com' + groups: + - 'admins' + - 'hsa-users' +``` + +Repeat for the second user. + +--- + +## Step 6 — Restart Authelia + +Use `restart`, not `reload` — config-file and environment changes are only picked up on a +full restart. (The users/groups file in Step 5 is auto-reloaded by Authelia, but a restart +covers everything at once.) + +```bash +sudo systemctl restart authelia +``` + +Verify it came up clean (no `level=error` or `level=fatal` lines): + +```bash +journalctl -u authelia -n 20 +``` + +--- + +## Verification + +Once the app is running, opening `http://localhost:8080` (or `https://hsa.maisym.com`) should +redirect you to `auth.maisym.com`. After login + YubiKey 2FA it should land on a page showing +your identity and `[hsa-users]` group. diff --git a/secret.sh b/secret.sh new file mode 100755 index 0000000..338b9d5 --- /dev/null +++ b/secret.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Generates a random OIDC client secret and prints: +# - the plaintext → goes in the app's .env as OIDC_CLIENT_SECRET +# - the Authelia hash → goes in Authelia's configuration.yml client_secret field +# Run this on the Authelia host (needs openssl + authelia in PATH). +set -euo pipefail + +SECRET=$(openssl rand -base64 48 | tr -d '/+=' | cut -c1-64) + +echo "=== Plaintext secret (app .env → OIDC_CLIENT_SECRET) ===" +echo "$SECRET" +echo "" +echo "=== Authelia hash (configuration.yml → client_secret) ===" +authelia crypto hash generate argon2 --password "$SECRET" diff --git a/spec.md b/spec.md new file mode 100644 index 0000000..13240a0 --- /dev/null +++ b/spec.md @@ -0,0 +1,109 @@ +HSA Receipt Tracker — Requirements (v1) +Purpose +Capture and archive HSA-eligible receipts for future reimbursement and tax substantiation. No parsing, no OCR, no reporting. +Users + +Two users, both with full access (shared visibility). +Authentication via Authelia OIDC. +Authorization via membership in an Authelia group (e.g. hsa-users). +Not in the group → 403. No other roles or gradations. + +Core flow + +User opens app on phone (mobile-first UI; camera access matters). +Takes a photo of a receipt, or selects an existing image / PDF from device. +Form prompts for: amount, date, category. +Submit → image stored to disk, metadata row inserted into DB. +Confirmation page, with options to view list or add another. + +Data model +receipts table: + +id (UUID) +uploaded_by (Authelia username or email) +uploaded_at (server timestamp) +receipt_date (user-supplied date on the receipt) +amount_cents (integer — never store money as float) +category (enum) +file_path (relative path on disk — the filesystem copy) +image_data (BLOB — the receipt file bytes, also stored in the DB itself) +file_size_bytes (integer — convenience for listings/exports) +original_filename (preserved for reference) +mime_type +deleted_at (nullable — soft delete) + +Categories (fixed list, hardcoded for v1): + +Medical +Dental +Vision +Pharmacy +Other + +Storage + +Receipt images/PDFs are stored in BOTH places on upload: + 1. Filesystem at a configurable path, filename randomized (UUID) on save + (file_path) — used as the primary path for serving. + 2. As a BLOB inside the SQLite database (image_data column) — so the single + .db file is a complete, self-contained dataset (metadata + files). + +Rationale: the filesystem copy keeps serving simple/efficient; the DB blob makes +backup and export trivial ("hand over one file" gets everything, even without the +files directory). Written once at upload; no edit in v1, so the two copies never +diverge. Acceptable cost because scale is tiny (two users, small files). +original_filename and mime_type kept as metadata for download/serving. +Backups remain JM's responsibility outside the app. + +Auth integration + +OIDC with PKCE against https://auth.jmopines.com. +Session cookie after successful callback. +/login, /callback, /logout, /healthz are public; everything else requires a valid session. +Group claim (hsa-users) gates access; otherwise 403. + +Deployment + +Runs as a systemd service in an LXC. +Caddy reverse proxy at https://hsa.jmopines.com (TBD: maisym.com vs jmopines.com). +SQLite DB + filesystem storage. No external dependencies (no Redis, no Postgres, no S3). + +Operations + +Soft delete supported (set deleted_at, hide from default list views). +No edit functionality in v1 — fix mistakes by deleting and re-adding. + +Database export + +Authenticated users (hsa-users group) can download the data for offline use. + +Two endpoints: + +GET /export/db — downloads a consistent copy of the SQLite database file. Because + images are stored as BLOBs in the DB, this single file IS the complete dataset + (metadata + all receipt images). This is the primary export. + - Must NOT serve the live DB file directly (avoids locking/corruption against the + running app). Use SQLite's online backup API (or VACUUM INTO a temp file) to + produce a point-in-time snapshot, then stream that. + - Content-Type: application/octet-stream; filename like hsa-export-YYYY-MM-DD.db. + +GET /export/archive (optional convenience) — downloads a zip with the image files + extracted to normal files (named by original_filename) plus a CSV/JSON of the + metadata, for someone who wants the pictures as browseable files rather than + inside a DB. + - Streamed zip to avoid buffering large archives in memory. + - filename like hsa-export-YYYY-MM-DD.zip. + +Notes: + - Amounts remain integer cents in the export; consumers divide by 100 for dollars. + - Read-only operation; no app state is mutated. + +Out of scope for v1 + +OCR / image content parsing +Reports, totals, dashboards +CSV / tax-software export +Reimbursement tracking (paid vs pending status) +In-place editing of existing receipts +Multi-tenancy or per-user data isolation +Notification / reminders \ No newline at end of file