From ca970104eeb6c9fc0f661dd4c140e511187356b7 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Fri, 29 May 2026 19:43:30 +0000 Subject: [PATCH] =?UTF-8?q?chore:=20initial=20commit=20=E2=80=94=20Expense?= =?UTF-8?q?Flow=20AI-Powered=20Expense=20Tracker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 14 + .gitignore | 28 + README.md | 278 +++++ go.mod | 11 + go.sum | 20 + internal/ai/deepseek.go | 191 ++++ internal/auth/otp.go | 103 ++ internal/auth/session.go | 100 ++ internal/database/db.go | 324 ++++++ internal/email/smtp.go | 277 +++++ internal/handlers/auth.go | 298 ++++++ internal/handlers/events.go | 240 +++++ internal/handlers/expenses.go | 338 ++++++ internal/handlers/file.go | 244 +++++ internal/utils/uuid.go | 11 + main.go | 195 ++++ static/css/style.css | 1845 +++++++++++++++++++++++++++++++++ static/icons/icon-192.png | Bin 0 -> 593 bytes static/icons/icon-512.png | Bin 0 -> 2200 bytes static/manifest.json | 20 + static/sw.js | 264 +++++ templates/dashboard.html | 95 ++ templates/event_expenses.html | 146 +++ templates/expense_list.html | 41 + templates/index.html | 47 + templates/receipt_form.html | 89 ++ 26 files changed, 5219 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/ai/deepseek.go create mode 100644 internal/auth/otp.go create mode 100644 internal/auth/session.go create mode 100644 internal/database/db.go create mode 100644 internal/email/smtp.go create mode 100644 internal/handlers/auth.go create mode 100644 internal/handlers/events.go create mode 100644 internal/handlers/expenses.go create mode 100644 internal/handlers/file.go create mode 100644 internal/utils/uuid.go create mode 100644 main.go create mode 100644 static/css/style.css create mode 100644 static/icons/icon-192.png create mode 100644 static/icons/icon-512.png create mode 100644 static/manifest.json create mode 100644 static/sw.js create mode 100644 templates/dashboard.html create mode 100644 templates/event_expenses.html create mode 100644 templates/expense_list.html create mode 100644 templates/index.html create mode 100644 templates/receipt_form.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8b2f231 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdedeea --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c71d063 --- /dev/null +++ b/README.md @@ -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** — `` 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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c3b2220 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fd552c3 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/ai/deepseek.go b/internal/ai/deepseek.go new file mode 100644 index 0000000..d1543f8 --- /dev/null +++ b/internal/ai/deepseek.go @@ -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 +} diff --git a/internal/auth/otp.go b/internal/auth/otp.go new file mode 100644 index 0000000..5713e7c --- /dev/null +++ b/internal/auth/otp.go @@ -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) +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..30bc49c --- /dev/null +++ b/internal/auth/session.go @@ -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 +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..8d895d3 --- /dev/null +++ b/internal/database/db.go @@ -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() +} diff --git a/internal/email/smtp.go b/internal/email/smtp.go new file mode 100644 index 0000000..ed03787 --- /dev/null +++ b/internal/email/smtp.go @@ -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" + } +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..febe2ab --- /dev/null +++ b/internal/handlers/auth.go @@ -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, `
%s
`, 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(` +
+ +
+ + + + + + +
+ +
+`)) + + 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() +} diff --git a/internal/handlers/events.go b/internal/handlers/events.go new file mode 100644 index 0000000..5a68163 --- /dev/null +++ b/internal/handlers/events.go @@ -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, `open`, 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) + } +} diff --git a/internal/handlers/expenses.go b/internal/handlers/expenses.go new file mode 100644 index 0000000..034f23f --- /dev/null +++ b/internal/handlers/expenses.go @@ -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, `
Expense saved successfully!
`) + fmt.Fprintf(w, `
%s
`, 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 "" +} diff --git a/internal/handlers/file.go b/internal/handlers/file.go new file mode 100644 index 0000000..c06f9cf --- /dev/null +++ b/internal/handlers/file.go @@ -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: " + 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 +} diff --git a/internal/utils/uuid.go b/internal/utils/uuid.go new file mode 100644 index 0000000..14bfe48 --- /dev/null +++ b/internal/utils/uuid.go @@ -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() +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4c81d8d --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..3711e7a --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,1845 @@ +/* ========================================================================== + ExpenseFlow — PWA Expense Tracker Stylesheet + Mobile-first responsive | Plain CSS | System-ui font + ========================================================================== */ + +/* -------------------------------------------------------------------------- + 0. CSS Custom Properties (Design Tokens) + -------------------------------------------------------------------------- */ +:root { + /* Colors */ + --color-primary: #10b981; + --color-primary-hover: #059669; + --color-primary-light: #d1fae5; + --color-primary-dark: #047857; + + --color-secondary: #1e293b; + --color-secondary-hover: #334155; + + --color-bg: #f8fafc; + --color-card: #ffffff; + --color-text: #0f172a; + --color-text-muted: #64748b; + --color-text-light: #94a3b8; + + --color-border: #e2e8f0; + --color-border-focus: #10b981; + + --color-danger: #ef4444; + --color-danger-hover: #dc2626; + --color-danger-light: #fef2f2; + + --color-success: #10b981; + --color-warning: #f59e0b; + + --color-open-bg: #d1fae5; + --color-open-text: #065f46; + --color-closed-bg: #f1f5f9; + --color-closed-text: #475569; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.04); + --shadow-xl: 0 10px 25px rgba(0, 0, 0, 0.08); + + /* Border radius */ + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-full: 9999px; + + /* Spacing scale */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-16: 64px; + + /* Typography */ + --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + --text-3xl: 1.875rem; + + --leading-tight: 1.25; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + + --font-normal: 400; + --font-medium: 500; + --font-semibold: 600; + --font-bold: 700; + + /* Transitions */ + --transition-fast: 150ms ease; + --transition-base: 200ms ease; + --transition-slow: 300ms ease; + + /* Layout */ + --max-width: 1024px; + --header-height: 60px; + + /* Z-index layers */ + --z-base: 1; + --z-dropdown: 100; + --z-modal: 200; + --z-toast: 300; +} + +/* -------------------------------------------------------------------------- + 1. Reset & Base + -------------------------------------------------------------------------- */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + -webkit-text-size-adjust: 100%; + -moz-text-size-adjust: 100%; + text-size-adjust: 100%; + scroll-behavior: smooth; +} + +body { + font-family: var(--font-family); + font-size: var(--text-base); + line-height: var(--leading-normal); + color: var(--color-text); + background-color: var(--color-bg); + min-height: 100vh; + min-height: 100dvh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +img { + max-width: 100%; + height: auto; + display: block; +} + +a { + color: var(--color-primary); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: var(--color-primary-hover); +} + +/* -------------------------------------------------------------------------- + 2. Typography + -------------------------------------------------------------------------- */ +h1, h2, h3, h4, h5, h6 { + line-height: var(--leading-tight); + font-weight: var(--font-semibold); + color: var(--color-text); +} + +h1 { font-size: var(--text-2xl); } +h2 { font-size: var(--text-xl); } +h3 { font-size: var(--text-lg); } + +p { + margin-bottom: var(--space-2); + color: var(--color-text-muted); +} + +small, .text-sm { + font-size: var(--text-sm); +} + +.text-xs { + font-size: var(--text-xs); +} + +.text-muted { + color: var(--color-text-muted); +} + +.text-light { + color: var(--color-text-light); +} + +.text-center { + text-align: center; +} + +.font-medium { + font-weight: var(--font-medium); +} + +.font-semibold { + font-weight: var(--font-semibold); +} + +.font-bold { + font-weight: var(--font-bold); +} + +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* -------------------------------------------------------------------------- + 3. Layout Helpers + -------------------------------------------------------------------------- */ +.container { + width: 100%; + max-width: var(--max-width); + margin-left: auto; + margin-right: auto; + padding-left: var(--space-4); + padding-right: var(--space-4); +} + +.flex { + display: flex; +} + +.flex-col { + flex-direction: column; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.items-center { + align-items: center; +} + +.items-start { + align-items: flex-start; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.justify-end { + justify-content: flex-end; +} + +.gap-1 { gap: var(--space-1); } +.gap-2 { gap: var(--space-2); } +.gap-3 { gap: var(--space-3); } +.gap-4 { gap: var(--space-4); } +.gap-6 { gap: var(--space-6); } + +.mt-1 { margin-top: var(--space-1); } +.mt-2 { margin-top: var(--space-2); } +.mt-3 { margin-top: var(--space-3); } +.mt-4 { margin-top: var(--space-4); } +.mt-6 { margin-top: var(--space-6); } +.mt-8 { margin-top: var(--space-8); } +.mb-1 { margin-bottom: var(--space-1); } +.mb-2 { margin-bottom: var(--space-2); } +.mb-3 { margin-bottom: var(--space-3); } +.mb-4 { margin-bottom: var(--space-4); } +.mb-6 { margin-bottom: var(--space-6); } +.mb-8 { margin-bottom: var(--space-8); } + +.p-2 { padding: var(--space-2); } +.p-3 { padding: var(--space-3); } +.p-4 { padding: var(--space-4); } +.p-6 { padding: var(--space-6); } + +.py-2 { padding-top: var(--space-2); padding-bottom: var(--space-2); } +.py-3 { padding-top: var(--space-3); padding-bottom: var(--space-3); } +.py-4 { padding-top: var(--space-4); padding-bottom: var(--space-4); } +.py-6 { padding-top: var(--space-6); padding-bottom: var(--space-6); } +.px-3 { padding-left: var(--space-3); padding-right: var(--space-3); } +.px-4 { padding-left: var(--space-4); padding-right: var(--space-4); } +.px-6 { padding-left: var(--space-6); padding-right: var(--space-6); } + +.w-full { + width: 100%; +} + +.hidden { + display: none; +} + +/* -------------------------------------------------------------------------- + 4. App Shell (Header, Main) + -------------------------------------------------------------------------- */ +.app-header { + position: sticky; + top: 0; + z-index: var(--z-dropdown); + display: flex; + align-items: center; + justify-content: space-between; + height: var(--header-height); + padding: 0 var(--space-4); + background-color: var(--color-card); + border-bottom: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); +} + +.app-header__title { + font-size: var(--text-lg); + font-weight: var(--font-bold); + color: var(--color-secondary); +} + +.app-header__title span { + color: var(--color-primary); +} + +.app-main { + padding: var(--space-4) 0 var(--space-8); + min-height: calc(100vh - var(--header-height)); + min-height: calc(100dvh - var(--header-height)); +} + +/* -------------------------------------------------------------------------- + 5. Cards + -------------------------------------------------------------------------- */ +.card { + background-color: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + transition: box-shadow var(--transition-base), transform var(--transition-base); +} + +.card:hover { + box-shadow: var(--shadow-lg); +} + +.card--clickable { + cursor: pointer; +} + +.card--clickable:active { + transform: scale(0.99); +} + +.card__body { + padding: var(--space-4); +} + +.card__footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-3) var(--space-4); + border-top: 1px solid var(--color-border); + background-color: var(--color-bg); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); +} + +.card__title { + font-size: var(--text-base); + font-weight: var(--font-semibold); + margin-bottom: var(--space-1); +} + +.card__subtitle { + font-size: var(--text-sm); + color: var(--color-text-muted); +} + +/* Login card — centered single-column */ +.login-card { + width: 100%; + max-width: 400px; + margin: var(--space-12) auto; + padding: var(--space-8) var(--space-6); + border-radius: var(--radius-xl); +} + +.login-card__logo { + display: flex; + justify-content: center; + margin-bottom: var(--space-6); +} + +.login-card__logo svg { + width: 48px; + height: 48px; + color: var(--color-primary); +} + +.login-card__heading { + text-align: center; + margin-bottom: var(--space-2); +} + +.login-card__subheading { + text-align: center; + color: var(--color-text-muted); + font-size: var(--text-sm); + margin-bottom: var(--space-6); +} + +/* -------------------------------------------------------------------------- + 6. Event Cards Grid + -------------------------------------------------------------------------- */ +.events-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-4); +} + +.event-card { + position: relative; +} + +.event-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-2); +} + +.event-card__meta { + display: flex; + align-items: center; + gap: var(--space-2); + margin-top: var(--space-2); + font-size: var(--text-sm); + color: var(--color-text-muted); +} + +.event-card__amount { + font-size: var(--text-lg); + font-weight: var(--font-bold); + color: var(--color-primary); + margin-top: var(--space-2); +} + +.event-card__description { + font-size: var(--text-sm); + color: var(--color-text-muted); + margin-top: var(--space-2); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.event-card__actions { + display: flex; + align-items: center; + gap: var(--space-2); + margin-top: var(--space-3); +} + +/* -------------------------------------------------------------------------- + 7. Status Badges + -------------------------------------------------------------------------- */ +.badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 2px 10px; + font-size: var(--text-xs); + font-weight: var(--font-medium); + line-height: 1.4; + border-radius: var(--radius-full); + white-space: nowrap; + user-select: none; +} + +.badge--open { + background-color: var(--color-open-bg); + color: var(--color-open-text); +} + +.badge--closed { + background-color: var(--color-closed-bg); + color: var(--color-closed-text); +} + +.badge--pending { + background-color: #fef3c7; + color: #92400e; +} + +.badge--approved { + background-color: var(--color-primary-light); + color: var(--color-primary-dark); +} + +.badge--rejected { + background-color: var(--color-danger-light); + color: #991b1b; +} + +/* -------------------------------------------------------------------------- + 8. Buttons + -------------------------------------------------------------------------- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + font-family: inherit; + font-size: var(--text-sm); + font-weight: var(--font-medium); + line-height: 1.4; + border: 1px solid transparent; + border-radius: var(--radius-md); + cursor: pointer; + user-select: none; + white-space: nowrap; + text-decoration: none; + transition: + background-color var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast), + box-shadow var(--transition-fast), + transform var(--transition-fast); + -webkit-tap-highlight-color: transparent; +} + +.btn:active { + transform: scale(0.97); +} + +.btn:disabled, +.btn--disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Primary */ +.btn--primary { + background-color: var(--color-primary); + color: #ffffff; + border-color: var(--color-primary); +} + +.btn--primary:hover:not(:disabled) { + background-color: var(--color-primary-hover); + border-color: var(--color-primary-hover); +} + +.btn--primary:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* Secondary */ +.btn--secondary { + background-color: var(--color-card); + color: var(--color-secondary); + border-color: var(--color-border); +} + +.btn--secondary:hover:not(:disabled) { + background-color: var(--color-bg); + border-color: var(--color-text-light); +} + +.btn--secondary:focus-visible { + outline: 2px solid var(--color-secondary); + outline-offset: 2px; +} + +/* Danger */ +.btn--danger { + background-color: var(--color-danger); + color: #ffffff; + border-color: var(--color-danger); +} + +.btn--danger:hover:not(:disabled) { + background-color: var(--color-danger-hover); + border-color: var(--color-danger-hover); +} + +.btn--danger:focus-visible { + outline: 2px solid var(--color-danger); + outline-offset: 2px; +} + +/* Ghost (no background, subtle border) */ +.btn--ghost { + background-color: transparent; + color: var(--color-text-muted); + border-color: transparent; +} + +.btn--ghost:hover:not(:disabled) { + background-color: var(--color-bg); + color: var(--color-text); +} + +/* Sizes */ +.btn--sm { + padding: var(--space-1) var(--space-3); + font-size: var(--text-xs); +} + +.btn--lg { + padding: var(--space-3) var(--space-6); + font-size: var(--text-base); +} + +/* Full width on mobile */ +.btn--block { + width: 100%; +} + +/* Icon-only button */ +.btn--icon { + padding: var(--space-2); + min-width: 36px; + min-height: 36px; +} + +/* Floating Action Button */ +.fab { + position: fixed; + bottom: var(--space-6); + right: var(--space-6); + z-index: var(--z-dropdown); + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + background-color: var(--color-primary); + color: #ffffff; + border: none; + border-radius: var(--radius-full); + box-shadow: var(--shadow-lg); + cursor: pointer; + transition: + background-color var(--transition-fast), + transform var(--transition-fast), + box-shadow var(--transition-fast); + -webkit-tap-highlight-color: transparent; +} + +.fab:hover { + background-color: var(--color-primary-hover); + box-shadow: var(--shadow-xl); + transform: translateY(-2px); +} + +.fab:active { + transform: scale(0.95); +} + +.fab svg { + width: 24px; + height: 24px; +} + +/* -------------------------------------------------------------------------- + 9. Form Inputs + -------------------------------------------------------------------------- */ +.form-group { + margin-bottom: var(--space-4); +} + +.form-label { + display: block; + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--color-text); + margin-bottom: var(--space-1); +} + +.form-label--required::after { + content: " *"; + color: var(--color-danger); +} + +.form-input, +.form-select, +.form-textarea { + display: block; + width: 100%; + padding: var(--space-3) var(--space-3); + font-family: inherit; + font-size: var(--text-base); + line-height: var(--leading-normal); + color: var(--color-text); + background-color: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast); + -webkit-appearance: none; + appearance: none; +} + +.form-input::placeholder, +.form-textarea::placeholder { + color: var(--color-text-light); +} + +.form-input:hover, +.form-select:hover, +.form-textarea:hover { + border-color: var(--color-text-light); +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: var(--color-border-focus); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15); +} + +.form-input--error { + border-color: var(--color-danger); +} + +.form-input--error:focus { + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); +} + +.form-error { + display: flex; + align-items: center; + gap: var(--space-1); + margin-top: var(--space-1); + font-size: var(--text-xs); + color: var(--color-danger); +} + +.form-textarea { + min-height: 100px; + resize: vertical; +} + +.form-select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right var(--space-3) center; + padding-right: var(--space-10); + cursor: pointer; +} + +/* Prevent zoom on mobile for inputs */ +@media screen and (max-width: 768px) { + .form-input, + .form-select, + .form-textarea { + font-size: 16px; /* Prevents iOS zoom on focus */ + } +} + +/* -------------------------------------------------------------------------- + 10. OTP Input (6-digit) + -------------------------------------------------------------------------- */ +.otp-container { + display: flex; + gap: var(--space-2); + justify-content: center; + margin: var(--space-6) 0; +} + +.otp-input { + width: 44px; + height: 52px; + padding: 0; + font-family: inherit; + font-size: var(--text-xl); + font-weight: var(--font-bold); + text-align: center; + color: var(--color-text); + background-color: var(--color-card); + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + outline: none; + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast); + -moz-appearance: textfield; + -webkit-appearance: none; + appearance: none; +} + +.otp-input::-webkit-outer-spin-button, +.otp-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.otp-input:hover { + border-color: var(--color-text-light); +} + +.otp-input:focus { + border-color: var(--color-border-focus); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15); +} + +.otp-input--filled { + border-color: var(--color-primary); + background-color: var(--color-primary-light); +} + +.otp-input--error { + border-color: var(--color-danger); + animation: shake 0.3s ease-in-out; +} + +/* -------------------------------------------------------------------------- + 11. File Input (hidden for camera capture) + -------------------------------------------------------------------------- */ +.file-input-wrapper { + position: relative; +} + +.file-input-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.file-input-label { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + font-family: inherit; + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--color-text); + background-color: var(--color-card); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: + border-color var(--transition-fast), + background-color var(--transition-fast); + width: 100%; +} + +.file-input-label:hover { + border-color: var(--color-primary); + background-color: var(--color-primary-light); +} + +.file-input-label:active { + transform: scale(0.99); +} + +.file-input-label--has-file { + border-style: solid; + border-color: var(--color-primary); + background-color: var(--color-primary-light); + color: var(--color-primary-dark); +} + +.file-input-preview { + margin-top: var(--space-2); + position: relative; + border-radius: var(--radius-md); + overflow: hidden; +} + +.file-input-preview img { + width: 100%; + max-height: 240px; + object-fit: cover; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.file-input-preview__remove { + position: absolute; + top: var(--space-2); + right: var(--space-2); + background-color: rgba(0, 0, 0, 0.6); + color: #ffffff; + border: none; + border-radius: var(--radius-full); + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: var(--text-sm); + transition: background-color var(--transition-fast); +} + +.file-input-preview__remove:hover { + background-color: rgba(0, 0, 0, 0.8); +} + +/* -------------------------------------------------------------------------- + 12. Tables / Expense List + -------------------------------------------------------------------------- */ +.expense-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.expense-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background-color: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: + border-color var(--transition-fast), + box-shadow var(--transition-fast); +} + +.expense-item:hover { + border-color: var(--color-text-light); + box-shadow: var(--shadow-sm); +} + +.expense-item__icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background-color: var(--color-primary-light); + color: var(--color-primary); + border-radius: var(--radius-sm); + flex-shrink: 0; +} + +.expense-item__icon svg { + width: 20px; + height: 20px; +} + +.expense-item__info { + flex: 1; + min-width: 0; +} + +.expense-item__title { + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--color-text); + margin-bottom: 2px; +} + +.expense-item__meta { + font-size: var(--text-xs); + color: var(--color-text-muted); +} + +.expense-item__amount { + font-size: var(--text-sm); + font-weight: var(--font-semibold); + color: var(--color-text); + white-space: nowrap; + flex-shrink: 0; +} + +.expense-item__actions { + display: flex; + align-items: center; + gap: var(--space-1); + flex-shrink: 0; +} + +/* -------------------------------------------------------------------------- + 13. Receipt Form (AI pre-filled edit form) + -------------------------------------------------------------------------- */ +.receipt-form { + background-color: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-md); +} + +.receipt-form__header { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-6); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--color-border); +} + +.receipt-form__header svg { + width: 24px; + height: 24px; + color: var(--color-primary); +} + +.receipt-form__title { + font-size: var(--text-lg); + font-weight: var(--font-semibold); +} + +.receipt-form__ai-badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 2px 8px; + background-color: #ede9fe; + color: #5b21b6; + font-size: var(--text-xs); + font-weight: var(--font-medium); + border-radius: var(--radius-full); + margin-left: auto; +} + +.receipt-form__ai-badge svg { + width: 12px; + height: 12px; + color: #5b21b6; +} + +.receipt-form__row { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-4); +} + +/* -------------------------------------------------------------------------- + 14. Modal / Dialog Overlay + -------------------------------------------------------------------------- */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: flex; + align-items: flex-end; + justify-content: center; + background-color: rgba(15, 23, 42, 0.5); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + padding: var(--space-4); + opacity: 0; + visibility: hidden; + transition: + opacity var(--transition-base), + visibility var(--transition-base); +} + +.modal-overlay--open { + opacity: 1; + visibility: visible; +} + +.modal { + width: 100%; + max-width: 500px; + max-height: 90vh; + max-height: 90dvh; + overflow-y: auto; + background-color: var(--color-card); + border-radius: var(--radius-xl) var(--radius-xl) 0 0; + box-shadow: var(--shadow-xl); + transform: translateY(100%); + transition: transform var(--transition-slow); + -webkit-overflow-scrolling: touch; +} + +.modal-overlay--open .modal { + transform: translateY(0); +} + +.modal__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4) var(--space-6); + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + background-color: var(--color-card); + z-index: 1; +} + +.modal__title { + font-size: var(--text-lg); + font-weight: var(--font-semibold); +} + +.modal__close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: none; + border: none; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + cursor: pointer; + transition: + background-color var(--transition-fast), + color var(--transition-fast); +} + +.modal__close:hover { + background-color: var(--color-bg); + color: var(--color-text); +} + +.modal__body { + padding: var(--space-6); +} + +.modal__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-3); + padding: var(--space-4) var(--space-6); + border-top: 1px solid var(--color-border); + background-color: var(--color-bg); +} + +/* Desktop: center modal */ +@media (min-width: 768px) { + .modal-overlay { + align-items: center; + } + + .modal { + border-radius: var(--radius-xl); + transform: translateY(0) scale(0.95); + opacity: 0; + transition: + transform var(--transition-base), + opacity var(--transition-base); + } + + .modal-overlay--open .modal { + transform: translateY(0) scale(1); + opacity: 1; + } +} + +/* -------------------------------------------------------------------------- + 15. HTMX Loading States + -------------------------------------------------------------------------- */ + +/* Pulse skeleton for content loading */ +.htmx-request .skeleton, +.skeleton { + background: linear-gradient( + 90deg, + var(--color-border) 25%, + #f1f5f9 50%, + var(--color-border) 75% + ); + background-size: 200% 100%; + animation: skeleton-pulse 1.5s ease-in-out infinite; + border-radius: var(--radius-sm); + color: transparent !important; + user-select: none; + pointer-events: none; +} + +.skeleton--text { + display: inline-block; + height: 1em; + width: 100%; +} + +.skeleton--heading { + height: 1.5em; + width: 60%; +} + +.skeleton--avatar { + width: 40px; + height: 40px; + border-radius: var(--radius-full); +} + +.skeleton--card { + height: 120px; +} + +/* HTMX spinner */ +.htmx-indicator { + display: none; + opacity: 0; + transition: opacity var(--transition-fast); +} + +.htmx-request .htmx-indicator, +.htmx-indicator.htmx-request { + display: inline-flex; + opacity: 1; +} + +/* Spinner animation */ +.spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +.spinner--sm { + width: 14px; + height: 14px; + border-width: 2px; +} + +.spinner--lg { + width: 32px; + height: 32px; + border-width: 3px; +} + +.spinner--white { + border-color: rgba(255, 255, 255, 0.3); + border-top-color: #ffffff; +} + +/* Button loading state */ +.btn--loading { + position: relative; + color: transparent !important; + pointer-events: none; +} + +.btn--loading::after { + content: ""; + position: absolute; + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #ffffff; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +.btn--secondary.btn--loading::after { + border-color: var(--color-border); + border-top-color: var(--color-primary); +} + +/* HTMX swap transitions: prevent layout shifts */ +.htmx-swapping { + opacity: 0; + transition: opacity var(--transition-fast); +} + +.htmx-settling { + opacity: 0; +} + +/* Container for replaced content — reserve space */ +.htmx-target-container { + position: relative; + min-height: 40px; +} + +/* -------------------------------------------------------------------------- + 16. Toast / Notification + -------------------------------------------------------------------------- */ +.toast-container { + position: fixed; + top: var(--space-4); + right: var(--space-4); + left: var(--space-4); + z-index: var(--z-toast); + display: flex; + flex-direction: column; + gap: var(--space-2); + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background-color: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + pointer-events: auto; + animation: toast-in 0.3s ease-out; +} + +.toast--success { + border-left: 4px solid var(--color-success); +} + +.toast--error { + border-left: 4px solid var(--color-danger); +} + +.toast--warning { + border-left: 4px solid var(--color-warning); +} + +.toast__message { + flex: 1; + font-size: var(--text-sm); + color: var(--color-text); +} + +.toast__dismiss { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: none; + border: none; + border-radius: var(--radius-sm); + color: var(--color-text-light); + cursor: pointer; + transition: + background-color var(--transition-fast), + color var(--transition-fast); +} + +.toast__dismiss:hover { + background-color: var(--color-bg); + color: var(--color-text); +} + +/* Desktop positioning */ +@media (min-width: 768px) { + .toast-container { + left: auto; + right: var(--space-6); + max-width: 380px; + } +} + +/* -------------------------------------------------------------------------- + 17. Empty State + -------------------------------------------------------------------------- */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-12) var(--space-4); + text-align: center; +} + +.empty-state__icon { + width: 64px; + height: 64px; + color: var(--color-text-light); + margin-bottom: var(--space-4); +} + +.empty-state__heading { + font-size: var(--text-lg); + font-weight: var(--font-semibold); + color: var(--color-text); + margin-bottom: var(--space-2); +} + +.empty-state__text { + font-size: var(--text-sm); + color: var(--color-text-muted); + max-width: 280px; + margin-bottom: var(--space-6); +} + +/* -------------------------------------------------------------------------- + 18. Login Page Specific + -------------------------------------------------------------------------- */ +.login-page { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 100vh; + min-height: 100dvh; + padding: var(--space-4); + background-color: var(--color-bg); +} + +.login-page__brand { + text-align: center; + margin-bottom: var(--space-8); +} + +.login-page__brand h1 { + font-size: var(--text-3xl); + font-weight: var(--font-bold); + color: var(--color-secondary); +} + +.login-page__brand h1 span { + color: var(--color-primary); +} + +.login-page__brand p { + font-size: var(--text-sm); + color: var(--color-text-muted); + margin-top: var(--space-1); +} + +/* Email step */ +.login-step__email, +.login-step__otp { + transition: opacity var(--transition-base); +} + +/* OTP resend */ +.otp-resend { + text-align: center; + font-size: var(--text-sm); + color: var(--color-text-muted); + margin-top: var(--space-4); +} + +.otp-resend button { + background: none; + border: none; + color: var(--color-primary); + font-family: inherit; + font-size: var(--text-sm); + font-weight: var(--font-medium); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + transition: color var(--transition-fast); +} + +.otp-resend button:hover { + color: var(--color-primary-hover); +} + +.otp-resend button:disabled { + color: var(--color-text-light); + cursor: not-allowed; + text-decoration: none; +} + +/* -------------------------------------------------------------------------- + 19. Dashboard Specific + -------------------------------------------------------------------------- */ +.dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-6); + flex-wrap: wrap; + gap: var(--space-3); +} + +.dashboard-header__title { + font-size: var(--text-xl); + font-weight: var(--font-bold); +} + +.dashboard-header__actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +/* Filters / tabs */ +.filter-tabs { + display: flex; + gap: var(--space-1); + margin-bottom: var(--space-4); + padding: var(--space-1); + background-color: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.filter-tab { + flex: 1; + padding: var(--space-2) var(--space-3); + font-family: inherit; + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--color-text-muted); + background: none; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + white-space: nowrap; + transition: + background-color var(--transition-fast), + color var(--transition-fast); +} + +.filter-tab:hover { + color: var(--color-text); + background-color: var(--color-bg); +} + +.filter-tab--active { + color: var(--color-primary); + background-color: var(--color-primary-light); +} + +/* Summary bar */ +.summary-bar { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-3); + margin-bottom: var(--space-6); +} + +.summary-item { + padding: var(--space-4); + background-color: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + text-align: center; +} + +.summary-item__value { + font-size: var(--text-2xl); + font-weight: var(--font-bold); + color: var(--color-text); +} + +.summary-item__label { + font-size: var(--text-xs); + color: var(--color-text-muted); + margin-top: var(--space-1); +} + +/* -------------------------------------------------------------------------- + 20. Receipt Capture Page + -------------------------------------------------------------------------- */ +.capture-page { + padding: var(--space-4); +} + +.capture-preview { + margin-bottom: var(--space-6); +} + +.capture-preview__image { + width: 100%; + max-height: 360px; + object-fit: contain; + background-color: #000000; + border-radius: var(--radius-lg); +} + +.capture-actions { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +/* -------------------------------------------------------------------------- + 21. Utility & Accessibility + -------------------------------------------------------------------------- */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Focus visible for keyboard navigation */ +:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +:focus:not(:focus-visible) { + outline: none; +} + +/* Reduced motion preference */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + html { + scroll-behavior: auto; + } +} + +/* Selection */ +::selection { + background-color: var(--color-primary-light); + color: var(--color-primary-dark); +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background-color: var(--color-border); + border-radius: var(--radius-full); +} + +::-webkit-scrollbar-thumb:hover { + background-color: var(--color-text-light); +} + +/* -------------------------------------------------------------------------- + 22. Responsive: Tablet (768px+) + -------------------------------------------------------------------------- */ +@media (min-width: 768px) { + .container { + padding-left: var(--space-8); + padding-right: var(--space-8); + } + + h1 { font-size: var(--text-3xl); } + h2 { font-size: var(--text-2xl); } + h3 { font-size: var(--text-xl); } + + .app-main { + padding: var(--space-6) 0 var(--space-12); + } + + /* Events grid: 2 columns */ + .events-grid { + grid-template-columns: repeat(2, 1fr); + } + + /* Summary bar: 4 columns */ + .summary-bar { + grid-template-columns: repeat(4, 1fr); + } + + /* Receipt form rows */ + .receipt-form__row { + grid-template-columns: 1fr 1fr; + } + + /* Login card vertical centering */ + .login-card { + margin-top: 0; + } + + /* OTP: slightly larger inputs */ + .otp-input { + width: 48px; + height: 56px; + font-size: var(--text-2xl); + } + + .fab { + bottom: var(--space-8); + right: var(--space-8); + } + + /* Dashboard header stack inline */ + .dashboard-header { + flex-wrap: nowrap; + } + + .empty-state__icon { + width: 80px; + height: 80px; + } +} + +/* -------------------------------------------------------------------------- + 23. Responsive: Desktop (1024px+) + -------------------------------------------------------------------------- */ +@media (min-width: 1024px) { + .container { + padding-left: var(--space-12); + padding-right: var(--space-12); + } + + .app-main { + padding: var(--space-8) 0 var(--space-16); + } + + /* Events grid: 3 columns */ + .events-grid { + grid-template-columns: repeat(3, 1fr); + } + + /* OTP: standard sizing */ + .otp-input { + width: 52px; + height: 60px; + } + + /* Larger FAB */ + .fab { + width: 60px; + height: 60px; + bottom: var(--space-10); + right: var(--space-10); + } + + .fab svg { + width: 28px; + height: 28px; + } +} + +/* -------------------------------------------------------------------------- + 24. Responsive: Large Desktop (1280px+) + -------------------------------------------------------------------------- */ +@media (min-width: 1280px) { + .container { + padding-left: var(--space-16); + padding-right: var(--space-16); + } + + .events-grid { + gap: var(--space-6); + } +} + +/* -------------------------------------------------------------------------- + 25. Keyframe Animations + -------------------------------------------------------------------------- */ +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes skeleton-pulse { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-4px); } + 40% { transform: translateX(4px); } + 60% { transform: translateX(-4px); } + 80% { transform: translateX(4px); } +} + +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes scale-in { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* Utility animation classes */ +.animate-fade-in { + animation: fade-in var(--transition-base) ease-out; +} + +.animate-fade-in-up { + animation: fade-in-up var(--transition-slow) ease-out; +} + +.animate-scale-in { + animation: scale-in var(--transition-base) ease-out; +} + +/* Stagger children animations */ +.animate-stagger > * { + opacity: 0; + animation: fade-in-up var(--transition-slow) ease-out forwards; +} + +.animate-stagger > *:nth-child(1) { animation-delay: 0ms; } +.animate-stagger > *:nth-child(2) { animation-delay: 60ms; } +.animate-stagger > *:nth-child(3) { animation-delay: 120ms; } +.animate-stagger > *:nth-child(4) { animation-delay: 180ms; } +.animate-stagger > *:nth-child(5) { animation-delay: 240ms; } +.animate-stagger > *:nth-child(6) { animation-delay: 300ms; } diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..5019f20aec0006ed59a2f217ba931a9b4679ecc8 GIT binary patch literal 593 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zf+;V3P23aSW-L^Y*eLBZGp#fdl;x zsYw(0k`&4w{W+61w{~Gx9s2=)HifecOC%dim?xw$T;g$<#pq$okU1(ij05Br^PZE+ V9)-2MX~3ky;OXk;vd$@?2>`_Ns`dZ? literal 0 HcmV?d00001 diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..7a00b3f9f9ff2eb16771bb328d60c91916069d0c GIT binary patch literal 2200 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe)e;yqm)Ln`LHy{5>>puljz zKq+8m#FAz)1v|s~pN8M97#vRjkY!-llg_}PaDsusVFDvVg90-HgCo$0CLRU`7D=EP zMhpxBDWeKT!(lW%jOL5c@?o@`9IXyUt3^ojAXX`{Q?~hty)UrE!QkoY=d#Wzp$Pz& C&l#Hl literal 0 HcmV?d00001 diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..cf44f0a --- /dev/null +++ b/static/manifest.json @@ -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" + } + ] +} diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..ba29c28 --- /dev/null +++ b/static/sw.js @@ -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( + 'Offline — ExpenseFlow

You\'re offline

ExpenseFlow needs an internet connection to load. Please check your connection and try again.

', + { + 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' } } + ); + }); + }) + ); +}); diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..6c36b62 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,95 @@ + + + + + + + Dashboard - ExpenseFlow + + + + + +
+
+

