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 <noreply@anthropic.com>
This commit is contained in:
Jean-Michel Tremblay 2026-06-17 21:40:12 -04:00
commit 8b7252dad4
55 changed files with 4186 additions and 0 deletions

41
.env.example Normal file
View file

@ -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=<plaintext from secret.sh — the one you saved>
# 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

21
.gitignore vendored Normal file
View file

@ -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.*

47
call_claude.sh Normal file
View file

@ -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"

63
cmd/hsa/main.go Normal file
View file

@ -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)
}
}

31
config.json Normal file
View file

@ -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"]
}
]
}

22
go.mod Normal file
View file

@ -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
)

27
go.sum Normal file
View file

@ -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=

1
internal/auth/auth.go Normal file
View file

@ -0,0 +1 @@
package auth

View file

@ -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...)
}

View file

@ -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)
}
}
}

11
internal/auth/authz.go Normal file
View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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
}

24
internal/auth/pkce.go Normal file
View file

@ -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[:])
}

View file

@ -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")
}
}

89
internal/auth/session.go Normal file
View file

@ -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
}

View file

@ -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")
}
}

16
internal/auth/state.go Normal file
View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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)
}

192
internal/classify/prompt.go Normal file
View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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
}

122
internal/config/config.go Normal file
View file

@ -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()
}

View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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, "'", "''")
}

View file

@ -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)
}
}

179
internal/storage/storage.go Normal file
View file

@ -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 }

View file

@ -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))
}
}

54
internal/web/export.go Normal file
View file

@ -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)
}

85
internal/web/manage.go Normal file
View file

@ -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)
}

110
internal/web/manage_test.go Normal file
View file

@ -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())
}
}

View file

@ -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,
})
}

105
internal/web/scan.go Normal file
View file

@ -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
}

View file

@ -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; }

25
internal/web/templates.go Normal file
View file

@ -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")
)

View file

@ -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}}

View file

@ -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}}

View file

@ -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}}

View file

@ -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}}

View file

@ -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}}

259
internal/web/upload.go Normal file
View file

@ -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])
}

195
internal/web/upload_test.go Normal file
View file

@ -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)
}
}
}

227
internal/web/web.go Normal file
View file

@ -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)
}

128
internal/web/web_test.go Normal file
View file

@ -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")
}
}

157
plan.md Normal file
View file

@ -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()` (43128 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.

5
scripts/build.sh Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/go-env.sh
go build ./...

6
scripts/go-env.sh Executable file
View file

@ -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

5
scripts/test.sh Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/go-env.sh
go test ./... "$@"

192
secret.md Normal file
View file

@ -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.

14
secret.sh Executable file
View file

@ -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"

109
spec.md Normal file
View file

@ -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