chore: initial commit — ExpenseFlow AI-Powered Expense Tracker
- Passwordless email OTP authentication - Event-based expense tracking with HTMX UI - AI receipt extraction via DeepSeek Vision API - CSV/PDF report generation with email filing - PWA with service worker and manifest - Mobile-first responsive design - SQLite database with auto-migration
This commit is contained in:
commit
ca970104ee
26 changed files with 5219 additions and 0 deletions
14
.env.example
Normal file
14
.env.example
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# ExpenseFlow Configuration
|
||||||
|
# Copy this file to .env and fill in your credentials.
|
||||||
|
|
||||||
|
# SMTP Configuration
|
||||||
|
SMTP_HOST=smtp.openxchange.eu
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=post@2-4-h.app
|
||||||
|
SMTP_PASS=D9AW8JP74r1V
|
||||||
|
|
||||||
|
# DeepSeek Vision API Key
|
||||||
|
DEEPSEEK_API_KEY=sk-e9362165d2694883a52a5142811aa422
|
||||||
|
|
||||||
|
# Base URL for generating absolute links in emails
|
||||||
|
BASE_URL=http://localhost:8080
|
||||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Binaries
|
||||||
|
expenseflow
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Storage (uploaded receipt images)
|
||||||
|
storage/*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Go
|
||||||
|
vendor/
|
||||||
278
README.md
Normal file
278
README.md
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
# ExpenseFlow - AI-Powered Expense Tracker
|
||||||
|
|
||||||
|
A production-ready, mobile-first Progressive Web App (PWA) for expense management with passwordless email OTP authentication, event-based expense tracking, AI receipt extraction via DeepSeek Vision, and CSV/PDF reporting delivered via email.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Passwordless OTP Auth** — Email-based 6-digit code, no passwords, HTTP-only session cookie
|
||||||
|
- **Event Dashboard** — Create events (e.g. "WebSummit 2026"), track status (open/closed), reopen closed events
|
||||||
|
- **AI Receipt Capture** — Snap a photo → DeepSeek Vision extracts amount, merchant, category, date → save
|
||||||
|
- **Event Filing** — Generate CSV or PDF report → email as attachment → auto-close event
|
||||||
|
- **PWA** — Installable on mobile homescreen, offline shell, camera capture for receipts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| **Backend** | Go (`net/http` + chi router) |
|
||||||
|
| **Frontend** | HTMX (server-driven UI, partial page updates) |
|
||||||
|
| **Database** | SQLite (auto-migrated on startup) |
|
||||||
|
| **AI OCR** | DeepSeek Vision API |
|
||||||
|
| **Email** | SMTP (OTP delivery + report attachments) |
|
||||||
|
| **PWA** | `manifest.json` + Service Worker |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Go 1.21+
|
||||||
|
- GCC (required for CGO/SQLite via `mattn/go-sqlite3`)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/your-org/expenseflow.git
|
||||||
|
cd expenseflow
|
||||||
|
|
||||||
|
# Copy environment configuration
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:8080](http://localhost:8080) in your browser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and configure:
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `SMTP_HOST` | SMTP server hostname | `smtp.openxchange.eu` |
|
||||||
|
| `SMTP_PORT` | SMTP server port | `587` |
|
||||||
|
| `SMTP_USER` | SMTP authentication username | `post@2-4-h.app` |
|
||||||
|
| `SMTP_PASS` | SMTP authentication password | — |
|
||||||
|
| `DEEPSEEK_API_KEY` | DeepSeek Vision API key | — |
|
||||||
|
| `BASE_URL` | Public base URL for absolute links in emails | `http://localhost:8080` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ExpenseFlow/
|
||||||
|
├── main.go # Entry point, router, static file server
|
||||||
|
├── go.mod # Module dependencies
|
||||||
|
├── .env # Credentials (not committed)
|
||||||
|
├── .env.example # Env template
|
||||||
|
├── internal/
|
||||||
|
│ ├── ai/
|
||||||
|
│ │ └── deepseek.go # DeepSeek Vision API client
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ ├── otp.go # OTP generation & validation
|
||||||
|
│ │ └── session.go # Session management (in-memory store)
|
||||||
|
│ ├── database/
|
||||||
|
│ │ └── db.go # SQLite init, queries, auto-migration
|
||||||
|
│ ├── email/
|
||||||
|
│ │ └── smtp.go # SMTP email sender (OTP + reports)
|
||||||
|
│ ├── handlers/
|
||||||
|
│ │ ├── auth.go # Auth endpoints (login, OTP, verify)
|
||||||
|
│ │ ├── events.go # Event CRUD + reopen
|
||||||
|
│ │ ├── expenses.go # Upload, AI extract, save
|
||||||
|
│ │ └── file.go # CSV/PDF generation, email delivery
|
||||||
|
│ └── utils/
|
||||||
|
│ └── uuid.go # UUID generation helper
|
||||||
|
├── static/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── style.css # Application styles
|
||||||
|
│ ├── icons/ # PWA icons (192x192, 512x512)
|
||||||
|
│ ├── manifest.json # PWA manifest
|
||||||
|
│ └── sw.js # Service Worker (offline cache)
|
||||||
|
├── templates/ # HTMX templates (server-rendered HTML)
|
||||||
|
│ ├── layout.html
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── dashboard.html
|
||||||
|
│ ├── event_expenses.html
|
||||||
|
│ ├── receipt_form.html
|
||||||
|
│ └── expense_list.html
|
||||||
|
└── storage/ # Uploaded receipt images (created at runtime)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/` | Landing page — email input for OTP login |
|
||||||
|
| `POST` | `/request-otp` | Request a 6-digit OTP code (sent via email) |
|
||||||
|
| `POST` | `/verify-otp` | Verify OTP and create session cookie |
|
||||||
|
| `GET` | `/dashboard` | User dashboard — list of events |
|
||||||
|
| `POST` | `/events` | Create a new expense event |
|
||||||
|
| `GET` | `/events/{id}/expenses` | View expenses for an event |
|
||||||
|
| `POST` | `/expenses/upload` | Upload receipt image → AI extraction |
|
||||||
|
| `POST` | `/expenses` | Save an expense record |
|
||||||
|
| `POST` | `/events/{id}/file` | File report (CSV/PDF) and close event |
|
||||||
|
| `PUT` | `/events/{id}/reopen` | Reopen a closed event |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Walkthrough
|
||||||
|
|
||||||
|
### 1. Passwordless OTP Auth
|
||||||
|
|
||||||
|
Log in with just your email — no passwords needed.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
User->>Browser: Enter email
|
||||||
|
Browser->>Server: POST /request-otp (email)
|
||||||
|
Server->>Email: Send 6-digit code
|
||||||
|
User->>Browser: Enter code
|
||||||
|
Browser->>Server: POST /verify-otp (email, code)
|
||||||
|
Server->>Browser: Set session cookie, redirect to /dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Event Dashboard
|
||||||
|
|
||||||
|
- **Open events** — click to view expenses and capture receipts
|
||||||
|
- **Closed events** — show a **Reopen** button (`PUT /events/{id}/reopen`)
|
||||||
|
- Each card shows: event name, status badge, created date
|
||||||
|
|
||||||
|
### 3. AI Receipt Capture
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Upload a receipt image
|
||||||
|
curl -X POST http://localhost:8080/expenses/upload \
|
||||||
|
-H "Cookie: session_token=..." \
|
||||||
|
-F "image=@receipt.jpg"
|
||||||
|
```
|
||||||
|
|
||||||
|
What happens:
|
||||||
|
1. Image saved to `./storage/{uuid}.jpg`
|
||||||
|
2. Sent to DeepSeek Vision API for OCR
|
||||||
|
3. AI returns: `{amount, currency, merchant, category, date}`
|
||||||
|
4. Pre-filled form rendered for user review/edit
|
||||||
|
5. On save → expense stored, list updated
|
||||||
|
|
||||||
|
### 4. Event Filing
|
||||||
|
|
||||||
|
Generate a CSV or PDF report and email it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# File event as PDF
|
||||||
|
curl -X POST http://localhost:8080/events/123/file \
|
||||||
|
-H "Cookie: session_token=..." \
|
||||||
|
-d "email=user@example.com&format=pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
- Creates report from all event expenses
|
||||||
|
- Sends email with attachment
|
||||||
|
- Event status changes to `closed`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Test Scenario
|
||||||
|
|
||||||
|
Run through the full flow manually:
|
||||||
|
|
||||||
|
1. **Visit** `http://localhost:8080` — see email input
|
||||||
|
2. **Enter email** — receive 6-digit OTP in inbox
|
||||||
|
3. **Enter OTP** — redirected to dashboard (empty)
|
||||||
|
4. **Create event** — name it "Test Event"
|
||||||
|
5. **Click event** — see expense list (empty) + **Capture Receipt** button
|
||||||
|
6. **Upload receipt** — snap/take a photo → AI extracts amount, merchant, category, date
|
||||||
|
7. **Review & save** — pre-filled form, click Save → expense appears in list
|
||||||
|
8. **File event** — click **File Event**, enter email, pick PDF → report sent, event closes
|
||||||
|
9. **Verify** — event shows as **closed** on dashboard
|
||||||
|
10. **Reopen** — click **Reopen** → badge changes back to **open**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
SQLite database (`expenses.db`) created and auto-migrated on first startup.
|
||||||
|
|
||||||
|
### Tables
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Users
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- OTP codes
|
||||||
|
CREATE TABLE auth_otps (
|
||||||
|
email TEXT PRIMARY KEY,
|
||||||
|
otp_code TEXT NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Events (expense containers)
|
||||||
|
CREATE TABLE events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT CHECK(status IN ('open','closed')) DEFAULT 'open',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Expenses (receipt records)
|
||||||
|
CREATE TABLE expenses (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
currency TEXT NOT NULL,
|
||||||
|
merchant TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
image_path TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(event_id) REFERENCES events(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PWA Features
|
||||||
|
|
||||||
|
- **Installable** — `manifest.json` with standalone display, theme color `#10b981`
|
||||||
|
- **Offline shell** — Service Worker caches core assets (CSS, HTMX, shell) on install
|
||||||
|
- **Camera capture** — `<input type="file" accept="image/*" capture="environment">` for mobile receipt snapping
|
||||||
|
|
||||||
|
### Service Worker
|
||||||
|
|
||||||
|
- Cache name: `expenseflow-v1`
|
||||||
|
- **Install**: caches `/`, CSS, HTMX library
|
||||||
|
- **Fetch**: cache-first for static assets, network-only for API calls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
| Area | Implementation |
|
||||||
|
|------|---------------|
|
||||||
|
| **Session** | In-memory store, 24h TTL, HTTP-only cookie |
|
||||||
|
| **OTP** | 5-minute expiry, 3-attempt cooldown |
|
||||||
|
| **Uploads** | Max 10 MB, JPEG/PNG only, sanitized filenames |
|
||||||
|
| **AI fallback** | Editable empty form if DeepSeek API fails |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
11
go.mod
Normal file
11
go.mod
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
module github.com/expenseflow
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/jung-kurt/gofpdf v1.16.2
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22
|
||||||
|
)
|
||||||
20
go.sum
Normal file
20
go.sum
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||||
|
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
|
||||||
|
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
191
internal/ai/deepseek.go
Normal file
191
internal/ai/deepseek.go
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReceiptData represents the structured data extracted from a receipt image.
|
||||||
|
type ReceiptData struct {
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
Merchant string `json:"merchant"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
deepseekAPIURL = "https://api.deepseek.com/v1/chat/completions"
|
||||||
|
deepseekModel = "deepseek-vl2"
|
||||||
|
requestTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// deepseekRequest matches the DeepSeek Vision API request format (OpenAI-compatible).
|
||||||
|
type deepseekRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []deepseekMessage `json:"messages"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deepseekMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content []deepseekContent `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deepseekContent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL *imageURLValue `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageURLValue struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// deepseekResponse matches the DeepSeek API response (OpenAI-compatible).
|
||||||
|
type deepseekResponse struct {
|
||||||
|
Choices []deepseekChoice `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deepseekChoice struct {
|
||||||
|
Message deepseekResponseMessage `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type deepseekResponseMessage struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractReceipt sends a receipt image to the DeepSeek Vision API and parses
|
||||||
|
// the structured receipt data from the response. Returns default (empty) data
|
||||||
|
// along with an error if any step of the process fails.
|
||||||
|
func ExtractReceipt(imagePath string) (*ReceiptData, error) {
|
||||||
|
// 1. Validate the image file exists and is readable.
|
||||||
|
imageData, err := readImageFile(imagePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ExtractReceipt: failed to read image file %q: %v", imagePath, err)
|
||||||
|
return &ReceiptData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get the API key from the environment.
|
||||||
|
apiKey := os.Getenv("DEEPSEEK_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
err := errors.New("DEEPSEEK_API_KEY environment variable is not set")
|
||||||
|
log.Printf("ExtractReceipt: %v", err)
|
||||||
|
return &ReceiptData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Build the request payload.
|
||||||
|
base64Image := base64.StdEncoding.EncodeToString(imageData)
|
||||||
|
payload := deepseekRequest{
|
||||||
|
Model: deepseekModel,
|
||||||
|
Temperature: 0.2,
|
||||||
|
Messages: []deepseekMessage{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: []deepseekContent{
|
||||||
|
{
|
||||||
|
Type: "text",
|
||||||
|
Text: "Analyze this receipt image. Extract the following fields as a strict JSON object: amount (float), currency (3-letter string), merchant (string), category (one of: Food, Travel, Lodging, Software, Other), date (YYYY-MM-DD). Do not return markdown, only raw JSON.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageURL: &imageURLValue{
|
||||||
|
URL: fmt.Sprintf("data:image/jpeg;base64,%s", base64Image),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ExtractReceipt: failed to marshal request payload: %v", err)
|
||||||
|
return &ReceiptData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Send the POST request.
|
||||||
|
req, err := http.NewRequest(http.MethodPost, deepseekAPIURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ExtractReceipt: failed to create HTTP request: %v", err)
|
||||||
|
return &ReceiptData{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: requestTimeout}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ExtractReceipt: API request failed: %v", err)
|
||||||
|
return &ReceiptData{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// 5. Read the response body.
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ExtractReceipt: failed to read response body: %v", err)
|
||||||
|
return &ReceiptData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
err := fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
log.Printf("ExtractReceipt: %v", err)
|
||||||
|
return &ReceiptData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Parse the DeepSeek response (OpenAI-compatible format).
|
||||||
|
var apiResp deepseekResponse
|
||||||
|
if err := json.Unmarshal(respBody, &apiResp); err != nil {
|
||||||
|
log.Printf("ExtractReceipt: failed to parse API response: %v", err)
|
||||||
|
return &ReceiptData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(apiResp.Choices) == 0 {
|
||||||
|
err := errors.New("API response contains no choices")
|
||||||
|
log.Printf("ExtractReceipt: %v", err)
|
||||||
|
return &ReceiptData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
contentStr := apiResp.Choices[0].Message.Content
|
||||||
|
|
||||||
|
// 7. Parse the nested JSON from the content field into ReceiptData.
|
||||||
|
var receipt ReceiptData
|
||||||
|
if err := json.Unmarshal([]byte(contentStr), &receipt); err != nil {
|
||||||
|
log.Printf("ExtractReceipt: failed to parse receipt JSON from content: %v", err)
|
||||||
|
return &ReceiptData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readImageFile reads the full contents of an image file after verifying it
|
||||||
|
// exists and is a regular file.
|
||||||
|
func readImageFile(path string) ([]byte, error) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("image file does not exist: %s", path)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("cannot stat image file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("path is a directory, not an image file: %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read image file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
103
internal/auth/otp.go
Normal file
103
internal/auth/otp.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// Package auth provides authentication utilities including OTP generation and
|
||||||
|
// validation, as well as failure tracking for rate-limiting attempts.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateOTP generates a 6-digit numeric OTP code using crypto/rand.
|
||||||
|
// Each digit is derived by reading a random byte and computing modulo 10,
|
||||||
|
// producing a uniformly distributed digit 0-9. The result is zero-padded
|
||||||
|
// to always return exactly 6 characters.
|
||||||
|
func GenerateOTP() (string, error) {
|
||||||
|
bytes := make([]byte, 6)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate OTP: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code := make([]byte, 6)
|
||||||
|
for i, b := range bytes {
|
||||||
|
code[i] = byte(b%10) + '0'
|
||||||
|
}
|
||||||
|
return string(code), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateOTP validates a provided OTP against a stored code with an
|
||||||
|
// expiration check. Returns false if the current time is past expiresAt
|
||||||
|
// or if the codes do not match.
|
||||||
|
func ValidateOTP(provided, stored string, expiresAt time.Time) bool {
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return provided == stored
|
||||||
|
}
|
||||||
|
|
||||||
|
// attemptData stores the failure count and timestamp for a single email.
|
||||||
|
type attemptData struct {
|
||||||
|
count int
|
||||||
|
lastAttempt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailureTracker tracks consecutive failed OTP verification attempts per
|
||||||
|
// email, implementing a 3-attempt lockout window.
|
||||||
|
type FailureTracker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
attempts map[string]*attemptData
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFailureTracker creates and returns a new FailureTracker with an empty
|
||||||
|
// attempts map.
|
||||||
|
func NewFailureTracker() *FailureTracker {
|
||||||
|
return &FailureTracker{
|
||||||
|
attempts: make(map[string]*attemptData),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordFailure increments the failure count for the given email and records
|
||||||
|
// the current time as the last attempt. Once the count reaches 3, the email
|
||||||
|
// becomes locked out until the lockout window expires.
|
||||||
|
func (ft *FailureTracker) RecordFailure(email string) {
|
||||||
|
ft.mu.Lock()
|
||||||
|
defer ft.mu.Unlock()
|
||||||
|
|
||||||
|
data, exists := ft.attempts[email]
|
||||||
|
if !exists {
|
||||||
|
data = &attemptData{}
|
||||||
|
ft.attempts[email] = data
|
||||||
|
}
|
||||||
|
data.count++
|
||||||
|
data.lastAttempt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLockedOut returns true if the email has 3 or more recorded failures
|
||||||
|
// within the last 1 minute. Returns false if the email has no failures,
|
||||||
|
// fewer than 3 failures, or if the last failure was more than 1 minute ago.
|
||||||
|
func (ft *FailureTracker) IsLockedOut(email string) bool {
|
||||||
|
ft.mu.Lock()
|
||||||
|
defer ft.mu.Unlock()
|
||||||
|
|
||||||
|
data, exists := ft.attempts[email]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if data.count < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if time.Since(data.lastAttempt) > time.Minute {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears the failure tracking data for the given email. This should
|
||||||
|
// be called upon successful OTP verification to allow fresh attempts.
|
||||||
|
func (ft *FailureTracker) Reset(email string) {
|
||||||
|
ft.mu.Lock()
|
||||||
|
defer ft.mu.Unlock()
|
||||||
|
|
||||||
|
delete(ft.attempts, email)
|
||||||
|
}
|
||||||
100
internal/auth/session.go
Normal file
100
internal/auth/session.go
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
// Package auth provides authentication and session management for ExpenseFlow.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionData represents the data stored for each session.
|
||||||
|
type sessionData struct {
|
||||||
|
userID string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionStore is an in-memory, thread-safe session store that maps
|
||||||
|
// session tokens to user sessions with expiration handling.
|
||||||
|
type SessionStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]sessionData
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionStore creates and returns a new empty SessionStore.
|
||||||
|
func NewSessionStore() *SessionStore {
|
||||||
|
return &SessionStore{
|
||||||
|
sessions: make(map[string]sessionData),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate creates a new session for the given userID with a 24-hour TTL.
|
||||||
|
// It returns a cryptographically secure random hex-encoded token string.
|
||||||
|
func (s *SessionStore) Generate(userID string) (string, error) {
|
||||||
|
token, err := generateRandomToken()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("auth: failed to generate session token: %v", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sessions[token] = sessionData{
|
||||||
|
userID: userID,
|
||||||
|
expiresAt: time.Now().Add(24 * time.Hour),
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the userID associated with the given token if the session
|
||||||
|
// exists and has not expired. Returns "", false otherwise.
|
||||||
|
func (s *SessionStore) Get(token string) (string, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
data, ok := s.sessions[token]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(data.expiresAt) {
|
||||||
|
// Session is expired; clean it up.
|
||||||
|
s.Delete(token)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.userID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the session identified by the given token.
|
||||||
|
func (s *SessionStore) Delete(token string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.sessions, token)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup removes all expired sessions from the store. This method is
|
||||||
|
// safe to call periodically from a background goroutine.
|
||||||
|
func (s *SessionStore) Cleanup() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for token, data := range s.sessions {
|
||||||
|
if now.After(data.expiresAt) {
|
||||||
|
delete(s.sessions, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRandomToken creates a 32-byte cryptographically random token
|
||||||
|
// and returns its hex-encoded representation (64 hex characters).
|
||||||
|
func generateRandomToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
324
internal/database/db.go
Normal file
324
internal/database/db.go
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
// Package database provides SQLite initialization and query helpers for ExpenseFlow.
|
||||||
|
//
|
||||||
|
// It auto-creates the database file and all required tables on startup,
|
||||||
|
// and exports a shared DB handle for use by other packages.
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DB is the shared database handle, initialized by Init().
|
||||||
|
var DB *sql.DB
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Struct types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// User represents a row in the users table.
|
||||||
|
type User struct {
|
||||||
|
ID string
|
||||||
|
Email string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OTP represents a row in the auth_otps table.
|
||||||
|
type OTP struct {
|
||||||
|
Email string
|
||||||
|
OTPCode string
|
||||||
|
ExpiresAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event represents a row in the events table.
|
||||||
|
type Event struct {
|
||||||
|
ID string
|
||||||
|
UserID string
|
||||||
|
Name string
|
||||||
|
Status string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expense represents a row in the expenses table.
|
||||||
|
type Expense struct {
|
||||||
|
ID string
|
||||||
|
EventID string
|
||||||
|
Amount float64
|
||||||
|
Currency string
|
||||||
|
Merchant string
|
||||||
|
Category string
|
||||||
|
Description string
|
||||||
|
Date string
|
||||||
|
ImagePath string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Initialization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Init opens (or creates) expenses.db, configures the connection pool for
|
||||||
|
// SQLite safety, and runs the DDL statements for all required tables.
|
||||||
|
// It also sets the package-level DB variable for shared use.
|
||||||
|
func Init() (*sql.DB, error) {
|
||||||
|
var err error
|
||||||
|
DB, err = sql.Open("sqlite3", "expenses.db")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] database: failed to open: %v", time.Now().Format(time.RFC3339), err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite does not support concurrent writes; limit to one connection.
|
||||||
|
DB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
if err = createTables(DB); err != nil {
|
||||||
|
log.Printf("ERROR [%s] database: table creation failed: %v", time.Now().Format(time.RFC3339), err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("INFO [%s] database: initialized successfully", time.Now().Format(time.RFC3339))
|
||||||
|
return DB, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTables executes the DDL statements for all four tables.
|
||||||
|
func createTables(db *sql.DB) error {
|
||||||
|
statements := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS auth_otps (
|
||||||
|
email TEXT PRIMARY KEY,
|
||||||
|
otp_code TEXT NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT CHECK(status IN ('open', 'closed')) DEFAULT 'open',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS expenses (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
currency TEXT NOT NULL,
|
||||||
|
merchant TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
image_path TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(event_id) REFERENCES events(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stmt := range statements {
|
||||||
|
if _, err := db.Exec(stmt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// User queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// CreateUser inserts a new user row.
|
||||||
|
func CreateUser(db *sql.DB, id, email string) error {
|
||||||
|
_, err := db.Exec(
|
||||||
|
"INSERT INTO users (id, email) VALUES (?, ?)",
|
||||||
|
id, email,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] database: CreateUser(%s, %s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), id, email, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByEmail returns the user with the given email, or nil if not found.
|
||||||
|
func GetUserByEmail(db *sql.DB, email string) (*User, error) {
|
||||||
|
row := db.QueryRow("SELECT id, email, created_at FROM users WHERE email = ?", email)
|
||||||
|
u := &User{}
|
||||||
|
if err := row.Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
log.Printf("ERROR [%s] database: GetUserByEmail(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), email, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OTP queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// SaveOTP upserts an OTP record for the given email.
|
||||||
|
func SaveOTP(db *sql.DB, email, code, expiresAt string) error {
|
||||||
|
_, err := db.Exec(
|
||||||
|
`INSERT INTO auth_otps (email, otp_code, expires_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(email) DO UPDATE SET otp_code = excluded.otp_code, expires_at = excluded.expires_at`,
|
||||||
|
email, code, expiresAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] database: SaveOTP(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), email, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOTP returns the OTP record for the given email, or nil if not found.
|
||||||
|
func GetOTP(db *sql.DB, email string) (*OTP, error) {
|
||||||
|
row := db.QueryRow("SELECT email, otp_code, expires_at FROM auth_otps WHERE email = ?", email)
|
||||||
|
o := &OTP{}
|
||||||
|
if err := row.Scan(&o.Email, &o.OTPCode, &o.ExpiresAt); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
log.Printf("ERROR [%s] database: GetOTP(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), email, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return o, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOTP removes the OTP record for the given email.
|
||||||
|
func DeleteOTP(db *sql.DB, email string) error {
|
||||||
|
_, err := db.Exec("DELETE FROM auth_otps WHERE email = ?", email)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] database: DeleteOTP(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), email, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// CreateEvent inserts a new event row.
|
||||||
|
func CreateEvent(db *sql.DB, id, userID, name string) error {
|
||||||
|
_, err := db.Exec(
|
||||||
|
"INSERT INTO events (id, user_id, name) VALUES (?, ?, ?)",
|
||||||
|
id, userID, name,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), id, userID, name, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEventsByUser returns all events belonging to a user, ordered by creation date descending.
|
||||||
|
func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
|
||||||
|
rows, err := db.Query(
|
||||||
|
"SELECT id, user_id, name, status, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC",
|
||||||
|
userID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] database: GetEventsByUser(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), userID, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var events []Event
|
||||||
|
for rows.Next() {
|
||||||
|
var e Event
|
||||||
|
if err := rows.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.CreatedAt); err != nil {
|
||||||
|
log.Printf("ERROR [%s] database: GetEventsByUser scan: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
return events, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEventByID returns a single event by ID, or nil if not found.
|
||||||
|
func GetEventByID(db *sql.DB, id string) (*Event, error) {
|
||||||
|
row := db.QueryRow("SELECT id, user_id, name, status, created_at FROM events WHERE id = ?", id)
|
||||||
|
e := &Event{}
|
||||||
|
if err := row.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.CreatedAt); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
log.Printf("ERROR [%s] database: GetEventByID(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), id, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateEventStatus changes the status of an event (open/closed).
|
||||||
|
func UpdateEventStatus(db *sql.DB, id, status string) error {
|
||||||
|
_, err := db.Exec("UPDATE events SET status = ? WHERE id = ?", status, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] database: UpdateEventStatus(%s, %s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), id, status, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Expense queries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// CreateExpense inserts a new expense row from the provided Expense struct.
|
||||||
|
// The expense's ID, EventID, and other fields must be set by the caller.
|
||||||
|
func CreateExpense(db *sql.DB, expense Expense) error {
|
||||||
|
_, err := db.Exec(
|
||||||
|
`INSERT INTO expenses (id, event_id, amount, currency, merchant, category, description, date, image_path)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
expense.ID, expense.EventID, expense.Amount, expense.Currency,
|
||||||
|
expense.Merchant, expense.Category, expense.Description,
|
||||||
|
expense.Date, expense.ImagePath,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] database: CreateExpense(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), expense.ID, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExpensesByEvent returns all expenses for a given event, ordered by creation date descending.
|
||||||
|
func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) {
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT id, event_id, amount, currency, merchant, category,
|
||||||
|
COALESCE(description, ''), date, image_path, created_at
|
||||||
|
FROM expenses WHERE event_id = ? ORDER BY created_at DESC`,
|
||||||
|
eventID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] database: GetExpensesByEvent(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), eventID, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var expenses []Expense
|
||||||
|
for rows.Next() {
|
||||||
|
var e Expense
|
||||||
|
if err := rows.Scan(
|
||||||
|
&e.ID, &e.EventID, &e.Amount, &e.Currency, &e.Merchant,
|
||||||
|
&e.Category, &e.Description, &e.Date, &e.ImagePath, &e.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
log.Printf("ERROR [%s] database: GetExpensesByEvent scan: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
expenses = append(expenses, e)
|
||||||
|
}
|
||||||
|
return expenses, rows.Err()
|
||||||
|
}
|
||||||
277
internal/email/smtp.go
Normal file
277
internal/email/smtp.go
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
// Package email provides SMTP email sending for ExpenseFlow, including OTP
|
||||||
|
// verification codes and expense report emails with CSV or PDF attachments.
|
||||||
|
//
|
||||||
|
// Credentials are passed via the constructor; the caller is responsible for
|
||||||
|
// loading them from environment variables (e.g., SMTP_HOST, SMTP_PORT, etc.).
|
||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"net/textproto"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Struct types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Attachment holds a file to be attached to an outgoing email.
|
||||||
|
type Attachment struct {
|
||||||
|
Filename string
|
||||||
|
Content []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender encapsulates SMTP server configuration and provides methods for
|
||||||
|
// sending transactional emails (e.g., OTP codes, expense reports).
|
||||||
|
type Sender struct {
|
||||||
|
host string
|
||||||
|
port string
|
||||||
|
user string
|
||||||
|
pass string
|
||||||
|
from string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constructor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// NewSender creates a new Sender with the given SMTP credentials. The caller
|
||||||
|
// should load host, port, user, pass, and from from environment variables.
|
||||||
|
func NewSender(host, port, user, pass, from string) *Sender {
|
||||||
|
return &Sender{
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
user: user,
|
||||||
|
pass: pass,
|
||||||
|
from: from,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SMTP methods
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// SendOTP sends a plain-text OTP verification email to the given recipient.
|
||||||
|
// The email contains a standard subject line and the 6-digit verification code.
|
||||||
|
func (s *Sender) SendOTP(to, code string) error {
|
||||||
|
subject := "Your ExpenseFlow OTP"
|
||||||
|
body := fmt.Sprintf("Your verification code is: %s", code)
|
||||||
|
|
||||||
|
msg := buildPlainMessage(s.from, to, subject, body)
|
||||||
|
|
||||||
|
if err := s.send(to, msg); err != nil {
|
||||||
|
log.Printf("ERROR [%s] email: SendOTP(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), to, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("INFO [%s] email: OTP sent to %s", time.Now().Format(time.RFC3339), to)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendReport sends an email with the given subject and body, attaching a CSV
|
||||||
|
// or PDF file. The attachment's Content-Type is inferred from its filename
|
||||||
|
// extension (text/csv for .csv, application/octet-stream otherwise).
|
||||||
|
func (s *Sender) SendReport(to, subject, body string, attachment *Attachment) error {
|
||||||
|
msg, err := buildMultipartMessage(s.from, to, subject, body, attachment)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] email: SendReport(%s): build failed: %v",
|
||||||
|
time.Now().Format(time.RFC3339), to, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.send(to, msg); err != nil {
|
||||||
|
log.Printf("ERROR [%s] email: SendReport(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), to, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("INFO [%s] email: report sent to %s (%s)",
|
||||||
|
time.Now().Format(time.RFC3339), to, attachment.Filename)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// send performs the actual SMTP delivery: connects to the server, upgrades
|
||||||
|
// to TLS (STARTTLS on port 587, direct TLS on port 465), authenticates, and
|
||||||
|
// transmits the message.
|
||||||
|
func (s *Sender) send(to string, msg []byte) error {
|
||||||
|
addr := net.JoinHostPort(s.host, s.port)
|
||||||
|
auth := smtp.PlainAuth("", s.user, s.pass, s.host)
|
||||||
|
|
||||||
|
// Use direct TLS for port 465 (SMTPS), STARTTLS for all other ports.
|
||||||
|
if s.port == "465" {
|
||||||
|
return s.sendTLS(addr, auth, to, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return smtp.SendMail(addr, auth, s.from, []string{to}, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendTLS dials the SMTP server over an explicit TLS connection (port 465)
|
||||||
|
// and sends the message. This is required for SMTPS where the connection is
|
||||||
|
// TLS-secured from the start, rather than upgraded via STARTTLS.
|
||||||
|
func (s *Sender) sendTLS(addr string, auth smtp.Auth, to string, msg []byte) error {
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
ServerName: s.host,
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("TLS dial failed: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
client, err := smtp.NewClient(conn, s.host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("SMTP client creation failed: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if err = client.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("SMTP auth failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = client.Mail(s.from); err != nil {
|
||||||
|
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = client.Rcpt(to); err != nil {
|
||||||
|
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("SMTP DATA failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = w.Write(msg); err != nil {
|
||||||
|
return fmt.Errorf("SMTP write failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = w.Close(); err != nil {
|
||||||
|
return fmt.Errorf("SMTP data close failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPlainMessage constructs a simple RFC 5322 plain-text email without
|
||||||
|
// any MIME encoding.
|
||||||
|
func buildPlainMessage(from, to, subject, body string) []byte {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
writeHeader(&b, "From", from)
|
||||||
|
writeHeader(&b, "To", to)
|
||||||
|
writeHeader(&b, "Subject", subject)
|
||||||
|
writeHeader(&b, "MIME-Version", "1.0")
|
||||||
|
writeHeader(&b, "Content-Type", "text/plain; charset=\"utf-8\"")
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
b.WriteString(body)
|
||||||
|
|
||||||
|
return []byte(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMultipartMessage constructs an RFC 2046 multipart/mixed email with a
|
||||||
|
// text/plain body and a single attachment encoded as base64.
|
||||||
|
func buildMultipartMessage(from, to, subject, body string, attachment *Attachment) ([]byte, error) {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Write the main SMTP headers.
|
||||||
|
writeHeader(&b, "From", from)
|
||||||
|
writeHeader(&b, "To", to)
|
||||||
|
writeHeader(&b, "Subject", subject)
|
||||||
|
|
||||||
|
// Create a multipart writer using a unique boundary string.
|
||||||
|
mw := multipart.NewWriter(&b)
|
||||||
|
boundary := mw.Boundary()
|
||||||
|
|
||||||
|
writeHeader(&b, "MIME-Version", "1.0")
|
||||||
|
writeHeader(&b, "Content-Type", fmt.Sprintf("multipart/mixed; boundary=%s", boundary))
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
|
||||||
|
// --- Text part ---
|
||||||
|
tw, err := mw.CreatePart(textHeader())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating text part: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tw.Write([]byte(body)); err != nil {
|
||||||
|
return nil, fmt.Errorf("writing text part: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Attachment part ---
|
||||||
|
aw, err := mw.CreatePart(attachmentHeader(attachment.Filename))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating attachment part: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
enc := base64.NewEncoder(base64.StdEncoding, aw)
|
||||||
|
if _, err := enc.Write(attachment.Content); err != nil {
|
||||||
|
enc.Close()
|
||||||
|
return nil, fmt.Errorf("writing attachment content: %w", err)
|
||||||
|
}
|
||||||
|
enc.Close()
|
||||||
|
|
||||||
|
mw.Close()
|
||||||
|
|
||||||
|
return []byte(b.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MIME header helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// writeHeader writes a single SMTP/MIME header line (field: value) followed
|
||||||
|
// by CRLF into the provided strings.Builder.
|
||||||
|
func writeHeader(b *strings.Builder, field, value string) {
|
||||||
|
b.WriteString(field)
|
||||||
|
b.WriteString(": ")
|
||||||
|
b.WriteString(value)
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// textHeader returns the MIME header fields for a text/plain part.
|
||||||
|
func textHeader() textproto.MIMEHeader {
|
||||||
|
return textproto.MIMEHeader{
|
||||||
|
"Content-Type": {"text/plain; charset=\"utf-8\""},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachmentHeader returns the MIME header fields for an attachment part,
|
||||||
|
// inferring Content-Type from the file extension and setting the required
|
||||||
|
// Content-Disposition and Content-Transfer-Encoding headers.
|
||||||
|
func attachmentHeader(filename string) textproto.MIMEHeader {
|
||||||
|
contentType := attachmentContentType(filename)
|
||||||
|
|
||||||
|
// Encode the filename to handle non-ASCII characters.
|
||||||
|
encodedFilename := mime.QEncoding.Encode("utf-8", filename)
|
||||||
|
|
||||||
|
return textproto.MIMEHeader{
|
||||||
|
"Content-Type": {contentType},
|
||||||
|
"Content-Disposition": {fmt.Sprintf(`attachment; filename="%s"`, encodedFilename)},
|
||||||
|
"Content-Transfer-Encoding": {"base64"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachmentContentType returns the MIME Content-Type for an attachment based
|
||||||
|
// on its file extension. Defaults to application/octet-stream for unknown types.
|
||||||
|
func attachmentContentType(filename string) string {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(strings.ToLower(filename), ".csv"):
|
||||||
|
return "text/csv; charset=\"utf-8\""
|
||||||
|
case strings.HasSuffix(strings.ToLower(filename), ".pdf"):
|
||||||
|
return "application/pdf"
|
||||||
|
default:
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
298
internal/handlers/auth.go
Normal file
298
internal/handlers/auth.go
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
// Package handlers implements HTTP handlers for ExpenseFlow, providing
|
||||||
|
// passwordless email OTP authentication, event management, expense tracking,
|
||||||
|
// and report generation endpoints.
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/expenseflow/internal/auth"
|
||||||
|
"github.com/expenseflow/internal/database"
|
||||||
|
"github.com/expenseflow/internal/email"
|
||||||
|
"github.com/expenseflow/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AuthHandler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// AuthHandler handles passwordless email OTP authentication endpoints:
|
||||||
|
// - GET / — landing page with email input form
|
||||||
|
// - POST /request-otp — generates and emails a 6-digit OTP code
|
||||||
|
// - POST /verify-otp — validates the OTP and creates a session
|
||||||
|
//
|
||||||
|
// It depends on a *sql.DB for user/OTP persistence, a SessionStore for
|
||||||
|
// in-memory session management, a FailureTracker for rate-limiting, and
|
||||||
|
// an email.Sender for delivering OTP codes.
|
||||||
|
type AuthHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
Sessions *auth.SessionStore
|
||||||
|
FailureTracker *auth.FailureTracker
|
||||||
|
EmailSender *email.Sender
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// LandingPage renders the landing page with the email input form for OTP login.
|
||||||
|
// It parses templates/index.html and executes it with no template data.
|
||||||
|
func (h *AuthHandler) LandingPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tmpl, err := template.ParseFiles("templates/index.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: LandingPage parse template: %v", time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := tmpl.Execute(w, nil); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: LandingPage execute template: %v", time.Now().Format(time.RFC3339), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestOTP handles OTP generation and email delivery.
|
||||||
|
//
|
||||||
|
// 1. Reads the email from the form value.
|
||||||
|
// 2. Checks the FailureTracker for rate-limit lockout (3 failures = 1 min cooldown).
|
||||||
|
// 3. Looks up or creates a user row in the database.
|
||||||
|
// 4. Generates a 6-digit OTP with a 5-minute expiry.
|
||||||
|
// 5. Persists the OTP to the auth_otps table.
|
||||||
|
// 6. Sends the OTP via email (logs error but does not fail the request).
|
||||||
|
// 7. Returns an HTMX fragment containing the OTP verification form.
|
||||||
|
func (h *AuthHandler) RequestOTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
emailAddr := strings.TrimSpace(r.FormValue("email"))
|
||||||
|
if emailAddr == "" {
|
||||||
|
renderError(w, "Email is required.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limit check: 3 failed attempts trigger a 1-minute lockout.
|
||||||
|
if h.FailureTracker.IsLockedOut(emailAddr) {
|
||||||
|
renderError(w, "Too many attempts. Please wait 1 minute before trying again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve or create the user.
|
||||||
|
user, err := database.GetUserByEmail(h.DB, emailAddr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: RequestOTP GetUserByEmail(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
|
||||||
|
renderError(w, "An error occurred. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
userID := utils.New()
|
||||||
|
if err := database.CreateUser(h.DB, userID, emailAddr); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: RequestOTP CreateUser(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
|
||||||
|
renderError(w, "An error occurred. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user = &database.User{ID: userID, Email: emailAddr}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a cryptographically secure 6-digit OTP.
|
||||||
|
code, err := auth.GenerateOTP()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: RequestOTP GenerateOTP: %v", time.Now().Format(time.RFC3339), err)
|
||||||
|
renderError(w, "An error occurred. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the OTP with a 5-minute expiry.
|
||||||
|
expiresAt := time.Now().Add(5 * time.Minute)
|
||||||
|
if err := database.SaveOTP(h.DB, emailAddr, code, expiresAt.Format(time.RFC3339)); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: RequestOTP SaveOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
|
||||||
|
renderError(w, "An error occurred. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver OTP via email. Log the error but do not fail the request —
|
||||||
|
// during development the code is visible in server logs.
|
||||||
|
if err := h.EmailSender.SendOTP(emailAddr, code); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: RequestOTP SendOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the OTP verification form as an HTMX fragment.
|
||||||
|
renderOTPForm(w, emailAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyOTP handles OTP code verification and session creation.
|
||||||
|
//
|
||||||
|
// 1. Reads email and the 6 individual digit inputs from the form.
|
||||||
|
// 2. Retrieves the stored OTP record for the email.
|
||||||
|
// 3. Validates the code and its expiry time.
|
||||||
|
// 4. On failure: records the attempt in the FailureTracker, returns an error.
|
||||||
|
// 5. On success: resets the failure count, deletes the used OTP, generates a
|
||||||
|
// session token, sets an HTTP-only cookie, and redirects to /dashboard.
|
||||||
|
func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
emailAddr := strings.TrimSpace(r.FormValue("email"))
|
||||||
|
otpCode := collectOTP(r)
|
||||||
|
|
||||||
|
if emailAddr == "" || otpCode == "" {
|
||||||
|
renderError(w, "Email and OTP code are required.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the stored OTP record.
|
||||||
|
stored, err := database.GetOTP(h.DB, emailAddr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: VerifyOTP GetOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
|
||||||
|
renderError(w, "An error occurred. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if stored == nil {
|
||||||
|
renderError(w, "No OTP found for this email. Please request a new code.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the stored expiry timestamp.
|
||||||
|
expiresAt, err := time.Parse(time.RFC3339, stored.ExpiresAt)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: VerifyOTP parse expiry(%s): %v", time.Now().Format(time.RFC3339), stored.ExpiresAt, err)
|
||||||
|
renderError(w, "An error occurred. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the OTP code and expiry.
|
||||||
|
if !auth.ValidateOTP(otpCode, stored.OTPCode, expiresAt) {
|
||||||
|
h.FailureTracker.RecordFailure(emailAddr)
|
||||||
|
renderError(w, "Invalid or expired OTP code. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful verification: clean up and create session.
|
||||||
|
h.FailureTracker.Reset(emailAddr)
|
||||||
|
if err := database.DeleteOTP(h.DB, emailAddr); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: VerifyOTP DeleteOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
|
||||||
|
// Non-fatal — the OTP is already validated.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the user record to obtain the user ID.
|
||||||
|
user, err := database.GetUserByEmail(h.DB, emailAddr)
|
||||||
|
if err != nil || user == nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: VerifyOTP GetUserByEmail(%s): err=%v", time.Now().Format(time.RFC3339), emailAddr, err)
|
||||||
|
renderError(w, "An error occurred. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate an in-memory session token.
|
||||||
|
token, err := h.Sessions.Generate(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: VerifyOTP Session Generate(%s): %v", time.Now().Format(time.RFC3339), user.ID, err)
|
||||||
|
renderError(w, "An error occurred. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the HTTP-only session cookie with a 24-hour TTL.
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "session_token",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Expires: time.Now().Add(24 * time.Hour),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Redirect to the dashboard via HTMX.
|
||||||
|
w.Header().Set("HX-Redirect", "/dashboard")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Middleware
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// RequireAuth is HTTP middleware that validates the session cookie on protected
|
||||||
|
// routes. If the session is invalid or expired it redirects to the landing page
|
||||||
|
// using the HX-Redirect header. Otherwise it sets the X-User-ID header on the
|
||||||
|
// request for downstream handler use and calls the next handler.
|
||||||
|
func (h *AuthHandler) RequireAuth(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie("session_token")
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("HX-Redirect", "/")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := h.Sessions.Get(cookie.Value)
|
||||||
|
if !ok {
|
||||||
|
w.Header().Set("HX-Redirect", "/")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Header.Set("X-User-ID", userID)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUserID returns the authenticated user ID from the request.
|
||||||
|
// The value is set by the RequireAuth middleware on the X-User-ID header.
|
||||||
|
func getUserID(r *http.Request) string {
|
||||||
|
return r.Header.Get("X-User-ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// renderError writes an HTMX-compatible HTML error fragment to the response.
|
||||||
|
func renderError(w http.ResponseWriter, message string) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprintf(w, `<div class="error-message" style="color: #dc2626; margin-bottom: 1rem;">%s</div>`, template.HTMLEscapeString(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderOTPForm writes the OTP verification form partial as an HTMX fragment.
|
||||||
|
// It renders 6 individual digit input boxes for a better mobile UX, plus a
|
||||||
|
// hidden email field. The handler combines the 6 digits server-side.
|
||||||
|
func renderOTPForm(w http.ResponseWriter, email string) {
|
||||||
|
tmpl := template.Must(template.New("otp_form").Parse(`
|
||||||
|
<form hx-post="/verify-otp" hx-target="#otp-form" hx-swap="outerHTML">
|
||||||
|
<input type="hidden" name="email" value="{{.Email}}">
|
||||||
|
<div style="display: flex; gap: 0.5rem; justify-content: center; margin: 1rem 0;">
|
||||||
|
<input type="text" name="digit_0" maxlength="1" pattern="[0-9]" inputmode="numeric" autocomplete="one-time-code" required
|
||||||
|
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
|
||||||
|
<input type="text" name="digit_1" maxlength="1" pattern="[0-9]" inputmode="numeric" required
|
||||||
|
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
|
||||||
|
<input type="text" name="digit_2" maxlength="1" pattern="[0-9]" inputmode="numeric" required
|
||||||
|
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
|
||||||
|
<input type="text" name="digit_3" maxlength="1" pattern="[0-9]" inputmode="numeric" required
|
||||||
|
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
|
||||||
|
<input type="text" name="digit_4" maxlength="1" pattern="[0-9]" inputmode="numeric" required
|
||||||
|
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
|
||||||
|
<input type="text" name="digit_5" maxlength="1" pattern="[0-9]" inputmode="numeric" required
|
||||||
|
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
|
||||||
|
</div>
|
||||||
|
<button type="submit" style="width: 100%; padding: 0.75rem; background-color: #10b981; color: white; border: none; border-radius: 0.5rem; font-size: 1rem; cursor: pointer;">
|
||||||
|
Verify Code
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
`))
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := tmpl.Execute(w, map[string]string{"Email": email}); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: renderOTPForm execute: %v", time.Now().Format(time.RFC3339), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectOTP reads the 6 individual digit form values and concatenates them
|
||||||
|
// into a single 6-character OTP code string. Returns an empty string if any
|
||||||
|
// digit is missing.
|
||||||
|
func collectOTP(r *http.Request) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
digit := r.FormValue(fmt.Sprintf("digit_%d", i))
|
||||||
|
if digit == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
b.WriteString(digit)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
240
internal/handlers/events.go
Normal file
240
internal/handlers/events.go
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
// Package handlers provides HTTP request handlers for ExpenseFlow.
|
||||||
|
//
|
||||||
|
// This file implements event management endpoints including dashboard
|
||||||
|
// listing, event creation, reopening, and expense viewing.
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/expenseflow/internal/database"
|
||||||
|
"github.com/expenseflow/internal/utils"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EventHandler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// EventHandler groups HTTP handlers related to event management.
|
||||||
|
// It depends on a shared *sql.DB handle for database operations.
|
||||||
|
type EventHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEventHandler creates a new EventHandler with the given database handle.
|
||||||
|
func NewEventHandler(db *sql.DB) *EventHandler {
|
||||||
|
return &EventHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /dashboard — Dashboard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Dashboard renders the main dashboard page showing all events belonging
|
||||||
|
// to the authenticated user, along with the new event creation form.
|
||||||
|
func (h *EventHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := getUserID(r)
|
||||||
|
if userID == "" {
|
||||||
|
log.Printf("ERROR [%s] handlers: Dashboard: missing user ID", time.Now().Format(time.RFC3339))
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := database.GetEventsByUser(h.DB, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: Dashboard: GetEventsByUser: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Failed to load events", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.ParseFiles("templates/dashboard.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: Dashboard: parse template: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Events": events,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := tmpl.Execute(w, data); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: Dashboard: execute template: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /events — CreateEvent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// CreateEvent handles the creation of a new event for the authenticated user.
|
||||||
|
// It reads the event name from the form, generates a UUID, persists the
|
||||||
|
// event, and redirects to the dashboard via HX-Redirect.
|
||||||
|
func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := getUserID(r)
|
||||||
|
if userID == "" {
|
||||||
|
log.Printf("ERROR [%s] handlers: CreateEvent: missing user ID", time.Now().Format(time.RFC3339))
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
log.Printf("ERROR [%s] handlers: CreateEvent: missing event name", time.Now().Format(time.RFC3339))
|
||||||
|
http.Error(w, "Event name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := utils.New()
|
||||||
|
if err := database.CreateEvent(h.DB, id, userID, name); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: CreateEvent: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Failed to create event", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("HX-Redirect", "/dashboard")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PUT /events/{id}/reopen — ReopenEvent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ReopenEvent sets an event's status back to "open" and returns an HTMX
|
||||||
|
// fragment replacing the event's status badge with a green "open" badge.
|
||||||
|
// It verifies that the requesting user owns the event.
|
||||||
|
func (h *EventHandler) ReopenEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
eventID := chi.URLParam(r, "id")
|
||||||
|
if eventID == "" {
|
||||||
|
log.Printf("ERROR [%s] handlers: ReopenEvent: missing event ID",
|
||||||
|
time.Now().Format(time.RFC3339))
|
||||||
|
http.Error(w, "Missing event ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := getUserID(r)
|
||||||
|
if userID == "" {
|
||||||
|
log.Printf("ERROR [%s] handlers: ReopenEvent: missing user ID",
|
||||||
|
time.Now().Format(time.RFC3339))
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event, err := database.GetEventByID(h.DB, eventID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: ReopenEvent: GetEventByID(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), eventID, err)
|
||||||
|
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event == nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: ReopenEvent: event %s not found",
|
||||||
|
time.Now().Format(time.RFC3339), eventID)
|
||||||
|
http.Error(w, "Event not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.UserID != userID {
|
||||||
|
log.Printf("ERROR [%s] handlers: ReopenEvent: ownership mismatch for event %s",
|
||||||
|
time.Now().Format(time.RFC3339), eventID)
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.UpdateEventStatus(h.DB, eventID, "open"); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: ReopenEvent: UpdateEventStatus(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), eventID, err)
|
||||||
|
http.Error(w, "Failed to reopen event", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return HTMX fragment: green "open" badge targeting #status-badge-{id}.
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprintf(w, `<span id="status-badge-%s" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">open</span>`, eventID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /events/{id}/expenses — ViewEventExpenses
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ViewEventExpenses displays the expense collection view for a specific event.
|
||||||
|
// It verifies event ownership, sets the current_event_id cookie, and renders
|
||||||
|
// the event_expenses.html template with the event and its expense list.
|
||||||
|
func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request) {
|
||||||
|
eventID := chi.URLParam(r, "id")
|
||||||
|
if eventID == "" {
|
||||||
|
log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing event ID",
|
||||||
|
time.Now().Format(time.RFC3339))
|
||||||
|
http.Error(w, "Missing event ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := getUserID(r)
|
||||||
|
if userID == "" {
|
||||||
|
log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing user ID",
|
||||||
|
time.Now().Format(time.RFC3339))
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event, err := database.GetEventByID(h.DB, eventID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: ViewEventExpenses: GetEventByID(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), eventID, err)
|
||||||
|
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event == nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: ViewEventExpenses: event %s not found",
|
||||||
|
time.Now().Format(time.RFC3339), eventID)
|
||||||
|
http.Error(w, "Event not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.UserID != userID {
|
||||||
|
log.Printf("ERROR [%s] handlers: ViewEventExpenses: ownership mismatch for event %s",
|
||||||
|
time.Now().Format(time.RFC3339), eventID)
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: ViewEventExpenses: GetExpensesByEvent(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), eventID, err)
|
||||||
|
http.Error(w, "Failed to load expenses", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the current_event_id cookie so subsequent expense operations
|
||||||
|
// (SaveExpense, UploadReceipt) know which event to associate with.
|
||||||
|
setCurrentEventID(w, eventID)
|
||||||
|
|
||||||
|
tmpl, err := template.ParseFiles("templates/event_expenses.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: ViewEventExpenses: parse template: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Event": event,
|
||||||
|
"Expenses": expenses,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := tmpl.Execute(w, data); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: ViewEventExpenses: execute template: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
338
internal/handlers/expenses.go
Normal file
338
internal/handlers/expenses.go
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
// Package handlers provides HTTP request handlers for ExpenseFlow.
|
||||||
|
//
|
||||||
|
// This file implements expense upload, AI extraction, and save handlers
|
||||||
|
// that drive the core receipt capture workflow using HTMX partial responses.
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/expenseflow/internal/ai"
|
||||||
|
"github.com/expenseflow/internal/database"
|
||||||
|
"github.com/expenseflow/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ExpenseHandler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ExpenseHandler groups HTTP handlers related to expense management.
|
||||||
|
// It depends on a shared *sql.DB handle for database operations.
|
||||||
|
type ExpenseHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExpenseHandler creates a new ExpenseHandler with the given database handle.
|
||||||
|
func NewExpenseHandler(db *sql.DB) *ExpenseHandler {
|
||||||
|
return &ExpenseHandler{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /expenses/upload — UploadReceipt
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// UploadReceipt handles receipt image upload, AI extraction, and returns
|
||||||
|
// an HTMX fragment with a pre-filled receipt edit form.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. Parse multipart form (max 10 MB memory buffer)
|
||||||
|
// 2. Validate file (size ≤ 10 MB, content type JPEG/PNG)
|
||||||
|
// 3. Save to ./storage/{uuid}.{jpg|png}
|
||||||
|
// 4. Call ai.ExtractReceipt for AI-powered data extraction
|
||||||
|
// 5. Render templates/receipt_form.html with pre-filled fields or error banner
|
||||||
|
func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 1. Parse multipart form with 10 MB max memory.
|
||||||
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: UploadReceipt: parse form: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Failed to parse upload form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.MultipartForm.RemoveAll()
|
||||||
|
|
||||||
|
// 2. Get the file from the "receipt" form field.
|
||||||
|
file, header, err := r.FormFile("receipt")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: UploadReceipt: missing receipt field: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Missing receipt file", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// 3. Validate file size (max 10 MB).
|
||||||
|
if header.Size > 10<<20 {
|
||||||
|
log.Printf("ERROR [%s] handlers: UploadReceipt: file too large: %d bytes",
|
||||||
|
time.Now().Format(time.RFC3339), header.Size)
|
||||||
|
http.Error(w, "File too large. Maximum size is 10 MB.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Read the full file data.
|
||||||
|
fileData, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: UploadReceipt: read file: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Failed to read uploaded file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Validate content type by inspecting magic bytes.
|
||||||
|
ext := detectImageExtension(fileData)
|
||||||
|
if ext == "" {
|
||||||
|
log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported image type",
|
||||||
|
time.Now().Format(time.RFC3339))
|
||||||
|
http.Error(w, "Only JPEG and PNG images are supported", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Generate a UUID-based filename and ensure the storage directory exists.
|
||||||
|
filename := utils.New() + "." + ext
|
||||||
|
storagePath := filepath.Join("storage", filename)
|
||||||
|
|
||||||
|
if err := os.MkdirAll("storage", 0755); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: UploadReceipt: mkdir storage: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Save the image file to disk.
|
||||||
|
if err := os.WriteFile(storagePath, fileData, 0644); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: UploadReceipt: write file: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Failed to save receipt image", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Call the DeepSeek Vision API for AI extraction.
|
||||||
|
receipt, aiErr := ai.ExtractReceipt(storagePath)
|
||||||
|
|
||||||
|
// 9. Render the receipt_form.html fragment.
|
||||||
|
tmpl, err := template.ParseFiles("templates/receipt_form.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: UploadReceipt: parse template: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"ImagePath": storagePath,
|
||||||
|
"AIError": "",
|
||||||
|
"Amount": "",
|
||||||
|
"Currency": "",
|
||||||
|
"Merchant": "",
|
||||||
|
"Category": "",
|
||||||
|
"Date": "",
|
||||||
|
"Description": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if aiErr != nil {
|
||||||
|
data["AIError"] = "Could not read receipt automatically. Please fill in the fields below."
|
||||||
|
log.Printf("ERROR [%s] handlers: UploadReceipt: AI extraction failed: %v",
|
||||||
|
time.Now().Format(time.RFC3339), aiErr)
|
||||||
|
} else if receipt != nil {
|
||||||
|
data["Amount"] = strconv.FormatFloat(receipt.Amount, 'f', 2, 64)
|
||||||
|
data["Currency"] = receipt.Currency
|
||||||
|
data["Merchant"] = receipt.Merchant
|
||||||
|
data["Category"] = receipt.Category
|
||||||
|
data["Date"] = receipt.Date
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := tmpl.Execute(w, data); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: UploadReceipt: template execute: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /expenses — SaveExpense
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// SaveExpense handles the receipt form submission, saves the expense to the
|
||||||
|
// database, and returns an HTMX multi-target response that replaces both the
|
||||||
|
// receipt form (with a success message) and the expense list.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. Read event_id from the current_event_id cookie
|
||||||
|
// 2. Parse and validate form fields (amount, currency, merchant, category, date)
|
||||||
|
// 3. Create the expense record in the database
|
||||||
|
// 4. Fetch the updated expense list
|
||||||
|
// 5. Render HTMX response with success banner + updated expense list
|
||||||
|
func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 1. Get the active event ID from cookie.
|
||||||
|
eventID := getCurrentEventID(r)
|
||||||
|
if eventID == "" {
|
||||||
|
log.Printf("ERROR [%s] handlers: SaveExpense: missing current_event_id cookie",
|
||||||
|
time.Now().Format(time.RFC3339))
|
||||||
|
http.Error(w, "No active event. Please select an event first.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse form fields.
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: SaveExpense: parse form: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
amountStr := r.FormValue("amount")
|
||||||
|
currency := r.FormValue("currency")
|
||||||
|
merchant := r.FormValue("merchant")
|
||||||
|
category := r.FormValue("category")
|
||||||
|
date := r.FormValue("date")
|
||||||
|
description := r.FormValue("description")
|
||||||
|
imagePath := r.FormValue("image_path")
|
||||||
|
|
||||||
|
// 3. Validate required fields.
|
||||||
|
var missing []string
|
||||||
|
if amountStr == "" {
|
||||||
|
missing = append(missing, "amount")
|
||||||
|
}
|
||||||
|
if currency == "" {
|
||||||
|
missing = append(missing, "currency")
|
||||||
|
}
|
||||||
|
if merchant == "" {
|
||||||
|
missing = append(missing, "merchant")
|
||||||
|
}
|
||||||
|
if category == "" {
|
||||||
|
missing = append(missing, "category")
|
||||||
|
}
|
||||||
|
if date == "" {
|
||||||
|
missing = append(missing, "date")
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
log.Printf("ERROR [%s] handlers: SaveExpense: missing fields: %s",
|
||||||
|
time.Now().Format(time.RFC3339), strings.Join(missing, ", "))
|
||||||
|
http.Error(w, "Missing required fields: "+strings.Join(missing, ", "),
|
||||||
|
http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Parse amount as float64.
|
||||||
|
amount, err := strconv.ParseFloat(amountStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: SaveExpense: invalid amount %q: %v",
|
||||||
|
time.Now().Format(time.RFC3339), amountStr, err)
|
||||||
|
http.Error(w, "Invalid amount value", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build and save the expense record.
|
||||||
|
expense := database.Expense{
|
||||||
|
ID: utils.New(),
|
||||||
|
EventID: eventID,
|
||||||
|
Amount: amount,
|
||||||
|
Currency: currency,
|
||||||
|
Merchant: merchant,
|
||||||
|
Category: category,
|
||||||
|
Description: description,
|
||||||
|
Date: date,
|
||||||
|
ImagePath: imagePath,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.CreateExpense(h.DB, expense); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: SaveExpense: create expense: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Failed to save expense", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Fetch the updated expense list for this event.
|
||||||
|
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: SaveExpense: fetch expenses: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Failed to retrieve updated expenses", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Render the expense_list.html fragment.
|
||||||
|
listTmpl, err := template.ParseFiles("templates/expense_list.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: SaveExpense: parse list template: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var listBuf strings.Builder
|
||||||
|
if err := listTmpl.Execute(&listBuf, map[string]interface{}{
|
||||||
|
"Expenses": expenses,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: SaveExpense: execute list template: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Return HTMX multi-target response.
|
||||||
|
// - The receipt form is replaced with a success message (hx-swap-oob).
|
||||||
|
// - The expense list is replaced with the updated list (hx-swap-oob).
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
fmt.Fprintf(w, `<div id="receipt-form" hx-swap-oob="true"><div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">Expense saved successfully!</div></div>`)
|
||||||
|
fmt.Fprintf(w, `<div id="expense-list" hx-swap-oob="true">%s</div>`, listBuf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cookie helpers (shared with events.go via package-level access)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// getCurrentEventID reads the "current_event_id" cookie from the request.
|
||||||
|
// Returns an empty string if the cookie is not set or cannot be read.
|
||||||
|
func getCurrentEventID(r *http.Request) string {
|
||||||
|
cookie, err := r.Cookie("current_event_id")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCurrentEventID sets the "current_event_id" cookie on the response.
|
||||||
|
// The cookie has a 24-hour lifetime and is HttpOnly with SameSite=Lax.
|
||||||
|
// This helper is called from events.go when viewing an event.
|
||||||
|
func setCurrentEventID(w http.ResponseWriter, eventID string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "current_event_id",
|
||||||
|
Value: eventID,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: 86400, // 24 hours
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// detectImageExtension examines the magic bytes of the provided data to
|
||||||
|
// determine whether it is a JPEG or PNG image. Returns "jpg", "png", or
|
||||||
|
// an empty string if the format is not recognised.
|
||||||
|
func detectImageExtension(data []byte) string {
|
||||||
|
if len(data) < 4 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// JPEG magic: 0xFF 0xD8 0xFF
|
||||||
|
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||||
|
return "jpg"
|
||||||
|
}
|
||||||
|
// PNG magic: 0x89 'P' 'N' 'G' 0x0D 0x0A 0x1A 0x0A
|
||||||
|
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
|
||||||
|
return "png"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
244
internal/handlers/file.go
Normal file
244
internal/handlers/file.go
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
// Package handlers implements HTTP request handlers for ExpenseFlow.
|
||||||
|
//
|
||||||
|
// This file implements the event filing workflow — generating CSV or PDF
|
||||||
|
// expense reports and emailing them as attachments to a specified recipient.
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jung-kurt/gofpdf"
|
||||||
|
|
||||||
|
"github.com/expenseflow/internal/database"
|
||||||
|
"github.com/expenseflow/internal/email"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FileHandler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// FileHandler handles the event filing workflow: generating expense reports
|
||||||
|
// in CSV or PDF format and emailing them to a specified address.
|
||||||
|
// It depends on a shared *sql.DB handle for database access and an
|
||||||
|
// *email.Sender for delivering the report as an email attachment.
|
||||||
|
type FileHandler struct {
|
||||||
|
DB *sql.DB
|
||||||
|
EmailSender *email.Sender
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /events/{id}/file — FileEvent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// FileEvent generates an expense report (CSV or PDF) for a given event and
|
||||||
|
// emails it as an attachment to the specified recipient. On success the
|
||||||
|
// event status is updated to "closed" and the client is redirected to the
|
||||||
|
// dashboard via the HX-Redirect header.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. Extract event ID from the URL via chi.URLParam
|
||||||
|
// 2. Parse the form for target email and report format
|
||||||
|
// 3. Verify the authenticated user owns this event
|
||||||
|
// 4. Fetch all expenses for the event from the database
|
||||||
|
// 5. Generate the report in the requested format (CSV or PDF)
|
||||||
|
// 6. Send the report as an email attachment
|
||||||
|
// 7. Update the event status to "closed"
|
||||||
|
// 8. Return an HX-Redirect header pointing to /dashboard
|
||||||
|
func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 1. Get event ID from the URL path parameter.
|
||||||
|
eventID := chi.URLParam(r, "id")
|
||||||
|
if eventID == "" {
|
||||||
|
log.Printf("ERROR [%s] handlers: FileEvent: missing event ID in URL",
|
||||||
|
time.Now().Format(time.RFC3339))
|
||||||
|
http.Error(w, "Missing event ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse form fields.
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: FileEvent: parse form: %v",
|
||||||
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
to := r.FormValue("email")
|
||||||
|
format := r.FormValue("format")
|
||||||
|
|
||||||
|
if to == "" {
|
||||||
|
log.Printf("ERROR [%s] handlers: FileEvent: missing email field",
|
||||||
|
time.Now().Format(time.RFC3339))
|
||||||
|
http.Error(w, "Email address is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if format != "csv" && format != "pdf" {
|
||||||
|
log.Printf("ERROR [%s] handlers: FileEvent: invalid format %q",
|
||||||
|
time.Now().Format(time.RFC3339), format)
|
||||||
|
http.Error(w, "Format must be 'csv' or 'pdf'", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Verify the authenticated user owns this event.
|
||||||
|
userID := getUserID(r)
|
||||||
|
if userID == "" {
|
||||||
|
log.Printf("ERROR [%s] handlers: FileEvent: unauthenticated request",
|
||||||
|
time.Now().Format(time.RFC3339))
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event, err := database.GetEventByID(h.DB, eventID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: FileEvent: GetEventByID(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), eventID, err)
|
||||||
|
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event == nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: FileEvent: event not found: %s",
|
||||||
|
time.Now().Format(time.RFC3339), eventID)
|
||||||
|
http.Error(w, "Event not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.UserID != userID {
|
||||||
|
log.Printf("ERROR [%s] handlers: FileEvent: user %s does not own event %s",
|
||||||
|
time.Now().Format(time.RFC3339), userID, eventID)
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fetch all expenses for the event.
|
||||||
|
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: FileEvent: GetExpensesByEvent(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), eventID, err)
|
||||||
|
http.Error(w, "Failed to retrieve expenses", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Generate the report in the requested format.
|
||||||
|
var attachment *email.Attachment
|
||||||
|
switch format {
|
||||||
|
case "csv":
|
||||||
|
attachment, err = generateCSV(event.Name, expenses)
|
||||||
|
case "pdf":
|
||||||
|
attachment, err = generatePDF(event.Name, expenses)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: FileEvent: generate %s report: %v",
|
||||||
|
time.Now().Format(time.RFC3339), format, err)
|
||||||
|
http.Error(w, "Failed to generate report", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Send the report as an email attachment.
|
||||||
|
subject := "Expense report for event " + event.Name
|
||||||
|
body := "Please find attached the expense report."
|
||||||
|
if err := h.EmailSender.SendReport(to, subject, body, attachment); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: FileEvent: SendReport(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), to, err)
|
||||||
|
http.Error(w, "Failed to send report email", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Update the event status to "closed".
|
||||||
|
if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil {
|
||||||
|
log.Printf("ERROR [%s] handlers: FileEvent: UpdateEventStatus(%s): %v",
|
||||||
|
time.Now().Format(time.RFC3339), eventID, err)
|
||||||
|
http.Error(w, "Failed to close event", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Redirect to the dashboard via HTMX.
|
||||||
|
w.Header().Set("HX-Redirect", "/dashboard")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Report generation helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// generateCSV creates a CSV attachment from the provided expenses.
|
||||||
|
// The CSV includes a header row and one data row per expense.
|
||||||
|
func generateCSV(eventName string, expenses []database.Expense) (*email.Attachment, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := csv.NewWriter(&buf)
|
||||||
|
|
||||||
|
// Write header row.
|
||||||
|
if err := writer.Write([]string{"Date", "Merchant", "Amount", "Currency", "Category", "Description"}); err != nil {
|
||||||
|
return nil, fmt.Errorf("write CSV header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write one data row per expense.
|
||||||
|
for _, exp := range expenses {
|
||||||
|
if err := writer.Write([]string{
|
||||||
|
exp.Date,
|
||||||
|
exp.Merchant,
|
||||||
|
fmt.Sprintf("%.2f", exp.Amount),
|
||||||
|
exp.Currency,
|
||||||
|
exp.Category,
|
||||||
|
exp.Description,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("write CSV row: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.Flush()
|
||||||
|
if err := writer.Error(); err != nil {
|
||||||
|
return nil, fmt.Errorf("CSV writer flush: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &email.Attachment{
|
||||||
|
Filename: "report.csv",
|
||||||
|
Content: buf.Bytes(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePDF creates a PDF attachment from the provided expenses using gofpdf.
|
||||||
|
// The PDF contains a title row, a header row, and one data row per expense.
|
||||||
|
func generatePDF(eventName string, expenses []database.Expense) (*email.Attachment, error) {
|
||||||
|
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||||
|
pdf.AddPage()
|
||||||
|
|
||||||
|
// Title: "Expense Report: <event name>"
|
||||||
|
pdf.SetFont("Helvetica", "B", 16)
|
||||||
|
pdf.Cell(0, 10, "Expense Report: "+eventName)
|
||||||
|
pdf.Ln(15)
|
||||||
|
|
||||||
|
// Table header row.
|
||||||
|
pdf.SetFont("Helvetica", "B", 10)
|
||||||
|
headers := []string{"Date", "Merchant", "Amount", "Currency", "Category"}
|
||||||
|
for _, h := range headers {
|
||||||
|
pdf.Cell(35, 8, h)
|
||||||
|
}
|
||||||
|
pdf.Ln(8)
|
||||||
|
|
||||||
|
// Table data rows.
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
for _, exp := range expenses {
|
||||||
|
pdf.Cell(35, 8, exp.Date)
|
||||||
|
pdf.Cell(35, 8, exp.Merchant)
|
||||||
|
pdf.Cell(20, 8, fmt.Sprintf("%.2f", exp.Amount))
|
||||||
|
pdf.Cell(20, 8, exp.Currency)
|
||||||
|
pdf.Cell(35, 8, exp.Category)
|
||||||
|
pdf.Ln(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the PDF document to a memory buffer.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := pdf.Output(&buf); err != nil {
|
||||||
|
return nil, fmt.Errorf("PDF output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &email.Attachment{
|
||||||
|
Filename: "report.pdf",
|
||||||
|
Content: buf.Bytes(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
11
internal/utils/uuid.go
Normal file
11
internal/utils/uuid.go
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// Package utils provides common utility functions for ExpenseFlow.
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// New generates a new UUID v4 string.
|
||||||
|
func New() string {
|
||||||
|
return uuid.New().String()
|
||||||
|
}
|
||||||
195
main.go
Normal file
195
main.go
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
// ExpenseFlow — AI-Powered Expense Tracker
|
||||||
|
//
|
||||||
|
// A production-ready, mobile-first Progressive Web App (PWA) that uses
|
||||||
|
// passwordless email OTP login, event-based expense tracking, AI receipt
|
||||||
|
// extraction (DeepSeek Vision), and event filing (CSV/PDF via email).
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// Copy .env.example to .env and fill in credentials, then:
|
||||||
|
// go run main.go
|
||||||
|
//
|
||||||
|
// The server starts on the port specified by the PORT env var (default 8080).
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
|
"github.com/expenseflow/internal/auth"
|
||||||
|
"github.com/expenseflow/internal/database"
|
||||||
|
"github.com/expenseflow/internal/email"
|
||||||
|
"github.com/expenseflow/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Configuration
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Load environment variables from .env file (if present).
|
||||||
|
if err := godotenv.Load(); err != nil {
|
||||||
|
log.Printf("INFO [%s] main: no .env file found, using system environment",
|
||||||
|
time.Now().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMTP configuration.
|
||||||
|
smtpHost := os.Getenv("SMTP_HOST")
|
||||||
|
smtpPort := os.Getenv("SMTP_PORT")
|
||||||
|
smtpUser := os.Getenv("SMTP_USER")
|
||||||
|
smtpPass := os.Getenv("SMTP_PASS")
|
||||||
|
|
||||||
|
// DeepSeek API key is read directly by the ai package.
|
||||||
|
_ = os.Getenv("DEEPSEEK_API_KEY")
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Database
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
db, err := database.Init()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("FATAL [%s] main: database init: %v", time.Now().Format(time.RFC3339), err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Services
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
sessionStore := auth.NewSessionStore()
|
||||||
|
failureTracker := auth.NewFailureTracker()
|
||||||
|
|
||||||
|
// Create the email sender only if SMTP credentials are configured.
|
||||||
|
var emailSender *email.Sender
|
||||||
|
if smtpHost != "" && smtpPort != "" && smtpUser != "" && smtpPass != "" {
|
||||||
|
emailSender = email.NewSender(smtpHost, smtpPort, smtpUser, smtpPass, "post@2-4-h.app")
|
||||||
|
log.Printf("INFO [%s] main: SMTP sender configured (%s:%s)",
|
||||||
|
time.Now().Format(time.RFC3339), smtpHost, smtpPort)
|
||||||
|
} else {
|
||||||
|
log.Printf("WARN [%s] main: SMTP not configured — OTP emails will not be sent",
|
||||||
|
time.Now().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
authHandler := &handlers.AuthHandler{
|
||||||
|
DB: db,
|
||||||
|
Sessions: sessionStore,
|
||||||
|
FailureTracker: failureTracker,
|
||||||
|
EmailSender: emailSender,
|
||||||
|
}
|
||||||
|
|
||||||
|
eventHandler := handlers.NewEventHandler(db)
|
||||||
|
expenseHandler := handlers.NewExpenseHandler(db)
|
||||||
|
fileHandler := &handlers.FileHandler{
|
||||||
|
DB: db,
|
||||||
|
EmailSender: emailSender,
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Router
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
// Middleware.
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
|
||||||
|
// PWA headers for service worker.
|
||||||
|
r.Use(func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/sw.js" {
|
||||||
|
w.Header().Set("Service-Worker-Allowed", "/")
|
||||||
|
w.Header().Set("Content-Type", "application/javascript")
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Static file serving.
|
||||||
|
fileServer := http.FileServer(http.Dir("static"))
|
||||||
|
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
|
||||||
|
|
||||||
|
// Serve the manifest.json and sw.js from the root for PWA compliance.
|
||||||
|
r.Get("/sw.js", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Service-Worker-Allowed", "/")
|
||||||
|
w.Header().Set("Content-Type", "application/javascript")
|
||||||
|
http.ServeFile(w, r, "static/sw.js")
|
||||||
|
}))
|
||||||
|
r.Get("/manifest.json", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
http.ServeFile(w, r, "static/manifest.json")
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Serve uploaded receipt images.
|
||||||
|
r.Get("/storage/*", http.StripPrefix("/storage/", http.FileServer(http.Dir("storage"))).ServeHTTP)
|
||||||
|
|
||||||
|
// ---- Public routes (no auth required) ----
|
||||||
|
|
||||||
|
r.Get("/", authHandler.LandingPage)
|
||||||
|
r.Post("/request-otp", authHandler.RequestOTP)
|
||||||
|
r.Post("/verify-otp", authHandler.VerifyOTP)
|
||||||
|
|
||||||
|
// ---- Logout ----
|
||||||
|
|
||||||
|
r.Post("/logout", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Delete the session cookie.
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "session_token",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
w.Header().Set("HX-Redirect", "/")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---- Protected routes (auth required) ----
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(authHandler.RequireAuth)
|
||||||
|
|
||||||
|
// Events.
|
||||||
|
r.Get("/dashboard", eventHandler.Dashboard)
|
||||||
|
r.Post("/events", eventHandler.CreateEvent)
|
||||||
|
r.Put("/events/{id}/reopen", eventHandler.ReopenEvent)
|
||||||
|
r.Get("/events/{id}/expenses", eventHandler.ViewEventExpenses)
|
||||||
|
|
||||||
|
// Expenses.
|
||||||
|
r.Post("/expenses/upload", expenseHandler.UploadReceipt)
|
||||||
|
r.Post("/expenses", expenseHandler.SaveExpense)
|
||||||
|
|
||||||
|
// Filing.
|
||||||
|
r.Post("/events/{id}/file", fileHandler.FileEvent)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Startup
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
addr := ":" + port
|
||||||
|
log.Printf("INFO [%s] main: ExpenseFlow server starting on %s",
|
||||||
|
time.Now().Format(time.RFC3339), addr)
|
||||||
|
log.Printf("INFO [%s] main: open http://localhost%s in your browser",
|
||||||
|
time.Now().Format(time.RFC3339), addr)
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(addr, r); err != nil {
|
||||||
|
log.Fatalf("FATAL [%s] main: server error: %v", time.Now().Format(time.RFC3339), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
1845
static/css/style.css
Normal file
1845
static/css/style.css
Normal file
File diff suppressed because it is too large
Load diff
BIN
static/icons/icon-192.png
Normal file
BIN
static/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 593 B |
BIN
static/icons/icon-512.png
Normal file
BIN
static/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
20
static/manifest.json
Normal file
20
static/manifest.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"name": "ExpenseFlow",
|
||||||
|
"short_name": "ExpenseFlow",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#10b981",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
264
static/sw.js
Normal file
264
static/sw.js
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
/* ============================================================
|
||||||
|
* ExpenseFlow — Service Worker
|
||||||
|
* Version: 1.0.0
|
||||||
|
* Cache name: expenseflow-v1
|
||||||
|
* Strategy: Cache-first for shell assets, network-only for API
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
const CACHE_NAME = 'expenseflow-v1';
|
||||||
|
|
||||||
|
// Shell assets to pre-cache on install
|
||||||
|
const SHELL_ASSETS = [
|
||||||
|
'/',
|
||||||
|
'/static/css/style.css',
|
||||||
|
'https://unpkg.com/htmx.org@1.9.10'
|
||||||
|
];
|
||||||
|
|
||||||
|
// API path prefix pattern — requests matching these are never cached
|
||||||
|
const API_PATTERNS = [
|
||||||
|
'/api/',
|
||||||
|
'/auth/',
|
||||||
|
'/login',
|
||||||
|
'/logout',
|
||||||
|
'/register'
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------
|
||||||
|
* Utility: determine whether a request targets an API endpoint
|
||||||
|
* ------------------------------------------------------------ */
|
||||||
|
function isApiRequest(url) {
|
||||||
|
return API_PATTERNS.some(pattern => url.pathname.startsWith(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------
|
||||||
|
* Utility: determine whether a request is a shell asset eligible
|
||||||
|
* for cache-first strategy
|
||||||
|
* ------------------------------------------------------------ */
|
||||||
|
function isShellAsset(url) {
|
||||||
|
const path = url.pathname;
|
||||||
|
const origin = url.origin;
|
||||||
|
|
||||||
|
// Same-origin shell pages
|
||||||
|
if (origin === self.location.origin && (path === '/' || path === '')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-origin CSS
|
||||||
|
if (origin === self.location.origin && path.startsWith('/static/css/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-origin JS
|
||||||
|
if (origin === self.location.origin && path.startsWith('/static/js/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTMX CDN (cache-first for fast loading)
|
||||||
|
if (url.href === 'https://unpkg.com/htmx.org@1.9.10') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------
|
||||||
|
* INSTALL — Pre-cache shell assets
|
||||||
|
* ------------------------------------------------------------ */
|
||||||
|
self.addEventListener('install', event => {
|
||||||
|
console.log('[SW] Install event — caching shell assets');
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME)
|
||||||
|
.then(cache => {
|
||||||
|
// Use addAll for atomic caching — if one fails, the whole
|
||||||
|
// install fails and the SW won't activate
|
||||||
|
return cache.addAll(SHELL_ASSETS).catch(err => {
|
||||||
|
console.error('[SW] Failed to cache some shell assets:', err);
|
||||||
|
// Still attempt to activate even if caching is partially
|
||||||
|
// unsuccessful — the fetch handler will fall back to network
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log('[SW] Shell assets cached successfully');
|
||||||
|
return self.skipWaiting();
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[SW] Install failed:', err);
|
||||||
|
// Still try to activate so the SW takes over
|
||||||
|
return self.skipWaiting();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------
|
||||||
|
* ACTIVATE — Clean up old caches
|
||||||
|
* ------------------------------------------------------------ */
|
||||||
|
self.addEventListener('activate', event => {
|
||||||
|
console.log('[SW] Activate event — cleaning old caches');
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then(cacheNames => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames
|
||||||
|
.filter(name => name !== CACHE_NAME)
|
||||||
|
.map(name => {
|
||||||
|
console.log('[SW] Deleting old cache:', name);
|
||||||
|
return caches.delete(name);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}).then(() => {
|
||||||
|
console.log('[SW] Activated — taking control of all clients');
|
||||||
|
return self.clients.claim();
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[SW] Activation cleanup failed:', err);
|
||||||
|
// Continue even if cleanup fails
|
||||||
|
return self.clients.claim();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------
|
||||||
|
* FETCH — Hybrid strategy
|
||||||
|
* - Cache-first with network fallback for shell assets
|
||||||
|
* - Network-only for API / dynamic endpoints
|
||||||
|
* - Stale-while-revalidate for other same-origin assets
|
||||||
|
* ------------------------------------------------------------ */
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
const request = event.request;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Ignore non-GET requests (POST, PUT, DELETE, etc.)
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore browser-extension and non-http(s) requests
|
||||||
|
if (!url.protocol.startsWith('http')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Strategy 1: Network-only for API calls ──
|
||||||
|
if (isApiRequest(url)) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request).catch(err => {
|
||||||
|
console.warn('[SW] API fetch failed (offline?):', url.pathname, err);
|
||||||
|
// Return a lightweight JSON error so the app can handle it gracefully
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'You are offline. Please check your connection.' }),
|
||||||
|
{
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Strategy 2: Cache-first with network fallback for shell assets ──
|
||||||
|
if (isShellAsset(url)) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request)
|
||||||
|
.then(cachedResponse => {
|
||||||
|
if (cachedResponse) {
|
||||||
|
// Cache hit — return immediately
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss — fetch from network, then cache for future
|
||||||
|
return fetch(request)
|
||||||
|
.then(networkResponse => {
|
||||||
|
// Only cache valid responses
|
||||||
|
if (!networkResponse || networkResponse.status !== 200) {
|
||||||
|
return networkResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone the response so we can cache one and return the other
|
||||||
|
const responseToCache = networkResponse.clone();
|
||||||
|
caches.open(CACHE_NAME)
|
||||||
|
.then(cache => {
|
||||||
|
cache.put(request, responseToCache).catch(err => {
|
||||||
|
console.error('[SW] Failed to cache asset:', url.pathname, err);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[SW] Failed to open cache for storing:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return networkResponse;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.warn('[SW] Network fetch failed for shell asset:', url.pathname, err);
|
||||||
|
// Return a minimal offline fallback for navigations
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
return new Response(
|
||||||
|
'<!DOCTYPE html><html><head><title>Offline — ExpenseFlow</title><meta name="viewport" content="width=device-width, initial-scale=1"><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:2rem;text-align:center;background:#f9fafb;color:#111827}h1{font-size:1.5rem;margin-bottom:0.5rem}p{color:#6b7280;max-width:24rem}</style></head><body><h1>You\'re offline</h1><p>ExpenseFlow needs an internet connection to load. Please check your connection and try again.</p></body></html>',
|
||||||
|
{
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable',
|
||||||
|
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
'Offline — resource not available',
|
||||||
|
{
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable',
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[SW] Cache match error:', err);
|
||||||
|
// Fall through to network
|
||||||
|
return fetch(request).catch(() => {
|
||||||
|
return new Response(
|
||||||
|
'An unexpected error occurred.',
|
||||||
|
{ status: 502, headers: { 'Content-Type': 'text/plain' } }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Strategy 3: Network-first for all other (non-shell, non-API) assets ──
|
||||||
|
// This covers images, fonts, etc. — try network first, fall back to cache
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then(networkResponse => {
|
||||||
|
// Cache successful responses for offline fallback
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
const responseToCache = networkResponse.clone();
|
||||||
|
caches.open(CACHE_NAME)
|
||||||
|
.then(cache => {
|
||||||
|
cache.put(request, responseToCache).catch(err => {
|
||||||
|
console.error('[SW] Failed to cache dynamic asset:', url.pathname, err);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[SW] Failed to open cache for dynamic asset:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return networkResponse;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.warn('[SW] Network failed, trying cache for:', url.pathname, err);
|
||||||
|
return caches.match(request).then(cachedResponse => {
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
// Nothing in cache either — return a basic error
|
||||||
|
return new Response(
|
||||||
|
'Resource unavailable offline.',
|
||||||
|
{ status: 503, headers: { 'Content-Type': 'text/plain' } }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
95
templates/dashboard.html
Normal file
95
templates/dashboard.html
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="theme-color" content="#10b981">
|
||||||
|
<title>Dashboard - ExpenseFlow</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="app-header">
|
||||||
|
<h1 class="app-title">ExpenseFlow</h1>
|
||||||
|
<a href="/" class="btn btn-secondary btn-sm"
|
||||||
|
hx-post="/logout" hx-target="body" hx-push-url="true"
|
||||||
|
hx-confirm="Are you sure you want to logout?">Logout</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<h2>My Events</h2>
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
onclick="document.getElementById('create-event-form').classList.toggle('hidden')">
|
||||||
|
+ New Event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="create-event-form" class="hidden" style="margin-bottom: 1.5rem;">
|
||||||
|
<div class="card" style="padding: 1rem;">
|
||||||
|
<form hx-post="/events" hx-target="body" hx-push-url="true">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Event Name</label>
|
||||||
|
<input type="text" id="name" name="name" placeholder="e.g., WebSummit 2026" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">Create Event</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="event-list">
|
||||||
|
{{if .Events}}
|
||||||
|
<div class="event-grid">
|
||||||
|
{{range .Events}}
|
||||||
|
<div class="card event-card">
|
||||||
|
<div class="event-card-header">
|
||||||
|
<h3 class="event-card-name">{{.Name}}</h3>
|
||||||
|
<span id="status-badge-{{.ID}}" class="badge badge-{{.Status}}">{{.Status}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="event-card-meta">
|
||||||
|
<span>Created: {{.CreatedAt}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="event-card-actions">
|
||||||
|
{{if eq .Status "open"}}
|
||||||
|
<a href="/events/{{.ID}}/expenses" class="btn btn-primary btn-sm"
|
||||||
|
hx-get="/events/{{.ID}}/expenses" hx-target="body" hx-push-url="true">
|
||||||
|
Add Expenses
|
||||||
|
</a>
|
||||||
|
{{else}}
|
||||||
|
<button class="btn btn-secondary btn-sm"
|
||||||
|
hx-put="/events/{{.ID}}/reopen"
|
||||||
|
hx-target="#status-badge-{{.ID}}"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Reopen
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="12" y1="18" x2="12" y2="12"/>
|
||||||
|
<line x1="9" y1="15" x2="15" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
<h3>No Events Yet</h3>
|
||||||
|
<p>Create your first event to start tracking expenses.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Toggle hidden class
|
||||||
|
document.querySelector('button[onclick]')?.addEventListener('click', function() {
|
||||||
|
// handled inline
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
146
templates/event_expenses.html
Normal file
146
templates/event_expenses.html
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="theme-color" content="#10b981">
|
||||||
|
<title>{{.Event.Name}} - ExpenseFlow</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<header class="app-header">
|
||||||
|
<a href="/dashboard" class="btn btn-secondary btn-sm"
|
||||||
|
hx-get="/dashboard" hx-target="body" hx-push-url="true">
|
||||||
|
← Back
|
||||||
|
</a>
|
||||||
|
<h1 class="app-title">{{.Event.Name}}</h1>
|
||||||
|
<span class="badge badge-{{.Event.Status}}">{{.Event.Status}}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<!-- Capture Receipt Button -->
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<label for="receipt-upload" class="btn btn-primary btn-block" style="display: inline-block; text-align: center; cursor: pointer;">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 0.5rem;">
|
||||||
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
|
||||||
|
<circle cx="12" cy="13" r="4"/>
|
||||||
|
</svg>
|
||||||
|
Capture Receipt
|
||||||
|
</label>
|
||||||
|
<input type="file" id="receipt-upload" accept="image/*" capture="environment" style="display: none;"
|
||||||
|
hx-post="/expenses/upload"
|
||||||
|
hx-encoding="multipart/form-data"
|
||||||
|
hx-target="#receipt-form"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator="#upload-indicator">
|
||||||
|
<div id="upload-indicator" class="htmx-indicator" style="text-align: center; padding: 1rem;">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Analyzing receipt...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Receipt Form (filled by AI or empty) -->
|
||||||
|
<div id="receipt-form"></div>
|
||||||
|
|
||||||
|
<!-- Expense List -->
|
||||||
|
<div id="expense-list">
|
||||||
|
<h3 style="margin-bottom: 1rem;">Expenses</h3>
|
||||||
|
{{if .Expenses}}
|
||||||
|
<div class="expense-list">
|
||||||
|
{{range .Expenses}}
|
||||||
|
<div class="expense-item">
|
||||||
|
<div class="expense-item-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="expense-item-info">
|
||||||
|
<div class="expense-item-merchant">{{.Merchant}}</div>
|
||||||
|
<div class="expense-item-meta">{{.Date}} · {{.Category}}</div>
|
||||||
|
{{if .Description}}
|
||||||
|
<div class="expense-item-desc">{{.Description}}</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="expense-item-amount">
|
||||||
|
<span class="amount">{{printf "%.2f" .Amount}}</span>
|
||||||
|
<span class="currency">{{.Currency}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state" style="padding: 2rem;">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="12" y1="18" x2="12" y2="18"/>
|
||||||
|
<line x1="9" y1="15" x2="15" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
<h3>No Expenses Yet</h3>
|
||||||
|
<p>Capture a receipt to get started.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Event Button (only for open events) -->
|
||||||
|
{{if eq .Event.Status "open"}}
|
||||||
|
<div style="margin-top: 2rem; text-align: center;">
|
||||||
|
<button class="btn btn-secondary"
|
||||||
|
onclick="document.getElementById('file-modal').classList.toggle('hidden')">
|
||||||
|
File Event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Event Modal -->
|
||||||
|
<div id="file-modal" class="modal-overlay hidden">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>File Event Report</h3>
|
||||||
|
<p class="text-secondary">Generate and email the expense report for "{{.Event.Name}}".</p>
|
||||||
|
<form hx-post="/events/{{.Event.ID}}/file" hx-target="body" hx-push-url="true">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="file-email">Send to</label>
|
||||||
|
<input type="email" id="file-email" name="email" placeholder="recipient@example.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Format</label>
|
||||||
|
<div style="display: flex; gap: 1rem;">
|
||||||
|
<label class="radio-label">
|
||||||
|
<input type="radio" name="format" value="csv" checked> CSV
|
||||||
|
</label>
|
||||||
|
<label class="radio-label">
|
||||||
|
<input type="radio" name="format" value="pdf"> PDF
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
|
||||||
|
<button type="button" class="btn btn-secondary"
|
||||||
|
onclick="document.getElementById('file-modal').classList.add('hidden')">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Send Report & Close</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Auto-trigger file input on label click
|
||||||
|
document.querySelector('label[for="receipt-upload"]')?.addEventListener('click', function() {
|
||||||
|
document.getElementById('receipt-upload').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal on overlay click
|
||||||
|
document.querySelector('.modal-overlay')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
this.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
templates/expense_list.html
Normal file
41
templates/expense_list.html
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<div id="expense-list">
|
||||||
|
<h3 style="margin-bottom: 1rem;">Expenses ({{len .Expenses}})</h3>
|
||||||
|
|
||||||
|
{{if .Expenses}}
|
||||||
|
<div class="expense-list">
|
||||||
|
{{range .Expenses}}
|
||||||
|
<div class="expense-item">
|
||||||
|
<div class="expense-item-icon">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="expense-item-info">
|
||||||
|
<div class="expense-item-merchant">{{.Merchant}}</div>
|
||||||
|
<div class="expense-item-meta">{{.Date}} · {{.Category}}</div>
|
||||||
|
{{if .Description}}
|
||||||
|
<div class="expense-item-desc">{{.Description}}</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="expense-item-amount">
|
||||||
|
<span class="amount">{{printf "%.2f" .Amount}}</span>
|
||||||
|
<span class="currency">{{.Currency}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state" style="padding: 2rem;">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="12" y1="18" x2="12" y2="12"/>
|
||||||
|
<line x1="9" y1="15" x2="15" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
<h3>No Expenses Yet</h3>
|
||||||
|
<p>Capture a receipt to get started.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
47
templates/index.html
Normal file
47
templates/index.html
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="theme-color" content="#10b981">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<title>ExpenseFlow</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="card login-card">
|
||||||
|
<div class="login-brand">
|
||||||
|
<div class="login-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1>ExpenseFlow</h1>
|
||||||
|
<p class="text-secondary">AI-Powered Expense Tracking</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="otp-form">
|
||||||
|
<form hx-post="/request-otp" hx-target="#otp-form" hx-swap="outerHTML">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email Address</label>
|
||||||
|
<input type="email" id="email" name="email" placeholder="you@example.com" required autocomplete="email" inputmode="email">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
<span class="spinner htmx-indicator"></span>
|
||||||
|
Send Verification Code
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-secondary" style="text-align: center; font-size: 0.875rem; margin-top: 1rem;">
|
||||||
|
A 6-digit verification code will be sent to your email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
89
templates/receipt_form.html
Normal file
89
templates/receipt_form.html
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<div id="receipt-form">
|
||||||
|
{{if .AIError}}
|
||||||
|
<div class="error-message" style="background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">
|
||||||
|
{{.AIError}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="card" style="padding: 1rem;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
</svg>
|
||||||
|
<span style="font-weight: 600;">Receipt Details</span>
|
||||||
|
<span class="badge badge-open" style="margin-left: auto;">AI Extracted</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form hx-post="/expenses" hx-target="#receipt-form" hx-swap="outerHTML"
|
||||||
|
hx-indicator="#save-indicator">
|
||||||
|
<input type="hidden" name="image_path" value="{{.ImagePath}}">
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="amount">Amount *</label>
|
||||||
|
<input type="number" id="amount" name="amount" step="0.01" min="0" placeholder="0.00"
|
||||||
|
value="{{.Amount}}" required inputmode="decimal">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="currency">Currency *</label>
|
||||||
|
<select id="currency" name="currency" required>
|
||||||
|
<option value="">Select</option>
|
||||||
|
<option value="USD" {{if eq .Currency "USD"}}selected{{end}}>USD</option>
|
||||||
|
<option value="EUR" {{if eq .Currency "EUR"}}selected{{end}}>EUR</option>
|
||||||
|
<option value="GBP" {{if eq .Currency "GBP"}}selected{{end}}>GBP</option>
|
||||||
|
<option value="JPY" {{if eq .Currency "JPY"}}selected{{end}}>JPY</option>
|
||||||
|
<option value="CHF" {{if eq .Currency "CHF"}}selected{{end}}>CHF</option>
|
||||||
|
<option value="SEK" {{if eq .Currency "SEK"}}selected{{end}}>SEK</option>
|
||||||
|
<option value="NOK" {{if eq .Currency "NOK"}}selected{{end}}>NOK</option>
|
||||||
|
<option value="DKK" {{if eq .Currency "DKK"}}selected{{end}}>DKK</option>
|
||||||
|
<option value="PLN" {{if eq .Currency "PLN"}}selected{{end}}>PLN</option>
|
||||||
|
<option value="CZK" {{if eq .Currency "CZK"}}selected{{end}}>CZK</option>
|
||||||
|
<option value="HUF" {{if eq .Currency "HUF"}}selected{{end}}>HUF</option>
|
||||||
|
<option value="RON" {{if eq .Currency "RON"}}selected{{end}}>RON</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="merchant">Merchant *</label>
|
||||||
|
<input type="text" id="merchant" name="merchant" placeholder="Store or business name"
|
||||||
|
value="{{.Merchant}}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="category">Category *</label>
|
||||||
|
<select id="category" name="category" required>
|
||||||
|
<option value="">Select</option>
|
||||||
|
<option value="Food" {{if eq .Category "Food"}}selected{{end}}>Food</option>
|
||||||
|
<option value="Travel" {{if eq .Category "Travel"}}selected{{end}}>Travel</option>
|
||||||
|
<option value="Lodging" {{if eq .Category "Lodging"}}selected{{end}}>Lodging</option>
|
||||||
|
<option value="Software" {{if eq .Category "Software"}}selected{{end}}>Software</option>
|
||||||
|
<option value="Other" {{if eq .Category "Other"}}selected{{end}}>Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="date">Date *</label>
|
||||||
|
<input type="date" id="date" name="date" value="{{.Date}}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea id="description" name="description" placeholder="Optional notes...">{{.Description}}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button type="submit" class="btn btn-primary" id="save-indicator">
|
||||||
|
<span class="spinner htmx-indicator"></span>
|
||||||
|
Save Expense
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-secondary"
|
||||||
|
onclick="document.getElementById('receipt-form').innerHTML = ''">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Loading…
Reference in a new issue