ExpenseFlow

+ Logout +
+ +
+
+

My Events

+ +
+ + + +
+ {{if .Events}} +
+ {{range .Events}} +
+
+

{{.Name}}

+ {{.Status}} +
+
+ Created: {{.CreatedAt}} +
+
+ {{if eq .Status "open"}} + + Add Expenses + + {{else}} + + {{end}} +
+
+ {{end}} +
+ {{else}} +
+ + + + + + +

No Events Yet

+

Create your first event to start tracking expenses.

+
+ {{end}} +
+
+
+ + + + diff --git a/templates/event_expenses.html b/templates/event_expenses.html new file mode 100644 index 0000000..70f5c79 --- /dev/null +++ b/templates/event_expenses.html @@ -0,0 +1,146 @@ + + + + + + + {{.Event.Name}} - ExpenseFlow + + + + + +
+
+ + ← Back + +

{{.Event.Name}}

+ {{.Event.Status}} +
+ +
+ +
+ + +
+
+

Analyzing receipt...

+
+
+ + +
+ + +
+

Expenses

+ {{if .Expenses}} +
+ {{range .Expenses}} +
+
+ + + + + +
+
+
{{.Merchant}}
+
{{.Date}} · {{.Category}}
+ {{if .Description}} +
{{.Description}}
+ {{end}} +
+
+ {{printf "%.2f" .Amount}} + {{.Currency}} +
+
+ {{end}} +
+ {{else}} +
+ + + + + + +

