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:
commit
8b7252dad4
55 changed files with 4186 additions and 0 deletions
41
.env.example
Normal file
41
.env.example
Normal 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
21
.gitignore
vendored
Normal 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
47
call_claude.sh
Normal 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
63
cmd/hsa/main.go
Normal 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
31
config.json
Normal 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
22
go.mod
Normal 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
27
go.sum
Normal 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
1
internal/auth/auth.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package auth
|
||||
14
internal/auth/authorize.go
Normal file
14
internal/auth/authorize.go
Normal 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...)
|
||||
}
|
||||
52
internal/auth/authorize_test.go
Normal file
52
internal/auth/authorize_test.go
Normal 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
11
internal/auth/authz.go
Normal 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
|
||||
}
|
||||
28
internal/auth/authz_test.go
Normal file
28
internal/auth/authz_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
23
internal/auth/loginstate.go
Normal file
23
internal/auth/loginstate.go
Normal 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
24
internal/auth/pkce.go
Normal 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[:])
|
||||
}
|
||||
54
internal/auth/pkce_test.go
Normal file
54
internal/auth/pkce_test.go
Normal 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
89
internal/auth/session.go
Normal 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
|
||||
}
|
||||
77
internal/auth/session_test.go
Normal file
77
internal/auth/session_test.go
Normal 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
16
internal/auth/state.go
Normal 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
|
||||
}
|
||||
22
internal/auth/state_test.go
Normal file
22
internal/auth/state_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
310
internal/classify/classify.go
Normal file
310
internal/classify/classify.go
Normal 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
|
||||
}
|
||||
156
internal/classify/classify_test.go
Normal file
156
internal/classify/classify_test.go
Normal 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
192
internal/classify/prompt.go
Normal 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
|
||||
}
|
||||
90
internal/classify/prompt_test.go
Normal file
90
internal/classify/prompt_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
92
internal/config/catalog.go
Normal file
92
internal/config/catalog.go
Normal 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
122
internal/config/config.go
Normal 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()
|
||||
}
|
||||
75
internal/receipt/receipt.go
Normal file
75
internal/receipt/receipt.go
Normal 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
|
||||
}
|
||||
37
internal/receipt/receipt_test.go
Normal file
37
internal/receipt/receipt_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
70
internal/storage/lookup.go
Normal file
70
internal/storage/lookup.go
Normal 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
|
||||
}
|
||||
41
internal/storage/snapshot.go
Normal file
41
internal/storage/snapshot.go
Normal 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, "'", "''")
|
||||
}
|
||||
71
internal/storage/snapshot_test.go
Normal file
71
internal/storage/snapshot_test.go
Normal 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
179
internal/storage/storage.go
Normal 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 }
|
||||
175
internal/storage/storage_test.go
Normal file
175
internal/storage/storage_test.go
Normal 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
54
internal/web/export.go
Normal 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
85
internal/web/manage.go
Normal 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
110
internal/web/manage_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
63
internal/web/middleware.go
Normal file
63
internal/web/middleware.go
Normal 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
105
internal/web/scan.go
Normal 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
|
||||
}
|
||||
83
internal/web/static/style.css
Normal file
83
internal/web/static/style.css
Normal 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
25
internal/web/templates.go
Normal 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")
|
||||
)
|
||||
12
internal/web/templates/base.html
Normal file
12
internal/web/templates/base.html
Normal 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}}
|
||||
7
internal/web/templates/confirm.html
Normal file
7
internal/web/templates/confirm.html
Normal 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}}
|
||||
9
internal/web/templates/export.html
Normal file
9
internal/web/templates/export.html
Normal 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}}
|
||||
34
internal/web/templates/manage.html
Normal file
34
internal/web/templates/manage.html
Normal 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}}
|
||||
29
internal/web/templates/upload.html
Normal file
29
internal/web/templates/upload.html
Normal 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
259
internal/web/upload.go
Normal 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
195
internal/web/upload_test.go
Normal 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
227
internal/web/web.go
Normal 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
128
internal/web/web_test.go
Normal 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
157
plan.md
Normal 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()` (43–128 URL-safe chars) and
|
||||
`ChallengeS256(verifier)`. Test with the **RFC 7636 Appendix B** known vector
|
||||
(fixed verifier → known challenge) so the encoding is provably correct.
|
||||
2. **State / nonce** — sufficient length, two calls differ (CSRF + replay defense).
|
||||
3. **Session codec** — `Encode(session)`/`Decode(cookie)` round-trips; a **tampered**
|
||||
value is rejected; a value signed with the **wrong key** is rejected.
|
||||
4. **Authorization decision** — `IsAuthorized(groups, required) bool`. Table tests:
|
||||
in-group → true; empty groups → false; other-group-only → false. *This is the
|
||||
403 rule, tested in isolation.*
|
||||
5. **Authorize-URL builder** — construct `oauth2.Config` with fixed endpoints (no
|
||||
network/discovery) and assert `AuthCodeURL` carries `state`, `code_challenge`,
|
||||
`code_challenge_method=S256`, and the right scopes.
|
||||
|
||||
### Step 0.5 — Handlers tested via `httptest` (no live Authelia)
|
||||
|
||||
- `GET /healthz` → 200.
|
||||
- `GET /` (protected) behind `RequireAuth`: forged **valid** session cookie → 200 and
|
||||
the page shows identity + groups; **no/invalid** cookie → 302 to `/login`.
|
||||
- `GET /logout` → clears cookie, 302.
|
||||
|
||||
### Step 0.6 — Live round-trip (manual verification — the M0 acceptance)
|
||||
|
||||
Not unit-testable (needs real Authelia + a human clicking login):
|
||||
|
||||
- `GET /login` → builds PKCE+state, stashes verifier/state in a short-lived cookie,
|
||||
302 to Authelia's authorize endpoint.
|
||||
- `GET /callback` → validate `state`, exchange `code` (with PKCE verifier), verify
|
||||
the ID token, extract claims, run `IsAuthorized`, set the session cookie, redirect
|
||||
to `/`. (The pure sub-parts — claim extraction, group gate — are already
|
||||
red/green from 0.4.)
|
||||
|
||||
**Manual test script:**
|
||||
1. Run the app with real config (locally with the dev redirect, or deployed in the
|
||||
LXC behind Caddy).
|
||||
2. Browser → app → redirected to `auth.maisym.com` → log in → returned to success
|
||||
page showing identity + `[hsa-users]` + **GRANTED**.
|
||||
3. Negative path: an account **not** in `hsa-users` → **403**.
|
||||
|
||||
### M0 done =
|
||||
|
||||
All red/green units green, `httptest` handler tests green, and the manual
|
||||
round-trip (both positive and 403 paths) confirmed against live Authelia.
|
||||
|
||||
---
|
||||
|
||||
## Open questions (revisit before/at execution — not guessing)
|
||||
|
||||
1. **Module path / repo name** — `maisym.com/hsa`? a GitHub path? Will there be a
|
||||
GitHub remote, or local-only for now?
|
||||
2. **Dev redirect URI** — do you want to test M0 *locally first* (needs a
|
||||
`http://localhost:8080/callback` redirect added to the Authelia client), or
|
||||
**deploy-to-LXC-first** and test only at `https://hsa.maisym.com`?
|
||||
3. **Token endpoint auth method** — `client_secret_basic` vs `_post`. Pick when we
|
||||
wire it; basic is the default assumption.
|
||||
4. **Infra ownership** — Caddy vhost + systemd unit: you handle, or you want me to
|
||||
draft the unit file / Caddyfile snippet as part of M0?
|
||||
|
||||
## Deferred to later milestones (noted so we don't design against them)
|
||||
|
||||
- SQLite driver choice — lean **`modernc.org/sqlite`** (pure Go, keeps the static
|
||||
binary, no cgo). Decide at M1.
|
||||
- Edit (v1: schema/storage must not preclude it — on a future edit, update FS file
|
||||
and BLOB in one transaction, keep `file_path` UUID stable).
|
||||
- Client-side image compression (phase 2): re-encode camera images to JPEG/WebP at a
|
||||
quality factor before upload; PDFs pass through untouched.
|
||||
5
scripts/build.sh
Executable file
5
scripts/build.sh
Executable 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
6
scripts/go-env.sh
Executable 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
5
scripts/test.sh
Executable 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
192
secret.md
Normal 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
14
secret.sh
Executable 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
109
spec.md
Normal 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
|
||||
Loading…
Reference in a new issue