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