chore: initial commit — ExpenseFlow AI-Powered Expense Tracker

- Passwordless email OTP authentication
- Event-based expense tracking with HTMX UI
- AI receipt extraction via DeepSeek Vision API
- CSV/PDF report generation with email filing
- PWA with service worker and manifest
- Mobile-first responsive design
- SQLite database with auto-migration
This commit is contained in:
Claus Lohmar 2026-05-29 19:43:30 +00:00
commit ca970104ee
26 changed files with 5219 additions and 0 deletions

14
.env.example Normal file
View file

@ -0,0 +1,14 @@
# ExpenseFlow Configuration
# Copy this file to .env and fill in your credentials.
# SMTP Configuration
SMTP_HOST=smtp.openxchange.eu
SMTP_PORT=587
SMTP_USER=post@2-4-h.app
SMTP_PASS=D9AW8JP74r1V
# DeepSeek Vision API Key
DEEPSEEK_API_KEY=sk-e9362165d2694883a52a5142811aa422
# Base URL for generating absolute links in emails
BASE_URL=http://localhost:8080

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Binaries
expenseflow
*.exe
# Database
*.db
*.db-journal
*.db-wal
*.db-shm
# Environment
.env
# Storage (uploaded receipt images)
storage/*
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Go
vendor/

278
README.md Normal file
View file

@ -0,0 +1,278 @@
# ExpenseFlow - AI-Powered Expense Tracker
A production-ready, mobile-first Progressive Web App (PWA) for expense management with passwordless email OTP authentication, event-based expense tracking, AI receipt extraction via DeepSeek Vision, and CSV/PDF reporting delivered via email.
---
## Features
- **Passwordless OTP Auth** — Email-based 6-digit code, no passwords, HTTP-only session cookie
- **Event Dashboard** — Create events (e.g. "WebSummit 2026"), track status (open/closed), reopen closed events
- **AI Receipt Capture** — Snap a photo → DeepSeek Vision extracts amount, merchant, category, date → save
- **Event Filing** — Generate CSV or PDF report → email as attachment → auto-close event
- **PWA** — Installable on mobile homescreen, offline shell, camera capture for receipts
---
## Tech Stack
| Layer | Technology |
|-------|-----------|
| **Backend** | Go (`net/http` + chi router) |
| **Frontend** | HTMX (server-driven UI, partial page updates) |
| **Database** | SQLite (auto-migrated on startup) |
| **AI OCR** | DeepSeek Vision API |
| **Email** | SMTP (OTP delivery + report attachments) |
| **PWA** | `manifest.json` + Service Worker |
---
## Quick Start
### Prerequisites
- Go 1.21+
- GCC (required for CGO/SQLite via `mattn/go-sqlite3`)
### Setup
```bash
# Clone the repository
git clone https://github.com/your-org/expenseflow.git
cd expenseflow
# Copy environment configuration
cp .env.example .env
# Run the application
go run main.go
```
Open [http://localhost:8080](http://localhost:8080) in your browser.
---
## Environment Variables
Copy `.env.example` to `.env` and configure:
| Variable | Description | Default |
|----------|-------------|---------|
| `SMTP_HOST` | SMTP server hostname | `smtp.openxchange.eu` |
| `SMTP_PORT` | SMTP server port | `587` |
| `SMTP_USER` | SMTP authentication username | `post@2-4-h.app` |
| `SMTP_PASS` | SMTP authentication password | — |
| `DEEPSEEK_API_KEY` | DeepSeek Vision API key | — |
| `BASE_URL` | Public base URL for absolute links in emails | `http://localhost:8080` |
---
## Project Structure
```
ExpenseFlow/
├── main.go # Entry point, router, static file server
├── go.mod # Module dependencies
├── .env # Credentials (not committed)
├── .env.example # Env template
├── internal/
│ ├── ai/
│ │ └── deepseek.go # DeepSeek Vision API client
│ ├── auth/
│ │ ├── otp.go # OTP generation & validation
│ │ └── session.go # Session management (in-memory store)
│ ├── database/
│ │ └── db.go # SQLite init, queries, auto-migration
│ ├── email/
│ │ └── smtp.go # SMTP email sender (OTP + reports)
│ ├── handlers/
│ │ ├── auth.go # Auth endpoints (login, OTP, verify)
│ │ ├── events.go # Event CRUD + reopen
│ │ ├── expenses.go # Upload, AI extract, save
│ │ └── file.go # CSV/PDF generation, email delivery
│ └── utils/
│ └── uuid.go # UUID generation helper
├── static/
│ ├── css/
│ │ └── style.css # Application styles
│ ├── icons/ # PWA icons (192x192, 512x512)
│ ├── manifest.json # PWA manifest
│ └── sw.js # Service Worker (offline cache)
├── templates/ # HTMX templates (server-rendered HTML)
│ ├── layout.html
│ ├── index.html
│ ├── dashboard.html
│ ├── event_expenses.html
│ ├── receipt_form.html
│ └── expense_list.html
└── storage/ # Uploaded receipt images (created at runtime)
```
---
## API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/` | Landing page — email input for OTP login |
| `POST` | `/request-otp` | Request a 6-digit OTP code (sent via email) |
| `POST` | `/verify-otp` | Verify OTP and create session cookie |
| `GET` | `/dashboard` | User dashboard — list of events |
| `POST` | `/events` | Create a new expense event |
| `GET` | `/events/{id}/expenses` | View expenses for an event |
| `POST` | `/expenses/upload` | Upload receipt image → AI extraction |
| `POST` | `/expenses` | Save an expense record |
| `POST` | `/events/{id}/file` | File report (CSV/PDF) and close event |
| `PUT` | `/events/{id}/reopen` | Reopen a closed event |
---
## Feature Walkthrough
### 1. Passwordless OTP Auth
Log in with just your email — no passwords needed.
```mermaid
sequenceDiagram
User->>Browser: Enter email
Browser->>Server: POST /request-otp (email)
Server->>Email: Send 6-digit code
User->>Browser: Enter code
Browser->>Server: POST /verify-otp (email, code)
Server->>Browser: Set session cookie, redirect to /dashboard
```
### 2. Event Dashboard
- **Open events** — click to view expenses and capture receipts
- **Closed events** — show a **Reopen** button (`PUT /events/{id}/reopen`)
- Each card shows: event name, status badge, created date
### 3. AI Receipt Capture
```bash
# Upload a receipt image
curl -X POST http://localhost:8080/expenses/upload \
-H "Cookie: session_token=..." \
-F "image=@receipt.jpg"
```
What happens:
1. Image saved to `./storage/{uuid}.jpg`
2. Sent to DeepSeek Vision API for OCR
3. AI returns: `{amount, currency, merchant, category, date}`
4. Pre-filled form rendered for user review/edit
5. On save → expense stored, list updated
### 4. Event Filing
Generate a CSV or PDF report and email it:
```bash
# File event as PDF
curl -X POST http://localhost:8080/events/123/file \
-H "Cookie: session_token=..." \
-d "email=user@example.com&format=pdf"
```
- Creates report from all event expenses
- Sends email with attachment
- Event status changes to `closed`
---
## Acceptance Test Scenario
Run through the full flow manually:
1. **Visit** `http://localhost:8080` — see email input
2. **Enter email** — receive 6-digit OTP in inbox
3. **Enter OTP** — redirected to dashboard (empty)
4. **Create event** — name it "Test Event"
5. **Click event** — see expense list (empty) + **Capture Receipt** button
6. **Upload receipt** — snap/take a photo → AI extracts amount, merchant, category, date
7. **Review & save** — pre-filled form, click Save → expense appears in list
8. **File event** — click **File Event**, enter email, pick PDF → report sent, event closes
9. **Verify** — event shows as **closed** on dashboard
10. **Reopen** — click **Reopen** → badge changes back to **open**
---
## Database
SQLite database (`expenses.db`) created and auto-migrated on first startup.
### Tables
```sql
-- Users
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- OTP codes
CREATE TABLE auth_otps (
email TEXT PRIMARY KEY,
otp_code TEXT NOT NULL,
expires_at DATETIME NOT NULL
);
-- Events (expense containers)
CREATE TABLE events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT CHECK(status IN ('open','closed')) DEFAULT 'open',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
);
-- Expenses (receipt records)
CREATE TABLE expenses (
id TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT NOT NULL,
merchant TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT,
date TEXT NOT NULL,
image_path TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(event_id) REFERENCES events(id) ON DELETE CASCADE
);
```
---
## PWA Features
- **Installable**`manifest.json` with standalone display, theme color `#10b981`
- **Offline shell** — Service Worker caches core assets (CSS, HTMX, shell) on install
- **Camera capture**`<input type="file" accept="image/*" capture="environment">` for mobile receipt snapping
### Service Worker
- Cache name: `expenseflow-v1`
- **Install**: caches `/`, CSS, HTMX library
- **Fetch**: cache-first for static assets, network-only for API calls
---
## Security
| Area | Implementation |
|------|---------------|
| **Session** | In-memory store, 24h TTL, HTTP-only cookie |
| **OTP** | 5-minute expiry, 3-attempt cooldown |
| **Uploads** | Max 10 MB, JPEG/PNG only, sanitized filenames |
| **AI fallback** | Editable empty form if DeepSeek API fails |
---
## License
MIT

11
go.mod Normal file
View file

@ -0,0 +1,11 @@
module github.com/expenseflow
go 1.22
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/jung-kurt/gofpdf v1.16.2
github.com/mattn/go-sqlite3 v1.14.22
)

20
go.sum Normal file
View file

@ -0,0 +1,20 @@
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

191
internal/ai/deepseek.go Normal file
View file

@ -0,0 +1,191 @@
package ai
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
// ReceiptData represents the structured data extracted from a receipt image.
type ReceiptData struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
Merchant string `json:"merchant"`
Category string `json:"category"`
Date string `json:"date"`
}
const (
deepseekAPIURL = "https://api.deepseek.com/v1/chat/completions"
deepseekModel = "deepseek-vl2"
requestTimeout = 30 * time.Second
)
// deepseekRequest matches the DeepSeek Vision API request format (OpenAI-compatible).
type deepseekRequest struct {
Model string `json:"model"`
Messages []deepseekMessage `json:"messages"`
Temperature float64 `json:"temperature"`
}
type deepseekMessage struct {
Role string `json:"role"`
Content []deepseekContent `json:"content"`
}
type deepseekContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *imageURLValue `json:"image_url,omitempty"`
}
type imageURLValue struct {
URL string `json:"url"`
}
// deepseekResponse matches the DeepSeek API response (OpenAI-compatible).
type deepseekResponse struct {
Choices []deepseekChoice `json:"choices"`
}
type deepseekChoice struct {
Message deepseekResponseMessage `json:"message"`
}
type deepseekResponseMessage struct {
Content string `json:"content"`
}
// ExtractReceipt sends a receipt image to the DeepSeek Vision API and parses
// the structured receipt data from the response. Returns default (empty) data
// along with an error if any step of the process fails.
func ExtractReceipt(imagePath string) (*ReceiptData, error) {
// 1. Validate the image file exists and is readable.
imageData, err := readImageFile(imagePath)
if err != nil {
log.Printf("ExtractReceipt: failed to read image file %q: %v", imagePath, err)
return &ReceiptData{}, err
}
// 2. Get the API key from the environment.
apiKey := os.Getenv("DEEPSEEK_API_KEY")
if apiKey == "" {
err := errors.New("DEEPSEEK_API_KEY environment variable is not set")
log.Printf("ExtractReceipt: %v", err)
return &ReceiptData{}, err
}
// 3. Build the request payload.
base64Image := base64.StdEncoding.EncodeToString(imageData)
payload := deepseekRequest{
Model: deepseekModel,
Temperature: 0.2,
Messages: []deepseekMessage{
{
Role: "user",
Content: []deepseekContent{
{
Type: "text",
Text: "Analyze this receipt image. Extract the following fields as a strict JSON object: amount (float), currency (3-letter string), merchant (string), category (one of: Food, Travel, Lodging, Software, Other), date (YYYY-MM-DD). Do not return markdown, only raw JSON.",
},
{
Type: "image_url",
ImageURL: &imageURLValue{
URL: fmt.Sprintf("data:image/jpeg;base64,%s", base64Image),
},
},
},
},
},
}
body, err := json.Marshal(payload)
if err != nil {
log.Printf("ExtractReceipt: failed to marshal request payload: %v", err)
return &ReceiptData{}, err
}
// 4. Send the POST request.
req, err := http.NewRequest(http.MethodPost, deepseekAPIURL, bytes.NewReader(body))
if err != nil {
log.Printf("ExtractReceipt: failed to create HTTP request: %v", err)
return &ReceiptData{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
client := &http.Client{Timeout: requestTimeout}
resp, err := client.Do(req)
if err != nil {
log.Printf("ExtractReceipt: API request failed: %v", err)
return &ReceiptData{}, err
}
defer resp.Body.Close()
// 5. Read the response body.
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("ExtractReceipt: failed to read response body: %v", err)
return &ReceiptData{}, err
}
if resp.StatusCode != http.StatusOK {
err := fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody))
log.Printf("ExtractReceipt: %v", err)
return &ReceiptData{}, err
}
// 6. Parse the DeepSeek response (OpenAI-compatible format).
var apiResp deepseekResponse
if err := json.Unmarshal(respBody, &apiResp); err != nil {
log.Printf("ExtractReceipt: failed to parse API response: %v", err)
return &ReceiptData{}, err
}
if len(apiResp.Choices) == 0 {
err := errors.New("API response contains no choices")
log.Printf("ExtractReceipt: %v", err)
return &ReceiptData{}, err
}
contentStr := apiResp.Choices[0].Message.Content
// 7. Parse the nested JSON from the content field into ReceiptData.
var receipt ReceiptData
if err := json.Unmarshal([]byte(contentStr), &receipt); err != nil {
log.Printf("ExtractReceipt: failed to parse receipt JSON from content: %v", err)
return &ReceiptData{}, err
}
return &receipt, nil
}
// readImageFile reads the full contents of an image file after verifying it
// exists and is a regular file.
func readImageFile(path string) ([]byte, error) {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("image file does not exist: %s", path)
}
return nil, fmt.Errorf("cannot stat image file %s: %w", path, err)
}
if info.IsDir() {
return nil, fmt.Errorf("path is a directory, not an image file: %s", path)
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("cannot read image file %s: %w", path, err)
}
return data, nil
}

103
internal/auth/otp.go Normal file
View file

@ -0,0 +1,103 @@
// Package auth provides authentication utilities including OTP generation and
// validation, as well as failure tracking for rate-limiting attempts.
package auth
import (
"crypto/rand"
"fmt"
"sync"
"time"
)
// GenerateOTP generates a 6-digit numeric OTP code using crypto/rand.
// Each digit is derived by reading a random byte and computing modulo 10,
// producing a uniformly distributed digit 0-9. The result is zero-padded
// to always return exactly 6 characters.
func GenerateOTP() (string, error) {
bytes := make([]byte, 6)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate OTP: %w", err)
}
code := make([]byte, 6)
for i, b := range bytes {
code[i] = byte(b%10) + '0'
}
return string(code), nil
}
// ValidateOTP validates a provided OTP against a stored code with an
// expiration check. Returns false if the current time is past expiresAt
// or if the codes do not match.
func ValidateOTP(provided, stored string, expiresAt time.Time) bool {
if time.Now().After(expiresAt) {
return false
}
return provided == stored
}
// attemptData stores the failure count and timestamp for a single email.
type attemptData struct {
count int
lastAttempt time.Time
}
// FailureTracker tracks consecutive failed OTP verification attempts per
// email, implementing a 3-attempt lockout window.
type FailureTracker struct {
mu sync.Mutex
attempts map[string]*attemptData
}
// NewFailureTracker creates and returns a new FailureTracker with an empty
// attempts map.
func NewFailureTracker() *FailureTracker {
return &FailureTracker{
attempts: make(map[string]*attemptData),
}
}
// RecordFailure increments the failure count for the given email and records
// the current time as the last attempt. Once the count reaches 3, the email
// becomes locked out until the lockout window expires.
func (ft *FailureTracker) RecordFailure(email string) {
ft.mu.Lock()
defer ft.mu.Unlock()
data, exists := ft.attempts[email]
if !exists {
data = &attemptData{}
ft.attempts[email] = data
}
data.count++
data.lastAttempt = time.Now()
}
// IsLockedOut returns true if the email has 3 or more recorded failures
// within the last 1 minute. Returns false if the email has no failures,
// fewer than 3 failures, or if the last failure was more than 1 minute ago.
func (ft *FailureTracker) IsLockedOut(email string) bool {
ft.mu.Lock()
defer ft.mu.Unlock()
data, exists := ft.attempts[email]
if !exists {
return false
}
if data.count < 3 {
return false
}
if time.Since(data.lastAttempt) > time.Minute {
return false
}
return true
}
// Reset clears the failure tracking data for the given email. This should
// be called upon successful OTP verification to allow fresh attempts.
func (ft *FailureTracker) Reset(email string) {
ft.mu.Lock()
defer ft.mu.Unlock()
delete(ft.attempts, email)
}

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

@ -0,0 +1,100 @@
// Package auth provides authentication and session management for ExpenseFlow.
package auth
import (
"crypto/rand"
"encoding/hex"
"log"
"sync"
"time"
)
// sessionData represents the data stored for each session.
type sessionData struct {
userID string
expiresAt time.Time
}
// SessionStore is an in-memory, thread-safe session store that maps
// session tokens to user sessions with expiration handling.
type SessionStore struct {
mu sync.RWMutex
sessions map[string]sessionData
}
// NewSessionStore creates and returns a new empty SessionStore.
func NewSessionStore() *SessionStore {
return &SessionStore{
sessions: make(map[string]sessionData),
}
}
// Generate creates a new session for the given userID with a 24-hour TTL.
// It returns a cryptographically secure random hex-encoded token string.
func (s *SessionStore) Generate(userID string) (string, error) {
token, err := generateRandomToken()
if err != nil {
log.Printf("auth: failed to generate session token: %v", err)
return "", err
}
s.mu.Lock()
s.sessions[token] = sessionData{
userID: userID,
expiresAt: time.Now().Add(24 * time.Hour),
}
s.mu.Unlock()
return token, nil
}
// Get returns the userID associated with the given token if the session
// exists and has not expired. Returns "", false otherwise.
func (s *SessionStore) Get(token string) (string, bool) {
s.mu.RLock()
data, ok := s.sessions[token]
s.mu.RUnlock()
if !ok {
return "", false
}
if time.Now().After(data.expiresAt) {
// Session is expired; clean it up.
s.Delete(token)
return "", false
}
return data.userID, true
}
// Delete removes the session identified by the given token.
func (s *SessionStore) Delete(token string) {
s.mu.Lock()
delete(s.sessions, token)
s.mu.Unlock()
}
// Cleanup removes all expired sessions from the store. This method is
// safe to call periodically from a background goroutine.
func (s *SessionStore) Cleanup() {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for token, data := range s.sessions {
if now.After(data.expiresAt) {
delete(s.sessions, token)
}
}
}
// generateRandomToken creates a 32-byte cryptographically random token
// and returns its hex-encoded representation (64 hex characters).
func generateRandomToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

324
internal/database/db.go Normal file
View file

@ -0,0 +1,324 @@
// Package database provides SQLite initialization and query helpers for ExpenseFlow.
//
// It auto-creates the database file and all required tables on startup,
// and exports a shared DB handle for use by other packages.
package database
import (
"database/sql"
"log"
"time"
_ "github.com/mattn/go-sqlite3"
)
// DB is the shared database handle, initialized by Init().
var DB *sql.DB
// ---------------------------------------------------------------------------
// Struct types
// ---------------------------------------------------------------------------
// User represents a row in the users table.
type User struct {
ID string
Email string
CreatedAt string
}
// OTP represents a row in the auth_otps table.
type OTP struct {
Email string
OTPCode string
ExpiresAt string
}
// Event represents a row in the events table.
type Event struct {
ID string
UserID string
Name string
Status string
CreatedAt string
}
// Expense represents a row in the expenses table.
type Expense struct {
ID string
EventID string
Amount float64
Currency string
Merchant string
Category string
Description string
Date string
ImagePath string
CreatedAt string
}
// ---------------------------------------------------------------------------
// Initialization
// ---------------------------------------------------------------------------
// Init opens (or creates) expenses.db, configures the connection pool for
// SQLite safety, and runs the DDL statements for all required tables.
// It also sets the package-level DB variable for shared use.
func Init() (*sql.DB, error) {
var err error
DB, err = sql.Open("sqlite3", "expenses.db")
if err != nil {
log.Printf("ERROR [%s] database: failed to open: %v", time.Now().Format(time.RFC3339), err)
return nil, err
}
// SQLite does not support concurrent writes; limit to one connection.
DB.SetMaxOpenConns(1)
if err = createTables(DB); err != nil {
log.Printf("ERROR [%s] database: table creation failed: %v", time.Now().Format(time.RFC3339), err)
return nil, err
}
log.Printf("INFO [%s] database: initialized successfully", time.Now().Format(time.RFC3339))
return DB, nil
}
// createTables executes the DDL statements for all four tables.
func createTables(db *sql.DB) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS auth_otps (
email TEXT PRIMARY KEY,
otp_code TEXT NOT NULL,
expires_at DATETIME NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT CHECK(status IN ('open', 'closed')) DEFAULT 'open',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)`,
`CREATE TABLE IF NOT EXISTS expenses (
id TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT NOT NULL,
merchant TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT,
date TEXT NOT NULL,
image_path TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(event_id) REFERENCES events(id) ON DELETE CASCADE
)`,
}
for _, stmt := range statements {
if _, err := db.Exec(stmt); err != nil {
return err
}
}
return nil
}
// ---------------------------------------------------------------------------
// User queries
// ---------------------------------------------------------------------------
// CreateUser inserts a new user row.
func CreateUser(db *sql.DB, id, email string) error {
_, err := db.Exec(
"INSERT INTO users (id, email) VALUES (?, ?)",
id, email,
)
if err != nil {
log.Printf("ERROR [%s] database: CreateUser(%s, %s): %v",
time.Now().Format(time.RFC3339), id, email, err)
}
return err
}
// GetUserByEmail returns the user with the given email, or nil if not found.
func GetUserByEmail(db *sql.DB, email string) (*User, error) {
row := db.QueryRow("SELECT id, email, created_at FROM users WHERE email = ?", email)
u := &User{}
if err := row.Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
log.Printf("ERROR [%s] database: GetUserByEmail(%s): %v",
time.Now().Format(time.RFC3339), email, err)
return nil, err
}
return u, nil
}
// ---------------------------------------------------------------------------
// OTP queries
// ---------------------------------------------------------------------------
// SaveOTP upserts an OTP record for the given email.
func SaveOTP(db *sql.DB, email, code, expiresAt string) error {
_, err := db.Exec(
`INSERT INTO auth_otps (email, otp_code, expires_at)
VALUES (?, ?, ?)
ON CONFLICT(email) DO UPDATE SET otp_code = excluded.otp_code, expires_at = excluded.expires_at`,
email, code, expiresAt,
)
if err != nil {
log.Printf("ERROR [%s] database: SaveOTP(%s): %v",
time.Now().Format(time.RFC3339), email, err)
}
return err
}
// GetOTP returns the OTP record for the given email, or nil if not found.
func GetOTP(db *sql.DB, email string) (*OTP, error) {
row := db.QueryRow("SELECT email, otp_code, expires_at FROM auth_otps WHERE email = ?", email)
o := &OTP{}
if err := row.Scan(&o.Email, &o.OTPCode, &o.ExpiresAt); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
log.Printf("ERROR [%s] database: GetOTP(%s): %v",
time.Now().Format(time.RFC3339), email, err)
return nil, err
}
return o, nil
}
// DeleteOTP removes the OTP record for the given email.
func DeleteOTP(db *sql.DB, email string) error {
_, err := db.Exec("DELETE FROM auth_otps WHERE email = ?", email)
if err != nil {
log.Printf("ERROR [%s] database: DeleteOTP(%s): %v",
time.Now().Format(time.RFC3339), email, err)
}
return err
}
// ---------------------------------------------------------------------------
// Event queries
// ---------------------------------------------------------------------------
// CreateEvent inserts a new event row.
func CreateEvent(db *sql.DB, id, userID, name string) error {
_, err := db.Exec(
"INSERT INTO events (id, user_id, name) VALUES (?, ?, ?)",
id, userID, name,
)
if err != nil {
log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s): %v",
time.Now().Format(time.RFC3339), id, userID, name, err)
}
return err
}
// GetEventsByUser returns all events belonging to a user, ordered by creation date descending.
func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
rows, err := db.Query(
"SELECT id, user_id, name, status, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC",
userID,
)
if err != nil {
log.Printf("ERROR [%s] database: GetEventsByUser(%s): %v",
time.Now().Format(time.RFC3339), userID, err)
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
var e Event
if err := rows.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.CreatedAt); err != nil {
log.Printf("ERROR [%s] database: GetEventsByUser scan: %v",
time.Now().Format(time.RFC3339), err)
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// GetEventByID returns a single event by ID, or nil if not found.
func GetEventByID(db *sql.DB, id string) (*Event, error) {
row := db.QueryRow("SELECT id, user_id, name, status, created_at FROM events WHERE id = ?", id)
e := &Event{}
if err := row.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.CreatedAt); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
log.Printf("ERROR [%s] database: GetEventByID(%s): %v",
time.Now().Format(time.RFC3339), id, err)
return nil, err
}
return e, nil
}
// UpdateEventStatus changes the status of an event (open/closed).
func UpdateEventStatus(db *sql.DB, id, status string) error {
_, err := db.Exec("UPDATE events SET status = ? WHERE id = ?", status, id)
if err != nil {
log.Printf("ERROR [%s] database: UpdateEventStatus(%s, %s): %v",
time.Now().Format(time.RFC3339), id, status, err)
}
return err
}
// ---------------------------------------------------------------------------
// Expense queries
// ---------------------------------------------------------------------------
// CreateExpense inserts a new expense row from the provided Expense struct.
// The expense's ID, EventID, and other fields must be set by the caller.
func CreateExpense(db *sql.DB, expense Expense) error {
_, err := db.Exec(
`INSERT INTO expenses (id, event_id, amount, currency, merchant, category, description, date, image_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
expense.ID, expense.EventID, expense.Amount, expense.Currency,
expense.Merchant, expense.Category, expense.Description,
expense.Date, expense.ImagePath,
)
if err != nil {
log.Printf("ERROR [%s] database: CreateExpense(%s): %v",
time.Now().Format(time.RFC3339), expense.ID, err)
}
return err
}
// GetExpensesByEvent returns all expenses for a given event, ordered by creation date descending.
func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) {
rows, err := db.Query(
`SELECT id, event_id, amount, currency, merchant, category,
COALESCE(description, ''), date, image_path, created_at
FROM expenses WHERE event_id = ? ORDER BY created_at DESC`,
eventID,
)
if err != nil {
log.Printf("ERROR [%s] database: GetExpensesByEvent(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
return nil, err
}
defer rows.Close()
var expenses []Expense
for rows.Next() {
var e Expense
if err := rows.Scan(
&e.ID, &e.EventID, &e.Amount, &e.Currency, &e.Merchant,
&e.Category, &e.Description, &e.Date, &e.ImagePath, &e.CreatedAt,
); err != nil {
log.Printf("ERROR [%s] database: GetExpensesByEvent scan: %v",
time.Now().Format(time.RFC3339), err)
return nil, err
}
expenses = append(expenses, e)
}
return expenses, rows.Err()
}

277
internal/email/smtp.go Normal file
View file

@ -0,0 +1,277 @@
// Package email provides SMTP email sending for ExpenseFlow, including OTP
// verification codes and expense report emails with CSV or PDF attachments.
//
// Credentials are passed via the constructor; the caller is responsible for
// loading them from environment variables (e.g., SMTP_HOST, SMTP_PORT, etc.).
package email
import (
"crypto/tls"
"encoding/base64"
"fmt"
"log"
"mime"
"mime/multipart"
"net"
"net/smtp"
"net/textproto"
"strings"
"time"
)
// ---------------------------------------------------------------------------
// Struct types
// ---------------------------------------------------------------------------
// Attachment holds a file to be attached to an outgoing email.
type Attachment struct {
Filename string
Content []byte
}
// Sender encapsulates SMTP server configuration and provides methods for
// sending transactional emails (e.g., OTP codes, expense reports).
type Sender struct {
host string
port string
user string
pass string
from string
}
// ---------------------------------------------------------------------------
// Constructor
// ---------------------------------------------------------------------------
// NewSender creates a new Sender with the given SMTP credentials. The caller
// should load host, port, user, pass, and from from environment variables.
func NewSender(host, port, user, pass, from string) *Sender {
return &Sender{
host: host,
port: port,
user: user,
pass: pass,
from: from,
}
}
// ---------------------------------------------------------------------------
// SMTP methods
// ---------------------------------------------------------------------------
// SendOTP sends a plain-text OTP verification email to the given recipient.
// The email contains a standard subject line and the 6-digit verification code.
func (s *Sender) SendOTP(to, code string) error {
subject := "Your ExpenseFlow OTP"
body := fmt.Sprintf("Your verification code is: %s", code)
msg := buildPlainMessage(s.from, to, subject, body)
if err := s.send(to, msg); err != nil {
log.Printf("ERROR [%s] email: SendOTP(%s): %v",
time.Now().Format(time.RFC3339), to, err)
return err
}
log.Printf("INFO [%s] email: OTP sent to %s", time.Now().Format(time.RFC3339), to)
return nil
}
// SendReport sends an email with the given subject and body, attaching a CSV
// or PDF file. The attachment's Content-Type is inferred from its filename
// extension (text/csv for .csv, application/octet-stream otherwise).
func (s *Sender) SendReport(to, subject, body string, attachment *Attachment) error {
msg, err := buildMultipartMessage(s.from, to, subject, body, attachment)
if err != nil {
log.Printf("ERROR [%s] email: SendReport(%s): build failed: %v",
time.Now().Format(time.RFC3339), to, err)
return err
}
if err := s.send(to, msg); err != nil {
log.Printf("ERROR [%s] email: SendReport(%s): %v",
time.Now().Format(time.RFC3339), to, err)
return err
}
log.Printf("INFO [%s] email: report sent to %s (%s)",
time.Now().Format(time.RFC3339), to, attachment.Filename)
return nil
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// send performs the actual SMTP delivery: connects to the server, upgrades
// to TLS (STARTTLS on port 587, direct TLS on port 465), authenticates, and
// transmits the message.
func (s *Sender) send(to string, msg []byte) error {
addr := net.JoinHostPort(s.host, s.port)
auth := smtp.PlainAuth("", s.user, s.pass, s.host)
// Use direct TLS for port 465 (SMTPS), STARTTLS for all other ports.
if s.port == "465" {
return s.sendTLS(addr, auth, to, msg)
}
return smtp.SendMail(addr, auth, s.from, []string{to}, msg)
}
// sendTLS dials the SMTP server over an explicit TLS connection (port 465)
// and sends the message. This is required for SMTPS where the connection is
// TLS-secured from the start, rather than upgraded via STARTTLS.
func (s *Sender) sendTLS(addr string, auth smtp.Auth, to string, msg []byte) error {
tlsConfig := &tls.Config{
ServerName: s.host,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("TLS dial failed: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, s.host)
if err != nil {
return fmt.Errorf("SMTP client creation failed: %w", err)
}
defer client.Close()
if err = client.Auth(auth); err != nil {
return fmt.Errorf("SMTP auth failed: %w", err)
}
if err = client.Mail(s.from); err != nil {
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
}
if err = client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP DATA failed: %w", err)
}
if _, err = w.Write(msg); err != nil {
return fmt.Errorf("SMTP write failed: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("SMTP data close failed: %w", err)
}
return client.Quit()
}
// buildPlainMessage constructs a simple RFC 5322 plain-text email without
// any MIME encoding.
func buildPlainMessage(from, to, subject, body string) []byte {
var b strings.Builder
writeHeader(&b, "From", from)
writeHeader(&b, "To", to)
writeHeader(&b, "Subject", subject)
writeHeader(&b, "MIME-Version", "1.0")
writeHeader(&b, "Content-Type", "text/plain; charset=\"utf-8\"")
b.WriteString("\r\n")
b.WriteString(body)
return []byte(b.String())
}
// buildMultipartMessage constructs an RFC 2046 multipart/mixed email with a
// text/plain body and a single attachment encoded as base64.
func buildMultipartMessage(from, to, subject, body string, attachment *Attachment) ([]byte, error) {
var b strings.Builder
// Write the main SMTP headers.
writeHeader(&b, "From", from)
writeHeader(&b, "To", to)
writeHeader(&b, "Subject", subject)
// Create a multipart writer using a unique boundary string.
mw := multipart.NewWriter(&b)
boundary := mw.Boundary()
writeHeader(&b, "MIME-Version", "1.0")
writeHeader(&b, "Content-Type", fmt.Sprintf("multipart/mixed; boundary=%s", boundary))
b.WriteString("\r\n")
// --- Text part ---
tw, err := mw.CreatePart(textHeader())
if err != nil {
return nil, fmt.Errorf("creating text part: %w", err)
}
if _, err := tw.Write([]byte(body)); err != nil {
return nil, fmt.Errorf("writing text part: %w", err)
}
// --- Attachment part ---
aw, err := mw.CreatePart(attachmentHeader(attachment.Filename))
if err != nil {
return nil, fmt.Errorf("creating attachment part: %w", err)
}
enc := base64.NewEncoder(base64.StdEncoding, aw)
if _, err := enc.Write(attachment.Content); err != nil {
enc.Close()
return nil, fmt.Errorf("writing attachment content: %w", err)
}
enc.Close()
mw.Close()
return []byte(b.String()), nil
}
// ---------------------------------------------------------------------------
// MIME header helpers
// ---------------------------------------------------------------------------
// writeHeader writes a single SMTP/MIME header line (field: value) followed
// by CRLF into the provided strings.Builder.
func writeHeader(b *strings.Builder, field, value string) {
b.WriteString(field)
b.WriteString(": ")
b.WriteString(value)
b.WriteString("\r\n")
}
// textHeader returns the MIME header fields for a text/plain part.
func textHeader() textproto.MIMEHeader {
return textproto.MIMEHeader{
"Content-Type": {"text/plain; charset=\"utf-8\""},
}
}
// attachmentHeader returns the MIME header fields for an attachment part,
// inferring Content-Type from the file extension and setting the required
// Content-Disposition and Content-Transfer-Encoding headers.
func attachmentHeader(filename string) textproto.MIMEHeader {
contentType := attachmentContentType(filename)
// Encode the filename to handle non-ASCII characters.
encodedFilename := mime.QEncoding.Encode("utf-8", filename)
return textproto.MIMEHeader{
"Content-Type": {contentType},
"Content-Disposition": {fmt.Sprintf(`attachment; filename="%s"`, encodedFilename)},
"Content-Transfer-Encoding": {"base64"},
}
}
// attachmentContentType returns the MIME Content-Type for an attachment based
// on its file extension. Defaults to application/octet-stream for unknown types.
func attachmentContentType(filename string) string {
switch {
case strings.HasSuffix(strings.ToLower(filename), ".csv"):
return "text/csv; charset=\"utf-8\""
case strings.HasSuffix(strings.ToLower(filename), ".pdf"):
return "application/pdf"
default:
return "application/octet-stream"
}
}

298
internal/handlers/auth.go Normal file
View file

@ -0,0 +1,298 @@
// Package handlers implements HTTP handlers for ExpenseFlow, providing
// passwordless email OTP authentication, event management, expense tracking,
// and report generation endpoints.
package handlers
import (
"database/sql"
"fmt"
"html/template"
"log"
"net/http"
"strings"
"time"
"github.com/expenseflow/internal/auth"
"github.com/expenseflow/internal/database"
"github.com/expenseflow/internal/email"
"github.com/expenseflow/internal/utils"
)
// ---------------------------------------------------------------------------
// AuthHandler
// ---------------------------------------------------------------------------
// AuthHandler handles passwordless email OTP authentication endpoints:
// - GET / — landing page with email input form
// - POST /request-otp — generates and emails a 6-digit OTP code
// - POST /verify-otp — validates the OTP and creates a session
//
// It depends on a *sql.DB for user/OTP persistence, a SessionStore for
// in-memory session management, a FailureTracker for rate-limiting, and
// an email.Sender for delivering OTP codes.
type AuthHandler struct {
DB *sql.DB
Sessions *auth.SessionStore
FailureTracker *auth.FailureTracker
EmailSender *email.Sender
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
// LandingPage renders the landing page with the email input form for OTP login.
// It parses templates/index.html and executes it with no template data.
func (h *AuthHandler) LandingPage(w http.ResponseWriter, r *http.Request) {
tmpl, err := template.ParseFiles("templates/index.html")
if err != nil {
log.Printf("ERROR [%s] handlers: LandingPage parse template: %v", time.Now().Format(time.RFC3339), err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, nil); err != nil {
log.Printf("ERROR [%s] handlers: LandingPage execute template: %v", time.Now().Format(time.RFC3339), err)
}
}
// RequestOTP handles OTP generation and email delivery.
//
// 1. Reads the email from the form value.
// 2. Checks the FailureTracker for rate-limit lockout (3 failures = 1 min cooldown).
// 3. Looks up or creates a user row in the database.
// 4. Generates a 6-digit OTP with a 5-minute expiry.
// 5. Persists the OTP to the auth_otps table.
// 6. Sends the OTP via email (logs error but does not fail the request).
// 7. Returns an HTMX fragment containing the OTP verification form.
func (h *AuthHandler) RequestOTP(w http.ResponseWriter, r *http.Request) {
emailAddr := strings.TrimSpace(r.FormValue("email"))
if emailAddr == "" {
renderError(w, "Email is required.")
return
}
// Rate-limit check: 3 failed attempts trigger a 1-minute lockout.
if h.FailureTracker.IsLockedOut(emailAddr) {
renderError(w, "Too many attempts. Please wait 1 minute before trying again.")
return
}
// Retrieve or create the user.
user, err := database.GetUserByEmail(h.DB, emailAddr)
if err != nil {
log.Printf("ERROR [%s] handlers: RequestOTP GetUserByEmail(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
renderError(w, "An error occurred. Please try again.")
return
}
if user == nil {
userID := utils.New()
if err := database.CreateUser(h.DB, userID, emailAddr); err != nil {
log.Printf("ERROR [%s] handlers: RequestOTP CreateUser(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
renderError(w, "An error occurred. Please try again.")
return
}
user = &database.User{ID: userID, Email: emailAddr}
}
// Generate a cryptographically secure 6-digit OTP.
code, err := auth.GenerateOTP()
if err != nil {
log.Printf("ERROR [%s] handlers: RequestOTP GenerateOTP: %v", time.Now().Format(time.RFC3339), err)
renderError(w, "An error occurred. Please try again.")
return
}
// Persist the OTP with a 5-minute expiry.
expiresAt := time.Now().Add(5 * time.Minute)
if err := database.SaveOTP(h.DB, emailAddr, code, expiresAt.Format(time.RFC3339)); err != nil {
log.Printf("ERROR [%s] handlers: RequestOTP SaveOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
renderError(w, "An error occurred. Please try again.")
return
}
// Deliver OTP via email. Log the error but do not fail the request —
// during development the code is visible in server logs.
if err := h.EmailSender.SendOTP(emailAddr, code); err != nil {
log.Printf("ERROR [%s] handlers: RequestOTP SendOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
}
// Render the OTP verification form as an HTMX fragment.
renderOTPForm(w, emailAddr)
}
// VerifyOTP handles OTP code verification and session creation.
//
// 1. Reads email and the 6 individual digit inputs from the form.
// 2. Retrieves the stored OTP record for the email.
// 3. Validates the code and its expiry time.
// 4. On failure: records the attempt in the FailureTracker, returns an error.
// 5. On success: resets the failure count, deletes the used OTP, generates a
// session token, sets an HTTP-only cookie, and redirects to /dashboard.
func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
emailAddr := strings.TrimSpace(r.FormValue("email"))
otpCode := collectOTP(r)
if emailAddr == "" || otpCode == "" {
renderError(w, "Email and OTP code are required.")
return
}
// Fetch the stored OTP record.
stored, err := database.GetOTP(h.DB, emailAddr)
if err != nil {
log.Printf("ERROR [%s] handlers: VerifyOTP GetOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
renderError(w, "An error occurred. Please try again.")
return
}
if stored == nil {
renderError(w, "No OTP found for this email. Please request a new code.")
return
}
// Parse the stored expiry timestamp.
expiresAt, err := time.Parse(time.RFC3339, stored.ExpiresAt)
if err != nil {
log.Printf("ERROR [%s] handlers: VerifyOTP parse expiry(%s): %v", time.Now().Format(time.RFC3339), stored.ExpiresAt, err)
renderError(w, "An error occurred. Please try again.")
return
}
// Validate the OTP code and expiry.
if !auth.ValidateOTP(otpCode, stored.OTPCode, expiresAt) {
h.FailureTracker.RecordFailure(emailAddr)
renderError(w, "Invalid or expired OTP code. Please try again.")
return
}
// Successful verification: clean up and create session.
h.FailureTracker.Reset(emailAddr)
if err := database.DeleteOTP(h.DB, emailAddr); err != nil {
log.Printf("ERROR [%s] handlers: VerifyOTP DeleteOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
// Non-fatal — the OTP is already validated.
}
// Retrieve the user record to obtain the user ID.
user, err := database.GetUserByEmail(h.DB, emailAddr)
if err != nil || user == nil {
log.Printf("ERROR [%s] handlers: VerifyOTP GetUserByEmail(%s): err=%v", time.Now().Format(time.RFC3339), emailAddr, err)
renderError(w, "An error occurred. Please try again.")
return
}
// Generate an in-memory session token.
token, err := h.Sessions.Generate(user.ID)
if err != nil {
log.Printf("ERROR [%s] handlers: VerifyOTP Session Generate(%s): %v", time.Now().Format(time.RFC3339), user.ID, err)
renderError(w, "An error occurred. Please try again.")
return
}
// Set the HTTP-only session cookie with a 24-hour TTL.
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(24 * time.Hour),
})
// Redirect to the dashboard via HTMX.
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// Middleware
// ---------------------------------------------------------------------------
// RequireAuth is HTTP middleware that validates the session cookie on protected
// routes. If the session is invalid or expired it redirects to the landing page
// using the HX-Redirect header. Otherwise it sets the X-User-ID header on the
// request for downstream handler use and calls the next handler.
func (h *AuthHandler) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_token")
if err != nil {
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusUnauthorized)
return
}
userID, ok := h.Sessions.Get(cookie.Value)
if !ok {
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusUnauthorized)
return
}
r.Header.Set("X-User-ID", userID)
next.ServeHTTP(w, r)
})
}
// getUserID returns the authenticated user ID from the request.
// The value is set by the RequireAuth middleware on the X-User-ID header.
func getUserID(r *http.Request) string {
return r.Header.Get("X-User-ID")
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// renderError writes an HTMX-compatible HTML error fragment to the response.
func renderError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="error-message" style="color: #dc2626; margin-bottom: 1rem;">%s</div>`, template.HTMLEscapeString(message))
}
// renderOTPForm writes the OTP verification form partial as an HTMX fragment.
// It renders 6 individual digit input boxes for a better mobile UX, plus a
// hidden email field. The handler combines the 6 digits server-side.
func renderOTPForm(w http.ResponseWriter, email string) {
tmpl := template.Must(template.New("otp_form").Parse(`
<form hx-post="/verify-otp" hx-target="#otp-form" hx-swap="outerHTML">
<input type="hidden" name="email" value="{{.Email}}">
<div style="display: flex; gap: 0.5rem; justify-content: center; margin: 1rem 0;">
<input type="text" name="digit_0" maxlength="1" pattern="[0-9]" inputmode="numeric" autocomplete="one-time-code" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
<input type="text" name="digit_1" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
<input type="text" name="digit_2" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
<input type="text" name="digit_3" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
<input type="text" name="digit_4" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
<input type="text" name="digit_5" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
</div>
<button type="submit" style="width: 100%; padding: 0.75rem; background-color: #10b981; color: white; border: none; border-radius: 0.5rem; font-size: 1rem; cursor: pointer;">
Verify Code
</button>
</form>
`))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, map[string]string{"Email": email}); err != nil {
log.Printf("ERROR [%s] handlers: renderOTPForm execute: %v", time.Now().Format(time.RFC3339), err)
}
}
// collectOTP reads the 6 individual digit form values and concatenates them
// into a single 6-character OTP code string. Returns an empty string if any
// digit is missing.
func collectOTP(r *http.Request) string {
var b strings.Builder
for i := 0; i < 6; i++ {
digit := r.FormValue(fmt.Sprintf("digit_%d", i))
if digit == "" {
return ""
}
b.WriteString(digit)
}
return b.String()
}

240
internal/handlers/events.go Normal file
View file

@ -0,0 +1,240 @@
// Package handlers provides HTTP request handlers for ExpenseFlow.
//
// This file implements event management endpoints including dashboard
// listing, event creation, reopening, and expense viewing.
package handlers
import (
"database/sql"
"fmt"
"html/template"
"log"
"net/http"
"time"
"github.com/expenseflow/internal/database"
"github.com/expenseflow/internal/utils"
"github.com/go-chi/chi/v5"
)
// ---------------------------------------------------------------------------
// EventHandler
// ---------------------------------------------------------------------------
// EventHandler groups HTTP handlers related to event management.
// It depends on a shared *sql.DB handle for database operations.
type EventHandler struct {
DB *sql.DB
}
// NewEventHandler creates a new EventHandler with the given database handle.
func NewEventHandler(db *sql.DB) *EventHandler {
return &EventHandler{DB: db}
}
// ---------------------------------------------------------------------------
// GET /dashboard — Dashboard
// ---------------------------------------------------------------------------
// Dashboard renders the main dashboard page showing all events belonging
// to the authenticated user, along with the new event creation form.
func (h *EventHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: Dashboard: missing user ID", time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
events, err := database.GetEventsByUser(h.DB, userID)
if err != nil {
log.Printf("ERROR [%s] handlers: Dashboard: GetEventsByUser: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to load events", http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles("templates/dashboard.html")
if err != nil {
log.Printf("ERROR [%s] handlers: Dashboard: parse template: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Events": events,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: Dashboard: execute template: %v",
time.Now().Format(time.RFC3339), err)
}
}
// ---------------------------------------------------------------------------
// POST /events — CreateEvent
// ---------------------------------------------------------------------------
// CreateEvent handles the creation of a new event for the authenticated user.
// It reads the event name from the form, generates a UUID, persists the
// event, and redirects to the dashboard via HX-Redirect.
func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: CreateEvent: missing user ID", time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
name := r.FormValue("name")
if name == "" {
log.Printf("ERROR [%s] handlers: CreateEvent: missing event name", time.Now().Format(time.RFC3339))
http.Error(w, "Event name is required", http.StatusBadRequest)
return
}
id := utils.New()
if err := database.CreateEvent(h.DB, id, userID, name); err != nil {
log.Printf("ERROR [%s] handlers: CreateEvent: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to create event", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// PUT /events/{id}/reopen — ReopenEvent
// ---------------------------------------------------------------------------
// ReopenEvent sets an event's status back to "open" and returns an HTMX
// fragment replacing the event's status badge with a green "open" badge.
// It verifies that the requesting user owns the event.
func (h *EventHandler) ReopenEvent(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
if eventID == "" {
log.Printf("ERROR [%s] handlers: ReopenEvent: missing event ID",
time.Now().Format(time.RFC3339))
http.Error(w, "Missing event ID", http.StatusBadRequest)
return
}
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: ReopenEvent: missing user ID",
time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil {
log.Printf("ERROR [%s] handlers: ReopenEvent: GetEventByID(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
return
}
if event == nil {
log.Printf("ERROR [%s] handlers: ReopenEvent: event %s not found",
time.Now().Format(time.RFC3339), eventID)
http.Error(w, "Event not found", http.StatusNotFound)
return
}
if event.UserID != userID {
log.Printf("ERROR [%s] handlers: ReopenEvent: ownership mismatch for event %s",
time.Now().Format(time.RFC3339), eventID)
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := database.UpdateEventStatus(h.DB, eventID, "open"); err != nil {
log.Printf("ERROR [%s] handlers: ReopenEvent: UpdateEventStatus(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to reopen event", http.StatusInternalServerError)
return
}
// Return HTMX fragment: green "open" badge targeting #status-badge-{id}.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<span id="status-badge-%s" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">open</span>`, eventID)
}
// ---------------------------------------------------------------------------
// GET /events/{id}/expenses — ViewEventExpenses
// ---------------------------------------------------------------------------
// ViewEventExpenses displays the expense collection view for a specific event.
// It verifies event ownership, sets the current_event_id cookie, and renders
// the event_expenses.html template with the event and its expense list.
func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
if eventID == "" {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing event ID",
time.Now().Format(time.RFC3339))
http.Error(w, "Missing event ID", http.StatusBadRequest)
return
}
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing user ID",
time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: GetEventByID(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
return
}
if event == nil {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: event %s not found",
time.Now().Format(time.RFC3339), eventID)
http.Error(w, "Event not found", http.StatusNotFound)
return
}
if event.UserID != userID {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: ownership mismatch for event %s",
time.Now().Format(time.RFC3339), eventID)
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
if err != nil {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: GetExpensesByEvent(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to load expenses", http.StatusInternalServerError)
return
}
// Set the current_event_id cookie so subsequent expense operations
// (SaveExpense, UploadReceipt) know which event to associate with.
setCurrentEventID(w, eventID)
tmpl, err := template.ParseFiles("templates/event_expenses.html")
if err != nil {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: parse template: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Event": event,
"Expenses": expenses,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: execute template: %v",
time.Now().Format(time.RFC3339), err)
}
}

View file

@ -0,0 +1,338 @@
// Package handlers provides HTTP request handlers for ExpenseFlow.
//
// This file implements expense upload, AI extraction, and save handlers
// that drive the core receipt capture workflow using HTMX partial responses.
package handlers
import (
"database/sql"
"fmt"
"html/template"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/expenseflow/internal/ai"
"github.com/expenseflow/internal/database"
"github.com/expenseflow/internal/utils"
)
// ---------------------------------------------------------------------------
// ExpenseHandler
// ---------------------------------------------------------------------------
// ExpenseHandler groups HTTP handlers related to expense management.
// It depends on a shared *sql.DB handle for database operations.
type ExpenseHandler struct {
DB *sql.DB
}
// NewExpenseHandler creates a new ExpenseHandler with the given database handle.
func NewExpenseHandler(db *sql.DB) *ExpenseHandler {
return &ExpenseHandler{DB: db}
}
// ---------------------------------------------------------------------------
// POST /expenses/upload — UploadReceipt
// ---------------------------------------------------------------------------
// UploadReceipt handles receipt image upload, AI extraction, and returns
// an HTMX fragment with a pre-filled receipt edit form.
//
// Flow:
// 1. Parse multipart form (max 10 MB memory buffer)
// 2. Validate file (size ≤ 10 MB, content type JPEG/PNG)
// 3. Save to ./storage/{uuid}.{jpg|png}
// 4. Call ai.ExtractReceipt for AI-powered data extraction
// 5. Render templates/receipt_form.html with pre-filled fields or error banner
func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
// 1. Parse multipart form with 10 MB max memory.
if err := r.ParseMultipartForm(10 << 20); err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: parse form: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to parse upload form", http.StatusBadRequest)
return
}
defer r.MultipartForm.RemoveAll()
// 2. Get the file from the "receipt" form field.
file, header, err := r.FormFile("receipt")
if err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: missing receipt field: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Missing receipt file", http.StatusBadRequest)
return
}
defer file.Close()
// 3. Validate file size (max 10 MB).
if header.Size > 10<<20 {
log.Printf("ERROR [%s] handlers: UploadReceipt: file too large: %d bytes",
time.Now().Format(time.RFC3339), header.Size)
http.Error(w, "File too large. Maximum size is 10 MB.", http.StatusBadRequest)
return
}
// 4. Read the full file data.
fileData, err := io.ReadAll(file)
if err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: read file: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to read uploaded file", http.StatusInternalServerError)
return
}
// 5. Validate content type by inspecting magic bytes.
ext := detectImageExtension(fileData)
if ext == "" {
log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported image type",
time.Now().Format(time.RFC3339))
http.Error(w, "Only JPEG and PNG images are supported", http.StatusBadRequest)
return
}
// 6. Generate a UUID-based filename and ensure the storage directory exists.
filename := utils.New() + "." + ext
storagePath := filepath.Join("storage", filename)
if err := os.MkdirAll("storage", 0755); err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: mkdir storage: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
// 7. Save the image file to disk.
if err := os.WriteFile(storagePath, fileData, 0644); err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: write file: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to save receipt image", http.StatusInternalServerError)
return
}
// 8. Call the DeepSeek Vision API for AI extraction.
receipt, aiErr := ai.ExtractReceipt(storagePath)
// 9. Render the receipt_form.html fragment.
tmpl, err := template.ParseFiles("templates/receipt_form.html")
if err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: parse template: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"ImagePath": storagePath,
"AIError": "",
"Amount": "",
"Currency": "",
"Merchant": "",
"Category": "",
"Date": "",
"Description": "",
}
if aiErr != nil {
data["AIError"] = "Could not read receipt automatically. Please fill in the fields below."
log.Printf("ERROR [%s] handlers: UploadReceipt: AI extraction failed: %v",
time.Now().Format(time.RFC3339), aiErr)
} else if receipt != nil {
data["Amount"] = strconv.FormatFloat(receipt.Amount, 'f', 2, 64)
data["Currency"] = receipt.Currency
data["Merchant"] = receipt.Merchant
data["Category"] = receipt.Category
data["Date"] = receipt.Date
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: template execute: %v",
time.Now().Format(time.RFC3339), err)
}
}
// ---------------------------------------------------------------------------
// POST /expenses — SaveExpense
// ---------------------------------------------------------------------------
// SaveExpense handles the receipt form submission, saves the expense to the
// database, and returns an HTMX multi-target response that replaces both the
// receipt form (with a success message) and the expense list.
//
// Flow:
// 1. Read event_id from the current_event_id cookie
// 2. Parse and validate form fields (amount, currency, merchant, category, date)
// 3. Create the expense record in the database
// 4. Fetch the updated expense list
// 5. Render HTMX response with success banner + updated expense list
func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
// 1. Get the active event ID from cookie.
eventID := getCurrentEventID(r)
if eventID == "" {
log.Printf("ERROR [%s] handlers: SaveExpense: missing current_event_id cookie",
time.Now().Format(time.RFC3339))
http.Error(w, "No active event. Please select an event first.", http.StatusBadRequest)
return
}
// 2. Parse form fields.
if err := r.ParseForm(); err != nil {
log.Printf("ERROR [%s] handlers: SaveExpense: parse form: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
return
}
amountStr := r.FormValue("amount")
currency := r.FormValue("currency")
merchant := r.FormValue("merchant")
category := r.FormValue("category")
date := r.FormValue("date")
description := r.FormValue("description")
imagePath := r.FormValue("image_path")
// 3. Validate required fields.
var missing []string
if amountStr == "" {
missing = append(missing, "amount")
}
if currency == "" {
missing = append(missing, "currency")
}
if merchant == "" {
missing = append(missing, "merchant")
}
if category == "" {
missing = append(missing, "category")
}
if date == "" {
missing = append(missing, "date")
}
if len(missing) > 0 {
log.Printf("ERROR [%s] handlers: SaveExpense: missing fields: %s",
time.Now().Format(time.RFC3339), strings.Join(missing, ", "))
http.Error(w, "Missing required fields: "+strings.Join(missing, ", "),
http.StatusBadRequest)
return
}
// 4. Parse amount as float64.
amount, err := strconv.ParseFloat(amountStr, 64)
if err != nil {
log.Printf("ERROR [%s] handlers: SaveExpense: invalid amount %q: %v",
time.Now().Format(time.RFC3339), amountStr, err)
http.Error(w, "Invalid amount value", http.StatusBadRequest)
return
}
// 5. Build and save the expense record.
expense := database.Expense{
ID: utils.New(),
EventID: eventID,
Amount: amount,
Currency: currency,
Merchant: merchant,
Category: category,
Description: description,
Date: date,
ImagePath: imagePath,
}
if err := database.CreateExpense(h.DB, expense); err != nil {
log.Printf("ERROR [%s] handlers: SaveExpense: create expense: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to save expense", http.StatusInternalServerError)
return
}
// 6. Fetch the updated expense list for this event.
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
if err != nil {
log.Printf("ERROR [%s] handlers: SaveExpense: fetch expenses: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to retrieve updated expenses", http.StatusInternalServerError)
return
}
// 7. Render the expense_list.html fragment.
listTmpl, err := template.ParseFiles("templates/expense_list.html")
if err != nil {
log.Printf("ERROR [%s] handlers: SaveExpense: parse list template: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
var listBuf strings.Builder
if err := listTmpl.Execute(&listBuf, map[string]interface{}{
"Expenses": expenses,
}); err != nil {
log.Printf("ERROR [%s] handlers: SaveExpense: execute list template: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
// 8. Return HTMX multi-target response.
// - The receipt form is replaced with a success message (hx-swap-oob).
// - The expense list is replaced with the updated list (hx-swap-oob).
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div id="receipt-form" hx-swap-oob="true"><div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">Expense saved successfully!</div></div>`)
fmt.Fprintf(w, `<div id="expense-list" hx-swap-oob="true">%s</div>`, listBuf.String())
}
// ---------------------------------------------------------------------------
// Cookie helpers (shared with events.go via package-level access)
// ---------------------------------------------------------------------------
// getCurrentEventID reads the "current_event_id" cookie from the request.
// Returns an empty string if the cookie is not set or cannot be read.
func getCurrentEventID(r *http.Request) string {
cookie, err := r.Cookie("current_event_id")
if err != nil {
return ""
}
return cookie.Value
}
// setCurrentEventID sets the "current_event_id" cookie on the response.
// The cookie has a 24-hour lifetime and is HttpOnly with SameSite=Lax.
// This helper is called from events.go when viewing an event.
func setCurrentEventID(w http.ResponseWriter, eventID string) {
http.SetCookie(w, &http.Cookie{
Name: "current_event_id",
Value: eventID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400, // 24 hours
})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// detectImageExtension examines the magic bytes of the provided data to
// determine whether it is a JPEG or PNG image. Returns "jpg", "png", or
// an empty string if the format is not recognised.
func detectImageExtension(data []byte) string {
if len(data) < 4 {
return ""
}
// JPEG magic: 0xFF 0xD8 0xFF
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "jpg"
}
// PNG magic: 0x89 'P' 'N' 'G' 0x0D 0x0A 0x1A 0x0A
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
return "png"
}
return ""
}

244
internal/handlers/file.go Normal file
View file

@ -0,0 +1,244 @@
// Package handlers implements HTTP request handlers for ExpenseFlow.
//
// This file implements the event filing workflow — generating CSV or PDF
// expense reports and emailing them as attachments to a specified recipient.
package handlers
import (
"bytes"
"database/sql"
"encoding/csv"
"fmt"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/jung-kurt/gofpdf"
"github.com/expenseflow/internal/database"
"github.com/expenseflow/internal/email"
)
// ---------------------------------------------------------------------------
// FileHandler
// ---------------------------------------------------------------------------
// FileHandler handles the event filing workflow: generating expense reports
// in CSV or PDF format and emailing them to a specified address.
// It depends on a shared *sql.DB handle for database access and an
// *email.Sender for delivering the report as an email attachment.
type FileHandler struct {
DB *sql.DB
EmailSender *email.Sender
}
// ---------------------------------------------------------------------------
// POST /events/{id}/file — FileEvent
// ---------------------------------------------------------------------------
// FileEvent generates an expense report (CSV or PDF) for a given event and
// emails it as an attachment to the specified recipient. On success the
// event status is updated to "closed" and the client is redirected to the
// dashboard via the HX-Redirect header.
//
// Flow:
// 1. Extract event ID from the URL via chi.URLParam
// 2. Parse the form for target email and report format
// 3. Verify the authenticated user owns this event
// 4. Fetch all expenses for the event from the database
// 5. Generate the report in the requested format (CSV or PDF)
// 6. Send the report as an email attachment
// 7. Update the event status to "closed"
// 8. Return an HX-Redirect header pointing to /dashboard
func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
// 1. Get event ID from the URL path parameter.
eventID := chi.URLParam(r, "id")
if eventID == "" {
log.Printf("ERROR [%s] handlers: FileEvent: missing event ID in URL",
time.Now().Format(time.RFC3339))
http.Error(w, "Missing event ID", http.StatusBadRequest)
return
}
// 2. Parse form fields.
if err := r.ParseForm(); err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: parse form: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
return
}
to := r.FormValue("email")
format := r.FormValue("format")
if to == "" {
log.Printf("ERROR [%s] handlers: FileEvent: missing email field",
time.Now().Format(time.RFC3339))
http.Error(w, "Email address is required", http.StatusBadRequest)
return
}
if format != "csv" && format != "pdf" {
log.Printf("ERROR [%s] handlers: FileEvent: invalid format %q",
time.Now().Format(time.RFC3339), format)
http.Error(w, "Format must be 'csv' or 'pdf'", http.StatusBadRequest)
return
}
// 3. Verify the authenticated user owns this event.
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: FileEvent: unauthenticated request",
time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: GetEventByID(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
return
}
if event == nil {
log.Printf("ERROR [%s] handlers: FileEvent: event not found: %s",
time.Now().Format(time.RFC3339), eventID)
http.Error(w, "Event not found", http.StatusNotFound)
return
}
if event.UserID != userID {
log.Printf("ERROR [%s] handlers: FileEvent: user %s does not own event %s",
time.Now().Format(time.RFC3339), userID, eventID)
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// 4. Fetch all expenses for the event.
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: GetExpensesByEvent(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to retrieve expenses", http.StatusInternalServerError)
return
}
// 5. Generate the report in the requested format.
var attachment *email.Attachment
switch format {
case "csv":
attachment, err = generateCSV(event.Name, expenses)
case "pdf":
attachment, err = generatePDF(event.Name, expenses)
}
if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: generate %s report: %v",
time.Now().Format(time.RFC3339), format, err)
http.Error(w, "Failed to generate report", http.StatusInternalServerError)
return
}
// 6. Send the report as an email attachment.
subject := "Expense report for event " + event.Name
body := "Please find attached the expense report."
if err := h.EmailSender.SendReport(to, subject, body, attachment); err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: SendReport(%s): %v",
time.Now().Format(time.RFC3339), to, err)
http.Error(w, "Failed to send report email", http.StatusInternalServerError)
return
}
// 7. Update the event status to "closed".
if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: UpdateEventStatus(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to close event", http.StatusInternalServerError)
return
}
// 8. Redirect to the dashboard via HTMX.
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// Report generation helpers
// ---------------------------------------------------------------------------
// generateCSV creates a CSV attachment from the provided expenses.
// The CSV includes a header row and one data row per expense.
func generateCSV(eventName string, expenses []database.Expense) (*email.Attachment, error) {
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
// Write header row.
if err := writer.Write([]string{"Date", "Merchant", "Amount", "Currency", "Category", "Description"}); err != nil {
return nil, fmt.Errorf("write CSV header: %w", err)
}
// Write one data row per expense.
for _, exp := range expenses {
if err := writer.Write([]string{
exp.Date,
exp.Merchant,
fmt.Sprintf("%.2f", exp.Amount),
exp.Currency,
exp.Category,
exp.Description,
}); err != nil {
return nil, fmt.Errorf("write CSV row: %w", err)
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return nil, fmt.Errorf("CSV writer flush: %w", err)
}
return &email.Attachment{
Filename: "report.csv",
Content: buf.Bytes(),
}, nil
}
// generatePDF creates a PDF attachment from the provided expenses using gofpdf.
// The PDF contains a title row, a header row, and one data row per expense.
func generatePDF(eventName string, expenses []database.Expense) (*email.Attachment, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
// Title: "Expense Report: <event name>"
pdf.SetFont("Helvetica", "B", 16)
pdf.Cell(0, 10, "Expense Report: "+eventName)
pdf.Ln(15)
// Table header row.
pdf.SetFont("Helvetica", "B", 10)
headers := []string{"Date", "Merchant", "Amount", "Currency", "Category"}
for _, h := range headers {
pdf.Cell(35, 8, h)
}
pdf.Ln(8)
// Table data rows.
pdf.SetFont("Helvetica", "", 10)
for _, exp := range expenses {
pdf.Cell(35, 8, exp.Date)
pdf.Cell(35, 8, exp.Merchant)
pdf.Cell(20, 8, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(20, 8, exp.Currency)
pdf.Cell(35, 8, exp.Category)
pdf.Ln(8)
}
// Write the PDF document to a memory buffer.
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("PDF output: %w", err)
}
return &email.Attachment{
Filename: "report.pdf",
Content: buf.Bytes(),
}, nil
}

11
internal/utils/uuid.go Normal file
View file

@ -0,0 +1,11 @@
// Package utils provides common utility functions for ExpenseFlow.
package utils
import (
"github.com/google/uuid"
)
// New generates a new UUID v4 string.
func New() string {
return uuid.New().String()
}

195
main.go Normal file
View file

@ -0,0 +1,195 @@
// ExpenseFlow — AI-Powered Expense Tracker
//
// A production-ready, mobile-first Progressive Web App (PWA) that uses
// passwordless email OTP login, event-based expense tracking, AI receipt
// extraction (DeepSeek Vision), and event filing (CSV/PDF via email).
//
// Usage:
// Copy .env.example to .env and fill in credentials, then:
// go run main.go
//
// The server starts on the port specified by the PORT env var (default 8080).
package main
import (
"log"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/joho/godotenv"
"github.com/expenseflow/internal/auth"
"github.com/expenseflow/internal/database"
"github.com/expenseflow/internal/email"
"github.com/expenseflow/internal/handlers"
)
func main() {
// -----------------------------------------------------------------------
// Configuration
// -----------------------------------------------------------------------
// Load environment variables from .env file (if present).
if err := godotenv.Load(); err != nil {
log.Printf("INFO [%s] main: no .env file found, using system environment",
time.Now().Format(time.RFC3339))
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
// SMTP configuration.
smtpHost := os.Getenv("SMTP_HOST")
smtpPort := os.Getenv("SMTP_PORT")
smtpUser := os.Getenv("SMTP_USER")
smtpPass := os.Getenv("SMTP_PASS")
// DeepSeek API key is read directly by the ai package.
_ = os.Getenv("DEEPSEEK_API_KEY")
// -----------------------------------------------------------------------
// Database
// -----------------------------------------------------------------------
db, err := database.Init()
if err != nil {
log.Fatalf("FATAL [%s] main: database init: %v", time.Now().Format(time.RFC3339), err)
}
defer db.Close()
// -----------------------------------------------------------------------
// Services
// -----------------------------------------------------------------------
sessionStore := auth.NewSessionStore()
failureTracker := auth.NewFailureTracker()
// Create the email sender only if SMTP credentials are configured.
var emailSender *email.Sender
if smtpHost != "" && smtpPort != "" && smtpUser != "" && smtpPass != "" {
emailSender = email.NewSender(smtpHost, smtpPort, smtpUser, smtpPass, "post@2-4-h.app")
log.Printf("INFO [%s] main: SMTP sender configured (%s:%s)",
time.Now().Format(time.RFC3339), smtpHost, smtpPort)
} else {
log.Printf("WARN [%s] main: SMTP not configured — OTP emails will not be sent",
time.Now().Format(time.RFC3339))
}
// -----------------------------------------------------------------------
// Handlers
// -----------------------------------------------------------------------
authHandler := &handlers.AuthHandler{
DB: db,
Sessions: sessionStore,
FailureTracker: failureTracker,
EmailSender: emailSender,
}
eventHandler := handlers.NewEventHandler(db)
expenseHandler := handlers.NewExpenseHandler(db)
fileHandler := &handlers.FileHandler{
DB: db,
EmailSender: emailSender,
}
// -----------------------------------------------------------------------
// Router
// -----------------------------------------------------------------------
r := chi.NewRouter()
// Middleware.
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RealIP)
// PWA headers for service worker.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/sw.js" {
w.Header().Set("Service-Worker-Allowed", "/")
w.Header().Set("Content-Type", "application/javascript")
}
next.ServeHTTP(w, r)
})
})
// Static file serving.
fileServer := http.FileServer(http.Dir("static"))
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
// Serve the manifest.json and sw.js from the root for PWA compliance.
r.Get("/sw.js", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Service-Worker-Allowed", "/")
w.Header().Set("Content-Type", "application/javascript")
http.ServeFile(w, r, "static/sw.js")
}))
r.Get("/manifest.json", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
http.ServeFile(w, r, "static/manifest.json")
}))
// Serve uploaded receipt images.
r.Get("/storage/*", http.StripPrefix("/storage/", http.FileServer(http.Dir("storage"))).ServeHTTP)
// ---- Public routes (no auth required) ----
r.Get("/", authHandler.LandingPage)
r.Post("/request-otp", authHandler.RequestOTP)
r.Post("/verify-otp", authHandler.VerifyOTP)
// ---- Logout ----
r.Post("/logout", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Delete the session cookie.
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusOK)
}))
// ---- Protected routes (auth required) ----
r.Group(func(r chi.Router) {
r.Use(authHandler.RequireAuth)
// Events.
r.Get("/dashboard", eventHandler.Dashboard)
r.Post("/events", eventHandler.CreateEvent)
r.Put("/events/{id}/reopen", eventHandler.ReopenEvent)
r.Get("/events/{id}/expenses", eventHandler.ViewEventExpenses)
// Expenses.
r.Post("/expenses/upload", expenseHandler.UploadReceipt)
r.Post("/expenses", expenseHandler.SaveExpense)
// Filing.
r.Post("/events/{id}/file", fileHandler.FileEvent)
})
// -----------------------------------------------------------------------
// Startup
// -----------------------------------------------------------------------
addr := ":" + port
log.Printf("INFO [%s] main: ExpenseFlow server starting on %s",
time.Now().Format(time.RFC3339), addr)
log.Printf("INFO [%s] main: open http://localhost%s in your browser",
time.Now().Format(time.RFC3339), addr)
if err := http.ListenAndServe(addr, r); err != nil {
log.Fatalf("FATAL [%s] main: server error: %v", time.Now().Format(time.RFC3339), err)
}
}

1845
static/css/style.css Normal file

File diff suppressed because it is too large Load diff

BIN
static/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

BIN
static/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

20
static/manifest.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "ExpenseFlow",
"short_name": "ExpenseFlow",
"start_url": "/",
"display": "standalone",
"theme_color": "#10b981",
"background_color": "#ffffff",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

264
static/sw.js Normal file
View file

@ -0,0 +1,264 @@
/* ============================================================
* ExpenseFlow Service Worker
* Version: 1.0.0
* Cache name: expenseflow-v1
* Strategy: Cache-first for shell assets, network-only for API
* ============================================================ */
const CACHE_NAME = 'expenseflow-v1';
// Shell assets to pre-cache on install
const SHELL_ASSETS = [
'/',
'/static/css/style.css',
'https://unpkg.com/htmx.org@1.9.10'
];
// API path prefix pattern — requests matching these are never cached
const API_PATTERNS = [
'/api/',
'/auth/',
'/login',
'/logout',
'/register'
];
/* ------------------------------------------------------------
* Utility: determine whether a request targets an API endpoint
* ------------------------------------------------------------ */
function isApiRequest(url) {
return API_PATTERNS.some(pattern => url.pathname.startsWith(pattern));
}
/* ------------------------------------------------------------
* Utility: determine whether a request is a shell asset eligible
* for cache-first strategy
* ------------------------------------------------------------ */
function isShellAsset(url) {
const path = url.pathname;
const origin = url.origin;
// Same-origin shell pages
if (origin === self.location.origin && (path === '/' || path === '')) {
return true;
}
// Same-origin CSS
if (origin === self.location.origin && path.startsWith('/static/css/')) {
return true;
}
// Same-origin JS
if (origin === self.location.origin && path.startsWith('/static/js/')) {
return true;
}
// HTMX CDN (cache-first for fast loading)
if (url.href === 'https://unpkg.com/htmx.org@1.9.10') {
return true;
}
return false;
}
/* ------------------------------------------------------------
* INSTALL Pre-cache shell assets
* ------------------------------------------------------------ */
self.addEventListener('install', event => {
console.log('[SW] Install event — caching shell assets');
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
// Use addAll for atomic caching — if one fails, the whole
// install fails and the SW won't activate
return cache.addAll(SHELL_ASSETS).catch(err => {
console.error('[SW] Failed to cache some shell assets:', err);
// Still attempt to activate even if caching is partially
// unsuccessful — the fetch handler will fall back to network
throw err;
});
})
.then(() => {
console.log('[SW] Shell assets cached successfully');
return self.skipWaiting();
})
.catch(err => {
console.error('[SW] Install failed:', err);
// Still try to activate so the SW takes over
return self.skipWaiting();
})
);
});
/* ------------------------------------------------------------
* ACTIVATE Clean up old caches
* ------------------------------------------------------------ */
self.addEventListener('activate', event => {
console.log('[SW] Activate event — cleaning old caches');
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
}).then(() => {
console.log('[SW] Activated — taking control of all clients');
return self.clients.claim();
}).catch(err => {
console.error('[SW] Activation cleanup failed:', err);
// Continue even if cleanup fails
return self.clients.claim();
})
);
});
/* ------------------------------------------------------------
* FETCH Hybrid strategy
* - Cache-first with network fallback for shell assets
* - Network-only for API / dynamic endpoints
* - Stale-while-revalidate for other same-origin assets
* ------------------------------------------------------------ */
self.addEventListener('fetch', event => {
const request = event.request;
const url = new URL(request.url);
// Ignore non-GET requests (POST, PUT, DELETE, etc.)
if (request.method !== 'GET') {
return;
}
// Ignore browser-extension and non-http(s) requests
if (!url.protocol.startsWith('http')) {
return;
}
// ── Strategy 1: Network-only for API calls ──
if (isApiRequest(url)) {
event.respondWith(
fetch(request).catch(err => {
console.warn('[SW] API fetch failed (offline?):', url.pathname, err);
// Return a lightweight JSON error so the app can handle it gracefully
return new Response(
JSON.stringify({ error: 'You are offline. Please check your connection.' }),
{
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'application/json' }
}
);
})
);
return;
}
// ── Strategy 2: Cache-first with network fallback for shell assets ──
if (isShellAsset(url)) {
event.respondWith(
caches.match(request)
.then(cachedResponse => {
if (cachedResponse) {
// Cache hit — return immediately
return cachedResponse;
}
// Cache miss — fetch from network, then cache for future
return fetch(request)
.then(networkResponse => {
// Only cache valid responses
if (!networkResponse || networkResponse.status !== 200) {
return networkResponse;
}
// Clone the response so we can cache one and return the other
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(request, responseToCache).catch(err => {
console.error('[SW] Failed to cache asset:', url.pathname, err);
});
})
.catch(err => {
console.error('[SW] Failed to open cache for storing:', err);
});
return networkResponse;
})
.catch(err => {
console.warn('[SW] Network fetch failed for shell asset:', url.pathname, err);
// Return a minimal offline fallback for navigations
if (request.mode === 'navigate') {
return new Response(
'<!DOCTYPE html><html><head><title>Offline — ExpenseFlow</title><meta name="viewport" content="width=device-width, initial-scale=1"><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:2rem;text-align:center;background:#f9fafb;color:#111827}h1{font-size:1.5rem;margin-bottom:0.5rem}p{color:#6b7280;max-width:24rem}</style></head><body><h1>You\'re offline</h1><p>ExpenseFlow needs an internet connection to load. Please check your connection and try again.</p></body></html>',
{
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/html; charset=utf-8' }
}
);
}
return new Response(
'Offline — resource not available',
{
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
}
);
});
})
.catch(err => {
console.error('[SW] Cache match error:', err);
// Fall through to network
return fetch(request).catch(() => {
return new Response(
'An unexpected error occurred.',
{ status: 502, headers: { 'Content-Type': 'text/plain' } }
);
});
})
);
return;
}
// ── Strategy 3: Network-first for all other (non-shell, non-API) assets ──
// This covers images, fonts, etc. — try network first, fall back to cache
event.respondWith(
fetch(request)
.then(networkResponse => {
// Cache successful responses for offline fallback
if (networkResponse && networkResponse.status === 200) {
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(request, responseToCache).catch(err => {
console.error('[SW] Failed to cache dynamic asset:', url.pathname, err);
});
})
.catch(err => {
console.error('[SW] Failed to open cache for dynamic asset:', err);
});
}
return networkResponse;
})
.catch(err => {
console.warn('[SW] Network failed, trying cache for:', url.pathname, err);
return caches.match(request).then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
// Nothing in cache either — return a basic error
return new Response(
'Resource unavailable offline.',
{ status: 503, headers: { 'Content-Type': 'text/plain' } }
);
});
})
);
});