No Expenses Yet

+

Capture a receipt to get started.

+
+ {{end}} +
+ + + {{if eq .Event.Status "open"}} +
+ +
+ + + + {{end}} +
+
+ + + + diff --git a/templates/expense_list.html b/templates/expense_list.html new file mode 100644 index 0000000..1a57689 --- /dev/null +++ b/templates/expense_list.html @@ -0,0 +1,41 @@ +
+

Expenses ({{len .Expenses}})

+ + {{if .Expenses}} +
+ {{range .Expenses}} +
+
+ + + + + +
+
+
{{.Merchant}}
+
{{.Date}} · {{.Category}}
+ {{if .Description}} +
{{.Description}}
+ {{end}} +
+
+ {{printf "%.2f" .Amount}} + {{.Currency}} +
+
+ {{end}} +
+ {{else}} +
+ + + + + + +

No Expenses Yet

+

Capture a receipt to get started.

+
+ {{end}} +
diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..ec30285 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,47 @@ + + + + + + + + + ExpenseFlow + + + + + + + + + diff --git a/templates/receipt_form.html b/templates/receipt_form.html new file mode 100644 index 0000000..7f1f82e --- /dev/null +++ b/templates/receipt_form.html @@ -0,0 +1,89 @@ +
+ {{if .AIError}} +
+ {{.AIError}} +
+ {{end}} + +
+
+ + + + + Receipt Details + AI Extracted +
+ +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
+