95
templates/dashboard.html Normal file
View file

@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#10b981">
<title>Dashboard - ExpenseFlow</title>
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<div class="app-shell">
<header class="app-header">
<h1 class="app-title">ExpenseFlow</h1>
<a href="/" class="btn btn-secondary btn-sm"
hx-post="/logout" hx-target="body" hx-push-url="true"
hx-confirm="Are you sure you want to logout?">Logout</a>
</header>
<main class="main-content">
<div class="dashboard-header">
<h2>My Events</h2>
<button class="btn btn-primary"
onclick="document.getElementById('create-event-form').classList.toggle('hidden')">
+ New Event
</button>
</div>
<div id="create-event-form" class="hidden" style="margin-bottom: 1.5rem;">
<div class="card" style="padding: 1rem;">
<form hx-post="/events" hx-target="body" hx-push-url="true">
<div class="form-group">
<label for="name">Event Name</label>
<input type="text" id="name" name="name" placeholder="e.g., WebSummit 2026" required>
</div>
<button type="submit" class="btn btn-primary btn-block">Create Event</button>
</form>
</div>
</div>
<div id="event-list">
{{if .Events}}
<div class="event-grid">
{{range .Events}}
<div class="card event-card">
<div class="event-card-header">
<h3 class="event-card-name">{{.Name}}</h3>
<span id="status-badge-{{.ID}}" class="badge badge-{{.Status}}">{{.Status}}</span>
</div>
<div class="event-card-meta">
<span>Created: {{.CreatedAt}}</span>
</div>
<div class="event-card-actions">
{{if eq .Status "open"}}
<a href="/events/{{.ID}}/expenses" class="btn btn-primary btn-sm"
hx-get="/events/{{.ID}}/expenses" hx-target="body" hx-push-url="true">
Add Expenses
</a>
{{else}}
<button class="btn btn-secondary btn-sm"
hx-put="/events/{{.ID}}/reopen"
hx-target="#status-badge-{{.ID}}"
hx-swap="outerHTML">
Reopen
</button>
{{end}}
</div>
</div>
{{end}}
</div>
{{else}}
<div class="empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="12"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
<h3>No Events Yet</h3>
<p>Create your first event to start tracking expenses.</p>
</div>
{{end}}
</div>
</main>
</div>
<script>
// Toggle hidden class
document.querySelector('button[onclick]')?.addEventListener('click', function() {
// handled inline
});
</script>
</body>
</html>

View file

@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#10b981">
<title>{{.Event.Name}} - ExpenseFlow</title>
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<div class="app-shell">
<header class="app-header">
<a href="/dashboard" class="btn btn-secondary btn-sm"
hx-get="/dashboard" hx-target="body" hx-push-url="true">
&larr; Back
</a>
<h1 class="app-title">{{.Event.Name}}</h1>
<span class="badge badge-{{.Event.Status}}">{{.Event.Status}}</span>
</header>
<main class="main-content">
<!-- Capture Receipt Button -->
<div style="margin-bottom: 1.5rem;">
<label for="receipt-upload" class="btn btn-primary btn-block" style="display: inline-block; text-align: center; cursor: pointer;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 0.5rem;">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
Capture Receipt
</label>
<input type="file" id="receipt-upload" accept="image/*" capture="environment" style="display: none;"
hx-post="/expenses/upload"
hx-encoding="multipart/form-data"
hx-target="#receipt-form"
hx-swap="innerHTML"
hx-indicator="#upload-indicator">
<div id="upload-indicator" class="htmx-indicator" style="text-align: center; padding: 1rem;">
<div class="spinner"></div>
<p>Analyzing receipt...</p>
</div>
</div>
<!-- Receipt Form (filled by AI or empty) -->
<div id="receipt-form"></div>
<!-- Expense List -->
<div id="expense-list">
<h3 style="margin-bottom: 1rem;">Expenses</h3>
{{if .Expenses}}
<div class="expense-list">
{{range .Expenses}}
<div class="expense-item">
<div class="expense-item-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</div>
<div class="expense-item-info">
<div class="expense-item-merchant">{{.Merchant}}</div>
<div class="expense-item-meta">{{.Date}} &middot; {{.Category}}</div>
{{if .Description}}
<div class="expense-item-desc">{{.Description}}</div>
{{end}}
</div>
<div class="expense-item-amount">
<span class="amount">{{printf "%.2f" .Amount}}</span>
<span class="currency">{{.Currency}}</span>
</div>
</div>
{{end}}
</div>
{{else}}
<div class="empty-state" style="padding: 2rem;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="18"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
<h3>No Expenses Yet</h3>
<p>Capture a receipt to get started.</p>
</div>
{{end}}
</div>
<!-- File Event Button (only for open events) -->
{{if eq .Event.Status "open"}}
<div style="margin-top: 2rem; text-align: center;">
<button class="btn btn-secondary"
onclick="document.getElementById('file-modal').classList.toggle('hidden')">
File Event
</button>
</div>
<!-- File Event Modal -->
<div id="file-modal" class="modal-overlay hidden">
<div class="modal-content">
<h3>File Event Report</h3>
<p class="text-secondary">Generate and email the expense report for "{{.Event.Name}}".</p>
<form hx-post="/events/{{.Event.ID}}/file" hx-target="body" hx-push-url="true">
<div class="form-group">
<label for="file-email">Send to</label>
<input type="email" id="file-email" name="email" placeholder="recipient@example.com" required>
</div>
<div class="form-group">
<label>Format</label>
<div style="display: flex; gap: 1rem;">
<label class="radio-label">
<input type="radio" name="format" value="csv" checked> CSV
</label>
<label class="radio-label">
<input type="radio" name="format" value="pdf"> PDF
</label>
</div>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button" class="btn btn-secondary"
onclick="document.getElementById('file-modal').classList.add('hidden')">Cancel</button>
<button type="submit" class="btn btn-primary">Send Report & Close</button>
</div>
</form>
</div>
</div>
{{end}}
</main>
</div>
<script>
// Auto-trigger file input on label click
document.querySelector('label[for="receipt-upload"]')?.addEventListener('click', function() {
document.getElementById('receipt-upload').click();
});
// Close modal on overlay click
document.querySelector('.modal-overlay')?.addEventListener('click', function(e) {
if (e.target === this) {
this.classList.add('hidden');
}
});
</script>
</body>
</html>

View file

@ -0,0 +1,41 @@
<div id="expense-list">
<h3 style="margin-bottom: 1rem;">Expenses ({{len .Expenses}})</h3>
{{if .Expenses}}
<div class="expense-list">
{{range .Expenses}}
<div class="expense-item">
<div class="expense-item-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</div>
<div class="expense-item-info">
<div class="expense-item-merchant">{{.Merchant}}</div>
<div class="expense-item-meta">{{.Date}} &middot; {{.Category}}</div>
{{if .Description}}
<div class="expense-item-desc">{{.Description}}</div>
{{end}}
</div>
<div class="expense-item-amount">
<span class="amount">{{printf "%.2f" .Amount}}</span>
<span class="currency">{{.Currency}}</span>
</div>
</div>
{{end}}
</div>
{{else}}
<div class="empty-state" style="padding: 2rem;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="12"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
<h3>No Expenses Yet</h3>
<p>Capture a receipt to get started.</p>
</div>
{{end}}
</div>

47
templates/index.html Normal file
View file

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#10b981">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<title>ExpenseFlow</title>
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<div class="login-page">
<div class="card login-card">
<div class="login-brand">
<div class="login-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
</div>
<h1>ExpenseFlow</h1>
<p class="text-secondary">AI-Powered Expense Tracking</p>
</div>
<div id="otp-form">
<form hx-post="/request-otp" hx-target="#otp-form" hx-swap="outerHTML">
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" placeholder="you@example.com" required autocomplete="email" inputmode="email">
</div>
<button type="submit" class="btn btn-primary btn-block">
<span class="spinner htmx-indicator"></span>
Send Verification Code
</button>
</form>
</div>
<p class="text-secondary" style="text-align: center; font-size: 0.875rem; margin-top: 1rem;">
A 6-digit verification code will be sent to your email.
</p>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,89 @@
<div id="receipt-form">
{{if .AIError}}
<div class="error-message" style="background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">
{{.AIError}}
</div>
{{end}}
<div class="card" style="padding: 1rem;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span style="font-weight: 600;">Receipt Details</span>
<span class="badge badge-open" style="margin-left: auto;">AI Extracted</span>
</div>
<form hx-post="/expenses" hx-target="#receipt-form" hx-swap="outerHTML"
hx-indicator="#save-indicator">
<input type="hidden" name="image_path" value="{{.ImagePath}}">
<div class="form-row">
<div class="form-group">
<label for="amount">Amount *</label>
<input type="number" id="amount" name="amount" step="0.01" min="0" placeholder="0.00"
value="{{.Amount}}" required inputmode="decimal">
</div>
<div class="form-group">
<label for="currency">Currency *</label>
<select id="currency" name="currency" required>
<option value="">Select</option>
<option value="USD" {{if eq .Currency "USD"}}selected{{end}}>USD</option>
<option value="EUR" {{if eq .Currency "EUR"}}selected{{end}}>EUR</option>
<option value="GBP" {{if eq .Currency "GBP"}}selected{{end}}>GBP</option>
<option value="JPY" {{if eq .Currency "JPY"}}selected{{end}}>JPY</option>
<option value="CHF" {{if eq .Currency "CHF"}}selected{{end}}>CHF</option>
<option value="SEK" {{if eq .Currency "SEK"}}selected{{end}}>SEK</option>
<option value="NOK" {{if eq .Currency "NOK"}}selected{{end}}>NOK</option>
<option value="DKK" {{if eq .Currency "DKK"}}selected{{end}}>DKK</option>
<option value="PLN" {{if eq .Currency "PLN"}}selected{{end}}>PLN</option>
<option value="CZK" {{if eq .Currency "CZK"}}selected{{end}}>CZK</option>
<option value="HUF" {{if eq .Currency "HUF"}}selected{{end}}>HUF</option>
<option value="RON" {{if eq .Currency "RON"}}selected{{end}}>RON</option>
</select>
</div>
</div>
<div class="form-group">
<label for="merchant">Merchant *</label>
<input type="text" id="merchant" name="merchant" placeholder="Store or business name"
value="{{.Merchant}}" required>
</div>
<div class="form-row">
<div class="form-group">
<label for="category">Category *</label>
<select id="category" name="category" required>
<option value="">Select</option>
<option value="Food" {{if eq .Category "Food"}}selected{{end}}>Food</option>
<option value="Travel" {{if eq .Category "Travel"}}selected{{end}}>Travel</option>
<option value="Lodging" {{if eq .Category "Lodging"}}selected{{end}}>Lodging</option>
<option value="Software" {{if eq .Category "Software"}}selected{{end}}>Software</option>
<option value="Other" {{if eq .Category "Other"}}selected{{end}}>Other</option>
</select>
</div>
<div class="form-group">
<label for="date">Date *</label>
<input type="date" id="date" name="date" value="{{.Date}}" required>
</div>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" placeholder="Optional notes...">{{.Description}}</textarea>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn btn-primary" id="save-indicator">
<span class="spinner htmx-indicator"></span>
Save Expense
</button>
<button type="button" class="btn btn-secondary"
onclick="document.getElementById('receipt-form').innerHTML = ''">
Cancel
</button>
</div>
</form>
</div>
</div>