Compare commits

..

No commits in common. "main" and "v1.0.0" have entirely different histories.
main ... v1.0.0

39 changed files with 1177 additions and 5236 deletions

View file

@ -1,26 +1,14 @@
# NextExpense Configuration # ExpenseFlow Configuration
# Copy this file to .env and fill in your credentials. # Copy this file to .env and fill in your credentials.
# Run `bash install.sh` for interactive setup.
# --- AI Provider --- # SMTP Configuration
# Choose one: gemini (default), openai SMTP_HOST=smtp.openxchange.eu
AI_PROVIDER=gemini SMTP_PORT=587
SMTP_USER=post@2-4-h.app
SMTP_PASS=D9AW8JP74r1V
# For AI_PROVIDER=gemini: # DeepSeek Vision API Key
# GEMINI_API_KEY=your-gemini-api-key DEEPSEEK_API_KEY=sk-e9362165d2694883a52a5142811aa422
# For AI_PROVIDER=openai (also works with Ollama, LocalAI, etc.): # Base URL for generating absolute links in emails
# OPENAI_API_KEY=sk-...
# AI_MODEL=gpt-4o-mini
# AI_BASE_URL=https://api.openai.com/v1
# For Ollama: AI_BASE_URL=http://localhost:11434, AI_MODEL=glm-ocr
# --- SMTP (optional — needed for OTP emails and report delivery) ---
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=your-email@example.com
# SMTP_PASS=your-password
# --- General ---
PORT=8080
BASE_URL=http://localhost:8080 BASE_URL=http://localhost:8080

2
.gitignore vendored
View file

@ -30,5 +30,3 @@ expenseflow-*
# Go # Go
vendor/ vendor/
.go/
app

View file

@ -1,85 +0,0 @@
# NextExpense — AI-Powered Expense Tracker
# Makefile for build, install, and deployment
BINARY = app
OUTDIR = dist
VERSION = v1.0.0
LDFLAGS = -s -w
.PHONY: all build clean install uninstall run stop restart logs
all: build
# -------------------------------------------------------------------
# Build
# -------------------------------------------------------------------
build:
go build -ldflags="$(LDFLAGS)" -o $(BINARY) .
build-linux-amd64:
GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(OUTDIR)/$(BINARY)-linux-amd64 .
build-linux-arm64:
GOOS=linux GOARCH=arm64 CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc go build -ldflags="$(LDFLAGS)" -o $(OUTDIR)/$(BINARY)-linux-arm64 .
build-all: build-linux-amd64 build-linux-arm64
# -------------------------------------------------------------------
# Install (systemd service)
# -------------------------------------------------------------------
install: build
@echo "==> Installing NextExpense..."
cp $(BINARY) /usr/local/bin/$(BINARY)
@if [ ! -f /etc/$(BINARY)/.env ]; then \
mkdir -p /etc/$(BINARY); \
cp .env.example /etc/$(BINARY)/.env; \
echo "==> Created /etc/$(BINARY)/.env — edit it with your credentials"; \
fi
@if [ ! -f /etc/systemd/system/$(BINARY).service ]; then \
cp contrib/$(BINARY).service /etc/systemd/system/; \
systemctl daemon-reload; \
systemctl enable $(BINARY); \
echo "==> Systemd service installed"; \
fi
@echo "==> Run 'systemctl start $(BINARY)' to start"
@echo "==> Run 'systemctl status $(BINARY)' to check status"
uninstall:
-systemctl stop $(BINARY) 2>/dev/null
-systemctl disable $(BINARY) 2>/dev/null
-rm -f /etc/systemd/system/$(BINARY).service
-systemctl daemon-reload
-rm -f /usr/local/bin/$(BINARY)
@echo "==> NextExpense uninstalled"
# -------------------------------------------------------------------
# Service management
# -------------------------------------------------------------------
run:
./$(BINARY)
start:
systemctl start $(BINARY)
stop:
systemctl stop $(BINARY)
restart:
systemctl restart $(BINARY)
logs:
journalctl -u $(BINARY) -f
status:
systemctl status $(BINARY)
# -------------------------------------------------------------------
# Clean
# -------------------------------------------------------------------
clean:
rm -f $(BINARY)
rm -rf $(OUTDIR)

452
README.md
View file

@ -1,113 +1,121 @@
# NextExpense — AI-Powered Expense Tracker # ExpenseFlow — AI-Powered Expense Tracker
> A production-ready, mobile-first Progressive Web App (PWA) for expense management with passwordless OTP login, AI receipt extraction (Gemini / OpenAI-compatible), and CSV/PDF email reporting with receipt images. > 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).
**Tech Stack:** Go 1.23+ · HTMX · SQLite (pure Go) · Google Gemini / OpenAI · PWA **Tech Stack:** Go 1.22+ · HTMX · SQLite · DeepSeek Vision API · PWA
--- ---
## 🚀 One-Command Install ## 📦 Quick Start — Binary Distribution
Pre-compiled binaries are available for download from the [Releases](https://git.lohmar.co.uk/cclohmar/ExpenseFlow/releases) page.
### Download & Run
```bash ```bash
curl -fsSL https://git.lohmar.co.uk/cclohmar/NextExpense/raw/branch/main/install.sh | bash -s -- --install # Linux (amd64)
curl -L -o expenseflow https://git.lohmar.co.uk/cclohmar/ExpenseFlow/releases/download/v1.0.0/expenseflow-linux-amd64
chmod +x expenseflow
./expenseflow
# Linux (arm64) — Raspberry Pi, etc.
curl -L -o expenseflow https://git.lohmar.co.uk/cclohmar/ExpenseFlow/releases/download/v1.0.0/expenseflow-linux-arm64
chmod +x expenseflow
./expenseflow
# macOS (Intel)
curl -L -o expenseflow https://git.lohmar.co.uk/cclohmar/ExpenseFlow/releases/download/v1.0.0/expenseflow-darwin-amd64
chmod +x expenseflow
./expenseflow
# macOS (Apple Silicon M1/M2/M3)
curl -L -o expenseflow https://git.lohmar.co.uk/cclohmar/ExpenseFlow/releases/download/v1.0.0/expenseflow-darwin-arm64
chmod +x expenseflow
./expenseflow
``` ```
Or from a local clone: The server starts on `http://localhost:8080` by default. Set `PORT=3000` to change the port.
```bash > **Note:** The binary embeds no configuration. You must create a `.env` file (see [Configuration](#-configuration) below) in the same directory you run the binary from.
./install.sh --install
```
Running without flags shows help:
```bash
./install.sh # Show help
./install.sh --help # Same
```
The installer will:
1. Clone the repo to `/opt/nextexpense/`
2. Build the binary (pure Go, no CGO, no dependencies)
3. Ask which AI provider to use:
```
1) Google Gemini (cloud API, needs API key)
2) OpenAI / Compatible (OpenAI, Perplexity, Groq, etc.)
```
4. Prompt for SMTP settings (for OTP emails and report delivery)
5. Create `/opt/nextexpense/.env` with all configuration
6. Set up a systemd service that auto-starts on boot
7. Start NextExpense
**Result:** A fully configured, always-running expense tracker at `http://YOUR_SERVER:8080`.
--- ---
## 🔄 Updating ## 🔧 Building from Source
### Prerequisites
- **Go 1.22+** — [Download](https://go.dev/dl/)
- **GCC** (CGO is required for the SQLite driver)
```bash ```bash
./install.sh --update # Debian/Ubuntu
sudo apt install build-essential
# macOS
xcode-select --install
# Alpine
apk add build-base
``` ```
Update pulls the latest code, copies updated templates/static, rebuilds the binary, automatically backs up the database, and restarts the service. ### Clone & Build
## 🗑️ Uninstalling
```bash ```bash
./install.sh --remove git clone https://git.lohmar.co.uk/cclohmar/ExpenseFlow.git
``` cd ExpenseFlow
Stops the service, removes the systemd unit, and deletes `/opt/nextexpense/` (asks for confirmation).
---
## 🔧 Building from Source (install.sh does this automatically)
The installer automatically installs Go (if missing) and builds from source.
No pre-built binaries are distributed — building from source guarantees the latest code compiled for your exact platform.
### Manual Build
```bash
git clone https://git.lohmar.co.uk/cclohmar/NextExpense.git
cd NextExpense
# Build for your current platform # Build for your current platform
CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w" -o app . go build -o expenseflow .
# Cross-compile for any platform (no extra tools needed!) # The binary is now ready: ./expenseflow
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -buildvcs=false -ldflags="-s -w" -o app-linux-arm64 .
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -buildvcs=false -ldflags="-s -w" -o app-darwin-amd64 .
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -buildvcs=false -ldflags="-s -w" -o app-darwin-arm64 .
``` ```
The binary is fully static — zero runtime dependencies, no CGO, no libc. ### Cross-Compilation
The binary uses CGO (for `mattn/go-sqlite3`), so cross-compilation requires a cross-compiler.
```bash
# Linux amd64
GOOS=linux GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-linux-gnu-gcc go build -o expenseflow-linux-amd64 .
# Linux arm64 (Raspberry Pi, etc.)
GOOS=linux GOARCH=arm64 CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc go build -o expenseflow-linux-arm64 .
# macOS Intel
GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 CC=o64-clang go build -o expenseflow-darwin-amd64 .
# macOS Apple Silicon
GOOS=darwin GOARCH=arm64 CGO_ENABLED=1 CC=aarch64-apple-darwin-clang go build -o expenseflow-darwin-arm64 .
```
> **Tip:** For macOS cross-compilation from Linux, use [osxcross](https://github.com/tpoechtrager/osxcross). For ARM Linux, install `gcc-aarch64-linux-gnu`.
--- ---
## ⚙️ Configuration ## ⚙️ Configuration
Configuration is via environment variables in `.env`. The install script builds this for you interactively. Copy `.env.example` to `.env` and fill in your credentials:
```bash
cp .env.example .env
```
| Variable | Required | Default | Description | | Variable | Required | Default | Description |
|----------|----------|---------|-------------| |----------|----------|---------|-------------|
| `PORT` | No | `8080` | HTTP server port | | `SMTP_HOST` | Yes* | — | SMTP server hostname |
| `SMTP_PORT` | Yes* | — | SMTP server port (usually 587) |
| `SMTP_USER` | Yes* | — | SMTP username |
| `SMTP_PASS` | Yes* | — | SMTP password |
| `DEEPSEEK_API_KEY` | Yes* | — | DeepSeek Vision API key |
| `BASE_URL` | No | `http://localhost:8080` | Public URL for email links | | `BASE_URL` | No | `http://localhost:8080` | Public URL for email links |
| **AI Provider** | | | | | `PORT` | No | `8080` | HTTP server port |
| `AI_PROVIDER` | No | `gemini` | `gemini` or `openai` |
| `GEMINI_API_KEY` | For Gemini | — | Google Gemini API key |
| `OPENAI_API_KEY` | For OpenAI | — | API key (omit for Ollama via OpenAI compat) |
| `AI_MODEL` | For OpenAI | `gpt-4o-mini` | Model name |
| `AI_BASE_URL` | For OpenAI | `https://api.openai.com/v1` | API endpoint |
| **SMTP** | | | |
| `SMTP_HOST` | See note | — | SMTP server hostname |
| `SMTP_PORT` | See note | `587` | SMTP server port |
| `SMTP_USER` | See note | — | SMTP username |
| `SMTP_PASS` | See note | — | SMTP password |
> **Note:** SMTP is optional — without it, OTP codes are logged to the server console for testing and reports cannot be emailed. *\* The app will start without SMTP/DeepSeek configured, but OTP emails and AI extraction will not work.*
### Getting Credentials
- **DeepSeek Vision API:** Sign up at [platform.deepseek.com](https://platform.deepseek.com) and create an API key.
- **SMTP:** Use any SMTP provider. The default config points to an OX hosting SMTP server.
--- ---
@ -116,128 +124,208 @@ Configuration is via environment variables in `.env`. The install script builds
### 1. Start the Server ### 1. Start the Server
```bash ```bash
# If installed via systemd: ./expenseflow
systemctl start nextexpense
# Or run directly:
./app
``` ```
### 2. Open in Browser ### 2. Open in Browser
Navigate to `http://YOUR_SERVER:8080` Navigate to [http://localhost:8080](http://localhost:8080)
### 3. Full Acceptance Flow ### 3. Full Acceptance Flow
``` ```
1. Enter your email → click "Send Verification Code" 1. Enter your email → click "Send Verification Code"
2. Check your inbox (or server log) for the 6-digit OTP 2. Check your inbox for the 6-digit OTP code
3. Enter OTP → click "Verify Code" 3. Enter the OTP → click "Verify Code"
4. Create a month (e.g. "July 2026") → click "Open" 4. Create an event (e.g., "WebSummit 2026")
5. Click "+ New" to add an event → set claim currency + conversion sample 5. Click "Add Expenses" on the event card
6. Click "Open" on the event 6. Click "Capture Receipt" → take a photo or select an image
7. Tap "📷 Camera" or "📁 Upload" to add a receipt 7. AI extracts: amount, merchant, category, date → pre-fills the form
8. AI extracts amount, merchant, category, date → form is pre-filled 8. Review and click "Save Expense"
9. Click "Save Expense" 9. Click "File Event" → enter recipient email → choose CSV or PDF
10. Back on the month view, click "Generate Monthly Report" 10. Report is emailed and event status changes to "closed"
11. Monthly ZIP contains CSV + PDF report + all receipt images 11. Click "Reopen" to re-open a closed event
``` ```
--- ---
## 📡 API Reference ## 📡 API Reference
### Public ### Public Endpoints (no authentication)
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| `GET` | `/` | Landing page | | `GET` | `/` | Landing page with email login form |
| `POST` | `/request-otp` | Request OTP code | | `POST` | `/request-otp` | Request a 6-digit OTP code (sends email) |
| `POST` | `/verify-otp` | Verify OTP and create session | | `POST` | `/verify-otp` | Verify OTP code and create session |
| `POST` | `/logout` | Clear session | | `POST` | `/logout` | Clear session and redirect to login |
### Protected (requires session) ### Protected Endpoints (require session cookie)
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| `GET` | `/dashboard` | Month list | | `GET` | `/dashboard` | Event dashboard |
| `POST` | `/months` | Create month | | `POST` | `/events` | Create a new event |
| `GET` | `/months/{mid}` | View month + events | | `GET` | `/events/{id}/expenses` | View event with expense list |
| `PUT` | `/months/{mid}` | Update month | | `PUT` | `/events/{id}/reopen` | Reopen a closed event |
| `DELETE` | `/months/{mid}` | Delete month | | `POST` | `/expenses/upload` | Upload receipt image (multipart) |
| `POST` | `/months/{mid}/events` | Create event | | `POST` | `/expenses` | Save expense from form data |
| `PUT` | `/months/{mid}/events/{eid}` | Update event | | `POST` | `/events/{id}/file` | Generate report (CSV/PDF) and email it |
| `DELETE` | `/months/{mid}/events/{eid}` | Delete event |
| `GET` | `/months/{mid}/events/{eid}/expenses` | View event + expenses | ### Static Files
| `POST` | `/months/{mid}/events/{eid}/generate` | Generate event report |
| `POST` | `/months/{mid}/generate` | Generate monthly report | | Path | Description |
| `POST` | `/expenses/upload` | Upload receipt image/PDF | |------|-------------|
| `POST` | `/expenses` | Save expense | | `/static/css/style.css` | Application stylesheet |
| `GET` | `/expenses/{id}/edit` | Get edit form | | `/static/icons/icon-192.png` | PWA icon (192×192) |
| `PUT` | `/expenses/{id}` | Update expense | | `/static/icons/icon-512.png` | PWA icon (512×512) |
| `DELETE` | `/expenses/{id}` | Delete expense | | `/manifest.json` | PWA manifest |
| `/sw.js` | Service worker |
| `/storage/{filename}` | Uploaded receipt images |
--- ---
## 🏗️ Project Structure ## 🏗️ Project Structure
``` ```
/opt/nextexpense/ ExpenseFlow/
├── app # Compiled binary ├── main.go # Entry point, router, middleware, server
├── .env # Configuration (optional) ├── go.mod / go.sum # Go module definition
├── backups/ # Automatic DB backups (from -update) ├── .env.example # Environment variable template
├── templates/ # Go HTML templates ├── README.md # This file
├── static/ # CSS, icons, favicon, service worker ├── internal/
├── templates/ # Go HTML templates │ ├── database/db.go # SQLite init, auto-migration, 11 query functions
├── static/ # CSS, icons, favicon, service worker │ ├── auth/
│ ├── css/style.css │ │ ├── otp.go # 6-digit OTP generation + 3-fail lockout
│ ├── favicon.svg # Rx logo │ │ └── session.go # In-memory session store (crypto tokens, 24h TTL)
│ ├── manifest.json │ ├── handlers/
│ ├── sw.js │ │ ├── auth.go # Auth endpoints + middleware
│ └── icons/ │ │ ├── events.go # Event CRUD + dashboard
├── storage/ # Uploaded receipt images (runtime) │ │ ├── expenses.go # Receipt upload, AI extraction, save
├── install.sh # Installer script │ │ └── file.go # CSV/PDF generation + email filing
├── Makefile # Build targets │ ├── ai/deepseek.go # DeepSeek Vision API client
└── contrib/ │ ├── email/smtp.go # SMTP sender (OTP + attachments)
└── nextexpense.service # Systemd service file │ └── utils/uuid.go # UUID generation
├── templates/
│ ├── index.html # Landing page
│ ├── dashboard.html # Event cards + create form
│ ├── event_expenses.html # Event detail + capture + filing
│ ├── receipt_form.html # AI-prefilled edit form
│ └── expense_list.html # HTMX expense list fragment
├── static/
│ ├── css/style.css # Mobile-first responsive CSS (1845 lines)
│ ├── manifest.json # PWA manifest
│ ├── sw.js # Service worker
│ └── icons/ # PWA placeholder icons
└── storage/ # Uploaded receipts (created at runtime)
``` ```
--- ---
## 🧩 Features ## 🧩 Features in Detail
### 🔐 Passwordless OTP Authentication ### 🔐 Passwordless OTP Authentication
- Email-based 6-digit code, 5-minute expiry - Email-based 6-digit code, 5-minute expiry
- 3 failed attempts → 1-minute cooldown - Auto-creates user account on first login
- HTTP-only session cookie, 24h TTL - 3 failed attempts trigger a 1-minute cooldown
- HTTP-only session cookie (`SameSite=Lax`, 24h TTL)
- No passwords to store or forget
### 📋 Event-Based Expense Tracking
- Group expenses into events (trips, conferences, months)
- Open/closed lifecycle with reopen support
- Dashboard with event cards showing name, status, and creation date
### 🤖 AI Receipt Extraction ### 🤖 AI Receipt Extraction
- **Google Gemini** (default) — cloud vision API - Upload receipt images (JPEG/PNG, max 10 MB)
- **OpenAI-compatible** — works with OpenAI, Perplexity, Groq, Together AI, etc. - DeepSeek Vision API extracts: amount, currency, merchant, category, date
- Supports: JPEG, PNG, WebP, HEIC, PDF (email receipts from Uber, etc.) - Editable pre-filled form on failure or success
- Images stored locally in `./storage/`
### 💱 Currency Conversion
- Sample-based: enter a real receipt amount and what you were charged
- System computes the rate automatically
- Each expense stores original + converted amount
### 📧 Email Reporting ### 📧 Email Reporting
- CSV or PDF report - Generate CSV (via `encoding/csv`) or PDF (via `gofpdf`)
- Receipt images bundled as ZIP (`expense-{event}-images.zip`) - Automatic email delivery via SMTP with file attachment
- Filenames: `expense-{event}-report.csv`, `expense-{event}-report.pdf` - Event auto-closes after successful filing
- Item numbers match between report rows and ZIP images - Multipart MIME support with proper content headers
### 📱 Progressive Web App ### 📱 Progressive Web App
- Installable on mobile home screen - Installable on mobile and desktop (manifest.json)
- Camera capture + gallery upload - Offline shell caching (service worker)
- Dark "Terminal Mint" theme (`#0F172A` base) - Camera capture for receipts (`capture="environment"`)
- Rx favicon in emerald green - Theme color: `#10b981` (emerald green)
- Responsive design: 320px → 768px → 1024px+
--- ---
## 🗄️ Database ## 🗄️ Database Schema (SQLite)
SQLite, auto-created on first run — 5 tables: `users`, `auth_otps`, `months`, `events`, `expenses`. Auto-created on first run — 4 tables with foreign keys:
```sql
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE auth_otps (
email TEXT PRIMARY KEY,
otp_code TEXT NOT NULL,
expires_at DATETIME NOT NULL
);
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)
);
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
);
```
---
## 🐳 Docker
```dockerfile
FROM golang:1.22-alpine AS builder
RUN apk add --no-cache build-base
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o expenseflow .
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/expenseflow .
COPY --from=builder /app/templates ./templates
COPY --from=builder /app/static ./static
COPY --from=builder /app/.env.example ./.env.example
EXPOSE 8080
CMD ["./expenseflow"]
```
```bash
docker build -t expenseflow .
docker run -p 8080:8080 -v $(pwd)/.env:/app/.env -v $(pwd)/storage:/app/storage expenseflow
```
--- ---
@ -245,13 +333,57 @@ SQLite, auto-created on first run — 5 tables: `users`, `auth_otps`, `months`,
| Area | Implementation | | Area | Implementation |
|------|---------------| |------|---------------|
| Sessions | `crypto/rand` tokens, in-memory, 24h TTL | | **Sessions** | Cryptographically random tokens (32 bytes, hex-encoded), in-memory store, 24h TTL |
| OTP | `crypto/rand` codes, 5min expiry, lockout after 3 failures | | **OTP** | 6-digit codes from `crypto/rand`, 5-minute expiry, 3-fail lockout (1 minute) |
| Cookies | HTTP-only, SameSite=Lax, path-restricted | | **Cookies** | HTTP-only, SameSite=Lax, path restricted |
| SQL | Parameterized queries everywhere | | **SQL Injection** | Parameterized queries on all database operations |
| Uploads | Magic byte validation, max 10MB, UUID filenames | | **File Upload** | Magic byte validation (JPEG/PNG), 10 MB limit, sanitized filenames (UUID) |
| Config | `.env` is optional — app runs with defaults if absent | | **Credentials** | All secrets via environment variables only — never hardcoded |
| **HTMX** | Server-rendered HTML, no client-side data exposure |
--- ---
*Built with Go, HTMX, SQLite and ❤️* ## 🧪 Development
### Run Tests
```bash
go vet ./...
go test ./...
```
### Manual Smoke Test
```bash
# Start server
go run main.go &
# Test landing page
curl -s http://localhost:8080/ | head -5
# Test OTP request
curl -s -X POST -d "email=test@example.com" http://localhost:8080/request-otp
# Check database
sqlite3 expenses.db "SELECT * FROM auth_otps;"
```
---
## 📄 License
MIT — Free to use, modify, and distribute.
---
## 🙌 Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/my-feature`)
3. Commit changes (`git commit -am 'feat: add my feature'`)
4. Push (`git push origin feature/my-feature`)
5. Open a Pull Request
---
*Built with Go, HTMX, SQLite, and ❤️*

View file

@ -9,7 +9,7 @@
# darwin/arm64: aarch64-apple-darwin-clang (via osxcross) # darwin/arm64: aarch64-apple-darwin-clang (via osxcross)
# #
# Usage: ./build-all.sh # Usage: ./build-all.sh
# Output: ./dist/app-{platform} # Output: ./dist/expenseflow-{platform}
set -euo pipefail set -euo pipefail
@ -23,7 +23,7 @@ echo "==> Building ExpenseFlow $VERSION"
build() { build() {
local GOOS="$1" GOARCH="$2" CC="$3" SUFFIX="$4" local GOOS="$1" GOARCH="$2" CC="$3" SUFFIX="$4"
local OUT="$OUTDIR/app-$SUFFIX" local OUT="$OUTDIR/expenseflow-$SUFFIX"
echo " $SUFFIX ..." echo " $SUFFIX ..."
GOOS="$GOOS" GOARCH="$GOARCH" CGO_ENABLED=1 CC="$CC" \ GOOS="$GOOS" GOARCH="$GOARCH" CGO_ENABLED=1 CC="$CC" \
go build -ldflags="$LDFLAGS" -o "$OUT" . go build -ldflags="$LDFLAGS" -o "$OUT" .

View file

@ -1,25 +0,0 @@
[Unit]
Description=NextExpense — AI-Powered Expense Tracker
Documentation=https://git.lohmar.co.uk/cclohmar/NextExpense
After=network.target
[Service]
Type=simple
User=nextexpense
Group=nextexpense
WorkingDirectory=/opt/nextexpense
ExecStart=/opt/nextexpense/app
Restart=always
RestartSec=5
EnvironmentFile=-/opt/nextexpense/.env
StandardOutput=append:/var/log/nextexpense.log
StandardError=append:/var/log/nextexpense.log
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
[Install]
WantedBy=multi-user.target

19
go.mod
View file

@ -1,24 +1,11 @@
module github.com/cclohmar/NextExpense module github.com/expenseflow
go 1.23.0 go 1.22
require ( require (
github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/chi/v5 v5.1.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/jung-kurt/gofpdf v1.16.2 github.com/jung-kurt/gofpdf v1.16.2
golang.org/x/image v0.18.0 github.com/mattn/go-sqlite3 v1.14.22
modernc.org/sqlite v1.37.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sys v0.33.0 // indirect
modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )

49
go.sum
View file

@ -1,11 +1,7 @@
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= 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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 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/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@ -13,53 +9,12 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= 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 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= 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/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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= 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= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View file

@ -1,550 +0,0 @@
#!/usr/bin/env bash
#
# NextExpense Installer
# =====================
#
# Usage:
# ./install.sh — Show help
# ./install.sh --install — Full fresh install
# ./install.sh --update — Pull latest, rebuild, restart
# ./install.sh --remove — Stop service, remove all files
#
# Pipe install (requires --install flag):
# curl -fsSL https://git.lohmar.co.uk/cclohmar/NextExpense/raw/branch/main/install.sh | bash -s -- --install
#
# Installs to /opt/nextexpense/ and sets up a systemd service.
# Uses sudo internally only for operations that require it.
#
set -euo pipefail
INSTALL_DIR="/opt/nextexpense"
SERVICE_NAME="nextexpense"
REPO_URL="https://git.lohmar.co.uk/cclohmar/NextExpense.git"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${GREEN}[✓]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
err() { echo -e "${RED}[✗]${NC} $1"; }
ask() { echo -en "${CYAN}${NC} $1"; }
# Helper: run a command with sudo if not already root
sudo_if() {
if [ "$EUID" -eq 0 ]; then
"$@"
else
sudo "$@"
fi
}
# ──────────────────────────────────────────────────────────────────
# HELP
# ──────────────────────────────────────────────────────────────────
show_help() {
cat << EOF
╔═══════════════════════════════════════════╗
║ NextExpense Installer ║
╚═══════════════════════════════════════════╝
Usage: ./install.sh [FLAG]
Flags:
--install, -i Full fresh install (clone, build, configure, start service)
--update, -u Pull latest, rebuild binary, restart service
--remove, -r Stop service, remove files (asks for confirmation)
--help, -h Show this help
Examples:
./install.sh --install
./install.sh --update
./install.sh --remove
Pipe install:
curl -fsSL $REPO_URL/install.sh | bash -s -- --install
EOF
}
# ──────────────────────────────────────────────────────────────────
# REMOVE / UNINSTALL
# ──────────────────────────────────────────────────────────────────
do_remove() {
echo ""
echo " ╔═══════════════════════════════════════╗"
echo " ║ NextExpense — Uninstall ║"
echo " ╚═══════════════════════════════════════╝"
echo ""
if [ ! -d "$INSTALL_DIR" ] && [ ! -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then
warn "Nothing to remove — NextExpense is not installed."
exit 0
fi
echo " This will:"
echo " 1. Stop the ${SERVICE_NAME} service"
echo " 2. Disable and remove the systemd service file"
echo " 3. Remove ${INSTALL_DIR}/ (including database, receipts, and config)"
echo " 4. Remove /var/log/nextexpense.log"
echo ""
ask " Type 'yes' to confirm: "
read -r CONFIRM
if [ "$CONFIRM" != "yes" ]; then
warn "Aborted."
exit 0
fi
# Cache sudo credentials.
if [ "$EUID" -ne 0 ]; then
sudo -v 2>/dev/null || { err "Uninstall requires sudo access."; exit 1; }
fi
# Stop and disable service.
if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then
info "Stopping service..."
sudo_if systemctl stop "$SERVICE_NAME" 2>/dev/null || true
sudo_if systemctl disable "$SERVICE_NAME" 2>/dev/null || true
sudo_if rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
sudo_if systemctl daemon-reload
info "Service removed."
fi
# Remove install directory.
if [ -d "$INSTALL_DIR" ]; then
info "Removing $INSTALL_DIR..."
sudo_if rm -rf "$INSTALL_DIR"
fi
# Remove log file.
if [ -f "/var/log/nextexpense.log" ]; then
sudo_if rm -f "/var/log/nextexpense.log"
fi
echo ""
info "NextExpense has been uninstalled."
}
# ──────────────────────────────────────────────────────────────────
# UPDATE
# ──────────────────────────────────────────────────────────────────
do_update() {
echo ""
echo " ╔═══════════════════════════════════════╗"
echo " ║ NextExpense — Update ║"
echo " ╚═══════════════════════════════════════╝"
echo ""
# Cache sudo credentials upfront.
if [ "$EUID" -ne 0 ]; then
sudo -v 2>/dev/null || { err "This update requires sudo access."; exit 1; }
fi
if [ ! -d "$INSTALL_DIR" ]; then
err "$INSTALL_DIR does not exist. Run --install first."
exit 1
fi
cd "$INSTALL_DIR"
if [ -d .git ]; then
info "Pulling latest code..."
sudo_if git pull
else
warn "Not a git repository — re-cloning..."
cd /tmp
rm -rf nextexpense-update
sudo_if git clone "$REPO_URL" nextexpense-update
sudo_if rsync -a --delete nextexpense-update/ "$INSTALL_DIR/"
rm -rf nextexpense-update
cd "$INSTALL_DIR"
fi
# Determine the app user.
APP_USER=""
if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then
APP_USER=$(grep -Po '^User=\K.*' "/etc/systemd/system/${SERVICE_NAME}.service" 2>/dev/null || true)
fi
if [ -z "$APP_USER" ]; then
APP_USER=$(stat -c '%U' "$INSTALL_DIR/app" 2>/dev/null || stat -c '%U' "$INSTALL_DIR" 2>/dev/null || true)
fi
if [ -z "$APP_USER" ] || [ "$APP_USER" = "root" ]; then
APP_USER="${SUDO_USER:-$(whoami)}"
fi
sudo_if chown -R "${APP_USER}:${APP_USER}" "$INSTALL_DIR" 2>/dev/null || true
sudo_if chmod 755 "$INSTALL_DIR" "$INSTALL_DIR/templates" "$INSTALL_DIR/static" 2>/dev/null || true
sudo_if chmod 775 "$INSTALL_DIR/storage" 2>/dev/null || true
# Backup database.
if [ -f "$INSTALL_DIR/expenses.db" ]; then
BACKUP_DIR="$INSTALL_DIR/backups"
sudo_if mkdir -p "$BACKUP_DIR"
BACKUP_FILE="$BACKUP_DIR/expenses-$(date +%Y%m%d-%H%M%S).db"
sudo_if cp "$INSTALL_DIR/expenses.db" "$BACKUP_FILE"
sudo_if chown $(stat -c "%U:%G" "$INSTALL_DIR/expenses.db") "$BACKUP_FILE" 2>/dev/null || true
info "Database backed up to $BACKUP_FILE"
fi
# Copy updated files.
info "Updating templates and static assets..."
sudo_if rsync -a --delete templates/ "$INSTALL_DIR/templates/" 2>/dev/null || true
sudo_if rsync -a --delete static/ "$INSTALL_DIR/static/" 2>/dev/null || true
sudo_if cp install.sh "$INSTALL_DIR/" 2>/dev/null || true
# Stop service.
info "Stopping service..."
sudo_if systemctl stop "$SERVICE_NAME" 2>/dev/null || true
# Ensure Go is installed.
if ! command -v go &>/dev/null; then
info "Installing Go..."
if command -v apt-get &>/dev/null; then
sudo_if apt-get update -qq && sudo_if apt-get install -y -qq golang-go 2>&1 | tail -1
elif command -v dnf &>/dev/null; then
sudo_if dnf install -y golang 2>&1 | tail -1
elif command -v apk &>/dev/null; then
sudo_if apk add go 2>&1 | tail -1
else
err "No package manager found. Please install Go 1.23+ manually."
exit 1
fi
fi
info "Rebuilding binary..."
export GOMODCACHE="${INSTALL_DIR}/.go/mod"
export GOPATH="${INSTALL_DIR}/.go"
export GOCACHE="${INSTALL_DIR}/.go/build"
mkdir -p "${GOMODCACHE}" "${GOCACHE}" 2>/dev/null || sudo_if mkdir -p "${GOMODCACHE}" "${GOCACHE}"
sudo_if chown -R "${APP_USER}" "${INSTALL_DIR}/.go" "${INSTALL_DIR}/app" 2>/dev/null || true
sudo_if rm -f app
CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w" -o app .
sudo_if chown -R "${APP_USER}:${APP_USER}" "$INSTALL_DIR" 2>/dev/null || true
info "Starting service..."
sudo_if systemctl start "$SERVICE_NAME"
sleep 2
if systemctl is-active --quiet "$SERVICE_NAME"; then
info "Update complete — NextExpense is running"
else
warn "Service did not start. Check: systemctl status $SERVICE_NAME"
fi
}
# ──────────────────────────────────────────────────────────────────
# FRESH INSTALL
# ──────────────────────────────────────────────────────────────────
do_install() {
echo ""
echo " ╔═══════════════════════════════════════╗"
echo " ║ NextExpense Installer ║"
echo " ╚═══════════════════════════════════════╝"
echo ""
# Cache sudo credentials upfront.
if [ "$EUID" -ne 0 ]; then
sudo -v 2>/dev/null || { err "This installer requires sudo access. Please run: sudo ./install.sh --install"; exit 1; }
fi
# ── Install dependencies ───────────────────────────────────
if ! command -v git &>/dev/null; then
info "Installing git..."
if command -v apt-get &>/dev/null; then
sudo_if apt-get update -qq && sudo_if apt-get install -y -qq git 2>&1 | tail -1
elif command -v dnf &>/dev/null; then
sudo_if dnf install -y git 2>&1 | tail -1
elif command -v apk &>/dev/null; then
sudo_if apk add git 2>&1 | tail -1
else
err "Please install git manually, then re-run."
exit 1
fi
fi
if ! command -v curl &>/dev/null; then
info "Installing curl..."
if command -v apt-get &>/dev/null; then
sudo_if apt-get install -y -qq curl 2>&1 | tail -1
elif command -v dnf &>/dev/null; then
sudo_if dnf install -y curl 2>&1 | tail -1
elif command -v apk &>/dev/null; then
sudo_if apk add curl 2>&1 | tail -1
fi
fi
if ! command -v python3 &>/dev/null; then
info "Installing python3..."
if command -v apt-get &>/dev/null; then
sudo_if apt-get install -y -qq python3 2>&1 | tail -1
elif command -v dnf &>/dev/null; then
sudo_if dnf install -y python3 2>&1 | tail -1
elif command -v apk &>/dev/null; then
sudo_if apk add python3 2>&1 | tail -1
fi
fi
# ── Clone repo ────────────────────────────────────────────
if [ -d "$INSTALL_DIR" ]; then
warn "$INSTALL_DIR already exists — pulling latest..."
sudo_if git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true
cd "$INSTALL_DIR"
sudo_if git pull
else
info "Cloning repository to $INSTALL_DIR..."
sudo_if git clone "$REPO_URL" "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
sudo_if chown -R "$(whoami):$(whoami)" "$INSTALL_DIR" 2>/dev/null || true
# ── Build binary ──────────────────────────────────────────
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ARCH="amd64" ;;
aarch64) ARCH="arm64" ;;
*) err "Unsupported architecture: $ARCH"; exit 1 ;;
esac
if ! command -v go &>/dev/null; then
info "Installing Go..."
if command -v apt-get &>/dev/null; then
sudo_if apt-get update -qq && sudo_if apt-get install -y -qq golang-go 2>&1 | tail -1
elif command -v dnf &>/dev/null; then
sudo_if dnf install -y golang 2>&1 | tail -1
elif command -v apk &>/dev/null; then
sudo_if apk add go 2>&1 | tail -1
else
err "No package manager found. Please install Go 1.23+ manually."
exit 1
fi
fi
info "Building binary from source (pure Go, no CGO)..."
export GOMODCACHE="${INSTALL_DIR}/.go/mod"
export GOPATH="${INSTALL_DIR}/.go"
export GOCACHE="${INSTALL_DIR}/.go/build"
mkdir -p "${GOMODCACHE}" "${GOCACHE}" 2>/dev/null || sudo_if mkdir -p "${GOMODCACHE}" "${GOCACHE}"
sudo_if chown -R "$(whoami):$(whoami)" "${INSTALL_DIR}/.go" "${INSTALL_DIR}/app" 2>/dev/null || true
sudo_if rm -f app
CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w" -o app .
info "Binary ready: $INSTALL_DIR/app"
# ── Create runtime directories ─────────────────────────────
sudo_if mkdir -p storage
sudo_if chmod 755 templates static storage
# ── AI Provider ────────────────────────────────────────────
echo ""
echo " ── AI Provider ──"
echo " Which AI should process receipt images?"
echo " 1) Google Gemini (cloud API, needs API key)"
echo " 2) OpenAI / Compatible (also works with Ollama, LocalAI, etc.)"
echo ""
ask " Choose [1-2] (default: 1): "
read -r AI_CHOICE
AI_CHOICE="${AI_CHOICE:-1}"
case "$AI_CHOICE" in
2)
AI_PROVIDER="openai"
ask " API key (or press Enter for Ollama): "
read -r OPENAI_API_KEY
ask " Model [gpt-4o-mini]: "
read -r AI_MODEL
AI_MODEL="${AI_MODEL:-gpt-4o-mini}"
ask " Base URL [https://api.openai.com/v1]: "
read -r AI_BASE_URL
AI_BASE_URL="${AI_BASE_URL:-https://api.openai.com/v1}"
;;
*)
AI_PROVIDER="gemini"
ask " Gemini API key: "
read -r GEMINI_API_KEY
;;
esac
# ── SMTP Configuration ────────────────────────────────────
echo ""
echo " ── Email (SMTP) ──"
echo " Required for sending OTP codes and expense reports."
echo " Leave blank to skip (OTP codes will be logged to console)."
echo ""
ask " SMTP host: "
read -r SMTP_HOST
if [ -n "$SMTP_HOST" ]; then
ask " SMTP port [587]: "
read -r SMTP_PORT
SMTP_PORT="${SMTP_PORT:-587}"
ask " SMTP user: "
read -r SMTP_USER
ask " SMTP password: "
read -r SMTP_PASS
fi
# ── General settings ──────────────────────────────────────
echo ""
echo " ── General ──"
echo " If the app is behind a proxy (e.g. dev.lohmar.co.uk), enter the domain."
echo " Otherwise leave blank to use localhost."
echo ""
ask " Domain name [localhost]: "
read -r DOMAIN
DOMAIN="${DOMAIN:-localhost}"
ask " HTTPS? (y/N): "
read -r USE_HTTPS
if [[ "$USE_HTTPS" =~ ^[Yy]$ ]]; then
BASE_URL="https://${DOMAIN}"
else
BASE_URL="http://${DOMAIN}"
fi
ask " Web server port [8080]: "
read -r PORT
PORT="${PORT:-8080}"
if [ "$DOMAIN" = "localhost" ]; then
BASE_URL="http://localhost:${PORT}"
fi
# ── Write .env ─────────────────────────────────────────────
info "Creating .env..."
sudo_if tee "$INSTALL_DIR/.env" > /dev/null << ENVEOF
# NextExpense Configuration
# Generated by install.sh on $(date)
PORT=${PORT}
BASE_URL=${BASE_URL}
AI_PROVIDER=${AI_PROVIDER}
ENVEOF
case "$AI_PROVIDER" in
openai)
sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF
OPENAI_API_KEY=${OPENAI_API_KEY}
AI_MODEL=${AI_MODEL}
AI_BASE_URL=${AI_BASE_URL}
ENVEOF
;;
gemini)
sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF
GEMINI_API_KEY=${GEMINI_API_KEY}
ENVEOF
;;
esac
if [ -n "$SMTP_HOST" ]; then
sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF
SMTP_HOST=${SMTP_HOST}
SMTP_PORT=${SMTP_PORT}
SMTP_USER=${SMTP_USER}
SMTP_PASS=${SMTP_PASS}
ENVEOF
fi
sudo_if chmod 600 "$INSTALL_DIR/.env"
info "Configuration saved to $INSTALL_DIR/.env"
# ── Determine app user ────────────────────────────────────
APP_USER="${SUDO_USER:-$(whoami)}"
if [ "$APP_USER" = "root" ]; then
APP_USER="${SERVICE_NAME}"
if ! id -u "${APP_USER}" &>/dev/null 2>&1; then
info "Creating system user '${APP_USER}'..."
sudo_if useradd --system --no-create-home --home-dir "${INSTALL_DIR}" --shell /usr/sbin/nologin "${APP_USER}"
fi
else
info "Using existing user '${APP_USER}'"
fi
# ── Set ownership ─────────────────────────────────────────
sudo_if chown -R "${APP_USER}:${APP_USER}" "${INSTALL_DIR}"
sudo_if chmod 755 "${INSTALL_DIR}" "${INSTALL_DIR}/templates" "${INSTALL_DIR}/static"
sudo_if chmod 775 "${INSTALL_DIR}/storage"
# ── Systemd service ───────────────────────────────────────
info "Creating systemd service..."
sudo_if tee "/etc/systemd/system/${SERVICE_NAME}.service" > /dev/null << SERVEOF
[Unit]
Description=NextExpense — AI-Powered Expense Tracker
Documentation=https://git.lohmar.co.uk/cclohmar/NextExpense
After=network.target
[Service]
Type=simple
User=${APP_USER}
Group=${APP_USER}
WorkingDirectory=${INSTALL_DIR}
ExecStart=${INSTALL_DIR}/app
Restart=always
RestartSec=5
EnvironmentFile=-${INSTALL_DIR}/.env
StandardOutput=append:/var/log/nextexpense.log
StandardError=append:/var/log/nextexpense.log
[Install]
WantedBy=multi-user.target
SERVEOF
sudo_if systemctl daemon-reload
sudo_if systemctl enable "${SERVICE_NAME}"
info "Service created: /etc/systemd/system/${SERVICE_NAME}.service"
# ── Start ──────────────────────────────────────────────────
info "Starting NextExpense..."
sudo_if systemctl restart "${SERVICE_NAME}" 2>/dev/null || true
sleep 2
echo ""
echo " ╔═══════════════════════════════════════╗"
echo " ║ Installation Complete! ║"
echo " ╚═══════════════════════════════════════╝"
echo ""
echo " Install: $INSTALL_DIR"
echo " Binary: $INSTALL_DIR/app"
echo " Config: $INSTALL_DIR/.env"
echo " Templates: $INSTALL_DIR/templates/"
echo " Static: $INSTALL_DIR/static/"
echo " Storage: $INSTALL_DIR/storage/"
echo " Service: systemctl status $SERVICE_NAME"
echo " Logs: journalctl -u $SERVICE_NAME -f"
echo ""
if systemctl is-active --quiet "${SERVICE_NAME}"; then
info "NextExpense is running on port ${PORT}"
echo " Open http://localhost:${PORT} (or your server IP)"
else
warn "Service did not start. Check: systemctl status ${SERVICE_NAME}"
journalctl -u "${SERVICE_NAME}" -n 20 --no-pager
fi
echo ""
echo " ── Commands ──"
echo " Update: ./install.sh --update"
echo " Remove: ./install.sh --remove"
echo ""
}
# ──────────────────────────────────────────────────────────────────
# MAIN — Parse flags
# ──────────────────────────────────────────────────────────────────
MODE="${1:-}"
case "$MODE" in
--install|-i) do_install ;;
--update|-u) do_update ;;
--remove|-r) do_remove ;;
--help|-h) show_help ;;
"") show_help ;;
*)
# Backward-compat: bare `-update` still works
if [ "$MODE" = "-update" ]; then
do_update
else
err "Unknown flag: $MODE"
show_help
exit 1
fi
;;
esac

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

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

View file

@ -1,151 +0,0 @@
package ai
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
)
const geminiTimeout = 30 * time.Second
type geminiRequest struct {
Contents []geminiContent `json:"contents"`
}
type geminiContent struct {
Parts []geminiPart `json:"parts"`
}
type geminiPart struct {
Text string `json:"text,omitempty"`
InlineData *geminiFileData `json:"inline_data,omitempty"`
}
type geminiFileData struct {
MimeType string `json:"mime_type"`
Data string `json:"data"`
}
type geminiResponse struct {
Candidates []geminiCandidate `json:"candidates"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
type geminiCandidate struct {
Content geminiResponseContent `json:"content"`
}
type geminiResponseContent struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
}
type geminiProvider struct {
apiKey string
apiURL string
}
func newGeminiProvider() geminiProvider {
apiKey := os.Getenv("GEMINI_API_KEY")
model := os.Getenv("GEMINI_MODEL")
if model == "" {
model = "gemini-3.1-flash-lite"
}
return geminiProvider{
apiKey: apiKey,
apiURL: fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent", model),
}
}
func (p geminiProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) {
if p.apiKey == "" {
return &ReceiptData{}, errors.New("GEMINI_API_KEY environment variable not set")
}
imageData, err := readFile(imagePath)
if err != nil {
return &ReceiptData{}, fmt.Errorf("read file: %w", err)
}
mimeType := detectMimeType(imageData)
if mimeType == "" {
mimeType = "image/jpeg"
}
b64Data := base64.StdEncoding.EncodeToString(imageData)
payload := geminiRequest{
Contents: []geminiContent{{
Parts: []geminiPart{
{Text: fmt.Sprintf("Analyze this receipt. Extract as strict JSON with keys: \"merchant\" (string), \"amount\" (number), \"currency\" (3-letter code), \"category\" (string, MUST be exactly one of: %s), \"date\" (YYYY-MM-DD). Return ONLY valid JSON. No markdown.", categoryPrompt())},
{InlineData: &geminiFileData{MimeType: mimeType, Data: b64Data}},
},
}},
}
body, err := json.Marshal(payload)
if err != nil {
return &ReceiptData{}, fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequest(http.MethodPost, p.apiURL, bytes.NewReader(body))
if err != nil {
return &ReceiptData{}, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-goog-api-key", p.apiKey)
client := &http.Client{Timeout: geminiTimeout}
resp, err := client.Do(req)
if err != nil {
return &ReceiptData{}, fmt.Errorf("API request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return &ReceiptData{}, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return &ReceiptData{}, fmt.Errorf("Gemini status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
var apiResp geminiResponse
if err := json.Unmarshal(respBody, &apiResp); err != nil {
return &ReceiptData{}, fmt.Errorf("parse response: %w", err)
}
if apiResp.Error != nil {
return &ReceiptData{}, fmt.Errorf("Gemini error: %s", apiResp.Error.Message)
}
if len(apiResp.Candidates) == 0 {
return &ReceiptData{}, errors.New("no candidates in Gemini response")
}
parts := apiResp.Candidates[0].Content.Parts
if len(parts) == 0 {
return &ReceiptData{}, errors.New("no response text from Gemini")
}
contentStr := stripMarkdownFences(parts[0].Text)
var receipt ReceiptData
if err := json.Unmarshal([]byte(contentStr), &receipt); err != nil {
return &ReceiptData{}, fmt.Errorf("parse receipt JSON: %w (content: %s)", err, contentStr)
}
log.Printf("ExtractReceipt [gemini]: merchant=%q amount=%.2f %s category=%q date=%q",
receipt.Merchant, receipt.Amount, receipt.Currency, receipt.Category, receipt.Date)
return &receipt, nil
}

View file

@ -1,145 +0,0 @@
package ai
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
)
const openaiTimeout = 30 * time.Second
type openaiRequest struct {
Model string `json:"model"`
Messages []openaiMessage `json:"messages"`
Temperature float64 `json:"temperature"`
}
type openaiMessage struct {
Role string `json:"role"`
Content []openaiContent `json:"content"`
}
type openaiContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *openaiImage `json:"image_url,omitempty"`
}
type openaiImage struct {
URL string `json:"url"`
}
type openaiResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
type openaiProvider struct {
apiKey string
model string
baseURL string
}
func newOpenAIProvider() openaiProvider {
return openaiProvider{
apiKey: os.Getenv("OPENAI_API_KEY"),
model: envOrDefault("AI_MODEL", "gpt-4o-mini"),
baseURL: strings.TrimRight(envOrDefault("AI_BASE_URL", "https://api.openai.com/v1"), "/"),
}
}
func (p openaiProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) {
if p.apiKey == "" {
return &ReceiptData{}, errors.New("OPENAI_API_KEY environment variable not set")
}
imageData, err := readFile(imagePath)
if err != nil {
return &ReceiptData{}, fmt.Errorf("read file: %w", err)
}
mimeType := detectMimeType(imageData)
if mimeType == "" {
mimeType = "image/jpeg"
}
b64Data := base64.StdEncoding.EncodeToString(imageData)
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64Data)
payload := openaiRequest{
Model: p.model,
Temperature: 0.1,
Messages: []openaiMessage{{
Role: "user",
Content: []openaiContent{
{Type: "text", Text: fmt.Sprintf("Analyze this receipt image. Extract the following fields as a strict JSON object with these exact keys: \"merchant\" (string, store or business name), \"amount\" (number, total paid), \"currency\" (string, 3-letter code like KES, USD, EUR), \"category\" (string, MUST be exactly one of: %s), \"date\" (string, YYYY-MM-DD format). Return ONLY valid JSON. No markdown, no explanation, no code fences.", categoryPrompt())},
{Type: "image_url", ImageURL: &openaiImage{URL: dataURL}},
},
}},
}
body, err := json.Marshal(payload)
if err != nil {
return &ReceiptData{}, fmt.Errorf("marshal request: %w", err)
}
apiURL := p.baseURL + "/chat/completions"
req, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(body))
if err != nil {
return &ReceiptData{}, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.apiKey)
client := &http.Client{Timeout: openaiTimeout}
resp, err := client.Do(req)
if err != nil {
return &ReceiptData{}, fmt.Errorf("API request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return &ReceiptData{}, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return &ReceiptData{}, fmt.Errorf("API status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
var apiResp openaiResponse
if err := json.Unmarshal(respBody, &apiResp); err != nil {
return &ReceiptData{}, fmt.Errorf("parse response: %w", err)
}
if apiResp.Error != nil {
return &ReceiptData{}, fmt.Errorf("API error: %s", apiResp.Error.Message)
}
if len(apiResp.Choices) == 0 {
return &ReceiptData{}, errors.New("no response choices from API")
}
contentStr := stripMarkdownFences(apiResp.Choices[0].Message.Content)
var receipt ReceiptData
if err := json.Unmarshal([]byte(contentStr), &receipt); err != nil {
return &ReceiptData{}, fmt.Errorf("parse receipt JSON: %w (content: %s)", err, contentStr)
}
log.Printf("ExtractReceipt [openai-%s]: merchant=%q amount=%.2f %s",
p.model, receipt.Merchant, receipt.Amount, receipt.Currency)
return &receipt, nil
}

View file

@ -1,164 +0,0 @@
// Package ai provides receipt data extraction from images/PDFs using
// configurable AI providers (Gemini or OpenAI-compatible).
//
// Provider selection is done via environment variables:
//
// AI_PROVIDER=gemini (default, uses GEMINI_API_KEY)
// AI_PROVIDER=openai (uses OPENAI_API_KEY, AI_MODEL, AI_BASE_URL)
// (also works with Ollama, LocalAI, etc.)
package ai
import (
"fmt"
"os"
"strings"
)
// 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"`
}
// ValidCategories is the list of allowed expense categories the AI should
// classify receipts into.
var ValidCategories = []string{
"Airfare",
"Accommodation",
"Meals Self",
"Staff Meal",
"Client Meal",
"Travel - Taxi",
"Travel - Phone",
"Misc Travel",
"Mobile / Office Phone",
"Office Supplies",
"Postage / Couriers",
"Other Expenses",
"Hotel",
"Per Diem",
"Visa Fees",
"Connectivity (internet connections)",
}
// categoryPrompt returns the comma-separated category list for AI prompts.
func categoryPrompt() string {
return `"Airfare", "Accommodation", "Meals Self", "Staff Meal", "Client Meal", "Travel - Taxi", "Travel - Phone", "Misc Travel", "Mobile / Office Phone", "Office Supplies", "Postage / Couriers", "Other Expenses", "Hotel", "Per Diem", "Visa Fees", "Connectivity (internet connections)"`
}
// Provider is the interface that wraps receipt extraction.
// Each provider (Gemini, OpenAI, Ollama) implements this interface.
type Provider interface {
ExtractReceipt(imagePath string) (*ReceiptData, error)
}
// ExtractReceipt dispatches to the configured AI provider.
// The provider is selected based on the AI_PROVIDER environment variable.
func ExtractReceipt(imagePath string) (*ReceiptData, error) {
provider := getProvider()
return provider.ExtractReceipt(imagePath)
}
// getProvider returns the appropriate Provider based on environment config.
func getProvider() Provider {
providerName := strings.ToLower(strings.TrimSpace(os.Getenv("AI_PROVIDER")))
switch providerName {
case "openai":
return newOpenAIProvider()
default:
return newGeminiProvider()
}
}
// envOrDefault returns the environment variable value or a default if unset.
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// stripMarkdownFences removes markdown code fences from model output.
func stripMarkdownFences(s string) string {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```") {
s = s[3:]
if idx := strings.Index(s, "\n"); idx != -1 {
s = s[idx+1:]
}
}
if strings.HasSuffix(s, "```") {
s = s[:len(s)-3]
}
return strings.TrimSpace(s)
}
// readFile reads the full contents of a file from disk.
func readFile(path string) ([]byte, error) {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("receipt file not found: %w", err)
}
return nil, fmt.Errorf("stat file %q: %w", path, err)
}
if info.IsDir() {
return nil, fmt.Errorf("readFile: %q is a directory, not a file", path)
}
if info.Size() > 10<<20 {
return nil, fmt.Errorf("readFile: %q exceeds 10 MB limit", path)
}
return os.ReadFile(path)
}
// detectMimeType determines the MIME type from magic bytes.
func detectMimeType(data []byte) string {
if len(data) < 4 {
return ""
}
// JPEG
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "image/jpeg"
}
// PNG
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
return "image/png"
}
// WebP
if len(data) >= 12 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 &&
data[3] == 0x46 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 {
return "image/webp"
}
// GIF
if data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 {
return "image/gif"
}
// BMP
if data[0] == 0x42 && data[1] == 0x4D {
return "image/bmp"
}
// TIFF
if (data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00) ||
(data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A) {
return "image/tiff"
}
// PDF
if data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46 {
return "application/pdf"
}
// HEIC/HEIF (ftyp box at offset 4)
if len(data) >= 12 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70 {
brand := string(data[8:12])
switch brand {
case "heic", "heix", "hevc", "hevx", "mif1", "msf1":
return "image/heic"
case "avif":
return "image/avif"
}
}
return ""
}

View file

@ -4,7 +4,6 @@ package auth
import ( import (
"crypto/rand" "crypto/rand"
"crypto/subtle"
"fmt" "fmt"
"sync" "sync"
"time" "time"
@ -34,8 +33,7 @@ func ValidateOTP(provided, stored string, expiresAt time.Time) bool {
if time.Now().After(expiresAt) { if time.Now().After(expiresAt) {
return false return false
} }
// Use constant-time comparison to prevent timing side-channel attacks. return provided == stored
return subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1
} }
// attemptData stores the failure count and timestamp for a single email. // attemptData stores the failure count and timestamp for a single email.

View file

@ -9,7 +9,7 @@ import (
"log" "log"
"time" "time"
_ "modernc.org/sqlite" _ "github.com/mattn/go-sqlite3"
) )
// DB is the shared database handle, initialized by Init(). // DB is the shared database handle, initialized by Init().
@ -23,9 +23,6 @@ var DB *sql.DB
type User struct { type User struct {
ID string ID string
Email string Email string
Name string
Department string
Onboarded bool
CreatedAt string CreatedAt string
} }
@ -36,23 +33,12 @@ type OTP struct {
ExpiresAt string ExpiresAt string
} }
// Month represents a row in the months table.
type Month struct {
ID string
UserID string
Name string
CreatedAt string
}
// Event represents a row in the events table. // Event represents a row in the events table.
type Event struct { type Event struct {
ID string ID string
UserID string UserID string
MonthID string
Name string Name string
Status string Status string
BaseCurrency string
ExchangeRate float64
CreatedAt string CreatedAt string
} }
@ -62,8 +48,6 @@ type Expense struct {
EventID string EventID string
Amount float64 Amount float64
Currency string Currency string
ConvertedAmount float64
BaseCurrency string
Merchant string Merchant string
Category string Category string
Description string Description string
@ -72,16 +56,6 @@ type Expense struct {
CreatedAt string CreatedAt string
} }
// DownloadToken represents a download token for a generated report package.
type DownloadToken struct {
Token string
EventID string
Filename string
CreatedAt string
ExpiresAt string
Accessed bool
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Initialization // Initialization
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -91,41 +65,30 @@ type DownloadToken struct {
// It also sets the package-level DB variable for shared use. // It also sets the package-level DB variable for shared use.
func Init() (*sql.DB, error) { func Init() (*sql.DB, error) {
var err error var err error
DB, err = sql.Open("sqlite", "expenses.db") DB, err = sql.Open("sqlite3", "expenses.db")
if err != nil { if err != nil {
log.Printf("ERROR [%s] database: failed to open: %v", time.Now().Format(time.RFC3339), err) log.Printf("ERROR [%s] database: failed to open: %v", time.Now().Format(time.RFC3339), err)
return nil, err return nil, err
} }
// modernc.org/sqlite supports concurrent reads. // SQLite does not support concurrent writes; limit to one connection.
// A small pool handles HTMX concurrent requests efficiently. DB.SetMaxOpenConns(1)
DB.SetMaxOpenConns(4)
DB.SetMaxIdleConns(2)
if err = createTables(DB); err != nil { if err = createTables(DB); err != nil {
log.Printf("ERROR [%s] database: table creation failed: %v", time.Now().Format(time.RFC3339), err) log.Printf("ERROR [%s] database: table creation failed: %v", time.Now().Format(time.RFC3339), err)
return nil, err return nil, err
} }
// Run schema migrations for existing databases.
if err = migrateTables(DB); err != nil {
log.Printf("ERROR [%s] database: migration failed: %v", time.Now().Format(time.RFC3339), err)
return nil, err
}
log.Printf("INFO [%s] database: initialized successfully", time.Now().Format(time.RFC3339)) log.Printf("INFO [%s] database: initialized successfully", time.Now().Format(time.RFC3339))
return DB, nil return DB, nil
} }
// createTables executes the DDL statements for all tables. // createTables executes the DDL statements for all four tables.
func createTables(db *sql.DB) error { func createTables(db *sql.DB) error {
statements := []string{ statements := []string{
`CREATE TABLE IF NOT EXISTS users ( `CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL DEFAULT '',
department TEXT NOT NULL DEFAULT '',
onboarded INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, )`,
`CREATE TABLE IF NOT EXISTS auth_otps ( `CREATE TABLE IF NOT EXISTS auth_otps (
@ -133,32 +96,19 @@ func createTables(db *sql.DB) error {
otp_code TEXT NOT NULL, otp_code TEXT NOT NULL,
expires_at DATETIME NOT NULL expires_at DATETIME NOT NULL
)`, )`,
`CREATE TABLE IF NOT EXISTS months (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)`,
`CREATE TABLE IF NOT EXISTS events ( `CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
month_id TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
status TEXT CHECK(status IN ('open', 'closed')) DEFAULT 'open', status TEXT CHECK(status IN ('open', 'closed')) DEFAULT 'open',
base_currency TEXT NOT NULL DEFAULT 'EUR',
exchange_rate REAL NOT NULL DEFAULT 1.0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id), FOREIGN KEY(user_id) REFERENCES users(id)
FOREIGN KEY(month_id) REFERENCES months(id) ON DELETE CASCADE
)`, )`,
`CREATE TABLE IF NOT EXISTS expenses ( `CREATE TABLE IF NOT EXISTS expenses (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
event_id TEXT NOT NULL, event_id TEXT NOT NULL,
amount REAL NOT NULL, amount REAL NOT NULL,
currency TEXT NOT NULL, currency TEXT NOT NULL,
converted_amount REAL NOT NULL DEFAULT 0,
base_currency TEXT NOT NULL DEFAULT 'EUR',
merchant TEXT NOT NULL, merchant TEXT NOT NULL,
category TEXT NOT NULL, category TEXT NOT NULL,
description TEXT, description TEXT,
@ -167,14 +117,6 @@ func createTables(db *sql.DB) error {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(event_id) REFERENCES events(id) ON DELETE CASCADE FOREIGN KEY(event_id) REFERENCES events(id) ON DELETE CASCADE
)`, )`,
`CREATE TABLE IF NOT EXISTS download_tokens (
token TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
filename TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
accessed INTEGER NOT NULL DEFAULT 0
)`,
} }
for _, stmt := range statements { for _, stmt := range statements {
@ -185,23 +127,6 @@ func createTables(db *sql.DB) error {
return nil return nil
} }
// migrateTables applies schema changes to existing databases that were
// created before the current version. Each migration is idempotent —
// errors from ALTER TABLE (e.g. column already exists) are ignored.
func migrateTables(db *sql.DB) error {
migrations := []string{
"ALTER TABLE users ADD COLUMN name TEXT NOT NULL DEFAULT ''",
"ALTER TABLE users ADD COLUMN department TEXT NOT NULL DEFAULT ''",
"ALTER TABLE users ADD COLUMN onboarded INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE events ADD COLUMN month_id TEXT NOT NULL DEFAULT ''",
}
for _, stmt := range migrations {
db.Exec(stmt) // ignore errors — columns may already exist
}
return nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// User queries // User queries
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -221,9 +146,9 @@ func CreateUser(db *sql.DB, id, email string) error {
// GetUserByEmail returns the user with the given email, or nil if not found. // GetUserByEmail returns the user with the given email, or nil if not found.
func GetUserByEmail(db *sql.DB, email string) (*User, error) { func GetUserByEmail(db *sql.DB, email string) (*User, error) {
row := db.QueryRow("SELECT id, email, name, department, onboarded, created_at FROM users WHERE email = ?", email) row := db.QueryRow("SELECT id, email, created_at FROM users WHERE email = ?", email)
u := &User{} u := &User{}
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Department, &u.Onboarded, &u.CreatedAt); err != nil { if err := row.Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@ -234,34 +159,6 @@ func GetUserByEmail(db *sql.DB, email string) (*User, error) {
return u, nil return u, nil
} }
// GetUserByID returns the user with the given ID, or nil if not found.
func GetUserByID(db *sql.DB, id string) (*User, error) {
row := db.QueryRow("SELECT id, email, name, department, onboarded, created_at FROM users WHERE id = ?", id)
u := &User{}
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Department, &u.Onboarded, &u.CreatedAt); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
log.Printf("ERROR [%s] database: GetUserByID(%s): %v",
time.Now().Format(time.RFC3339), id, err)
return nil, err
}
return u, nil
}
// UpdateUserOnboarding saves the user's name and department and marks them as onboarded.
func UpdateUserOnboarding(db *sql.DB, userID, name, department string) error {
_, err := db.Exec(
"UPDATE users SET name = ?, department = ?, onboarded = 1 WHERE id = ?",
name, department, userID,
)
if err != nil {
log.Printf("ERROR [%s] database: UpdateUserOnboarding(%s): %v",
time.Now().Format(time.RFC3339), userID, err)
}
return err
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// OTP queries // OTP queries
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -306,137 +203,19 @@ func DeleteOTP(db *sql.DB, email string) error {
return err return err
} }
// ---------------------------------------------------------------------------
// Month queries
// ---------------------------------------------------------------------------
// CreateMonth inserts a new month row.
func CreateMonth(db *sql.DB, id, userID, name string) error {
_, err := db.Exec(
"INSERT INTO months (id, user_id, name) VALUES (?, ?, ?)",
id, userID, name,
)
if err != nil {
log.Printf("ERROR [%s] database: CreateMonth(%s, %s, %s): %v",
time.Now().Format(time.RFC3339), id, userID, name, err)
}
return err
}
// GetMonthsByUser returns all months belonging to a user, ordered by creation date descending.
func GetMonthsByUser(db *sql.DB, userID string) ([]Month, error) {
rows, err := db.Query(
"SELECT id, user_id, name, created_at FROM months WHERE user_id = ? ORDER BY created_at DESC",
userID,
)
if err != nil {
log.Printf("ERROR [%s] database: GetMonthsByUser(%s): %v",
time.Now().Format(time.RFC3339), userID, err)
return nil, err
}
defer rows.Close()
var months []Month
for rows.Next() {
var m Month
if err := rows.Scan(&m.ID, &m.UserID, &m.Name, &m.CreatedAt); err != nil {
log.Printf("ERROR [%s] database: GetMonthsByUser scan: %v",
time.Now().Format(time.RFC3339), err)
return nil, err
}
months = append(months, m)
}
return months, rows.Err()
}
// GetMonthByID returns a single month by ID, or nil if not found.
func GetMonthByID(db *sql.DB, id string) (*Month, error) {
row := db.QueryRow("SELECT id, user_id, name, created_at FROM months WHERE id = ?", id)
m := &Month{}
if err := row.Scan(&m.ID, &m.UserID, &m.Name, &m.CreatedAt); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
log.Printf("ERROR [%s] database: GetMonthByID(%s): %v",
time.Now().Format(time.RFC3339), id, err)
return nil, err
}
return m, nil
}
// UpdateMonth updates the name of an existing month.
func UpdateMonth(db *sql.DB, id, name string) error {
_, err := db.Exec("UPDATE months SET name = ? WHERE id = ?", name, id)
if err != nil {
log.Printf("ERROR [%s] database: UpdateMonth(%s): %v",
time.Now().Format(time.RFC3339), id, err)
}
return err
}
// DeleteMonth removes a month and all its events (cascade deletes expenses via FK).
func DeleteMonth(db *sql.DB, id string) error {
_, err := db.Exec("DELETE FROM months WHERE id = ?", id)
if err != nil {
log.Printf("ERROR [%s] database: DeleteMonth(%s): %v",
time.Now().Format(time.RFC3339), id, err)
}
return err
}
// GetMonthTotalClaim returns the sum of all converted_amounts across all
// events and expenses in a given month. Returns 0 if no expenses exist.
func GetMonthTotalClaim(db *sql.DB, monthID string) (float64, error) {
var total sql.NullFloat64
err := db.QueryRow(
`SELECT COALESCE(SUM(e.converted_amount), 0)
FROM expenses e
JOIN events ev ON e.event_id = ev.id
WHERE ev.month_id = ?`, monthID,
).Scan(&total)
if err != nil {
return 0, err
}
if total.Valid {
return total.Float64, nil
}
return 0, nil
}
// GetEventTotalClaim returns the sum of all converted_amounts for a given event.
func GetEventTotalClaim(db *sql.DB, eventID string) (float64, error) {
var total sql.NullFloat64
err := db.QueryRow(
`SELECT COALESCE(SUM(converted_amount), 0) FROM expenses WHERE event_id = ?`, eventID,
).Scan(&total)
if err != nil {
return 0, err
}
if total.Valid {
return total.Float64, nil
}
return 0, nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Event queries // Event queries
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CreateEvent inserts a new event row with optional base currency and exchange rate. // CreateEvent inserts a new event row.
func CreateEvent(db *sql.DB, id, userID, monthID, name, baseCurrency string, exchangeRate float64) error { func CreateEvent(db *sql.DB, id, userID, name string) error {
if baseCurrency == "" {
baseCurrency = "EUR"
}
if exchangeRate <= 0 {
exchangeRate = 1.0
}
_, err := db.Exec( _, err := db.Exec(
"INSERT INTO events (id, user_id, month_id, name, base_currency, exchange_rate) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO events (id, user_id, name) VALUES (?, ?, ?)",
id, userID, monthID, name, baseCurrency, exchangeRate, id, userID, name,
) )
if err != nil { if err != nil {
log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s, %s, %s, %.4f): %v", log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s): %v",
time.Now().Format(time.RFC3339), id, userID, monthID, name, baseCurrency, exchangeRate, err) time.Now().Format(time.RFC3339), id, userID, name, err)
} }
return err return err
} }
@ -444,7 +223,7 @@ func CreateEvent(db *sql.DB, id, userID, monthID, name, baseCurrency string, exc
// GetEventsByUser returns all events belonging to a user, ordered by creation date descending. // GetEventsByUser returns all events belonging to a user, ordered by creation date descending.
func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) { func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
rows, err := db.Query( rows, err := db.Query(
"SELECT id, user_id, month_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC", "SELECT id, user_id, name, status, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC",
userID, userID,
) )
if err != nil { if err != nil {
@ -457,7 +236,7 @@ func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
var events []Event var events []Event
for rows.Next() { for rows.Next() {
var e Event var e Event
if err := rows.Scan(&e.ID, &e.UserID, &e.MonthID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil { if err := rows.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.CreatedAt); err != nil {
log.Printf("ERROR [%s] database: GetEventsByUser scan: %v", log.Printf("ERROR [%s] database: GetEventsByUser scan: %v",
time.Now().Format(time.RFC3339), err) time.Now().Format(time.RFC3339), err)
return nil, err return nil, err
@ -467,37 +246,11 @@ func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
return events, rows.Err() return events, rows.Err()
} }
// GetEventsByMonth returns all events under a given month, ordered by creation date descending.
func GetEventsByMonth(db *sql.DB, monthID string) ([]Event, error) {
rows, err := db.Query(
"SELECT id, user_id, month_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE month_id = ? ORDER BY created_at DESC",
monthID,
)
if err != nil {
log.Printf("ERROR [%s] database: GetEventsByMonth(%s): %v",
time.Now().Format(time.RFC3339), monthID, 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.MonthID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil {
log.Printf("ERROR [%s] database: GetEventsByMonth 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. // GetEventByID returns a single event by ID, or nil if not found.
func GetEventByID(db *sql.DB, id string) (*Event, error) { func GetEventByID(db *sql.DB, id string) (*Event, error) {
row := db.QueryRow("SELECT id, user_id, month_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE id = ?", id) row := db.QueryRow("SELECT id, user_id, name, status, created_at FROM events WHERE id = ?", id)
e := &Event{} e := &Event{}
if err := row.Scan(&e.ID, &e.UserID, &e.MonthID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil { if err := row.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.CreatedAt); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@ -518,54 +271,17 @@ func UpdateEventStatus(db *sql.DB, id, status string) error {
return err return err
} }
// DeleteEvent removes an event and all its expenses from the database.
func DeleteEvent(db *sql.DB, id string) error {
_, err := db.Exec("DELETE FROM events WHERE id = ?", id)
if err != nil {
log.Printf("ERROR [%s] database: DeleteEvent(%s): %v",
time.Now().Format(time.RFC3339), id, err)
}
return err
}
// UpdateEvent updates the name, base currency and exchange rate of an existing event.
func UpdateEvent(db *sql.DB, id, name, baseCurrency string, exchangeRate float64) error {
_, err := db.Exec("UPDATE events SET name = ?, base_currency = ?, exchange_rate = ? WHERE id = ?", name, baseCurrency, exchangeRate, id)
if err != nil {
log.Printf("ERROR [%s] database: UpdateEvent(%s): %v",
time.Now().Format(time.RFC3339), id, err)
}
return err
}
// RecalculateExpenses updates all expense converted_amounts for an event
// using the new exchange rate. Expenses already in the base currency are left unchanged.
func RecalculateExpenses(db *sql.DB, eventID, baseCurrency string, exchangeRate float64) error {
_, err := db.Exec(
`UPDATE expenses SET
converted_amount = CASE WHEN currency != ? THEN ROUND(amount * ?, 2) ELSE amount END,
base_currency = ?
WHERE event_id = ?`,
baseCurrency, exchangeRate, baseCurrency, eventID,
)
if err != nil {
log.Printf("ERROR [%s] database: RecalculateExpenses(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
}
return err
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Expense queries // Expense queries
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CreateExpense inserts a new expense row from the provided Expense struct. // 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 { func CreateExpense(db *sql.DB, expense Expense) error {
_, err := db.Exec( _, err := db.Exec(
`INSERT INTO expenses (id, event_id, amount, currency, converted_amount, base_currency, merchant, category, description, date, image_path) `INSERT INTO expenses (id, event_id, amount, currency, merchant, category, description, date, image_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
expense.ID, expense.EventID, expense.Amount, expense.Currency, expense.ID, expense.EventID, expense.Amount, expense.Currency,
expense.ConvertedAmount, expense.BaseCurrency,
expense.Merchant, expense.Category, expense.Description, expense.Merchant, expense.Category, expense.Description,
expense.Date, expense.ImagePath, expense.Date, expense.ImagePath,
) )
@ -579,8 +295,8 @@ func CreateExpense(db *sql.DB, expense Expense) error {
// GetExpensesByEvent returns all expenses for a given event, ordered by creation date descending. // GetExpensesByEvent returns all expenses for a given event, ordered by creation date descending.
func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) { func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) {
rows, err := db.Query( rows, err := db.Query(
`SELECT id, event_id, amount, currency, converted_amount, base_currency, `SELECT id, event_id, amount, currency, merchant, category,
merchant, category, COALESCE(description, ''), date, image_path, created_at COALESCE(description, ''), date, image_path, created_at
FROM expenses WHERE event_id = ? ORDER BY created_at DESC`, FROM expenses WHERE event_id = ? ORDER BY created_at DESC`,
eventID, eventID,
) )
@ -595,8 +311,8 @@ func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) {
for rows.Next() { for rows.Next() {
var e Expense var e Expense
if err := rows.Scan( if err := rows.Scan(
&e.ID, &e.EventID, &e.Amount, &e.Currency, &e.ConvertedAmount, &e.BaseCurrency, &e.ID, &e.EventID, &e.Amount, &e.Currency, &e.Merchant,
&e.Merchant, &e.Category, &e.Description, &e.Date, &e.ImagePath, &e.CreatedAt, &e.Category, &e.Description, &e.Date, &e.ImagePath, &e.CreatedAt,
); err != nil { ); err != nil {
log.Printf("ERROR [%s] database: GetExpensesByEvent scan: %v", log.Printf("ERROR [%s] database: GetExpensesByEvent scan: %v",
time.Now().Format(time.RFC3339), err) time.Now().Format(time.RFC3339), err)
@ -606,143 +322,3 @@ func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) {
} }
return expenses, rows.Err() return expenses, rows.Err()
} }
// GetExpenseByID returns a single expense by its ID, or nil if not found.
func GetExpenseByID(db *sql.DB, id string) (*Expense, error) {
row := db.QueryRow(
`SELECT id, event_id, amount, currency, converted_amount, base_currency,
merchant, category, COALESCE(description, ''), date, image_path, created_at
FROM expenses WHERE id = ?`, id)
e := &Expense{}
if err := row.Scan(
&e.ID, &e.EventID, &e.Amount, &e.Currency, &e.ConvertedAmount, &e.BaseCurrency,
&e.Merchant, &e.Category, &e.Description, &e.Date, &e.ImagePath, &e.CreatedAt,
); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
log.Printf("ERROR [%s] database: GetExpenseByID(%s): %v",
time.Now().Format(time.RFC3339), id, err)
return nil, err
}
return e, nil
}
// UpdateExpense updates all editable fields of an existing expense.
func UpdateExpense(db *sql.DB, expense Expense) error {
_, err := db.Exec(
`UPDATE expenses SET amount=?, currency=?, converted_amount=?, base_currency=?,
merchant=?, category=?, description=?, date=? WHERE id=?`,
expense.Amount, expense.Currency, expense.ConvertedAmount, expense.BaseCurrency,
expense.Merchant, expense.Category, expense.Description, expense.Date, expense.ID,
)
if err != nil {
log.Printf("ERROR [%s] database: UpdateExpense(%s): %v",
time.Now().Format(time.RFC3339), expense.ID, err)
}
return err
}
// DeleteExpense removes a single expense by its ID.
func DeleteExpense(db *sql.DB, id string) error {
_, err := db.Exec("DELETE FROM expenses WHERE id = ?", id)
if err != nil {
log.Printf("ERROR [%s] database: DeleteExpense(%s): %v",
time.Now().Format(time.RFC3339), id, err)
}
return err
}
// ---------------------------------------------------------------------------
// Download token queries
// ---------------------------------------------------------------------------
// CreateDownloadToken inserts a new download token row.
func CreateDownloadToken(db *sql.DB, token, eventID, filename, expiresAt string) error {
_, err := db.Exec(
"INSERT INTO download_tokens (token, event_id, filename, expires_at) VALUES (?, ?, ?, ?)",
token, eventID, filename, expiresAt,
)
if err != nil {
log.Printf("ERROR [%s] database: CreateDownloadToken(%s): %v",
time.Now().Format(time.RFC3339), token, err)
}
return err
}
// GetDownloadTokenByToken retrieves a download token record by its token string.
func GetDownloadTokenByToken(db *sql.DB, token string) (*DownloadToken, error) {
dt := &DownloadToken{}
err := db.QueryRow(
"SELECT token, event_id, filename, created_at, expires_at, accessed FROM download_tokens WHERE token = ?",
token,
).Scan(&dt.Token, &dt.EventID, &dt.Filename, &dt.CreatedAt, &dt.ExpiresAt, &dt.Accessed)
if err != nil {
return nil, err
}
return dt, nil
}
// MarkDownloadTokenAccessed sets the accessed flag for a token.
func MarkDownloadTokenAccessed(db *sql.DB, token string) error {
_, err := db.Exec("UPDATE download_tokens SET accessed = 1 WHERE token = ?", token)
if err != nil {
log.Printf("ERROR [%s] database: MarkDownloadTokenAccessed(%s): %v",
time.Now().Format(time.RFC3339), token, err)
}
return err
}
// DeleteExpiredDownloadTokens removes tokens past their expiry and their files.
// Returns the filenames of deleted tokens so the caller can clean up disk files.
func DeleteExpiredDownloadTokens(db *sql.DB) ([]string, error) {
rows, err := db.Query("SELECT filename FROM download_tokens WHERE expires_at < datetime('now')")
if err != nil {
return nil, err
}
defer rows.Close()
var filenames []string
for rows.Next() {
var fn string
if err := rows.Scan(&fn); err != nil {
continue
}
filenames = append(filenames, fn)
}
if len(filenames) > 0 {
if _, err := db.Exec("DELETE FROM download_tokens WHERE expires_at < datetime('now')"); err != nil {
return filenames, err
}
}
return filenames, nil
}
// DeleteDownloadTokensByEvent removes all download tokens for a given event.
// Returns the filenames so the caller can clean up disk files.
func DeleteDownloadTokensByEvent(db *sql.DB, eventID string) ([]string, error) {
rows, err := db.Query("SELECT filename FROM download_tokens WHERE event_id = ?", eventID)
if err != nil {
return nil, err
}
defer rows.Close()
var filenames []string
for rows.Next() {
var fn string
if err := rows.Scan(&fn); err != nil {
continue
}
filenames = append(filenames, fn)
}
if len(filenames) > 0 {
if _, err := db.Exec("DELETE FROM download_tokens WHERE event_id = ?", eventID); err != nil {
return filenames, err
}
}
return filenames, nil
}

View file

@ -1,4 +1,4 @@
// Package email provides SMTP email sending for NextExpense, including OTP // Package email provides SMTP email sending for ExpenseFlow, including OTP
// verification codes and expense report emails with CSV or PDF attachments. // verification codes and expense report emails with CSV or PDF attachments.
// //
// Credentials are passed via the constructor; the caller is responsible for // Credentials are passed via the constructor; the caller is responsible for
@ -62,7 +62,7 @@ func NewSender(host, port, user, pass, from string) *Sender {
// SendOTP sends a plain-text OTP verification email to the given recipient. // 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. // The email contains a standard subject line and the 6-digit verification code.
func (s *Sender) SendOTP(to, code string) error { func (s *Sender) SendOTP(to, code string) error {
subject := "Your NextExpense OTP" subject := "Your ExpenseFlow OTP"
body := fmt.Sprintf("Your verification code is: %s", code) body := fmt.Sprintf("Your verification code is: %s", code)
msg := buildPlainMessage(s.from, to, subject, body) msg := buildPlainMessage(s.from, to, subject, body)
@ -73,14 +73,15 @@ func (s *Sender) SendOTP(to, code string) error {
return err return err
} }
log.Printf("INFO [%s] email: OTP code %s sent to %s", time.Now().Format(time.RFC3339), code, to) log.Printf("INFO [%s] email: OTP sent to %s", time.Now().Format(time.RFC3339), to)
return nil return nil
} }
// SendReport sends an email with the given subject and body, attaching one or // SendReport sends an email with the given subject and body, attaching a CSV
// more files (report CSV/PDF + ZIP of receipt images). // or PDF file. The attachment's Content-Type is inferred from its filename
func (s *Sender) SendReport(to, subject, body string, attachments []*Attachment) error { // extension (text/csv for .csv, application/octet-stream otherwise).
msg, err := buildMultipartMessage(s.from, to, subject, body, attachments) func (s *Sender) SendReport(to, subject, body string, attachment *Attachment) error {
msg, err := buildMultipartMessage(s.from, to, subject, body, attachment)
if err != nil { if err != nil {
log.Printf("ERROR [%s] email: SendReport(%s): build failed: %v", log.Printf("ERROR [%s] email: SendReport(%s): build failed: %v",
time.Now().Format(time.RFC3339), to, err) time.Now().Format(time.RFC3339), to, err)
@ -93,12 +94,8 @@ func (s *Sender) SendReport(to, subject, body string, attachments []*Attachment)
return err return err
} }
names := make([]string, len(attachments))
for i, a := range attachments {
names[i] = a.Filename
}
log.Printf("INFO [%s] email: report sent to %s (%s)", log.Printf("INFO [%s] email: report sent to %s (%s)",
time.Now().Format(time.RFC3339), to, strings.Join(names, ", ")) time.Now().Format(time.RFC3339), to, attachment.Filename)
return nil return nil
} }
@ -187,15 +184,13 @@ func buildPlainMessage(from, to, subject, body string) []byte {
// buildMultipartMessage constructs an RFC 2046 multipart/mixed email with a // buildMultipartMessage constructs an RFC 2046 multipart/mixed email with a
// text/plain body and a single attachment encoded as base64. // text/plain body and a single attachment encoded as base64.
func buildMultipartMessage(from, to, subject, body string, attachments []*Attachment) ([]byte, error) { func buildMultipartMessage(from, to, subject, body string, attachment *Attachment) ([]byte, error) {
var b strings.Builder var b strings.Builder
// Write the main SMTP headers with deliverability improvements. // Write the main SMTP headers.
writeHeader(&b, "From", from) writeHeader(&b, "From", from)
writeHeader(&b, "To", to) writeHeader(&b, "To", to)
writeHeader(&b, "Subject", subject) writeHeader(&b, "Subject", subject)
writeHeader(&b, "Message-ID", fmt.Sprintf("<%d.nextexpense@post.2-4-h.app>", time.Now().UnixNano()))
writeHeader(&b, "Date", time.Now().Format(time.RFC1123Z))
// Create a multipart writer using a unique boundary string. // Create a multipart writer using a unique boundary string.
mw := multipart.NewWriter(&b) mw := multipart.NewWriter(&b)
@ -214,20 +209,18 @@ func buildMultipartMessage(from, to, subject, body string, attachments []*Attach
return nil, fmt.Errorf("writing text part: %w", err) return nil, fmt.Errorf("writing text part: %w", err)
} }
// --- Attachment parts (report + receipt images zip) --- // --- Attachment part ---
for _, att := range attachments { aw, err := mw.CreatePart(attachmentHeader(attachment.Filename))
aw, err := mw.CreatePart(attachmentHeader(att.Filename))
if err != nil { if err != nil {
return nil, fmt.Errorf("creating attachment part %q: %w", att.Filename, err) return nil, fmt.Errorf("creating attachment part: %w", err)
} }
enc := base64.NewEncoder(base64.StdEncoding, aw) enc := base64.NewEncoder(base64.StdEncoding, aw)
if _, err := enc.Write(att.Content); err != nil { if _, err := enc.Write(attachment.Content); err != nil {
enc.Close() enc.Close()
return nil, fmt.Errorf("writing attachment %q: %w", att.Filename, err) return nil, fmt.Errorf("writing attachment content: %w", err)
} }
enc.Close() enc.Close()
}
mw.Close() mw.Close()
@ -275,8 +268,7 @@ func attachmentHeader(filename string) textproto.MIMEHeader {
func attachmentContentType(filename string) string { func attachmentContentType(filename string) string {
switch { switch {
case strings.HasSuffix(strings.ToLower(filename), ".csv"): case strings.HasSuffix(strings.ToLower(filename), ".csv"):
// Some providers block text/csv; use text/plain as fallback. return "text/csv; charset=\"utf-8\""
return "text/plain; charset=\"utf-8\""
case strings.HasSuffix(strings.ToLower(filename), ".pdf"): case strings.HasSuffix(strings.ToLower(filename), ".pdf"):
return "application/pdf" return "application/pdf"
default: default:

View file

@ -1,4 +1,4 @@
// Package handlers implements HTTP handlers for NextExpense, providing // Package handlers implements HTTP handlers for ExpenseFlow, providing
// passwordless email OTP authentication, event management, expense tracking, // passwordless email OTP authentication, event management, expense tracking,
// and report generation endpoints. // and report generation endpoints.
package handlers package handlers
@ -9,15 +9,13 @@ import (
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"os"
"strings" "strings"
"sync"
"time" "time"
"github.com/cclohmar/NextExpense/internal/auth" "github.com/expenseflow/internal/auth"
"github.com/cclohmar/NextExpense/internal/database" "github.com/expenseflow/internal/database"
"github.com/cclohmar/NextExpense/internal/email" "github.com/expenseflow/internal/email"
"github.com/cclohmar/NextExpense/internal/utils" "github.com/expenseflow/internal/utils"
) )
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -37,8 +35,6 @@ type AuthHandler struct {
Sessions *auth.SessionStore Sessions *auth.SessionStore
FailureTracker *auth.FailureTracker FailureTracker *auth.FailureTracker
EmailSender *email.Sender EmailSender *email.Sender
otpMu sync.Mutex // prevents OTP reuse via race conditions
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -48,16 +44,13 @@ type AuthHandler struct {
// LandingPage renders the landing page with the email input form for OTP login. // 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. // It parses templates/index.html and executes it with no template data.
func (h *AuthHandler) LandingPage(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) LandingPage(w http.ResponseWriter, r *http.Request) {
// If the user already has a valid session, redirect to the dashboard. tmpl, err := template.ParseFiles("templates/index.html")
if cookie, err := r.Cookie("session_token"); err == nil && cookie.Value != "" { if err != nil {
if _, ok := h.Sessions.Get(cookie.Value); ok { log.Printf("ERROR [%s] handlers: LandingPage parse template: %v", time.Now().Format(time.RFC3339), err)
w.Header().Set("HX-Redirect", "/dashboard") http.Error(w, "Internal server error", http.StatusInternalServerError)
w.WriteHeader(http.StatusOK)
return return
} }
}
tmpl := getTemplate("index.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, nil); err != nil { if err := tmpl.Execute(w, nil); err != nil {
log.Printf("ERROR [%s] handlers: LandingPage execute template: %v", time.Now().Format(time.RFC3339), err) log.Printf("ERROR [%s] handlers: LandingPage execute template: %v", time.Now().Format(time.RFC3339), err)
@ -94,7 +87,7 @@ func (h *AuthHandler) RequestOTP(w http.ResponseWriter, r *http.Request) {
return return
} }
if user == nil { if user == nil {
userID := utils.NewUUID() userID := utils.New()
if err := database.CreateUser(h.DB, userID, emailAddr); err != nil { 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) log.Printf("ERROR [%s] handlers: RequestOTP CreateUser(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
renderError(w, "An error occurred. Please try again.") renderError(w, "An error occurred. Please try again.")
@ -121,17 +114,12 @@ func (h *AuthHandler) RequestOTP(w http.ResponseWriter, r *http.Request) {
// Deliver OTP via email. Log the error but do not fail the request — // Deliver OTP via email. Log the error but do not fail the request —
// during development the code is visible in server logs. // during development the code is visible in server logs.
if h.EmailSender != nil {
if err := h.EmailSender.SendOTP(emailAddr, code); err != nil { 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) log.Printf("ERROR [%s] handlers: RequestOTP SendOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
} }
} else {
log.Printf("WARN [%s] handlers: RequestOTP(%s): SMTP not configured — OTP code %s not delivered via email",
time.Now().Format(time.RFC3339), emailAddr, code)
}
// Render the OTP verification form as an HTMX fragment. // Render the OTP verification form as an HTMX fragment.
renderOTPForm(w, emailAddr, "") renderOTPForm(w, emailAddr)
} }
// VerifyOTP handles OTP code verification and session creation. // VerifyOTP handles OTP code verification and session creation.
@ -147,7 +135,7 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
otpCode := collectOTP(r) otpCode := collectOTP(r)
if emailAddr == "" || otpCode == "" { if emailAddr == "" || otpCode == "" {
renderOTPForm(w, emailAddr, "Email and OTP code are required.") renderError(w, "Email and OTP code are required.")
return return
} }
@ -155,11 +143,11 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
stored, err := database.GetOTP(h.DB, emailAddr) stored, err := database.GetOTP(h.DB, emailAddr)
if err != nil { if err != nil {
log.Printf("ERROR [%s] handlers: VerifyOTP GetOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err) log.Printf("ERROR [%s] handlers: VerifyOTP GetOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
renderOTPForm(w, emailAddr, "An error occurred. Please try again.") renderError(w, "An error occurred. Please try again.")
return return
} }
if stored == nil { if stored == nil {
renderOTPForm(w, emailAddr, "No OTP found for this email. Please request a new code.") renderError(w, "No OTP found for this email. Please request a new code.")
return return
} }
@ -167,31 +155,29 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
expiresAt, err := time.Parse(time.RFC3339, stored.ExpiresAt) expiresAt, err := time.Parse(time.RFC3339, stored.ExpiresAt)
if err != nil { if err != nil {
log.Printf("ERROR [%s] handlers: VerifyOTP parse expiry(%s): %v", time.Now().Format(time.RFC3339), stored.ExpiresAt, err) log.Printf("ERROR [%s] handlers: VerifyOTP parse expiry(%s): %v", time.Now().Format(time.RFC3339), stored.ExpiresAt, err)
renderOTPForm(w, emailAddr, "An error occurred. Please try again.") renderError(w, "An error occurred. Please try again.")
return return
} }
// Validate + delete OTP atomically to prevent race-condition reuse. // Validate the OTP code and expiry.
h.otpMu.Lock()
if !auth.ValidateOTP(otpCode, stored.OTPCode, expiresAt) { if !auth.ValidateOTP(otpCode, stored.OTPCode, expiresAt) {
h.otpMu.Unlock()
h.FailureTracker.RecordFailure(emailAddr) h.FailureTracker.RecordFailure(emailAddr)
renderOTPForm(w, emailAddr, "Invalid or expired OTP code. Please try again.") renderError(w, "Invalid or expired OTP code. Please try again.")
return return
} }
// Successful verification: delete OTP immediately (still under lock). // Successful verification: clean up and create session.
h.FailureTracker.Reset(emailAddr) h.FailureTracker.Reset(emailAddr)
if err := database.DeleteOTP(h.DB, emailAddr); err != nil { 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) log.Printf("ERROR [%s] handlers: VerifyOTP DeleteOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
// Non-fatal — the OTP is already validated.
} }
h.otpMu.Unlock()
// Retrieve the user record to obtain the user ID. // Retrieve the user record to obtain the user ID.
user, err := database.GetUserByEmail(h.DB, emailAddr) user, err := database.GetUserByEmail(h.DB, emailAddr)
if err != nil || user == nil { if err != nil || user == nil {
log.Printf("ERROR [%s] handlers: VerifyOTP GetUserByEmail(%s): err=%v", time.Now().Format(time.RFC3339), emailAddr, err) log.Printf("ERROR [%s] handlers: VerifyOTP GetUserByEmail(%s): err=%v", time.Now().Format(time.RFC3339), emailAddr, err)
renderOTPForm(w, emailAddr, "An error occurred. Please try again.") renderError(w, "An error occurred. Please try again.")
return return
} }
@ -199,28 +185,22 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
token, err := h.Sessions.Generate(user.ID) token, err := h.Sessions.Generate(user.ID)
if err != nil { if err != nil {
log.Printf("ERROR [%s] handlers: VerifyOTP Session Generate(%s): %v", time.Now().Format(time.RFC3339), user.ID, err) log.Printf("ERROR [%s] handlers: VerifyOTP Session Generate(%s): %v", time.Now().Format(time.RFC3339), user.ID, err)
renderOTPForm(w, emailAddr, "An error occurred. Please try again.") renderError(w, "An error occurred. Please try again.")
return return
} }
// Set the session cookie (HttpOnly, SameSite=Lax, Secure, 24h). // Set the HTTP-only session cookie with a 24-hour TTL.
secure := strings.HasPrefix(os.Getenv("BASE_URL"), "https://")
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "session_token", Name: "session_token",
Value: token, Value: token,
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
Secure: secure,
Expires: time.Now().Add(24 * time.Hour), Expires: time.Now().Add(24 * time.Hour),
}) })
// Redirect to the appropriate page — onboarding if first login, dashboard otherwise. // Redirect to the dashboard via HTMX.
if user.Onboarded {
w.Header().Set("HX-Redirect", "/dashboard") w.Header().Set("HX-Redirect", "/dashboard")
} else {
w.Header().Set("HX-Redirect", "/onboarding")
}
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
@ -266,15 +246,38 @@ func getUserID(r *http.Request) string {
// renderError writes an HTMX-compatible HTML error fragment to the response. // renderError writes an HTMX-compatible HTML error fragment to the response.
func renderError(w http.ResponseWriter, message string) { func renderError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="error-message" style="color: #fca5a5; margin-bottom: 1rem;">%s</div>`, template.HTMLEscapeString(message)) 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 // renderOTPForm writes the OTP verification form partial as an HTMX fragment.
// using the cached otp_form template from templates.go. // It renders 6 individual digit input boxes for a better mobile UX, plus a
func renderOTPForm(w http.ResponseWriter, email string, errMsg string) { // hidden email field. The handler combines the 6 digits server-side.
tmpl := getTemplate("otp_form") 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") w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, map[string]string{"Email": email, "Error": errMsg}); err != nil { 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) log.Printf("ERROR [%s] handlers: renderOTPForm execute: %v", time.Now().Format(time.RFC3339), err)
} }
} }
@ -282,171 +285,14 @@ func renderOTPForm(w http.ResponseWriter, email string, errMsg string) {
// collectOTP reads the 6 individual digit form values and concatenates them // 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 // into a single 6-character OTP code string. Returns an empty string if any
// digit is missing. // digit is missing.
// Logout clears the session cookie and invalidates the server-side session.
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
// Invalidate the server-side session.
if cookie, err := r.Cookie("session_token"); err == nil && cookie.Value != "" {
h.Sessions.Delete(cookie.Value)
}
// Clear the cookie on the client side.
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusOK)
}
func collectOTP(r *http.Request) string { func collectOTP(r *http.Request) string {
code := r.FormValue("otp_code") var b strings.Builder
// Strip any non-digit characters (paste may include spaces/dashes). for i := 0; i < 6; i++ {
code = strings.Map(func(r rune) rune { digit := r.FormValue(fmt.Sprintf("digit_%d", i))
if r >= '0' && r <= '9' { if digit == "" {
return r
}
return -1
}, code)
if len(code) != 6 {
return "" return ""
} }
return code b.WriteString(digit)
} }
return b.String()
// ---------------------------------------------------------------------------
// Onboarding handlers
// ---------------------------------------------------------------------------
// OnboardingPage renders the onboarding form that captures the user's name
// and department for report personalisation. Only shown on first login.
func (h *AuthHandler) OnboardingPage(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusUnauthorized)
return
}
// If already onboarded, redirect to dashboard.
user, _ := database.GetUserByID(h.DB, userID)
if user != nil && user.Onboarded {
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
return
}
tmpl := getTemplate("onboarding.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, nil); err != nil {
log.Printf("ERROR [%s] handlers: OnboardingPage: execute template: %v", time.Now().Format(time.RFC3339), err)
}
}
// SaveOnboarding saves the user's name and department and marks onboarding
// as complete, then redirects to the dashboard.
func (h *AuthHandler) SaveOnboarding(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusUnauthorized)
return
}
if err := r.ParseForm(); err != nil {
renderOnboardingError(w, "Cannot parse form data.")
return
}
name := strings.TrimSpace(r.FormValue("name"))
department := strings.TrimSpace(r.FormValue("department"))
if name == "" {
renderOnboardingError(w, "Name is required.")
return
}
if department == "" {
department = "-"
}
if err := database.UpdateUserOnboarding(h.DB, userID, name, department); err != nil {
renderOnboardingError(w, "Failed to save. Please try again.")
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// Profile handlers
// ---------------------------------------------------------------------------
// ProfilePage renders the profile editor with the user's current name and
// department pre-filled. Requires onboarding to be completed first.
func (h *AuthHandler) ProfilePage(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusUnauthorized)
return
}
user, _ := database.GetUserByID(h.DB, userID)
if user == nil || !user.Onboarded {
w.Header().Set("HX-Redirect", "/onboarding")
w.WriteHeader(http.StatusOK)
return
}
tmpl := getTemplate("onboarding.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, map[string]string{
"Name": user.Name,
"Department": user.Department,
"Editing": "true",
}); err != nil {
log.Printf("ERROR [%s] handlers: ProfilePage: execute template: %v", time.Now().Format(time.RFC3339), err)
}
}
// SaveProfile updates the user's name and department, then redirects to the
// dashboard. Reuses the same DB call as onboarding.
func (h *AuthHandler) SaveProfile(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusUnauthorized)
return
}
if err := r.ParseForm(); err != nil {
renderOnboardingError(w, "Cannot parse form data.")
return
}
name := strings.TrimSpace(r.FormValue("name"))
department := strings.TrimSpace(r.FormValue("department"))
if name == "" {
renderOnboardingError(w, "Name is required.")
return
}
if department == "" {
department = "-"
}
if err := database.UpdateUserOnboarding(h.DB, userID, name, department); err != nil {
renderOnboardingError(w, "Failed to save. Please try again.")
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
func renderOnboardingError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div id="onboarding-error" style="background: #450a0a; border: 1px solid #7f1d1d; color: #fca5a5; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">%s</div>`,
template.HTMLEscapeString(message))
} }

View file

@ -1,7 +1,7 @@
// Package handlers provides HTTP request handlers for NextExpense. // Package handlers provides HTTP request handlers for ExpenseFlow.
// //
// This file implements event management endpoints including event creation, // This file implements event management endpoints including dashboard
// reopening, closing, and expense viewing — all scoped under a parent month. // listing, event creation, reopening, and expense viewing.
package handlers package handlers
import ( import (
@ -10,13 +10,10 @@ import (
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath"
"strconv"
"time" "time"
"github.com/cclohmar/NextExpense/internal/database" "github.com/expenseflow/internal/database"
"github.com/cclohmar/NextExpense/internal/utils" "github.com/expenseflow/internal/utils"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@ -36,218 +33,201 @@ func NewEventHandler(db *sql.DB) *EventHandler {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /months/{mid}/events — CreateEvent // GET /dashboard — Dashboard
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CreateEvent handles the creation of a new event under a given month. // Dashboard renders the main dashboard page showing all events belonging
func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { // to the authenticated user, along with the new event creation form.
monthID := chi.URLParam(r, "mid") func (h *EventHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
if monthID == "" {
http.Error(w, "Missing month ID", http.StatusBadRequest)
return
}
userID := getUserID(r) userID := getUserID(r)
if userID == "" { if userID == "" {
log.Printf("ERROR [%s] handlers: Dashboard: missing user ID", time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
// Verify month ownership. events, err := database.GetEventsByUser(h.DB, userID)
month, err := database.GetMonthByID(h.DB, monthID) if err != nil {
if err != nil || month == nil || month.UserID != userID { log.Printf("ERROR [%s] handlers: Dashboard: GetEventsByUser: %v",
http.Error(w, "Forbidden", http.StatusForbidden) 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 return
} }
name := r.FormValue("name") name := r.FormValue("name")
if 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) http.Error(w, "Event name is required", http.StatusBadRequest)
return return
} }
baseCurrency := r.FormValue("base_currency") id := utils.New()
if baseCurrency == "" { if err := database.CreateEvent(h.DB, id, userID, name); err != nil {
baseCurrency = "USD"
}
// Compute exchange rate from user-provided sample.
exchangeRate := 1.0
sampleReceipt := r.FormValue("sample_receipt_amount")
sampleClaim := r.FormValue("sample_claim_amount")
if sampleReceipt != "" && sampleClaim != "" {
sampleReceiptVal, err1 := strconv.ParseFloat(sampleReceipt, 64)
sampleClaimVal, err2 := strconv.ParseFloat(sampleClaim, 64)
if err1 == nil && err2 == nil && sampleReceiptVal > 0 && sampleClaimVal > 0 {
exchangeRate = sampleClaimVal / sampleReceiptVal
}
}
id := utils.NewUUID()
if err := database.CreateEvent(h.DB, id, userID, monthID, name, baseCurrency, exchangeRate); err != nil {
log.Printf("ERROR [%s] handlers: CreateEvent: %v", log.Printf("ERROR [%s] handlers: CreateEvent: %v",
time.Now().Format(time.RFC3339), err) time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to create event", http.StatusInternalServerError) http.Error(w, "Failed to create event", http.StatusInternalServerError)
return return
} }
w.Header().Set("HX-Redirect", "/months/"+monthID) w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// PUT /months/{mid}/events/{eid}/reopen — ReopenEvent // PUT /events/{id}/reopen — ReopenEvent
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ReopenEvent sets an event's status back to "open". Verifies month and event ownership. // 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) { func (h *EventHandler) ReopenEvent(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid") eventID := chi.URLParam(r, "id")
eventID := chi.URLParam(r, "eid") if eventID == "" {
if monthID == "" || eventID == "" { log.Printf("ERROR [%s] handlers: ReopenEvent: missing event ID",
http.Error(w, "Missing ID", http.StatusBadRequest) time.Now().Format(time.RFC3339))
http.Error(w, "Missing event ID", http.StatusBadRequest)
return return
} }
userID := getUserID(r) userID := getUserID(r)
if userID == "" { if userID == "" {
log.Printf("ERROR [%s] handlers: ReopenEvent: missing user ID",
time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
// Verify month ownership. event, err := database.GetEventByID(h.DB, eventID)
month, err := database.GetMonthByID(h.DB, monthID) if err != nil {
if err != nil || month == nil || month.UserID != userID { log.Printf("ERROR [%s] handlers: ReopenEvent: GetEventByID(%s): %v",
http.Error(w, "Forbidden", http.StatusForbidden) time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
return return
} }
if event == nil {
event, err := database.GetEventByID(h.DB, eventID) log.Printf("ERROR [%s] handlers: ReopenEvent: event %s not found",
if err != nil || event == nil { time.Now().Format(time.RFC3339), eventID)
http.Error(w, "Event not found", http.StatusNotFound) http.Error(w, "Event not found", http.StatusNotFound)
return return
} }
if event.MonthID != monthID || event.UserID != userID { 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) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }
if err := database.UpdateEventStatus(h.DB, eventID, "open"); err != nil { if err := database.UpdateEventStatus(h.DB, eventID, "open"); err != nil {
log.Printf("ERROR [%s] handlers: ReopenEvent: %v", time.Now().Format(time.RFC3339), err) 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) http.Error(w, "Failed to reopen event", http.StatusInternalServerError)
return return
} }
// Clean up stale download packages. // Return HTMX fragment: green "open" badge targeting #status-badge-{id}.
if oldFiles, err := database.DeleteDownloadTokensByEvent(h.DB, eventID); err == nil { w.Header().Set("Content-Type", "text/html; charset=utf-8")
for _, fn := range oldFiles { 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)
os.Remove(filepath.Join("storage", "postbox", fn))
}
}
w.Header().Set("HX-Redirect", "/months/"+monthID)
w.WriteHeader(http.StatusOK)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /months/{mid}/events/{eid}/close — CloseEvent // GET /events/{id}/expenses — ViewEventExpenses
// ---------------------------------------------------------------------------
// CloseEvent sets an event's status to "closed".
func (h *EventHandler) CloseEvent(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
if monthID == "" || eventID == "" {
http.Error(w, "Missing ID", http.StatusBadRequest)
return
}
userID := getUserID(r)
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Verify month ownership.
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil {
http.Error(w, "Event not found", http.StatusNotFound)
return
}
if event.MonthID != monthID || event.UserID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil {
log.Printf("ERROR [%s] handlers: CloseEvent: %v", time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to close event", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/months/"+monthID)
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// GET /months/{mid}/events/{eid}/expenses — ViewEventExpenses
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ViewEventExpenses displays the expense collection view for a specific event. // 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) { func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid") eventID := chi.URLParam(r, "id")
eventID := chi.URLParam(r, "eid") if eventID == "" {
if monthID == "" || eventID == "" { log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing event ID",
http.Error(w, "Missing ID", http.StatusBadRequest) time.Now().Format(time.RFC3339))
http.Error(w, "Missing event ID", http.StatusBadRequest)
return return
} }
userID := getUserID(r) userID := getUserID(r)
if userID == "" { if userID == "" {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing user ID",
time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
// Verify month ownership. event, err := database.GetEventByID(h.DB, eventID)
month, err := database.GetMonthByID(h.DB, monthID) if err != nil {
if err != nil || month == nil || month.UserID != userID { log.Printf("ERROR [%s] handlers: ViewEventExpenses: GetEventByID(%s): %v",
http.Error(w, "Forbidden", http.StatusForbidden) time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
return return
} }
if event == nil {
event, err := database.GetEventByID(h.DB, eventID) log.Printf("ERROR [%s] handlers: ViewEventExpenses: event %s not found",
if err != nil || event == nil { time.Now().Format(time.RFC3339), eventID)
http.Error(w, "Event not found", http.StatusNotFound) http.Error(w, "Event not found", http.StatusNotFound)
return return
} }
if event.MonthID != monthID || event.UserID != userID { 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) http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }
expenses, err := database.GetExpensesByEvent(h.DB, eventID) expenses, err := database.GetExpensesByEvent(h.DB, eventID)
if err != nil { if err != nil {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: %v", time.Now().Format(time.RFC3339), err) 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) http.Error(w, "Failed to load expenses", http.StatusInternalServerError)
return return
} }
// Set current_event_id cookie for expense operations. // Set the current_event_id cookie so subsequent expense operations
// (SaveExpense, UploadReceipt) know which event to associate with.
setCurrentEventID(w, eventID) setCurrentEventID(w, eventID)
tmpl := getTemplate("event_expenses.html") tmpl, err := template.ParseFiles("templates/event_expenses.html")
if err != nil {
for i := range expenses { log.Printf("ERROR [%s] handlers: ViewEventExpenses: parse template: %v",
expenses[i].ImagePath = normalizeImagePath(expenses[i].ImagePath) time.Now().Format(time.RFC3339), err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
} }
data := map[string]interface{}{ data := map[string]interface{}{
"Month": month,
"Event": event, "Event": event,
"Expenses": expenses, "Expenses": expenses,
} }
@ -258,145 +238,3 @@ func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request)
time.Now().Format(time.RFC3339), err) time.Now().Format(time.RFC3339), err)
} }
} }
// ---------------------------------------------------------------------------
// GET /months/{mid}/events/{eid}/edit — EditEvent
// ---------------------------------------------------------------------------
// EditEvent returns the event edit form fragment pre-filled with current data.
func (h *EventHandler) EditEvent(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
rcptCur := event.BaseCurrency
if rcptCur == "" {
rcptCur = "KES"
}
sampleClm := event.ExchangeRate * 1000
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="card" style="padding: 1rem;">
<h3 style="margin-bottom: 1rem;">Edit Event</h3>
<form hx-put="/months/%s/events/%s" hx-target="body" hx-push-url="true">
<div class="form-group">
<label class="form-label">Event</label>
<input type="text" name="name" value="%s" placeholder="Event name">
</div>
<div style="background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; padding: 1rem; margin-bottom: 0.5rem;">
<div style="font-size: 0.8rem; font-weight: 600; color: #6ee7b7; margin-bottom: 0.75rem;">Conversion Rate</div>
<div class="form-row" style="gap: 1rem;">
<div style="flex: 1;">
<label class="form-label">Claim Amount</label>
<input type="number" name="sample_claim_amount" value="%.2f" step="0.01" min="0.01" required>
</div>
<div style="flex: 0 0 80px;">
<label class="form-label">Currency</label>
<input type="text" name="base_currency" value="%s" required maxlength="3" style="text-transform: uppercase;">
</div>
</div>
<div class="form-row" style="gap: 1rem; margin-top: 0.5rem;">
<div style="flex: 1;">
<label class="form-label">Local Amount</label>
<input type="number" name="sample_receipt_amount" value="1000" step="0.01" min="0.01" required>
</div>
<div style="flex: 0 0 80px;">
<label class="form-label">Currency</label>
<input type="text" name="sample_receipt_currency" value="%s" required maxlength="3" style="text-transform: uppercase;">
</div>
</div>
<div style="font-size: 0.7rem; color: var(--color-text-muted); margin-top: 0.5rem;">
Rate = Claim / Local
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">Save Changes</button>
<div style="display:flex; gap:0.5rem; margin-top:0.5rem;">
<button type="button" class="btn btn-secondary" style="flex:1; text-align:center;"
onclick="document.getElementById('create-event-form').innerHTML='';document.getElementById('create-event-form').classList.add('hidden')">Cancel</button>
<button type="button" class="btn btn-secondary" style="flex:1; text-align:center; color:#fca5a5; border-color:#7f1d1d;"
onclick="if(confirm('Delete this event and all its receipts?')){htmx.trigger('#delete-event-%s','click')}">Delete</button>
<div hx-delete="/months/%s/events/%s" hx-target="body" hx-push-url="true" id="delete-event-%s" style="display:none"></div>
</div>
</form>
</div>`, monthID, event.ID, template.HTMLEscapeString(event.Name), sampleClm, template.HTMLEscapeString(event.BaseCurrency), template.HTMLEscapeString(rcptCur), event.ID, monthID, event.ID, event.ID)
}
// ---------------------------------------------------------------------------
// PUT /months/{mid}/events/{eid} — UpdateEvent
// ---------------------------------------------------------------------------
// UpdateEvent updates the event's name, base currency and exchange rate.
func (h *EventHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
name := r.FormValue("name")
if name == "" {
name = event.Name
}
baseCurrency := r.FormValue("base_currency")
if baseCurrency == "" {
baseCurrency = "USD"
}
exchangeRate := 1.0
sampleReceipt := r.FormValue("sample_receipt_amount")
sampleClaim := r.FormValue("sample_claim_amount")
if sampleReceipt != "" && sampleClaim != "" {
sampleReceiptVal, err1 := strconv.ParseFloat(sampleReceipt, 64)
sampleClaimVal, err2 := strconv.ParseFloat(sampleClaim, 64)
if err1 == nil && err2 == nil && sampleReceiptVal > 0 && sampleClaimVal > 0 {
exchangeRate = sampleClaimVal / sampleReceiptVal
}
}
if err := database.UpdateEvent(h.DB, eventID, name, baseCurrency, exchangeRate); err != nil {
log.Printf("ERROR [%s] handlers: UpdateEvent: %v", time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to update event", http.StatusInternalServerError)
return
}
if err := database.RecalculateExpenses(h.DB, eventID, baseCurrency, exchangeRate); err != nil {
log.Printf("ERROR [%s] handlers: UpdateEvent: recalc expenses: %v", time.Now().Format(time.RFC3339), err)
}
w.Header().Set("HX-Redirect", "/months/"+monthID)
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// DELETE /months/{mid}/events/{eid} — DeleteEvent
// ---------------------------------------------------------------------------
// DeleteEvent removes an event and its expenses after ownership verification.
func (h *EventHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := database.DeleteEvent(h.DB, eventID); err != nil {
log.Printf("ERROR [%s] handlers: DeleteEvent(%s): %v", time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to delete event", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/months/"+monthID)
w.WriteHeader(http.StatusOK)
}

View file

@ -1,36 +1,27 @@
// Package handlers provides HTTP request handlers for NextExpense. // Package handlers provides HTTP request handlers for ExpenseFlow.
// //
// This file implements expense upload, AI extraction, and save handlers // This file implements expense upload, AI extraction, and save handlers
// that drive the core receipt capture workflow using HTMX partial responses. // that drive the core receipt capture workflow using HTMX partial responses.
package handlers package handlers
import ( import (
"bytes"
"database/sql" "database/sql"
"fmt" "fmt"
"html/template" "html/template"
"image"
"image/jpeg"
"io" "io"
"log" "log"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/expenseflow/internal/ai"
"golang.org/x/image/draw" "github.com/expenseflow/internal/database"
"github.com/expenseflow/internal/utils"
"github.com/cclohmar/NextExpense/internal/ai"
"github.com/cclohmar/NextExpense/internal/database"
"github.com/cclohmar/NextExpense/internal/utils"
) )
var uuidRe = regexp.MustCompile(`^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$`)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ExpenseHandler // ExpenseHandler
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -61,10 +52,10 @@ func NewExpenseHandler(db *sql.DB) *ExpenseHandler {
// 5. Render templates/receipt_form.html with pre-filled fields or error banner // 5. Render templates/receipt_form.html with pre-filled fields or error banner
func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) { func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
// 1. Parse multipart form with 10 MB max memory. // 1. Parse multipart form with 10 MB max memory.
if err := r.ParseMultipartForm(11 << 20); err != nil { if err := r.ParseMultipartForm(10 << 20); err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: parse form: %v", log.Printf("ERROR [%s] handlers: UploadReceipt: parse form: %v",
time.Now().Format(time.RFC3339), err) time.Now().Format(time.RFC3339), err)
renderUploadError(w, "Failed to parse upload form.") http.Error(w, "Failed to parse upload form", http.StatusBadRequest)
return return
} }
defer r.MultipartForm.RemoveAll() defer r.MultipartForm.RemoveAll()
@ -74,7 +65,7 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: missing receipt field: %v", log.Printf("ERROR [%s] handlers: UploadReceipt: missing receipt field: %v",
time.Now().Format(time.RFC3339), err) time.Now().Format(time.RFC3339), err)
renderUploadError(w, "Missing receipt file.") http.Error(w, "Missing receipt file", http.StatusBadRequest)
return return
} }
defer file.Close() defer file.Close()
@ -83,7 +74,7 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
if header.Size > 10<<20 { if header.Size > 10<<20 {
log.Printf("ERROR [%s] handlers: UploadReceipt: file too large: %d bytes", log.Printf("ERROR [%s] handlers: UploadReceipt: file too large: %d bytes",
time.Now().Format(time.RFC3339), header.Size) time.Now().Format(time.RFC3339), header.Size)
renderUploadError(w, "File too large. Maximum size is 10 MB.") http.Error(w, "File too large. Maximum size is 10 MB.", http.StatusBadRequest)
return return
} }
@ -92,38 +83,27 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: read file: %v", log.Printf("ERROR [%s] handlers: UploadReceipt: read file: %v",
time.Now().Format(time.RFC3339), err) time.Now().Format(time.RFC3339), err)
renderUploadError(w, "Failed to read uploaded file.") http.Error(w, "Failed to read uploaded file", http.StatusInternalServerError)
return return
} }
// 5. Validate content type by inspecting magic bytes. // 5. Validate content type by inspecting magic bytes.
ext := detectImageExtension(fileData) ext := detectImageExtension(fileData)
if ext == "" { if ext == "" {
log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported file type: %q", log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported image type",
time.Now().Format(time.RFC3339), ext) time.Now().Format(time.RFC3339))
renderUploadError(w, "Unsupported file format. Please upload a receipt image (JPEG, PNG, HEIC) or PDF.") http.Error(w, "Only JPEG and PNG images are supported", http.StatusBadRequest)
return return
} }
// Resize the image (max 2048px, JPEG 85%) to keep attachment sizes manageable
// and prevent SMTP size-limit rejections when filing events with many receipts.
resized, resizeErr := resizeImage(fileData)
if resizeErr == nil && len(resized) > 0 {
fileData = resized
// If the original was not JPEG, update extension since output is always JPEG.
if ext != "jpg" && ext != "jpeg" {
ext = "jpg"
}
}
// 6. Generate a UUID-based filename and ensure the storage directory exists. // 6. Generate a UUID-based filename and ensure the storage directory exists.
filename := utils.NewUUID() + "." + ext filename := utils.New() + "." + ext
storagePath := filepath.Join("storage", filename) storagePath := filepath.Join("storage", filename)
if err := os.MkdirAll("storage", 0755); err != nil { if err := os.MkdirAll("storage", 0755); err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: mkdir storage: %v", log.Printf("ERROR [%s] handlers: UploadReceipt: mkdir storage: %v",
time.Now().Format(time.RFC3339), err) time.Now().Format(time.RFC3339), err)
renderUploadError(w, "Server error. Please try again.") http.Error(w, "Server error", http.StatusInternalServerError)
return return
} }
@ -131,35 +111,21 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
if err := os.WriteFile(storagePath, fileData, 0644); err != nil { if err := os.WriteFile(storagePath, fileData, 0644); err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: write file: %v", log.Printf("ERROR [%s] handlers: UploadReceipt: write file: %v",
time.Now().Format(time.RFC3339), err) time.Now().Format(time.RFC3339), err)
renderUploadError(w, "Failed to save receipt image. Please try again.") http.Error(w, "Failed to save receipt image", http.StatusInternalServerError)
return return
} }
// 8. Fetch the event's base currency and exchange rate. // 8. Call the DeepSeek Vision API for AI extraction.
eventID := getCurrentEventID(r) receipt, aiErr := ai.ExtractReceipt(storagePath)
var baseCurrency string
var exchangeRate float64
if eventID != "" {
if event, err := database.GetEventByID(h.DB, eventID); err == nil && event != nil {
baseCurrency = event.BaseCurrency
exchangeRate = event.ExchangeRate
}
}
if baseCurrency == "" {
baseCurrency = "EUR"
}
if exchangeRate <= 0 {
exchangeRate = 1.0
}
// 9. Call the Gemini Vision API for AI extraction (uses full disk path). // 9. Render the receipt_form.html fragment.
receipt, aiErr := ai.ExtractReceipt(filepath.Join("storage", filename)) tmpl, err := template.ParseFiles("templates/receipt_form.html")
if err != nil {
// Strip the storage/ prefix so the template can build a proper URL: /storage/{file} log.Printf("ERROR [%s] handlers: UploadReceipt: parse template: %v",
storagePath = filename time.Now().Format(time.RFC3339), err)
http.Error(w, "Template error", http.StatusInternalServerError)
// 10. Render the receipt_form.html fragment. return
tmpl := getTemplate("receipt_form.html") }
data := map[string]interface{}{ data := map[string]interface{}{
"ImagePath": storagePath, "ImagePath": storagePath,
@ -170,9 +136,6 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
"Category": "", "Category": "",
"Date": "", "Date": "",
"Description": "", "Description": "",
"BaseCurrency": baseCurrency,
"ExchangeRate": exchangeRate,
"ConvertedAmount": "",
} }
if aiErr != nil { if aiErr != nil {
@ -185,12 +148,6 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
data["Merchant"] = receipt.Merchant data["Merchant"] = receipt.Merchant
data["Category"] = receipt.Category data["Category"] = receipt.Category
data["Date"] = receipt.Date data["Date"] = receipt.Date
// Compute converted amount only if currencies differ.
if receipt.Currency != "" && receipt.Currency != baseCurrency && receipt.Amount > 0 {
converted := receipt.Amount * exchangeRate
data["ConvertedAmount"] = strconv.FormatFloat(converted, 'f', 2, 64)
}
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@ -240,10 +197,6 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
description := r.FormValue("description") description := r.FormValue("description")
imagePath := r.FormValue("image_path") imagePath := r.FormValue("image_path")
// Read conversion fields (hidden fields from receipt form).
baseCurrency := r.FormValue("base_currency")
convertedAmountStr := r.FormValue("converted_amount")
// 3. Validate required fields. // 3. Validate required fields.
var missing []string var missing []string
if amountStr == "" { if amountStr == "" {
@ -261,9 +214,6 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
if date == "" { if date == "" {
missing = append(missing, "date") missing = append(missing, "date")
} }
if description == "" {
missing = append(missing, "description")
}
if len(missing) > 0 { if len(missing) > 0 {
log.Printf("ERROR [%s] handlers: SaveExpense: missing fields: %s", log.Printf("ERROR [%s] handlers: SaveExpense: missing fields: %s",
time.Now().Format(time.RFC3339), strings.Join(missing, ", ")) time.Now().Format(time.RFC3339), strings.Join(missing, ", "))
@ -281,37 +231,12 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
return return
} }
// Parse converted amount (optional).
convertedAmount := 0.0
if convertedAmountStr != "" {
convertedAmount, _ = strconv.ParseFloat(convertedAmountStr, 64)
}
// Fetch the event to get its exchange rate for auto-calculation.
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Auto-calculate conversion.
if baseCurrency == "" || baseCurrency == currency {
baseCurrency = event.BaseCurrency
}
if convertedAmount <= 0 && baseCurrency != currency {
convertedAmount = amount * event.ExchangeRate
} else if convertedAmount <= 0 {
convertedAmount = amount
}
// 5. Build and save the expense record. // 5. Build and save the expense record.
expense := database.Expense{ expense := database.Expense{
ID: utils.NewUUID(), ID: utils.New(),
EventID: eventID, EventID: eventID,
Amount: amount, Amount: amount,
Currency: currency, Currency: currency,
ConvertedAmount: convertedAmount,
BaseCurrency: baseCurrency,
Merchant: merchant, Merchant: merchant,
Category: category, Category: category,
Description: description, Description: description,
@ -336,11 +261,13 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
} }
// 7. Render the expense_list.html fragment. // 7. Render the expense_list.html fragment.
// Normalize ImagePath for old DB entries that may have storage/ prefix. listTmpl, err := template.ParseFiles("templates/expense_list.html")
for i := range expenses { if err != nil {
expenses[i].ImagePath = normalizeImagePath(expenses[i].ImagePath) log.Printf("ERROR [%s] handlers: SaveExpense: parse list template: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Template error", http.StatusInternalServerError)
return
} }
listTmpl := getTemplate("expense_list.html")
var listBuf strings.Builder var listBuf strings.Builder
if err := listTmpl.Execute(&listBuf, map[string]interface{}{ if err := listTmpl.Execute(&listBuf, map[string]interface{}{
@ -360,243 +287,6 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `<div id="expense-list" hx-swap-oob="true">%s</div>`, listBuf.String()) fmt.Fprintf(w, `<div id="expense-list" hx-swap-oob="true">%s</div>`, listBuf.String())
} }
// ---------------------------------------------------------------------------
// GET /expenses/{id}/edit — EditExpense
// ---------------------------------------------------------------------------
// EditExpense returns the receipt form pre-filled with an existing expense's
// data, allowing the user to edit and re-save it.
func (h *ExpenseHandler) EditExpense(w http.ResponseWriter, r *http.Request) {
expenseID := chi.URLParam(r, "id")
if expenseID == "" {
http.Error(w, "Missing expense ID", http.StatusBadRequest)
return
}
expense, err := database.GetExpenseByID(h.DB, expenseID)
if err != nil {
log.Printf("ERROR [%s] handlers: EditExpense: GetExpenseByID(%s): %v",
time.Now().Format(time.RFC3339), expenseID, err)
http.Error(w, "Failed to retrieve expense", http.StatusInternalServerError)
return
}
if expense == nil {
http.Error(w, "Expense not found", http.StatusNotFound)
return
}
// Verify ownership: expense → event → month → user.
event, err := database.GetEventByID(h.DB, expense.EventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if !verifyMonthOwnership(h.DB, event.MonthID, getUserID(r)) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
tmpl := getTemplate("receipt_form.html")
data := map[string]interface{}{
"ImagePath": normalizeImagePath(expense.ImagePath),
"AIError": "",
"Amount": strconv.FormatFloat(expense.Amount, 'f', 2, 64),
"Currency": expense.Currency,
"Merchant": expense.Merchant,
"Category": expense.Category,
"Date": expense.Date,
"Description": expense.Description,
"BaseCurrency": event.BaseCurrency,
"ExchangeRate": event.ExchangeRate,
"ConvertedAmount": strconv.FormatFloat(expense.ConvertedAmount, 'f', 2, 64),
"EditID": expense.ID,
"ID": expense.ID,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: EditExpense: execute template: %v",
time.Now().Format(time.RFC3339), err)
}
}
// ---------------------------------------------------------------------------
// PUT /expenses/{id} — UpdateExpense
// ---------------------------------------------------------------------------
// UpdateExpense updates an existing expense record with form data and returns
// the updated expense list via HTMX multi-target response.
func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
expenseID := chi.URLParam(r, "id")
if expenseID == "" {
http.Error(w, "Missing expense ID", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
log.Printf("ERROR [%s] handlers: UpdateExpense: parse form: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
return
}
amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64)
convertedAmount, _ := strconv.ParseFloat(r.FormValue("converted_amount"), 64)
baseCurrency := r.FormValue("base_currency")
currency := r.FormValue("currency")
// Fetch the existing expense to preserve the event_id and image_path.
existing, err := database.GetExpenseByID(h.DB, expenseID)
if err != nil || existing == nil {
log.Printf("ERROR [%s] handlers: UpdateExpense: get existing: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Expense not found", http.StatusNotFound)
return
}
// Verify ownership: expense → event → month → user.
event, err := database.GetEventByID(h.DB, existing.EventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if !verifyMonthOwnership(h.DB, event.MonthID, getUserID(r)) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Auto-calculate conversion.
if baseCurrency == "" {
baseCurrency = event.BaseCurrency
}
if convertedAmount <= 0 && baseCurrency != currency {
convertedAmount = amount * event.ExchangeRate
} else if convertedAmount <= 0 {
convertedAmount = amount
}
expense := database.Expense{
ID: expenseID,
EventID: existing.EventID,
Amount: amount,
Currency: r.FormValue("currency"),
ConvertedAmount: convertedAmount,
BaseCurrency: baseCurrency,
Merchant: r.FormValue("merchant"),
Category: r.FormValue("category"),
Description: r.FormValue("description"),
Date: r.FormValue("date"),
ImagePath: existing.ImagePath,
}
if err := database.UpdateExpense(h.DB, expense); err != nil {
log.Printf("ERROR [%s] handlers: UpdateExpense: %v", time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to update expense", http.StatusInternalServerError)
return
}
// Return updated expense list via HTMX.
expenses, err := database.GetExpensesByEvent(h.DB, existing.EventID)
if err != nil {
log.Printf("ERROR [%s] handlers: UpdateExpense: fetch expenses: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to fetch expenses", http.StatusInternalServerError)
return
}
// Normalize ImagePath for old DB entries.
for i := range expenses {
expenses[i].ImagePath = normalizeImagePath(expenses[i].ImagePath)
}
listTmpl := getTemplate("expense_list.html")
var listBuf strings.Builder
if err := listTmpl.Execute(&listBuf, map[string]interface{}{"Expenses": expenses}); err != nil {
log.Printf("ERROR [%s] handlers: UpdateExpense: execute template: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
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 updated successfully!</div></div>`)
fmt.Fprintf(w, `<div id="expense-list" hx-swap-oob="true">%s</div>`, listBuf.String())
}
// ---------------------------------------------------------------------------
// DELETE /expenses/{id} — DeleteExpense
// ---------------------------------------------------------------------------
// DeleteExpense removes an individual expense after verifying ownership via
// the expense's parent event. On success it returns the updated expense list
// fragment for HTMX replacement.
func (h *ExpenseHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) {
expenseID := chi.URLParam(r, "id")
if expenseID == "" {
http.Error(w, "Missing expense ID", http.StatusBadRequest)
return
}
// Fetch the existing expense to get its event_id.
existing, err := database.GetExpenseByID(h.DB, expenseID)
if err != nil || existing == nil {
log.Printf("ERROR [%s] handlers: DeleteExpense: get existing(%s): %v",
time.Now().Format(time.RFC3339), expenseID, err)
http.Error(w, "Expense not found", http.StatusNotFound)
return
}
// Verify ownership: expense → event → month → user.
event, err := database.GetEventByID(h.DB, existing.EventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if !verifyMonthOwnership(h.DB, event.MonthID, getUserID(r)) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
eventID := existing.EventID
// Delete the expense from the database.
if err := database.DeleteExpense(h.DB, expenseID); err != nil {
log.Printf("ERROR [%s] handlers: DeleteExpense: %v", time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to delete expense", http.StatusInternalServerError)
return
}
// Fetch the updated expense list for this event.
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
if err != nil {
log.Printf("ERROR [%s] handlers: DeleteExpense: fetch expenses: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to fetch expenses", http.StatusInternalServerError)
return
}
// Normalize ImagePath for old DB entries.
for i := range expenses {
expenses[i].ImagePath = normalizeImagePath(expenses[i].ImagePath)
}
listTmpl := getTemplate("expense_list.html")
var listBuf strings.Builder
if err := listTmpl.Execute(&listBuf, map[string]interface{}{"Expenses": expenses}); err != nil {
log.Printf("ERROR [%s] handlers: DeleteExpense: execute template: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
// Return updated expense list + clear the receipt form (edit form may be open).
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div id="receipt-form" hx-swap-oob="true"></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) // Cookie helpers (shared with events.go via package-level access)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -608,10 +298,6 @@ func getCurrentEventID(r *http.Request) string {
if err != nil { if err != nil {
return "" return ""
} }
// Validate UUID format to prevent cookie tampering.
if !uuidRe.MatchString(cookie.Value) {
return ""
}
return cookie.Value return cookie.Value
} }
@ -633,118 +319,20 @@ func setCurrentEventID(w http.ResponseWriter, eventID string) {
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// renderUploadError writes an HTMX-compatible error fragment into the
// #receipt-form container (the upload target). Uses HTTP 200 so HTMX
// always swaps the content (HTMX skips 4xx/5xx by default).
func renderUploadError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `<div id="receipt-form"><div class="error-message" style="background: #450a0a; border: 1px solid #7f1d1d; color: #fca5a5; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">%s</div></div>`,
template.HTMLEscapeString(message))
}
// detectImageExtension examines the magic bytes of the provided data to // detectImageExtension examines the magic bytes of the provided data to
// determine its image format. Supports JPEG, PNG, WebP, GIF, BMP, TIFF, // determine whether it is a JPEG or PNG image. Returns "jpg", "png", or
// and HEIC/HEIF (common on iPhones). Returns the file extension (without // an empty string if the format is not recognised.
// dot) or an empty string if the format is not recognised.
func detectImageExtension(data []byte) string { func detectImageExtension(data []byte) string {
if len(data) < 4 { if len(data) < 4 {
return "" return ""
} }
// JPEG magic: 0xFF 0xD8 0xFF
// JPEG: FF D8 FF if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "jpg" return "jpg"
} }
// PNG magic: 0x89 'P' 'N' 'G' 0x0D 0x0A 0x1A 0x0A
// PNG: 89 50 4E 47 0D 0A 1A 0A if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E &&
data[3] == 0x47 && data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A {
return "png" return "png"
} }
// WebP: 52 49 46 46 .... 57 45 42 50
if len(data) >= 12 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 &&
data[3] == 0x46 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 {
return "webp"
}
// GIF: 47 49 46 38 (39 61 or 37 61)
if len(data) >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
data[3] == 0x38 && (data[4] == 0x39 || data[4] == 0x37) && data[5] == 0x61 {
return "gif"
}
// BMP: 42 4D
if data[0] == 0x42 && data[1] == 0x4D {
return "bmp"
}
// TIFF: 49 49 2A 00 or 4D 4D 00 2A
if (data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00) ||
(data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A) {
return "tiff"
}
// PDF: 25 50 44 46 (%PDF)
if len(data) >= 4 && data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46 {
return "pdf"
}
// HEIC/HEIF/AVIF: .... 66 74 79 70 ... (ftyp box)
// The ftyp box starts at offset 4 with brand at offset 8.
if len(data) >= 12 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70 {
brand := string(data[8:12])
switch brand {
case "heic", "heix", "hevc", "hevx", "mif1", "msf1":
return "heic"
case "avif":
return "avif"
}
}
return "" return ""
} }
// ---------------------------------------------------------------------------
// Image processing helpers
// ---------------------------------------------------------------------------
// resizeImage resizes image data to a maximum of 2048 pixels on the longest
// side while maintaining aspect ratio. Output is always JPEG at 85% quality.
// Returns the original data unchanged if the image is already smaller, or if
// decoding/resizing fails (e.g. unsupported format like HEIC).
func resizeImage(data []byte) ([]byte, error) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return data, err
}
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
const maxDim = 2048
if w <= maxDim && h <= maxDim {
return data, nil // already small enough
}
// Maintain aspect ratio.
var newW, newH int
if w > h {
newW = maxDim
newH = h * maxDim / w
} else {
newH = maxDim
newW = w * maxDim / h
}
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); err != nil {
return data, err
}
return buf.Bytes(), nil
}

View file

@ -1,30 +1,23 @@
// Package handlers implements HTTP request handlers for NextExpense. // Package handlers implements HTTP request handlers for ExpenseFlow.
// //
// This file implements the event filing workflow — generating CSV or PDF // This file implements the event filing workflow — generating CSV or PDF
// expense reports and emailing them as attachments to a specified recipient. // expense reports and emailing them as attachments to a specified recipient.
package handlers package handlers
import ( import (
"archive/zip"
"bytes" "bytes"
"crypto/rand"
"database/sql" "database/sql"
"encoding/csv" "encoding/csv"
"encoding/hex"
"fmt" "fmt"
"html/template"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath"
"strings"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jung-kurt/gofpdf" "github.com/jung-kurt/gofpdf"
"github.com/cclohmar/NextExpense/internal/database" "github.com/expenseflow/internal/database"
"github.com/cclohmar/NextExpense/internal/email" "github.com/expenseflow/internal/email"
) )
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -47,15 +40,24 @@ type FileHandler struct {
// FileEvent generates an expense report (CSV or PDF) for a given event and // 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 // 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 // event status is updated to "closed" and the client is redirected to the
// month view via the HX-Redirect header. // 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) { func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
// 1. Get month and event IDs from the URL path parameters. // 1. Get event ID from the URL path parameter.
monthID := chi.URLParam(r, "mid") eventID := chi.URLParam(r, "id")
eventID := chi.URLParam(r, "eid") if eventID == "" {
if monthID == "" || eventID == "" { log.Printf("ERROR [%s] handlers: FileEvent: missing event ID in URL",
log.Printf("ERROR [%s] handlers: FileEvent: missing ID in URL",
time.Now().Format(time.RFC3339)) time.Now().Format(time.RFC3339))
renderFileError(w, "Missing ID.") http.Error(w, "Missing event ID", http.StatusBadRequest)
return return
} }
@ -63,30 +65,32 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: parse form: %v", log.Printf("ERROR [%s] handlers: FileEvent: parse form: %v",
time.Now().Format(time.RFC3339), err) time.Now().Format(time.RFC3339), err)
renderFileError(w, "Cannot parse form data.") http.Error(w, "Cannot parse form data", http.StatusBadRequest)
return return
} }
to := r.FormValue("email") to := r.FormValue("email")
format := r.FormValue("format")
if to == "" { if to == "" {
log.Printf("ERROR [%s] handlers: FileEvent: missing email field", log.Printf("ERROR [%s] handlers: FileEvent: missing email field",
time.Now().Format(time.RFC3339)) time.Now().Format(time.RFC3339))
renderFileError(w, "Email address is required.") 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 return
} }
// 3. Verify the authenticated user owns this event (via month). // 3. Verify the authenticated user owns this event.
userID := getUserID(r) userID := getUserID(r)
if userID == "" { if userID == "" {
log.Printf("ERROR [%s] handlers: FileEvent: unauthenticated request", log.Printf("ERROR [%s] handlers: FileEvent: unauthenticated request",
time.Now().Format(time.RFC3339)) time.Now().Format(time.RFC3339))
renderFileError(w, "Session expired. Please log in again.") http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !verifyMonthOwnership(h.DB, monthID, userID) {
renderFileError(w, "You do not have permission to file this event.")
return return
} }
@ -94,19 +98,19 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: GetEventByID(%s): %v", log.Printf("ERROR [%s] handlers: FileEvent: GetEventByID(%s): %v",
time.Now().Format(time.RFC3339), eventID, err) time.Now().Format(time.RFC3339), eventID, err)
renderFileError(w, "Failed to retrieve event. Please try again.") http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
return return
} }
if event == nil { if event == nil {
log.Printf("ERROR [%s] handlers: FileEvent: event not found: %s", log.Printf("ERROR [%s] handlers: FileEvent: event not found: %s",
time.Now().Format(time.RFC3339), eventID) time.Now().Format(time.RFC3339), eventID)
renderFileError(w, "Event not found.") http.Error(w, "Event not found", http.StatusNotFound)
return return
} }
if event.MonthID != monthID || event.UserID != userID { if event.UserID != userID {
log.Printf("ERROR [%s] handlers: FileEvent: user %s does not own event %s", log.Printf("ERROR [%s] handlers: FileEvent: user %s does not own event %s",
time.Now().Format(time.RFC3339), userID, eventID) time.Now().Format(time.RFC3339), userID, eventID)
renderFileError(w, "You do not have permission to file this event.") http.Error(w, "Forbidden", http.StatusForbidden)
return return
} }
@ -115,63 +119,32 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: GetExpensesByEvent(%s): %v", log.Printf("ERROR [%s] handlers: FileEvent: GetExpensesByEvent(%s): %v",
time.Now().Format(time.RFC3339), eventID, err) time.Now().Format(time.RFC3339), eventID, err)
renderFileError(w, "Failed to retrieve expenses. Please try again.") http.Error(w, "Failed to retrieve expenses", http.StatusInternalServerError)
return return
} }
// Fetch user info for report personalisation. // 5. Generate the report in the requested format.
reportUser, _ := database.GetUserByID(h.DB, userID) var attachment *email.Attachment
userName := "" switch format {
userDept := "" case "csv":
if reportUser != nil { attachment, err = generateCSV(event.Name, expenses)
userName = reportUser.Name case "pdf":
userDept = reportUser.Department attachment, err = generatePDF(event.Name, expenses)
} }
// 5. Generate both CSV and PDF reports.
csvAttachment, err := generateCSV(event.Name, expenses, userName, userDept)
if err != nil { if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: generate CSV: %v", log.Printf("ERROR [%s] handlers: FileEvent: generate %s report: %v",
time.Now().Format(time.RFC3339), err) time.Now().Format(time.RFC3339), format, err)
renderFileError(w, "Failed to generate CSV report. Please try again.") http.Error(w, "Failed to generate report", http.StatusInternalServerError)
return
}
pdfAttachment, err := generatePDF(event.Name, expenses, userName, userDept)
if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: generate PDF: %v",
time.Now().Format(time.RFC3339), err)
renderFileError(w, "Failed to generate PDF report. Please try again.")
return return
} }
// 6. Create a ZIP of all receipt images. // 6. Send the report as an email attachment.
zipAttachment, zipErr := createReceiptZip(event.Name, expenses) subject := "Expense report for event " + event.Name
body := "Please find attached the expense report."
// 7. Build the list of attachments (CSV + PDF + ZIP if available). if err := h.EmailSender.SendReport(to, subject, body, attachment); err != nil {
attachments := []*email.Attachment{csvAttachment, pdfAttachment}
if zipErr == nil && zipAttachment != nil {
attachments = append(attachments, zipAttachment)
} else if zipErr != nil {
log.Printf("WARN [%s] handlers: FileEvent: receipt zip failed: %v",
time.Now().Format(time.RFC3339), zipErr)
}
// 8. Send the email with all attachments.
if h.EmailSender == nil {
log.Printf("ERROR [%s] handlers: FileEvent: SMTP not configured, cannot send email",
time.Now().Format(time.RFC3339))
renderFileError(w, "SMTP not configured. Please contact the administrator.")
return
}
subject := fmt.Sprintf("%s | Expense report for event %s", userName, event.Name)
if userName == "" {
subject = "Expense report for event " + event.Name
}
body := "Please find attached the expense report and receipt images."
if err := h.EmailSender.SendReport(to, subject, body, attachments); err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: SendReport(%s): %v", log.Printf("ERROR [%s] handlers: FileEvent: SendReport(%s): %v",
time.Now().Format(time.RFC3339), to, err) time.Now().Format(time.RFC3339), to, err)
renderFileError(w, "Failed to send report: "+err.Error()) http.Error(w, "Failed to send report email", http.StatusInternalServerError)
return return
} }
@ -179,698 +152,93 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil { if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: UpdateEventStatus(%s): %v", log.Printf("ERROR [%s] handlers: FileEvent: UpdateEventStatus(%s): %v",
time.Now().Format(time.RFC3339), eventID, err) time.Now().Format(time.RFC3339), eventID, err)
renderFileError(w, "Report sent but failed to close the event. Please try again.") http.Error(w, "Failed to close event", http.StatusInternalServerError)
return return
} }
// 8. Redirect to the month view via HTMX. // 8. Redirect to the dashboard via HTMX.
w.Header().Set("HX-Redirect", "/months/"+monthID) w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
// ---------------------------------------------------------------------------
// POST /events/{id}/generate — GenerateReport
// ---------------------------------------------------------------------------
// GenerateReport creates a report package (CSV/PDF + receipt images ZIP),
// stores it on disk with a crypto-random download token, and returns an
// HTMX fragment with download and email-link options. The event is NOT
// closed — the user can add more receipts and regenerate.
func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
if monthID == "" || eventID == "" {
log.Printf("ERROR [%s] handlers: GenerateReport: missing ID",
time.Now().Format(time.RFC3339))
renderFileError(w, "Missing ID.")
return
}
if err := r.ParseForm(); err != nil {
log.Printf("ERROR [%s] handlers: GenerateReport: parse form: %v",
time.Now().Format(time.RFC3339), err)
renderFileError(w, "Cannot parse form data.")
return
}
userID := getUserID(r)
if userID == "" {
renderFileError(w, "Session expired. Please log in again.")
return
}
if !verifyMonthOwnership(h.DB, monthID, userID) {
renderFileError(w, "You do not have permission to access this event.")
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil {
renderFileError(w, "Event not found.")
return
}
if event.MonthID != monthID || event.UserID != userID {
renderFileError(w, "You do not have permission to access this event.")
return
}
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
if err != nil {
renderFileError(w, "Failed to retrieve expenses.")
return
}
if len(expenses) == 0 {
renderFileError(w, "No expenses to include in the report.")
return
}
// Fetch user info for report personalisation.
repUser, _ := database.GetUserByID(h.DB, userID)
uName := ""
uDept := ""
if repUser != nil {
uName = repUser.Name
uDept = repUser.Department
}
// Generate both CSV and PDF reports.
csvAtt, err := generateCSV(event.Name, expenses, uName, uDept)
if err != nil {
log.Printf("ERROR [%s] handlers: GenerateReport: generate CSV: %v",
time.Now().Format(time.RFC3339), err)
renderFileError(w, "Failed to generate CSV report.")
return
}
pdfAtt, err := generatePDF(event.Name, expenses, uName, uDept)
if err != nil {
log.Printf("ERROR [%s] handlers: GenerateReport: generate PDF: %v",
time.Now().Format(time.RFC3339), err)
renderFileError(w, "Failed to generate PDF report.")
return
}
// Package everything into a single flat ZIP (report + receipt images).
var pkgBuf bytes.Buffer
pkg := zip.NewWriter(&pkgBuf)
// Add both report files.
addToZip(pkg, csvAtt.Filename, csvAtt.Content)
addToZip(pkg, pdfAtt.Filename, pdfAtt.Content)
// Add receipt images directly (not nested).
for i, exp := range expenses {
if exp.ImagePath == "" {
continue
}
normPath := normalizeImagePath(exp.ImagePath)
safePath := filepath.Join("storage", filepath.Base(normPath))
data, err := os.ReadFile(safePath)
if err != nil {
log.Printf("WARN [%s] handlers: GenerateReport: reading %q: %v",
time.Now().Format(time.RFC3339), safePath, err)
continue
}
ext := filepath.Ext(exp.ImagePath)
if ext == "" {
ext = ".jpg"
}
imgName := fmt.Sprintf("receipt-%d%s", i+1, ext)
addToZip(pkg, imgName, data)
}
if err := pkg.Close(); err != nil {
renderFileError(w, "Failed to create package.")
return
}
// Save to postbox directory.
os.MkdirAll("storage/postbox", 0755)
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
renderFileError(w, "Failed to generate download token.")
return
}
token := hex.EncodeToString(tokenBytes)
// Use event name as the download filename (GUID only in storage path).
safeEvent := sanitiseFilename(event.Name)
if safeEvent == "" {
safeEvent = "report"
}
dlName := safeEvent + ".zip"
pkgFilename := token + ".zip" // storage filename is always the GUID
pkgPath := filepath.Join("storage", "postbox", pkgFilename)
if err := os.WriteFile(pkgPath, pkgBuf.Bytes(), 0644); err != nil {
log.Printf("ERROR [%s] handlers: GenerateReport: write %s: %v",
time.Now().Format(time.RFC3339), pkgPath, err)
renderFileError(w, "Failed to save report package.")
return
}
// Store token in DB (24h expiry).
expiresAt := time.Now().Add(24 * time.Hour).Format(time.RFC3339)
if err := database.CreateDownloadToken(h.DB, token, eventID, pkgFilename, expiresAt); err != nil {
os.Remove(pkgPath)
renderFileError(w, "Failed to store download token.")
return
}
log.Printf("INFO [%s] handlers: GenerateReport: package %s created for event %s",
time.Now().Format(time.RFC3339), pkgFilename, eventID)
// Render the download/send fragment.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div id="report-package" style="background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; padding: 1rem; margin-top: 1rem;">
<div style="font-weight: 600; color: #6ee7b7; margin-bottom: 0.5rem;">Report Ready</div>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">CSV + PDF report &amp; %d receipt images packaged.</p>
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
<a href="/dl/%s/%s" class="btn btn-primary" style="flex:1; text-align:center; text-decoration:none; font-size:0.85rem;" download> Download Now</a>
</div>
<div style="border-top: 1px solid #065f46; padding-top: 0.75rem;">
<p style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 0.5rem;">Or send a download link via email (tiny email, no attachment limits):</p>
<form hx-post="/months/%s/events/%s/send-link" hx-target="#send-link-result" hx-indicator="#send-link-spinner" style="display: flex; gap: 0.5rem;">
<input type="hidden" name="token" value="%s">
<input type="email" name="email" placeholder="finance@company.com" required style="flex:1; padding:0.5rem; border:1px solid #475569; border-radius:0.375rem; background:#1e293b; color:#f8fafc; font-size:0.85rem;">
<button type="submit" class="btn btn-secondary" style="font-size:0.85rem; white-space:nowrap;">Send Link</button>
</form>
<div id="send-link-spinner" class="htmx-indicator" style="text-align:center; padding:0.5rem;"><div class="spinner"></div></div>
<div id="send-link-result"></div>
</div>
</div>`,
len(expenses),
template.HTMLEscapeString(token), template.HTMLEscapeString(dlName),
template.HTMLEscapeString(monthID), template.HTMLEscapeString(eventID), template.HTMLEscapeString(token))
}
// ---------------------------------------------------------------------------
// POST /months/{mid}/events/{eid}/send-link — SendDownloadLink
// ---------------------------------------------------------------------------
// SendDownloadLink emails a download link for a previously generated report
// package to the specified recipient.
func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to parse form.</div>`)
return
}
token := strings.TrimSpace(r.FormValue("token"))
to := strings.TrimSpace(r.FormValue("email"))
if token == "" || to == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Token and email are required.</div>`)
return
}
// Verify token exists and belongs to user's event.
dt, err := database.GetDownloadTokenByToken(h.DB, token)
if err != nil || dt == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Invalid or expired download token.</div>`)
return
}
event, err := database.GetEventByID(h.DB, dt.EventID)
if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Permission denied.</div>`)
return
}
if h.EmailSender == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">SMTP not configured.</div>`)
return
}
// Build the download URL using the request's Host header (most reliable),
// falling back to BASE_URL env var.
scheme := "https"
host := r.Host
baseURL := os.Getenv("BASE_URL")
if host == "" && baseURL != "" {
// Parse scheme and host from BASE_URL as fallback.
if strings.HasPrefix(baseURL, "https://") {
host = strings.TrimPrefix(baseURL, "https://")
} else if strings.HasPrefix(baseURL, "http://") {
scheme = "http"
host = strings.TrimPrefix(baseURL, "http://")
}
}
if host == "" {
host = "localhost:8080"
}
safeName := sanitiseFilename(event.Name)
if safeName == "" {
safeName = "report"
}
link := fmt.Sprintf("%s://%s/dl/%s/%s.zip", scheme, host, token, safeName)
// Fetch user name for the subject line.
repUser, _ := database.GetUserByID(h.DB, getUserID(r))
userName := ""
if repUser != nil {
userName = repUser.Name
}
subject := fmt.Sprintf("%s | Expense report: %s", userName, event.Name)
if userName == "" {
subject = "Expense report: " + event.Name
}
body := fmt.Sprintf("Expense report for %s is ready.\n\nDownload: %s\n\nThis link expires in 24 hours.", event.Name, link)
if err := h.EmailSender.SendReport(to, subject, body, nil); err != nil {
log.Printf("ERROR [%s] handlers: SendDownloadLink: %v",
time.Now().Format(time.RFC3339), err)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to send: %s</div>`,
template.HTMLEscapeString(err.Error()))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#6ee7b7; font-size:0.8rem; margin-top:0.5rem;">Download link sent to %s.</div>`,
template.HTMLEscapeString(to))
}
// ---------------------------------------------------------------------------
// GET /dl/{token} — ServeDownload
// ---------------------------------------------------------------------------
// ServeDownload streams a previously generated report package to the client.
// Access is controlled via the crypto-random token in the URL — no login
// required. The token is valid for 24 hours from creation.
// The optional filename suffix in the URL (e.g. /dl/{token}/Lagos-report.zip)
// is used for the Content-Disposition header but does not affect access control.
func (h *FileHandler) ServeDownload(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
if token == "" {
http.NotFound(w, r)
return
}
dt, err := database.GetDownloadTokenByToken(h.DB, token)
if err != nil || dt == nil {
http.NotFound(w, r)
return
}
// Check expiry.
expiresAt, err := time.Parse(time.RFC3339, dt.ExpiresAt)
if err != nil || time.Now().After(expiresAt) {
http.NotFound(w, r)
return
}
pkgPath := filepath.Join("storage", "postbox", dt.Filename)
if _, err := os.Stat(pkgPath); os.IsNotExist(err) {
http.NotFound(w, r)
return
}
// Mark as accessed.
database.MarkDownloadTokenAccessed(h.DB, token)
// Build a friendly download filename from the URL suffix, falling back to the token.
dlName := dt.Filename
if name := chi.URLParam(r, "name"); name != "" {
dlName = filepath.Base(name) // prevent path traversal in the suffix
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, dlName))
http.ServeFile(w, r, pkgPath)
}
// ---------------------------------------------------------------------------
// StartDownloadCleanup
// ---------------------------------------------------------------------------
// StartDownloadCleanup runs a background goroutine that periodically deletes
// expired download tokens and their associated files from disk.
func (h *FileHandler) StartDownloadCleanup() {
go func() {
for {
time.Sleep(1 * time.Hour)
filenames, err := database.DeleteExpiredDownloadTokens(h.DB)
if err != nil {
log.Printf("ERROR [%s] handlers: download cleanup: %v",
time.Now().Format(time.RFC3339), err)
continue
}
for _, fn := range filenames {
path := filepath.Join("storage", "postbox", fn)
if err := os.Remove(path); err != nil {
log.Printf("WARN [%s] handlers: download cleanup: remove %s: %v",
time.Now().Format(time.RFC3339), path, err)
}
}
if len(filenames) > 0 {
log.Printf("INFO [%s] handlers: download cleanup: removed %d expired packages",
time.Now().Format(time.RFC3339), len(filenames))
}
}
}()
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// addToZip adds a file to a zip.Writer. Errors are logged but not returned
// since a missing image in the ZIP is non-fatal — the report is the priority.
func addToZip(zw *zip.Writer, name string, data []byte) {
f, err := zw.Create(name)
if err != nil {
log.Printf("WARN [%s] handlers: addToZip: create %q: %v",
time.Now().Format(time.RFC3339), name, err)
return
}
if _, err := f.Write(data); err != nil {
log.Printf("WARN [%s] handlers: addToZip: write %q: %v",
time.Now().Format(time.RFC3339), name, err)
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Report generation helpers // Report generation helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// generateCSV creates a CSV attachment from the provided expenses. // generateCSV creates a CSV attachment from the provided expenses.
// The CSV includes a header row and one data row per expense. // The CSV includes a header row and one data row per expense.
// If the expenses use a different currency than the base currency, both func generateCSV(eventName string, expenses []database.Expense) (*email.Attachment, error) {
// original and converted amounts are included.
func generateCSV(eventName string, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
var buf bytes.Buffer var buf bytes.Buffer
writer := csv.NewWriter(&buf) writer := csv.NewWriter(&buf)
// Write user metadata row. // Write header row.
if userName != "" { if err := writer.Write([]string{"Date", "Merchant", "Amount", "Currency", "Category", "Description"}); err != nil {
metaLine := fmt.Sprintf("Prepared by: %s", userName) return nil, fmt.Errorf("write CSV header: %w", err)
if userDept != "" && userDept != "-" {
metaLine += fmt.Sprintf(" | Department: %s", userDept)
}
writer.Write([]string{metaLine})
writer.Write([]string{""})
} }
// Total claim summary. // Write one data row per expense.
var totalClaim float64
claimCur := ""
for _, exp := range expenses { for _, exp := range expenses {
cAmt := exp.ConvertedAmount if err := writer.Write([]string{
if cAmt <= 0 { exp.Date,
cAmt = exp.Amount exp.Merchant,
} fmt.Sprintf("%.2f", exp.Amount),
totalClaim += cAmt exp.Currency,
if claimCur == "" && exp.BaseCurrency != "" { exp.Category,
claimCur = exp.BaseCurrency exp.Description,
}); err != nil {
return nil, fmt.Errorf("write CSV row: %w", err)
} }
} }
if claimCur == "" && len(expenses) > 0 {
claimCur = expenses[0].Currency
}
writer.Write([]string{fmt.Sprintf("Total Claim: %.2f %s", totalClaim, claimCur)})
writer.Write([]string{""})
// Write header row with both local and claim columns.
header := []string{"#", "Date", "Merchant", "Local Amt", "Currency", "Claim Amt", "Claim Curr", "Category", "Description"}
writer.Write(header)
// Write data rows.
var tableLocal, tableClaim float64
for i, exp := range expenses {
claimAmt := exp.ConvertedAmount
claimCur := exp.BaseCurrency
if claimAmt <= 0 {
claimAmt = exp.Amount
}
if claimCur == "" {
claimCur = exp.Currency
}
row := []string{
fmt.Sprintf("%d", i+1),
exp.Date, exp.Merchant,
fmt.Sprintf("%.2f", exp.Amount), exp.Currency,
fmt.Sprintf("%.2f", claimAmt), claimCur,
exp.Category, exp.Description,
}
writer.Write(row)
tableLocal += exp.Amount
tableClaim += claimAmt
}
// Write totals row.
writer.Write([]string{"TOTAL", "", "", fmt.Sprintf("%.2f", tableLocal), "", fmt.Sprintf("%.2f", tableClaim), "", "", ""})
writer.Flush() writer.Flush()
if err := writer.Error(); err != nil { if err := writer.Error(); err != nil {
return nil, fmt.Errorf("CSV writer flush: %w", err) return nil, fmt.Errorf("CSV writer flush: %w", err)
} }
filename := fmt.Sprintf("expense-%s-report.csv", sanitiseFilename(eventName))
return &email.Attachment{ return &email.Attachment{
Filename: filename, Filename: "report.csv",
Content: buf.Bytes(), Content: buf.Bytes(),
}, nil }, nil
} }
// generatePDF creates a PDF attachment from the provided expenses using gofpdf. // 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. // The PDF contains a title row, a header row, and one data row per expense.
// If the expenses use a different currency than the base currency, both func generatePDF(eventName string, expenses []database.Expense) (*email.Attachment, error) {
// original and converted amounts are included. pdf := gofpdf.New("P", "mm", "A4", "")
func generatePDF(eventName string, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
pdf := gofpdf.New("L", "mm", "A4", "")
pdf.AddPage() pdf.AddPage()
// Title. // Title: "Expense Report: <event name>"
pdf.SetFont("Helvetica", "B", 14) pdf.SetFont("Helvetica", "B", 16)
pdf.Cell(0, 10, "Expense Report: "+eventName) 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) pdf.Ln(8)
// User info.
if userName != "" {
pdf.SetFont("Helvetica", "", 9)
infoLine := fmt.Sprintf("Prepared by: %s", userName)
if userDept != "" && userDept != "-" {
infoLine += fmt.Sprintf(" | Department: %s", userDept)
}
pdf.Cell(0, 6, infoLine)
pdf.Ln(6)
}
// Total claim summary.
var totalClaim float64
claimCur := ""
for _, exp := range expenses {
cAmt := exp.ConvertedAmount
if cAmt <= 0 {
cAmt = exp.Amount
}
totalClaim += cAmt
if claimCur == "" && exp.BaseCurrency != "" {
claimCur = exp.BaseCurrency
}
}
if claimCur == "" && len(expenses) > 0 {
claimCur = expenses[0].Currency
}
pdf.SetFont("Helvetica", "B", 10)
pdf.Cell(0, 8, fmt.Sprintf("Total Claim: %.2f %s", totalClaim, claimCur))
pdf.Ln(12)
// Table header.
pdf.SetFont("Helvetica", "B", 8)
headers := []string{"#", "Date", "Merchant", "Local Amt", "Cur", "Claim Amt", "Claim", "Category", "Description"}
colWidths := []float64{7, 22, 52, 18, 12, 18, 12, 36, 100}
for i, h := range headers {
pdf.Cell(colWidths[i], 7, h)
}
pdf.Ln(7)
// Table data rows. // Table data rows.
pdf.SetFont("Helvetica", "", 8) pdf.SetFont("Helvetica", "", 10)
marginBottom := 18.0
var totalLocal, tableClaim float64
for i, exp := range expenses {
if pdf.GetY() > 210-marginBottom {
pdf.AddPage()
pdf.SetFont("Helvetica", "B", 8)
for j, h := range headers {
pdf.Cell(colWidths[j], 7, h)
}
pdf.Ln(7)
pdf.SetFont("Helvetica", "", 8)
}
// Compute claim amount (auto-calc if not set).
claimAmt := exp.ConvertedAmount
claimCur := exp.BaseCurrency
if claimAmt <= 0 {
claimAmt = exp.Amount
}
if claimCur == "" {
claimCur = exp.Currency
}
itemNum := i + 1
pdf.Cell(colWidths[0], 6, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 6, exp.Date)
pdf.Cell(colWidths[2], 6, exp.Merchant)
pdf.Cell(colWidths[3], 6, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 6, exp.Currency)
pdf.Cell(colWidths[5], 6, fmt.Sprintf("%.2f", claimAmt))
pdf.Cell(colWidths[6], 6, claimCur)
pdf.Cell(colWidths[7], 6, exp.Category)
pdf.Cell(colWidths[8], 6, exp.Description)
pdf.Ln(6)
totalLocal += exp.Amount
tableClaim += claimAmt
}
// Totals row in claim currency.
pdf.SetDrawColor(71, 85, 105)
pdf.Line(10, pdf.GetY()+1, 287, pdf.GetY()+1)
pdf.Ln(3)
pdf.SetFont("Helvetica", "B", 9)
claimTotalCur := ""
for _, exp := range expenses { for _, exp := range expenses {
if exp.BaseCurrency != "" { pdf.Cell(35, 8, exp.Date)
claimTotalCur = exp.BaseCurrency pdf.Cell(35, 8, exp.Merchant)
break pdf.Cell(20, 8, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(20, 8, exp.Currency)
pdf.Cell(35, 8, exp.Category)
pdf.Ln(8)
} }
}
if claimTotalCur == "" {
claimTotalCur = expenses[0].Currency
}
pdf.Cell(colWidths[0], 8, "")
pdf.Cell(colWidths[1], 8, "")
pdf.Cell(colWidths[2], 8, "TOTAL")
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", totalLocal))
pdf.Cell(colWidths[4], 8, "")
pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", tableClaim))
pdf.Cell(colWidths[6], 8, claimTotalCur)
pdf.Cell(colWidths[7], 8, "")
pdf.Cell(colWidths[8], 8, "")
// Write to buffer. // Write the PDF document to a memory buffer.
var buf bytes.Buffer var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil { if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("PDF output: %w", err) return nil, fmt.Errorf("PDF output: %w", err)
} }
filename := fmt.Sprintf("expense-%s-report.pdf", sanitiseFilename(eventName))
return &email.Attachment{ return &email.Attachment{
Filename: filename, Filename: "report.pdf",
Content: buf.Bytes(), Content: buf.Bytes(),
}, nil }, nil
} }
// createReceiptZip creates a ZIP archive containing all receipt images from the
// given expenses. Each image is named {event-name}-{index}.{ext} inside the ZIP.
// Returns nil if there are no expenses with images, or if all image files are
// missing from disk.
func createReceiptZip(eventName string, expenses []database.Expense) (*email.Attachment, error) {
safeName := sanitiseFilename(eventName)
if safeName == "" {
safeName = "event"
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
added := 0
for i, exp := range expenses {
if exp.ImagePath == "" {
continue
}
// Normalize path (strip legacy "storage/" prefix if present),
// then construct the full path safely within the storage directory.
// filepath.Base prevents path traversal by extracting only the filename.
normPath := normalizeImagePath(exp.ImagePath)
safePath := filepath.Join("storage", filepath.Base(normPath))
// Read the image file from disk.
data, err := os.ReadFile(safePath)
if err != nil {
log.Printf("WARN [%s] handlers: createReceiptZip: reading %q: %v",
time.Now().Format(time.RFC3339), safePath, err)
continue
}
// Determine file extension from the image path.
ext := filepath.Ext(exp.ImagePath)
if ext == "" {
ext = ".jpg"
}
filename := fmt.Sprintf("%s-%d%s", safeName, i+1, ext)
f, err := zw.Create(filename)
if err != nil {
log.Printf("WARN [%s] handlers: createReceiptZip: creating entry %q: %v",
time.Now().Format(time.RFC3339), filename, err)
continue
}
if _, err := f.Write(data); err != nil {
log.Printf("WARN [%s] handlers: createReceiptZip: writing %q: %v",
time.Now().Format(time.RFC3339), filename, err)
continue
}
added++
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("closing zip: %w", err)
}
if added == 0 {
log.Printf("INFO [%s] handlers: createReceiptZip: no receipt images found for event %q",
time.Now().Format(time.RFC3339), eventName)
return nil, nil
}
return &email.Attachment{
Filename: fmt.Sprintf("expense-%s-images.zip", safeName),
Content: buf.Bytes(),
}, nil
}
// renderFileError writes an HTMX-compatible error fragment targeted at the
// #submit-error container on the event expenses page. Using a 200 status
// ensures HTMX always swaps the content (HTMX skips 4xx/5xx by default).
func renderFileError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("HX-Retarget", "#submit-error")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `<div id="submit-error" style="background: #450a0a; border: 1px solid #7f1d1d; color: #fca5a5; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">%s</div>`,
template.HTMLEscapeString(message))
}
// truncateString truncates a string to the given maximum length, appending "…"
// if the string was shortened.
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-1] + "…"
}
// sanitiseFilename converts a string into a safe filename (alphanumerics,
// hyphens, underscores only — no spaces or special characters).
func sanitiseFilename(s string) string {
var result []rune
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
result = append(result, r)
} else if r == ' ' || r == '.' {
result = append(result, '-')
}
}
if len(result) == 0 {
return "expenses"
}
return strings.Trim(string(result), "-")
}

View file

@ -1,29 +0,0 @@
package handlers
import (
"database/sql"
"strings"
"github.com/cclohmar/NextExpense/internal/database"
)
// normalizeImagePath strips a legacy "storage/" prefix if present, so that
// the template can safely build "/storage/{filename}" URLs regardless of
// whether the database entry was stored as "uuid.jpg" or "storage/uuid.jpg".
func normalizeImagePath(path string) string {
return strings.TrimPrefix(path, "storage/")
}
// verifyMonthOwnership checks that a month exists and belongs to the given user.
// If monthID is empty (pre-migration events), returns true since event ownership
// was already verified by the caller.
func verifyMonthOwnership(db *sql.DB, monthID, userID string) bool {
if monthID == "" {
return true
}
month, err := database.GetMonthByID(db, monthID)
if err != nil || month == nil {
return false
}
return month.UserID == userID
}

View file

@ -1,818 +0,0 @@
// Package handlers provides HTTP request handlers for NextExpense.
//
// This file implements month management endpoints including month listing,
// creation, editing, deletion, event viewing within a month, and monthly
// report generation that aggregates all events across a month.
package handlers
import (
"archive/zip"
"bytes"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/cclohmar/NextExpense/internal/database"
"github.com/cclohmar/NextExpense/internal/email"
"github.com/cclohmar/NextExpense/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/jung-kurt/gofpdf"
)
// ---------------------------------------------------------------------------
// MonthHandler
// ---------------------------------------------------------------------------
// MonthHandler groups HTTP handlers related to month management.
// It depends on a shared *sql.DB handle for database operations and an
// optional *email.Sender for delivering monthly reports via email.
type MonthHandler struct {
DB *sql.DB
EmailSender *email.Sender
}
// NewMonthHandler creates a new MonthHandler with the given database handle.
func NewMonthHandler(db *sql.DB) *MonthHandler {
return &MonthHandler{DB: db}
}
// ---------------------------------------------------------------------------
// GET /dashboard — ListMonths (replaces old EventHandler.Dashboard)
// ---------------------------------------------------------------------------
// ListMonths renders the main dashboard page showing all months belonging
// to the authenticated user, along with the create month form.
func (h *MonthHandler) ListMonths(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: ListMonths: missing user ID", time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Redirect to onboarding if the user hasn't completed it yet.
user, _ := database.GetUserByID(h.DB, userID)
if user != nil && !user.Onboarded {
w.Header().Set("HX-Redirect", "/onboarding")
w.WriteHeader(http.StatusOK)
return
}
months, err := database.GetMonthsByUser(h.DB, userID)
if err != nil {
log.Printf("ERROR [%s] handlers: ListMonths: GetMonthsByUser: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to load months", http.StatusInternalServerError)
return
}
// Sort by month name descending (latest first): "December 2026" before "January 2026".
sort.Slice(months, func(i, j int) bool {
yi, mi := parseMonthName(months[i].Name)
yj, mj := parseMonthName(months[j].Name)
if yi != yj {
return yi > yj
}
return mi > mj
})
// Compute total claim per month.
type MonthWithTotal struct {
Month database.Month
Total float64
}
var items []MonthWithTotal
for _, m := range months {
total, _ := database.GetMonthTotalClaim(h.DB, m.ID)
items = append(items, MonthWithTotal{Month: m, Total: total})
}
tmpl := getTemplate("dashboard.html")
data := map[string]interface{}{
"Months": items,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: ListMonths: execute template: %v",
time.Now().Format(time.RFC3339), err)
}
}
// ---------------------------------------------------------------------------
// POST /months — CreateMonth
// ---------------------------------------------------------------------------
// CreateMonth handles the creation of a new month for the authenticated user.
func (h *MonthHandler) CreateMonth(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: CreateMonth: missing user ID", time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
month := strings.TrimSpace(r.FormValue("month"))
year := strings.TrimSpace(r.FormValue("year"))
if month == "" || year == "" {
log.Printf("ERROR [%s] handlers: CreateMonth: missing month or year", time.Now().Format(time.RFC3339))
http.Error(w, "Month and year are required", http.StatusBadRequest)
return
}
name := month + " " + year
id := utils.NewUUID()
if err := database.CreateMonth(h.DB, id, userID, name); err != nil {
log.Printf("ERROR [%s] handlers: CreateMonth: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to create month", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// GET /months/{mid} — ViewMonth
// ---------------------------------------------------------------------------
// ViewMonth displays all events under a given month.
func (h *MonthHandler) ViewMonth(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
if monthID == "" {
http.Error(w, "Missing month ID", http.StatusBadRequest)
return
}
userID := getUserID(r)
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil {
http.Error(w, "Month not found", http.StatusNotFound)
return
}
if month.UserID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
events, err := database.GetEventsByMonth(h.DB, monthID)
if err != nil {
log.Printf("ERROR [%s] handlers: ViewMonth: GetEventsByMonth(%s): %v",
time.Now().Format(time.RFC3339), monthID, err)
http.Error(w, "Failed to load events", http.StatusInternalServerError)
return
}
// Compute total claim per event.
type EventWithTotal struct {
Event database.Event
Total float64
Currency string
}
var items []EventWithTotal
for _, evt := range events {
total, _ := database.GetEventTotalClaim(h.DB, evt.ID)
items = append(items, EventWithTotal{Event: evt, Total: total, Currency: evt.BaseCurrency})
}
tmpl := getTemplate("month_events.html")
data := map[string]interface{}{
"Month": month,
"Events": items,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: ViewMonth: execute template: %v",
time.Now().Format(time.RFC3339), err)
}
}
// ---------------------------------------------------------------------------
// GET /months/{mid}/edit — EditMonth
// ---------------------------------------------------------------------------
// EditMonth returns an inline edit form fragment for a month.
func (h *MonthHandler) EditMonth(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="card" style="padding: 1rem;">
<h3 style="margin-bottom: 1rem;">Edit Month</h3>
<form hx-put="/months/%s" hx-target="body" hx-push-url="true">
<div class="form-group">
<label class="form-label">Month Name</label>
<input type="text" name="name" value="%s" placeholder="e.g. July 2026">
</div>
<button type="submit" class="btn btn-primary btn-block">Save Changes</button>
<div style="display:flex; gap:0.5rem; margin-top:0.5rem;">
<button type="button" class="btn btn-secondary" style="flex:1; text-align:center;"
onclick="document.getElementById('create-form').innerHTML='';document.getElementById('create-form').classList.add('hidden')">Cancel</button>
<button type="button" class="btn btn-secondary" style="flex:1; text-align:center; color:#fca5a5; border-color:#7f1d1d;"
onclick="if(confirm('Delete this month and ALL its events and receipts?')){htmx.trigger('#delete-month-%s','click')}">Delete</button>
<div hx-delete="/months/%s" hx-target="body" hx-push-url="true" id="delete-month-%s" style="display:none"></div>
</div>
</form>
</div>`, month.ID, template.HTMLEscapeString(month.Name), month.ID, month.ID, month.ID)
}
// ---------------------------------------------------------------------------
// PUT /months/{mid} — UpdateMonth
// ---------------------------------------------------------------------------
// UpdateMonth updates a month's name after ownership verification.
func (h *MonthHandler) UpdateMonth(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
http.Error(w, "Month name is required", http.StatusBadRequest)
return
}
if err := database.UpdateMonth(h.DB, monthID, name); err != nil {
log.Printf("ERROR [%s] handlers: UpdateMonth(%s): %v", time.Now().Format(time.RFC3339), monthID, err)
http.Error(w, "Failed to update month", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// DELETE /months/{mid} — DeleteMonth
// ---------------------------------------------------------------------------
// DeleteMonth removes a month and all its events (cascade deletes expenses).
func (h *MonthHandler) DeleteMonth(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := database.DeleteMonth(h.DB, monthID); err != nil {
log.Printf("ERROR [%s] handlers: DeleteMonth(%s): %v", time.Now().Format(time.RFC3339), monthID, err)
http.Error(w, "Failed to delete month", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// POST /months/{mid}/generate — GenerateMonthlyReport
// ---------------------------------------------------------------------------
// GenerateMonthlyReport aggregates all expenses across all events in a month
// into a single report package (CSV/PDF + all receipt images ZIP), stores
// it with a download token, and returns an HTMX fragment with download link.
func (h *MonthHandler) GenerateMonthlyReport(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
if monthID == "" {
renderFileError(w, "Missing month ID.")
return
}
if err := r.ParseForm(); err != nil {
renderFileError(w, "Cannot parse form data.")
return
}
userID := getUserID(r)
if userID == "" {
renderFileError(w, "Session expired. Please log in again.")
return
}
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil {
renderFileError(w, "Month not found.")
return
}
if month.UserID != userID {
renderFileError(w, "You do not have permission to access this month.")
return
}
// Get all events for this month.
events, err := database.GetEventsByMonth(h.DB, monthID)
if err != nil {
renderFileError(w, "Failed to retrieve events.")
return
}
if len(events) == 0 {
renderFileError(w, "No events in this month.")
return
}
// Aggregate all expenses across all events.
var allExpenses []database.Expense
for _, evt := range events {
expenses, err := database.GetExpensesByEvent(h.DB, evt.ID)
if err != nil {
continue
}
allExpenses = append(allExpenses, expenses...)
}
if len(allExpenses) == 0 {
renderFileError(w, "No expenses to include in the report.")
return
}
// Fetch user info for report personalisation.
repUser, _ := database.GetUserByID(h.DB, userID)
uName := ""
uDept := ""
if repUser != nil {
uName = repUser.Name
uDept = repUser.Department
}
// Generate both CSV and PDF reports.
csvAtt, err := generateMonthlyCSV(month.Name, events, allExpenses, uName, uDept)
if err != nil {
log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: generate CSV: %v",
time.Now().Format(time.RFC3339), err)
renderFileError(w, "Failed to generate report.")
return
}
pdfAtt, err := generateMonthlyPDF(month.Name, events, allExpenses, uName, uDept)
if err != nil {
log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: generate PDF: %v",
time.Now().Format(time.RFC3339), err)
renderFileError(w, "Failed to generate report.")
return
}
// Package everything into a single flat ZIP.
var pkgBuf bytes.Buffer
pkg := zip.NewWriter(&pkgBuf)
addToZip(pkg, csvAtt.Filename, csvAtt.Content)
addToZip(pkg, pdfAtt.Filename, pdfAtt.Content)
// Add all receipt images from all events.
imgIdx := 0
for _, evt := range events {
expenses, _ := database.GetExpensesByEvent(h.DB, evt.ID)
for _, exp := range expenses {
if exp.ImagePath == "" {
continue
}
normPath := normalizeImagePath(exp.ImagePath)
safePath := filepath.Join("storage", filepath.Base(normPath))
data, err := os.ReadFile(safePath)
if err != nil {
continue
}
ext := filepath.Ext(exp.ImagePath)
if ext == "" {
ext = ".jpg"
}
imgIdx++
imgName := fmt.Sprintf("receipt-%d%s", imgIdx, ext)
addToZip(pkg, imgName, data)
}
}
if err := pkg.Close(); err != nil {
renderFileError(w, "Failed to create package.")
return
}
// Save to postbox directory.
os.MkdirAll("storage/postbox", 0755)
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
renderFileError(w, "Failed to generate download token.")
return
}
token := hex.EncodeToString(tokenBytes)
safeMonth := sanitiseFilename(month.Name)
if safeMonth == "" {
safeMonth = "monthly-report"
}
dlName := safeMonth + ".zip"
pkgFilename := token + ".zip"
pkgPath := filepath.Join("storage", "postbox", pkgFilename)
if err := os.WriteFile(pkgPath, pkgBuf.Bytes(), 0644); err != nil {
log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: write %s: %v",
time.Now().Format(time.RFC3339), pkgPath, err)
renderFileError(w, "Failed to save report package.")
return
}
// Store token in DB (24h expiry). Use monthID as event_id for token tracking.
expiresAt := time.Now().Add(24 * time.Hour).Format(time.RFC3339)
if err := database.CreateDownloadToken(h.DB, token, monthID, pkgFilename, expiresAt); err != nil {
os.Remove(pkgPath)
renderFileError(w, "Failed to store download token.")
return
}
log.Printf("INFO [%s] handlers: GenerateMonthlyReport: package %s created for month %s",
time.Now().Format(time.RFC3339), pkgFilename, monthID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div id="report-package" style="background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; padding: 1rem; margin-top: 1rem;">
<div style="font-weight: 600; color: #6ee7b7; margin-bottom: 0.5rem;">Monthly Report Ready</div>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">CSV + PDF &amp; %d events, %d receipt images packaged.</p>
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
<a href="/dl/%s/%s" class="btn btn-primary" style="flex:1; text-align:center; text-decoration:none; font-size:0.85rem;" download> Download Now</a>
</div>
<div style="border-top: 1px solid #065f46; padding-top: 0.75rem;">
<p style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 0.5rem;">Or send a download link via email:</p>
<form hx-post="/months/%s/send-link" hx-target="#send-link-result" hx-indicator="#send-link-spinner" style="display: flex; gap: 0.5rem;">
<input type="hidden" name="token" value="%s">
<input type="email" name="email" placeholder="finance@company.com" required style="flex:1; padding:0.5rem; border:1px solid #475569; border-radius:0.375rem; background:#1e293b; color:#f8fafc; font-size:0.85rem;">
<button type="submit" class="btn btn-secondary" style="font-size:0.85rem; white-space:nowrap;">Send Link</button>
</form>
<div id="send-link-spinner" class="htmx-indicator" style="text-align:center; padding:0.5rem;"><div class="spinner"></div></div>
<div id="send-link-result"></div>
</div>
</div>`,
len(events), imgIdx,
template.HTMLEscapeString(token), template.HTMLEscapeString(dlName),
template.HTMLEscapeString(monthID), template.HTMLEscapeString(token))
}
// ---------------------------------------------------------------------------
// POST /months/{mid}/send-link — SendMonthlyDownloadLink
// ---------------------------------------------------------------------------
// SendMonthlyDownloadLink emails a download link for a previously generated
// monthly report package.
func (h *MonthHandler) SendMonthlyDownloadLink(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to parse form.</div>`)
return
}
token := strings.TrimSpace(r.FormValue("token"))
to := strings.TrimSpace(r.FormValue("email"))
if token == "" || to == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Token and email are required.</div>`)
return
}
// Verify token exists.
dt, err := database.GetDownloadTokenByToken(h.DB, token)
if err != nil || dt == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Invalid or expired download token.</div>`)
return
}
// For monthly reports, the token's event_id stores the month_id.
// Verify the month belongs to the user.
month, err := database.GetMonthByID(h.DB, dt.EventID)
if err != nil || month == nil || month.UserID != getUserID(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Permission denied.</div>`)
return
}
if h.EmailSender == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">SMTP not configured.</div>`)
return
}
scheme := "https"
host := r.Host
baseURL := os.Getenv("BASE_URL")
if host == "" && baseURL != "" {
if strings.HasPrefix(baseURL, "https://") {
host = strings.TrimPrefix(baseURL, "https://")
} else if strings.HasPrefix(baseURL, "http://") {
scheme = "http"
host = strings.TrimPrefix(baseURL, "http://")
}
}
if host == "" {
host = "localhost:8080"
}
safeName := sanitiseFilename(month.Name)
if safeName == "" {
safeName = "monthly-report"
}
link := fmt.Sprintf("%s://%s/dl/%s/%s.zip", scheme, host, token, safeName)
// Fetch user name for the subject line.
user, _ := database.GetUserByID(h.DB, getUserID(r))
userName := ""
if user != nil {
userName = user.Name
}
subject := fmt.Sprintf("%s | Monthly Expense Report: %s", userName, month.Name)
if userName == "" {
subject = "Monthly Expense Report: " + month.Name
}
body := fmt.Sprintf("Monthly expense report for %s is ready.\n\nDownload: %s\n\nThis link expires in 24 hours.", month.Name, link)
if err := h.EmailSender.SendReport(to, subject, body, nil); err != nil {
log.Printf("ERROR [%s] handlers: SendMonthlyDownloadLink: %v",
time.Now().Format(time.RFC3339), err)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to send: %s</div>`,
template.HTMLEscapeString(err.Error()))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#6ee7b7; font-size:0.8rem; margin-top:0.5rem;">Download link sent to %s.</div>`,
template.HTMLEscapeString(to))
}
// ---------------------------------------------------------------------------
// Monthly report generation helpers
// ---------------------------------------------------------------------------
// generateMonthlyCSV creates a CSV attachment aggregating expenses across
// all events in a month. Each event is prefixed with a section header.
func generateMonthlyCSV(monthName string, events []database.Event, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
var buf bytes.Buffer
buf.WriteString(fmt.Sprintf("Monthly Expense Report: %s\r\n", monthName))
if userName != "" {
metaLine := fmt.Sprintf("Prepared by: %s", userName)
if userDept != "" && userDept != "-" {
metaLine += fmt.Sprintf(" | Department: %s", userDept)
}
buf.WriteString(metaLine + "\r\n")
}
buf.WriteString("\r\n")
// Total claim summary.
var totalClaim float64
claimCur := ""
for _, exp := range expenses {
cAmt := exp.ConvertedAmount
if cAmt <= 0 {
cAmt = exp.Amount
}
totalClaim += cAmt
if claimCur == "" && exp.BaseCurrency != "" {
claimCur = exp.BaseCurrency
}
}
if claimCur == "" && len(expenses) > 0 {
claimCur = expenses[0].Currency
}
buf.WriteString(fmt.Sprintf("Total Claim: %.2f %s\r\n", totalClaim, claimCur))
buf.WriteString("\r\n")
// Group expenses by event.
expensesByEvent := make(map[string][]database.Expense)
eventNames := make(map[string]string)
for _, evt := range events {
eventNames[evt.ID] = evt.Name
}
for _, exp := range expenses {
expensesByEvent[exp.EventID] = append(expensesByEvent[exp.EventID], exp)
}
itemNum := 1
var grandLocal, grandClaim float64
for _, evt := range events {
evtExpenses := expensesByEvent[evt.ID]
if len(evtExpenses) == 0 {
continue
}
buf.WriteString(fmt.Sprintf("\r\n--- %s ---\r\n", eventNames[evt.ID]))
buf.WriteString("#,Date,Merchant,Local Amt,Currency,Claim Amt,Claim Curr,Category,Description\r\n")
var evtLocal, evtClaim float64
for _, exp := range evtExpenses {
claimAmt := exp.ConvertedAmount
claimCur := exp.BaseCurrency
if claimAmt <= 0 {
claimAmt = exp.Amount
}
if claimCur == "" {
claimCur = exp.Currency
}
buf.WriteString(fmt.Sprintf("%d,%s,%s,%.2f,%s,%.2f,%s,%s,%s\r\n",
itemNum, exp.Date, exp.Merchant, exp.Amount, exp.Currency, claimAmt, claimCur, exp.Category, exp.Description))
evtLocal += exp.Amount
evtClaim += claimAmt
itemNum++
}
buf.WriteString(fmt.Sprintf("Event Total,,,%.2f,,%.2f,,\r\n", evtLocal, evtClaim))
grandLocal += evtLocal
grandClaim += evtClaim
}
buf.WriteString(fmt.Sprintf("\r\nGrand Total,,,%.2f,,%.2f,,\r\n", grandLocal, grandClaim))
filename := fmt.Sprintf("monthly-%s-report.csv", sanitiseFilename(monthName))
return &email.Attachment{
Filename: filename,
Content: []byte(buf.String()),
}, nil
}
// generateMonthlyPDF creates a landscape PDF attachment aggregating expenses
// across all events in a month, with a section per event. Full text, no truncation.
func generateMonthlyPDF(monthName string, events []database.Event, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
pdf := gofpdf.New("L", "mm", "A4", "")
pdf.AddPage()
// Title.
pdf.SetFont("Helvetica", "B", 14)
pdf.Cell(0, 10, "Monthly Expense Report: "+monthName)
pdf.Ln(8)
// User info.
if userName != "" {
pdf.SetFont("Helvetica", "", 9)
infoLine := fmt.Sprintf("Prepared by: %s", userName)
if userDept != "" && userDept != "-" {
infoLine += fmt.Sprintf(" | Department: %s", userDept)
}
pdf.Cell(0, 6, infoLine)
pdf.Ln(6)
}
// Total claim summary.
var totalClaim float64
claimCur := ""
for _, exp := range expenses {
cAmt := exp.ConvertedAmount
if cAmt <= 0 {
cAmt = exp.Amount
}
totalClaim += cAmt
if claimCur == "" && exp.BaseCurrency != "" {
claimCur = exp.BaseCurrency
}
}
if claimCur == "" && len(expenses) > 0 {
claimCur = expenses[0].Currency
}
pdf.SetFont("Helvetica", "B", 10)
pdf.Cell(0, 8, fmt.Sprintf("Total Claim: %.2f %s", totalClaim, claimCur))
pdf.Ln(12)
// Group expenses by event.
expensesByEvent := make(map[string][]database.Expense)
eventNames := make(map[string]string)
for _, evt := range events {
eventNames[evt.ID] = evt.Name
}
for _, exp := range expenses {
expensesByEvent[exp.EventID] = append(expensesByEvent[exp.EventID], exp)
}
itemNum := 1
// Landscape A4: 297mm wide, 10mm margins → 277mm usable.
colWidths := []float64{7, 22, 52, 18, 12, 18, 12, 36, 100}
headers := []string{"#", "Date", "Merchant", "Local Amt", "Cur", "Claim Amt", "Claim", "Category", "Description"}
marginBottom := 18.0
for _, evt := range events {
evtExpenses := expensesByEvent[evt.ID]
if len(evtExpenses) == 0 {
continue
}
// Event section header.
if pdf.GetY() > 180 {
pdf.AddPage()
}
pdf.SetFont("Helvetica", "B", 10)
pdf.Cell(0, 8, eventNames[evt.ID])
pdf.Ln(9)
// Column headers.
pdf.SetFont("Helvetica", "B", 8)
for j, h := range headers {
pdf.Cell(colWidths[j], 7, h)
}
pdf.Ln(7)
var evtLocal, evtClaim float64
pdf.SetFont("Helvetica", "", 8)
for _, exp := range evtExpenses {
if pdf.GetY() > 210-marginBottom {
pdf.AddPage()
pdf.SetFont("Helvetica", "B", 8)
for j, h := range headers {
pdf.Cell(colWidths[j], 7, h)
}
pdf.Ln(7)
pdf.SetFont("Helvetica", "", 8)
}
claimAmt := exp.ConvertedAmount
claimCur := exp.BaseCurrency
if claimAmt <= 0 {
claimAmt = exp.Amount
}
if claimCur == "" {
claimCur = exp.Currency
}
pdf.Cell(colWidths[0], 6, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 6, exp.Date)
pdf.Cell(colWidths[2], 6, exp.Merchant)
pdf.Cell(colWidths[3], 6, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 6, exp.Currency)
pdf.Cell(colWidths[5], 6, fmt.Sprintf("%.2f", claimAmt))
pdf.Cell(colWidths[6], 6, claimCur)
pdf.Cell(colWidths[7], 6, exp.Category)
pdf.Cell(colWidths[8], 6, exp.Description)
pdf.Ln(6)
evtLocal += exp.Amount
evtClaim += claimAmt
itemNum++
}
// Event subtotal in both local and claim currency.
pdf.SetDrawColor(71, 85, 105)
pdf.Line(10, pdf.GetY()+1, 287, pdf.GetY()+1)
pdf.Ln(3)
pdf.SetFont("Helvetica", "B", 9)
pdf.Cell(colWidths[0], 8, "")
pdf.Cell(colWidths[1], 8, "")
pdf.Cell(colWidths[2], 8, fmt.Sprintf("%s Total", eventNames[evt.ID]))
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", evtLocal))
pdf.Cell(colWidths[4], 8, "")
pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", evtClaim))
pdf.Cell(colWidths[6], 8, "")
pdf.Cell(colWidths[7], 8, "")
pdf.Cell(colWidths[8], 8, "")
pdf.Ln(10)
}
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("PDF output: %w", err)
}
filename := fmt.Sprintf("monthly-%s-report.pdf", sanitiseFilename(monthName))
return &email.Attachment{
Filename: filename,
Content: buf.Bytes(),
}, nil
}
// parseMonthName extracts year and month number from a name like "July 2026".
// Returns (0, 0) if parsing fails.
func parseMonthName(name string) (year, month int) {
parts := strings.Fields(name)
if len(parts) < 2 {
return 0, 0
}
months := map[string]int{
"january": 1, "february": 2, "march": 3, "april": 4,
"may": 5, "june": 6, "july": 7, "august": 8,
"september": 9, "october": 10, "november": 11, "december": 12,
}
m, ok := months[strings.ToLower(parts[0])]
if !ok {
return 0, 0
}
y, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0
}
return y, m
}

View file

@ -1,63 +0,0 @@
package handlers
import (
"html/template"
"log"
"path/filepath"
"sync"
)
var (
templatesOnce sync.Once
templates map[string]*template.Template
)
// getTemplate returns a cached template by filename (e.g. "dashboard.html").
// Templates are parsed once from the templates/ directory on first call.
func getTemplate(name string) *template.Template {
templatesOnce.Do(loadTemplates)
t := templates[name]
if t == nil {
log.Panicf("template %q not found in cache — did you delete templates/%s?", name, name)
}
return t
}
// loadTemplates walks the templates/ directory and pre-parses all .html files.
func loadTemplates() {
templates = make(map[string]*template.Template)
files, err := filepath.Glob("templates/*.html")
if err != nil {
log.Panicf("list templates: %v", err)
}
// Parse each file into its own named template.
for _, f := range files {
name := filepath.Base(f)
t, err := template.ParseFiles(f)
if err != nil {
log.Panicf("parse template %s: %v", f, err)
}
templates[name] = t
}
// Also register the inline OTP form template.
otpTmpl := template.Must(template.New("otp_form").Parse(otpFormHTML))
templates["otp_form"] = otpTmpl
log.Printf("Loaded %d templates", len(templates))
}
// otpFormHTML is the inline OTP form template fragment with single 6-digit field.
const otpFormHTML = `
<form hx-post="/verify-otp" hx-target="#otp-form" hx-swap="innerHTML">
<input type="hidden" name="email" value="{{.Email}}">
{{if .Error}}<div class="error-message" style="color: #fca5a5; background: #450a0a; border: 1px solid #7f1d1d; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">{{.Error}}</div>{{end}}
<div class="form-group" style="margin: 1rem 0;">
<input type="text" name="otp_code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required
placeholder="Enter 6-digit code"
style="width: 100%; padding: 1rem; font-size: 1.5rem; text-align: center; letter-spacing: 0.75rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc; box-sizing: border-box;">
</div>
<button type="submit" class="btn btn-primary btn-block">Verify Code</button>
</form>`

View file

@ -1,18 +1,11 @@
// Package utils provides common utility functions for NextExpense. // Package utils provides common utility functions for ExpenseFlow.
package utils package utils
import ( import (
"time"
"github.com/google/uuid" "github.com/google/uuid"
) )
// NewUUID generates a new UUID v4 string. // New generates a new UUID v4 string.
func NewUUID() string { func New() string {
return uuid.New().String() return uuid.New().String()
} }
// Timestamp returns the current UTC time formatted as RFC3339.
func Timestamp() string {
return time.Now().UTC().Format(time.RFC3339)
}

193
main.go
View file

@ -1,8 +1,8 @@
// NextExpense — AI-Powered Expense Tracker // ExpenseFlow — AI-Powered Expense Tracker
// //
// A production-ready, mobile-first Progressive Web App (PWA) that uses // A production-ready, mobile-first Progressive Web App (PWA) that uses
// passwordless email OTP login, event-based expense tracking, AI receipt // passwordless email OTP login, event-based expense tracking, AI receipt
// extraction (Gemini / OpenAI), and event filing (CSV/PDF via email). // extraction (DeepSeek Vision), and event filing (CSV/PDF via email).
// //
// Usage: // Usage:
// Copy .env.example to .env and fill in credentials, then: // Copy .env.example to .env and fill in credentials, then:
@ -13,25 +13,19 @@
package main package main
import ( import (
"context"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"github.com/cclohmar/NextExpense/internal/auth" "github.com/expenseflow/internal/auth"
"github.com/cclohmar/NextExpense/internal/database" "github.com/expenseflow/internal/database"
"github.com/cclohmar/NextExpense/internal/email" "github.com/expenseflow/internal/email"
"github.com/cclohmar/NextExpense/internal/handlers" "github.com/expenseflow/internal/handlers"
"github.com/cclohmar/NextExpense/internal/utils"
) )
func main() { func main() {
@ -41,7 +35,8 @@ func main() {
// Load environment variables from .env file (if present). // Load environment variables from .env file (if present).
if err := godotenv.Load(); err != nil { if err := godotenv.Load(); err != nil {
log.Printf("INFO main: no .env file found, using system environment") log.Printf("INFO [%s] main: no .env file found, using system environment",
time.Now().Format(time.RFC3339))
} }
port := os.Getenv("PORT") port := os.Getenv("PORT")
@ -55,13 +50,16 @@ func main() {
smtpUser := os.Getenv("SMTP_USER") smtpUser := os.Getenv("SMTP_USER")
smtpPass := os.Getenv("SMTP_PASS") smtpPass := os.Getenv("SMTP_PASS")
// DeepSeek API key is read directly by the ai package.
_ = os.Getenv("DEEPSEEK_API_KEY")
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Database // Database
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
db, err := database.Init() db, err := database.Init()
if err != nil { if err != nil {
log.Fatalf("FATAL main: database init: %v", err) log.Fatalf("FATAL [%s] main: database init: %v", time.Now().Format(time.RFC3339), err)
} }
defer db.Close() defer db.Close()
@ -72,21 +70,15 @@ func main() {
sessionStore := auth.NewSessionStore() sessionStore := auth.NewSessionStore()
failureTracker := auth.NewFailureTracker() failureTracker := auth.NewFailureTracker()
// Start background session cleanup.
go func() {
for {
time.Sleep(15 * time.Minute)
sessionStore.Cleanup()
}
}()
// Create the email sender only if SMTP credentials are configured. // Create the email sender only if SMTP credentials are configured.
var emailSender *email.Sender var emailSender *email.Sender
if smtpHost != "" && smtpPort != "" && smtpUser != "" && smtpPass != "" { if smtpHost != "" && smtpPort != "" && smtpUser != "" && smtpPass != "" {
emailSender = email.NewSender(smtpHost, smtpPort, smtpUser, smtpPass, smtpUser) emailSender = email.NewSender(smtpHost, smtpPort, smtpUser, smtpPass, "post@2-4-h.app")
log.Printf("INFO main: SMTP sender configured (%s:%s)", smtpHost, smtpPort) log.Printf("INFO [%s] main: SMTP sender configured (%s:%s)",
time.Now().Format(time.RFC3339), smtpHost, smtpPort)
} else { } else {
log.Printf("WARN main: SMTP not configured — OTP emails will not be sent") log.Printf("WARN [%s] main: SMTP not configured — OTP emails will not be sent",
time.Now().Format(time.RFC3339))
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -100,9 +92,6 @@ func main() {
EmailSender: emailSender, EmailSender: emailSender,
} }
monthHandler := handlers.NewMonthHandler(db)
monthHandler.EmailSender = emailSender
eventHandler := handlers.NewEventHandler(db) eventHandler := handlers.NewEventHandler(db)
expenseHandler := handlers.NewExpenseHandler(db) expenseHandler := handlers.NewExpenseHandler(db)
fileHandler := &handlers.FileHandler{ fileHandler := &handlers.FileHandler{
@ -110,9 +99,6 @@ func main() {
EmailSender: emailSender, EmailSender: emailSender,
} }
// Start background cleanup of expired download packages.
fileHandler.StartDownloadCleanup()
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Router // Router
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -124,38 +110,6 @@ func main() {
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(middleware.RealIP) r.Use(middleware.RealIP)
// Request body size limit (10 MB) on all endpoints.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 11<<20)
next.ServeHTTP(w, r)
})
})
// Security headers.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; script-src 'self' https://unpkg.com/htmx.org@1.9.10 'unsafe-inline'; style-src 'self' 'unsafe-inline'")
next.ServeHTTP(w, r)
})
})
// Request ID middleware for log tracing.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqID := r.Header.Get("X-Request-ID")
if reqID == "" {
reqID = utils.NewUUID()[:8]
}
ctx := context.WithValue(r.Context(), "req_id", reqID)
next.ServeHTTP(w, r.WithContext(ctx))
})
})
// PWA headers for service worker. // PWA headers for service worker.
r.Use(func(next http.Handler) http.Handler { r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -182,16 +136,8 @@ func main() {
http.ServeFile(w, r, "static/manifest.json") http.ServeFile(w, r, "static/manifest.json")
})) }))
// iOS PWA / Safari root-level icon requests. // Serve uploaded receipt images.
r.Get("/apple-touch-icon.png", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r.Get("/storage/*", http.StripPrefix("/storage/", http.FileServer(http.Dir("storage"))).ServeHTTP)
http.ServeFile(w, r, "static/icons/icon-180.png")
}))
r.Get("/apple-touch-icon-120x120.png", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/icons/icon-180.png")
}))
r.Get("/favicon.ico", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/favicon.svg")
}))
// ---- Public routes (no auth required) ---- // ---- Public routes (no auth required) ----
@ -199,66 +145,38 @@ func main() {
r.Post("/request-otp", authHandler.RequestOTP) r.Post("/request-otp", authHandler.RequestOTP)
r.Post("/verify-otp", authHandler.VerifyOTP) r.Post("/verify-otp", authHandler.VerifyOTP)
// Download link (token-based auth, no login required). // ---- Logout ----
r.Get("/dl/{token}", fileHandler.ServeDownload)
r.Get("/dl/{token}/{name}", fileHandler.ServeDownload)
// ---- Logout (invalidates server-side session + clears cookie) ---- r.Post("/logout", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Delete the session cookie.
r.Post("/logout", authHandler.Logout) 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) ---- // ---- Protected routes (auth required) ----
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(authHandler.RequireAuth) r.Use(authHandler.RequireAuth)
// Dashboard (months). // Events.
r.Get("/dashboard", monthHandler.ListMonths) r.Get("/dashboard", eventHandler.Dashboard)
r.Get("/onboarding", authHandler.OnboardingPage) r.Post("/events", eventHandler.CreateEvent)
r.Post("/onboarding", authHandler.SaveOnboarding) r.Put("/events/{id}/reopen", eventHandler.ReopenEvent)
r.Get("/profile", authHandler.ProfilePage) r.Get("/events/{id}/expenses", eventHandler.ViewEventExpenses)
r.Post("/profile", authHandler.SaveProfile)
// Months.
r.Post("/months", monthHandler.CreateMonth)
r.Put("/months/{mid}", monthHandler.UpdateMonth)
r.Delete("/months/{mid}", monthHandler.DeleteMonth)
r.Get("/months/{mid}/edit", monthHandler.EditMonth)
r.Get("/months/{mid}", monthHandler.ViewMonth)
r.Post("/months/{mid}/generate", monthHandler.GenerateMonthlyReport)
r.Post("/months/{mid}/send-link", monthHandler.SendMonthlyDownloadLink)
// Events (scoped under months).
r.Post("/months/{mid}/events", eventHandler.CreateEvent)
r.Put("/months/{mid}/events/{eid}", eventHandler.UpdateEvent)
r.Get("/months/{mid}/events/{eid}/edit", eventHandler.EditEvent)
r.Delete("/months/{mid}/events/{eid}", eventHandler.DeleteEvent)
r.Put("/months/{mid}/events/{eid}/reopen", eventHandler.ReopenEvent)
r.Post("/months/{mid}/events/{eid}/close", eventHandler.CloseEvent)
r.Get("/months/{mid}/events/{eid}/expenses", eventHandler.ViewEventExpenses)
// Expenses. // Expenses.
r.Post("/expenses/upload", expenseHandler.UploadReceipt) r.Post("/expenses/upload", expenseHandler.UploadReceipt)
r.Post("/expenses", expenseHandler.SaveExpense) r.Post("/expenses", expenseHandler.SaveExpense)
r.Get("/expenses/{id}/edit", expenseHandler.EditExpense)
r.Put("/expenses/{id}", expenseHandler.UpdateExpense)
r.Delete("/expenses/{id}", expenseHandler.DeleteExpense)
// Filing (event-level). // Filing.
r.Post("/months/{mid}/events/{eid}/file", fileHandler.FileEvent) r.Post("/events/{id}/file", fileHandler.FileEvent)
r.Post("/months/{mid}/events/{eid}/generate", fileHandler.GenerateReport)
r.Post("/months/{mid}/events/{eid}/send-link", fileHandler.SendDownloadLink)
// Storage (receipt images) — protected by auth + path traversal check.
r.With(authHandler.RequireAuth).Get("/storage/*", func(w http.ResponseWriter, r *http.Request) {
imagePath := strings.TrimPrefix(r.URL.Path, "/storage/")
cleanPath := filepath.Clean(imagePath)
if strings.HasPrefix(cleanPath, "..") || strings.Contains(cleanPath, "../") {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
http.ServeFile(w, r, filepath.Join("storage", cleanPath))
})
}) })
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -266,33 +184,12 @@ func main() {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
addr := ":" + port addr := ":" + port
srv := &http.Server{ log.Printf("INFO [%s] main: ExpenseFlow server starting on %s",
Addr: addr, time.Now().Format(time.RFC3339), addr)
Handler: r, log.Printf("INFO [%s] main: open http://localhost%s in your browser",
ReadHeaderTimeout: 10 * time.Second, time.Now().Format(time.RFC3339), addr)
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Graceful shutdown on SIGINT / SIGTERM. if err := http.ListenAndServe(addr, r); err != nil {
go func() { log.Fatalf("FATAL [%s] main: server error: %v", time.Now().Format(time.RFC3339), err)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
log.Printf("INFO main: received signal %v, shutting down...", sig)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("ERROR main: graceful shutdown: %v", err)
} }
}()
log.Printf("INFO main: NextExpense server starting on %s", addr)
log.Printf("INFO main: open http://localhost%s in your browser", addr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("FATAL main: server error: %v", err)
}
log.Printf("INFO main: server stopped")
} }

View file

@ -1,47 +1,47 @@
/* ========================================================================== /* ==========================================================================
NextExpense PWA Expense Tracker Stylesheet ExpenseFlow PWA Expense Tracker Stylesheet
"The Terminal Mint" dark palette | Mobile-first responsive Mobile-first responsive | Plain CSS | System-ui font
========================================================================== */ ========================================================================== */
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
0. CSS Custom Properties (Design Tokens) 0. CSS Custom Properties (Design Tokens)
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
:root { :root {
/* Colors — "The Terminal Mint" Dark Palette */ /* Colors */
--color-primary: #10b981; --color-primary: #10b981;
--color-primary-hover: #34d399; --color-primary-hover: #059669;
--color-primary-light: #064e3b; --color-primary-light: #d1fae5;
--color-primary-dark: #047857; --color-primary-dark: #047857;
--color-secondary: #334155; --color-secondary: #1e293b;
--color-secondary-hover: #475569; --color-secondary-hover: #334155;
--color-bg: #0f172a; --color-bg: #f8fafc;
--color-card: #1e293b; --color-card: #ffffff;
--color-text: #f8fafc; --color-text: #0f172a;
--color-text-muted: #94a3b8; --color-text-muted: #64748b;
--color-text-light: #64748b; --color-text-light: #94a3b8;
--color-border: #334155; --color-border: #e2e8f0;
--color-border-focus: #10b981; --color-border-focus: #10b981;
--color-danger: #ef4444; --color-danger: #ef4444;
--color-danger-hover: #f87171; --color-danger-hover: #dc2626;
--color-danger-light: #450a0a; --color-danger-light: #fef2f2;
--color-success: #10b981; --color-success: #10b981;
--color-warning: #f59e0b; --color-warning: #f59e0b;
--color-open-bg: #064e3b; --color-open-bg: #d1fae5;
--color-open-text: #6ee7b7; --color-open-text: #065f46;
--color-closed-bg: #334155; --color-closed-bg: #f1f5f9;
--color-closed-text: #cbd5e1; --color-closed-text: #475569;
/* Shadows — more pronounced on dark bg */ /* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2); --shadow-md: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.2); --shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 10px 25px rgba(0, 0, 0, 0.4); --shadow-xl: 0 10px 25px rgba(0, 0, 0, 0.08);
/* Border radius */ /* Border radius */
--radius-sm: 6px; --radius-sm: 6px;
@ -129,23 +129,6 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* Center app in a mobile-width shell on desktop */
.app-shell {
max-width: 480px;
margin: 0 auto;
min-height: 100vh;
min-height: 100dvh;
border-left: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
box-shadow: 0 0 40px rgba(0, 0, 0, 0.3);
}
@media (min-width: 481px) {
body {
background-color: #070d19;
}
}
img { img {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
@ -330,15 +313,11 @@ small, .text-sm {
} }
.app-main { .app-main {
padding: var(--space-6) var(--space-4) var(--space-10); padding: var(--space-4) 0 var(--space-8);
min-height: calc(100vh - var(--header-height)); min-height: calc(100vh - var(--header-height));
min-height: calc(100dvh - var(--header-height)); min-height: calc(100dvh - var(--header-height));
} }
.main-content {
padding: var(--space-5) var(--space-4) var(--space-10);
}
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
5. Cards 5. Cards
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@ -390,9 +369,9 @@ small, .text-sm {
/* Login card — centered single-column */ /* Login card — centered single-column */
.login-card { .login-card {
width: 100%; width: 100%;
max-width: 420px; max-width: 400px;
margin: var(--space-12) auto; margin: var(--space-12) auto;
padding: var(--space-10) var(--space-8); padding: var(--space-8) var(--space-6);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
} }
@ -522,7 +501,7 @@ small, .text-sm {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--space-2); gap: var(--space-2);
padding: var(--space-3) var(--space-5); padding: var(--space-2) var(--space-4);
font-family: inherit; font-family: inherit;
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: var(--font-medium); font-weight: var(--font-medium);
@ -682,17 +661,15 @@ small, .text-sm {
9. Form Inputs 9. Form Inputs
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
.form-group { .form-group {
margin-bottom: var(--space-5); margin-bottom: var(--space-4);
} }
.form-label { .form-label {
display: block; display: block;
font-size: var(--text-xs); font-size: var(--text-sm);
font-weight: var(--font-semibold); font-weight: var(--font-medium);
color: var(--color-text-muted); color: var(--color-text);
margin-bottom: var(--space-1); margin-bottom: var(--space-1);
text-transform: uppercase;
letter-spacing: 0.05em;
} }
.form-label--required::after { .form-label--required::after {
@ -700,44 +677,15 @@ small, .text-sm {
color: var(--color-danger); color: var(--color-danger);
} }
/* Bare input/select/textarea inside form-group get the same styling */
.form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]),
.form-group select,
.form-group textarea {
display: block;
width: 100%;
padding: var(--space-4) var(--space-4);
font-family: inherit;
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
color: var(--color-text);
background-color: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
-webkit-appearance: none;
appearance: none;
box-sizing: border-box;
}
.form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"])::placeholder,
.form-group textarea::placeholder {
color: var(--color-text-light);
font-size: var(--text-base);
}
/* Also keep the class-based selectors for explicit usage */
.form-input, .form-input,
.form-select, .form-select,
.form-textarea { .form-textarea {
display: block; display: block;
width: 100%; width: 100%;
padding: var(--space-4) var(--space-4); padding: var(--space-3) var(--space-3);
font-family: inherit; font-family: inherit;
font-size: var(--text-lg); font-size: var(--text-base);
line-height: var(--leading-relaxed); line-height: var(--leading-normal);
color: var(--color-text); color: var(--color-text);
background-color: var(--color-card); background-color: var(--color-card);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@ -747,27 +695,19 @@ small, .text-sm {
box-shadow var(--transition-fast); box-shadow var(--transition-fast);
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
box-sizing: border-box;
} }
.form-input::placeholder, .form-input::placeholder,
.form-textarea::placeholder { .form-textarea::placeholder {
color: var(--color-text-light); color: var(--color-text-light);
font-size: var(--text-base);
} }
.form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]):hover,
.form-group select:hover,
.form-group textarea:hover,
.form-input:hover, .form-input:hover,
.form-select:hover, .form-select:hover,
.form-textarea:hover { .form-textarea:hover {
border-color: var(--color-text-light); border-color: var(--color-text-light);
} }
.form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]):focus,
.form-group select:focus,
.form-group textarea:focus,
.form-input:focus, .form-input:focus,
.form-select:focus, .form-select:focus,
.form-textarea:focus { .form-textarea:focus {
@ -793,24 +733,11 @@ small, .text-sm {
color: var(--color-danger); color: var(--color-danger);
} }
.form-row {
display: flex;
gap: var(--space-4);
}
@media (max-width: 480px) {
.form-row {
flex-direction: column;
gap: var(--space-3);
}
}
.form-textarea { .form-textarea {
min-height: 100px; min-height: 100px;
resize: vertical; resize: vertical;
} }
.form-group select,
.form-select { .form-select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat; background-repeat: no-repeat;
@ -821,9 +748,6 @@ small, .text-sm {
/* Prevent zoom on mobile for inputs */ /* Prevent zoom on mobile for inputs */
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]),
.form-group select,
.form-group textarea,
.form-input, .form-input,
.form-select, .form-select,
.form-textarea { .form-textarea {
@ -1919,13 +1843,3 @@ small, .text-sm {
.animate-stagger > *:nth-child(4) { animation-delay: 180ms; } .animate-stagger > *:nth-child(4) { animation-delay: 180ms; }
.animate-stagger > *:nth-child(5) { animation-delay: 240ms; } .animate-stagger > *:nth-child(5) { animation-delay: 240ms; }
.animate-stagger > *:nth-child(6) { animation-delay: 300ms; } .animate-stagger > *:nth-child(6) { animation-delay: 300ms; }
/* Month card — left border accent distinguishes from event cards */
.month-card {
transition: border-color var(--transition-base), background var(--transition-base);
}
.month-card:hover {
border-color: var(--color-primary);
background: rgba(16, 185, 129, 0.05);
}

View file

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#10b981"/>
<text x="32" y="42" font-family="Georgia, 'Times New Roman', serif" font-size="36" font-weight="bold" fill="#ffffff" text-anchor="middle" letter-spacing="-2">Nx</text>
</svg>

Before

Width:  |  Height:  |  Size: 317 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -1,10 +1,10 @@
{ {
"name": "NextExpense", "name": "ExpenseFlow",
"short_name": "NextExpense", "short_name": "ExpenseFlow",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"theme_color": "#10b981", "theme_color": "#10b981",
"background_color": "#0f172a", "background_color": "#ffffff",
"icons": [ "icons": [
{ {
"src": "/static/icons/icon-192.png", "src": "/static/icons/icon-192.png",

View file

@ -1,17 +1,16 @@
/* ============================================================ /* ============================================================
* NextExpense Service Worker * ExpenseFlow Service Worker
* Version: 2.0.0 * Version: 1.0.0
* Cache name: nextexpense-v2 * Cache name: expenseflow-v1
* Strategy: Cache-first for shell assets, network-only for API * Strategy: Cache-first for shell assets, network-only for API
* ============================================================ */ * ============================================================ */
const CACHE_NAME = 'nextexpense-v2'; const CACHE_NAME = 'expenseflow-v1';
// Shell assets to pre-cache on install // Shell assets to pre-cache on install
const SHELL_ASSETS = [ const SHELL_ASSETS = [
'/', '/',
'/static/css/style.css', '/static/css/style.css',
'/static/favicon.svg',
'https://unpkg.com/htmx.org@1.9.10' 'https://unpkg.com/htmx.org@1.9.10'
]; ];
@ -195,7 +194,7 @@ self.addEventListener('fetch', event => {
// Return a minimal offline fallback for navigations // Return a minimal offline fallback for navigations
if (request.mode === 'navigate') { if (request.mode === 'navigate') {
return new Response( return new Response(
'<!DOCTYPE html><html><head><title>Offline — NextExpense</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:#0f172a;color:#f8fafc}h1{font-size:1.5rem;margin-bottom:0.5rem}p{color:#94a3b8;max-width:24rem}</style></head><body><h1>You\'re offline</h1><p>NextExpense needs an internet connection to load. Please check your connection and try again.</p></body></html>', '<!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, status: 503,
statusText: 'Service Unavailable', statusText: 'Service Unavailable',

View file

@ -4,115 +4,92 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <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="theme-color" content="#10b981">
<title>NextExpense</title> <title>Dashboard - ExpenseFlow</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="alternate icon" href="/static/icons/icon-192.png">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/static/css/style.css?v=5"> <link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head> </head>
<body> <body>
<div class="app-shell"> <div class="app-shell">
<header class="app-header"> <header class="app-header">
<h1 class="app-title"><img src="/static/favicon.svg" width="24" height="24" alt="" style="vertical-align: middle; margin-right: 0.5rem;">NextExpense</h1> <h1 class="app-title">ExpenseFlow</h1>
<div style="display: flex; gap: 0.5rem;"> <a href="/" class="btn btn-secondary btn-sm"
<button class="btn btn-secondary btn-sm" style="font-size: 0.75rem;" hx-post="/logout" hx-target="body" hx-push-url="true"
hx-get="/profile" hx-target="body" hx-push-url="true">Profile</button> hx-confirm="Are you sure you want to logout?">Logout</a>
<button class="btn btn-secondary btn-sm" style="font-size: 0.75rem;"
hx-post="/logout" hx-target="body" hx-push-url="true">Logout</button>
</div>
</header> </header>
<main class="main-content"> <main class="main-content">
<div class="dashboard-header"> <div class="dashboard-header">
<h2>My Months</h2> <h2>My Events</h2>
<button class="btn btn-primary btn-sm" <button class="btn btn-primary"
onclick="document.getElementById('create-form').classList.toggle('hidden')"> onclick="document.getElementById('create-event-form').classList.toggle('hidden')">
+ New + New Event
</button> </button>
</div> </div>
<div id="create-form" class="hidden" style="margin-bottom: 1rem;"> <div id="create-event-form" class="hidden" style="margin-bottom: 1.5rem;">
<div class="card" style="padding: 1rem;"> <div class="card" style="padding: 1rem;">
<h3 style="margin-bottom: 1rem;">New Month</h3> <form hx-post="/events" hx-target="body" hx-push-url="true">
<form hx-post="/months" hx-target="body" hx-push-url="true"> <div class="form-group">
<div class="form-row" style="gap: 1rem;"> <label for="name">Event Name</label>
<div style="flex: 2;"> <input type="text" id="name" name="name" placeholder="e.g., WebSummit 2026" required>
<label class="form-label">Month</label>
<select name="month" required style="width: 100%; padding: 0.6rem; border: 1px solid var(--color-border); border-radius: 0.375rem; background: #1e293b; color: #f8fafc; font-size: 0.9rem;">
<option value="">Select</option>
<option value="January">January</option>
<option value="February">February</option>
<option value="March">March</option>
<option value="April">April</option>
<option value="May">May</option>
<option value="June">June</option>
<option value="July">July</option>
<option value="August">August</option>
<option value="September">September</option>
<option value="October">October</option>
<option value="November">November</option>
<option value="December">December</option>
</select>
</div> </div>
<div style="flex: 1;"> <button type="submit" class="btn btn-primary btn-block">Create Event</button>
<label class="form-label">Year</label>
<select name="year" required style="width: 100%; padding: 0.6rem; border: 1px solid var(--color-border); border-radius: 0.375rem; background: #1e293b; color: #f8fafc; font-size: 0.9rem;">
<option value="">Select</option>
<option value="2024">2024</option>
<option value="2025">2025</option>
<option value="2026" selected>2026</option>
<option value="2027">2027</option>
<option value="2028">2028</option>
<option value="2029">2029</option>
<option value="2030">2030</option>
</select>
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">Create Month</button>
</form> </form>
</div> </div>
</div> </div>
<div id="month-list"> <div id="event-list">
{{if .Months}} {{if .Events}}
<div style="display: flex; flex-direction: column; gap: 0.5rem;"> <div class="event-grid">
{{range .Months}} {{range .Events}}
<div class="month-card" style="display: flex; align-items: center; justify-content: space-between; background: var(--color-card); border: 1px solid var(--color-border); border-left: 4px solid var(--color-primary); border-radius: 0.5rem; padding: 0.75rem 1rem;"> <div class="card event-card">
<div> <div class="event-card-header">
<div style="font-weight: 500; color: var(--color-text);">{{.Month.Name}}</div> <h3 class="event-card-name">{{.Name}}</h3>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">{{.Month.CreatedAt}}</div> <span id="status-badge-{{.ID}}" class="badge badge-{{.Status}}">{{.Status}}</span>
</div> </div>
{{if .Total}} <div class="event-card-meta">
<div style="text-align: right; margin-right: 0.75rem;"> <span>Created: {{.CreatedAt}}</span>
<div style="font-size: 0.7rem; color: var(--color-text-muted);">claim</div>
<div style="font-weight: 600; color: var(--color-primary); font-size: 0.95rem;">{{printf "%.2f" .Total}}</div>
</div> </div>
{{end}} <div class="event-card-actions">
<div style="display: flex; gap: 0.5rem;"> {{if eq .Status "open"}}
<button class="btn btn-primary btn-sm" <a href="/events/{{.ID}}/expenses" class="btn btn-primary btn-sm"
hx-get="/months/{{.Month.ID}}" hx-target="body" hx-push-url="true"> hx-get="/events/{{.ID}}/expenses" hx-target="body" hx-push-url="true">
Open Add Expenses
</button> </a>
{{else}}
<button class="btn btn-secondary btn-sm" <button class="btn btn-secondary btn-sm"
hx-get="/months/{{.Month.ID}}/edit" hx-put="/events/{{.ID}}/reopen"
hx-target="#create-form" hx-target="#status-badge-{{.ID}}"
hx-swap="innerHTML" hx-swap="outerHTML">
onclick="document.getElementById('create-form').classList.remove('hidden')" Reopen
style="font-size: 0.75rem;">
Edit
</button> </button>
{{end}}
</div> </div>
</div> </div>
{{end}} {{end}}
</div> </div>
{{else}} {{else}}
<div class="empty-state" style="text-align: center; padding: 3rem; color: #94a3b8;"> <div class="empty-state">
<p>No months yet. Create one to get started.</p> <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> </div>
{{end}} {{end}}
</div> </div>
</main> </main>
</div> </div>
<script>
// Toggle hidden class
document.querySelector('button[onclick]')?.addEventListener('click', function() {
// handled inline
});
</script>
</body> </body>
</html> </html>

View file

@ -4,135 +4,143 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <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="theme-color" content="#10b981">
<title>{{.Event.Name}} - NextExpense</title> <title>{{.Event.Name}} - ExpenseFlow</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="alternate icon" href="/static/icons/icon-192.png">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/static/css/style.css?v=5"> <link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head> </head>
<body> <body>
<div class="app-shell"> <div class="app-shell">
<header class="app-header"> <header class="app-header">
<a href="/months/{{.Month.ID}}" class="btn btn-secondary btn-sm" <a href="/dashboard" class="btn btn-secondary btn-sm"
hx-get="/months/{{.Month.ID}}" hx-target="body" hx-push-url="true">&larr; {{.Month.Name}}</a> hx-get="/dashboard" hx-target="body" hx-push-url="true">
<h1 class="app-title" style="font-size: 1.1rem;">{{.Event.Name}}</h1> &larr; Back
<button class="btn btn-secondary btn-sm" style="font-size: 0.75rem;" </a>
hx-post="/logout" hx-target="body" hx-push-url="true">Logout</button> <h1 class="app-title">{{.Event.Name}}</h1>
<span class="badge badge-{{.Event.Status}}">{{.Event.Status}}</span>
</header> </header>
<main class="main-content"> <main class="main-content">
<!-- Breadcrumb --> <!-- Capture Receipt Button -->
<div style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 1rem;"> <div style="margin-bottom: 1.5rem;">
<a href="/dashboard" style="color: var(--color-primary); text-decoration: none;" <label for="receipt-upload" class="btn btn-primary btn-block" style="display: inline-block; text-align: center; cursor: pointer;">
hx-get="/dashboard" hx-target="body" hx-push-url="true">Dashboard</a> <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;">
&rsaquo; <a href="/months/{{.Month.ID}}" style="color: var(--color-primary); text-decoration: none;" <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"/>
hx-get="/months/{{.Month.ID}}" hx-target="body" hx-push-url="true">{{.Month.Name}}</a> <circle cx="12" cy="13" r="4"/>
&rsaquo; {{.Event.Name}} </svg>
</div> Capture Receipt
<!-- Event Metadata -->
<div style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<div style="font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem;">Event Details</div>
<div class="form-row" style="margin:0;">
<div style="flex:2; font-size:0.9rem; color:var(--color-text);">{{.Event.Name}}</div>
<div style="flex:1; font-size:0.9rem; color:var(--color-text);">{{.Event.BaseCurrency}}</div>
<div style="flex:1; font-size:0.9rem; color:var(--color-text-muted);">{{printf "%.6f" .Event.ExchangeRate}}</div>
</div>
</div>
<!-- Receipt List -->
<div id="expense-list">
<h3 style="margin-bottom: 1rem; font-size: 1rem; font-weight: 600;">
Receipts
{{if .Event.BaseCurrency}}
<span style="font-weight: 400; color: var(--color-text-muted);">(claim in {{.Event.BaseCurrency}})</span>
{{end}}
</h3>
{{if .Expenses}}
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
{{range .Expenses}}
<div style="display: flex; align-items: center; background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 0.75rem;">
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; color: var(--color-text);">{{.Merchant}}</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">{{.Date}} · {{.Category}} {{if .Description}}· {{.Description}}{{end}}</div>
</div>
<div style="text-align: right; margin-right: 0.75rem;">
<div style="font-weight: 600; color: var(--color-text);">{{printf "%.2f" .Amount}} {{.Currency}}</div>
{{if .ConvertedAmount}}
<div style="font-size: 0.75rem; color: var(--color-primary);">{{printf "%.2f" .ConvertedAmount}} {{.BaseCurrency}}</div>
{{end}}
</div>
<div style="display: flex; gap: 0.25rem;">
{{if .ImagePath}}
<button class="btn btn-sm" style="background: none; border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: var(--color-text);"
onclick="document.getElementById('img-{{.ID}}').classList.toggle('hidden')"
title="View receipt image">🖼️</button>
{{end}}
<button class="btn btn-sm" style="background: none; border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: var(--color-text);"
hx-get="/expenses/{{.ID}}/edit"
hx-target="#receipt-form"
hx-swap="innerHTML"
title="Edit receipt">✏️</button>
<button class="btn btn-sm" style="background: none; border: 1px solid #7f1d1d; border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: #fca5a5;"
onclick="if(confirm('Delete this receipt?')) htmx.trigger('#del-{{.ID}}','click')"
title="Delete receipt">🗑️</button>
<div hx-delete="/expenses/{{.ID}}" hx-target="#expense-list" hx-swap="outerHTML" id="del-{{.ID}}" style="display:none"></div>
</div>
</div>
{{if .ImagePath}}
<div id="img-{{.ID}}" class="hidden" style="position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 999; align-items: center; justify-content: center; cursor: pointer; padding: 1rem;"
onclick="this.classList.add('hidden')">
<span style="position: absolute; top: 1rem; right: 1rem; font-size: 2rem; color: #fff; line-height: 1; cursor: pointer; z-index: 1000;">&times;</span>
<img src="/storage/{{.ImagePath}}" alt="Receipt" style="max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 0.5rem;" onclick="event.stopPropagation()">
</div>
{{end}}
{{end}}
</div>
{{else}}
<div style="text-align: center; padding: 2rem; color: var(--color-text-muted); font-size: 0.875rem;">
<p>No receipts yet.</p>
</div>
{{end}}
</div>
<!-- Receipt Form (edit/add) -->
<div id="receipt-form" style="margin-top: 1.5rem;"></div>
<!-- Add Receipt Buttons -->
<div style="margin-top: 1.5rem; display: flex; gap: 0.75rem;">
<label for="receipt-camera" class="btn btn-primary" style="flex: 1; text-align: center; cursor: pointer;">
📷 Camera
</label> </label>
<label for="receipt-upload" class="btn btn-secondary" style="flex: 1; text-align: center; cursor: pointer;"> <input type="file" id="receipt-upload" accept="image/*" capture="environment" style="display: none;"
📁 Upload
</label>
</div>
<!-- Hidden file input: camera (mobile) -->
<input type="file" id="receipt-camera" name="receipt" accept="image/*" capture="environment" style="display: none;"
hx-post="/expenses/upload" hx-post="/expenses/upload"
hx-encoding="multipart/form-data" hx-encoding="multipart/form-data"
hx-target="#receipt-form" hx-target="#receipt-form"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-indicator="#upload-indicator" hx-indicator="#upload-indicator">
hx-trigger="change">
<!-- Hidden file input: gallery / PDF upload -->
<input type="file" id="receipt-upload" name="receipt" accept="image/*,.pdf" style="display: none;"
hx-post="/expenses/upload"
hx-encoding="multipart/form-data"
hx-target="#receipt-form"
hx-swap="innerHTML"
hx-indicator="#upload-indicator"
hx-trigger="change">
<div id="upload-indicator" class="htmx-indicator" style="text-align: center; padding: 1rem;"> <div id="upload-indicator" class="htmx-indicator" style="text-align: center; padding: 1rem;">
<div class="spinner"></div> <div class="spinner"></div>
<p>Analyzing receipt...</p> <p>Analyzing receipt...</p>
</div> </div>
</div>
<!-- Receipt Form (filled by AI or empty) -->
<div id="receipt-form"></div>
<!-- Expense List -->
<div id="expense-list">
<h3 style="margin-bottom: 1rem;">Expenses</h3>
{{if .Expenses}}
<div class="expense-list">
{{range .Expenses}}
<div class="expense-item">
<div class="expense-item-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</div>
<div class="expense-item-info">
<div class="expense-item-merchant">{{.Merchant}}</div>
<div class="expense-item-meta">{{.Date}} &middot; {{.Category}}</div>
{{if .Description}}
<div class="expense-item-desc">{{.Description}}</div>
{{end}}
</div>
<div class="expense-item-amount">
<span class="amount">{{printf "%.2f" .Amount}}</span>
<span class="currency">{{.Currency}}</span>
</div>
</div>
{{end}}
</div>
{{else}}
<div class="empty-state" style="padding: 2rem;">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="18"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
<h3>No Expenses Yet</h3>
<p>Capture a receipt to get started.</p>
</div>
{{end}}
</div>
<!-- File Event Button (only for open events) -->
{{if eq .Event.Status "open"}}
<div style="margin-top: 2rem; text-align: center;">
<button class="btn btn-secondary"
onclick="document.getElementById('file-modal').classList.toggle('hidden')">
File Event
</button>
</div>
<!-- File Event Modal -->
<div id="file-modal" class="modal-overlay hidden">
<div class="modal-content">
<h3>File Event Report</h3>
<p class="text-secondary">Generate and email the expense report for "{{.Event.Name}}".</p>
<form hx-post="/events/{{.Event.ID}}/file" hx-target="body" hx-push-url="true">
<div class="form-group">
<label for="file-email">Send to</label>
<input type="email" id="file-email" name="email" placeholder="recipient@example.com" required>
</div>
<div class="form-group">
<label>Format</label>
<div style="display: flex; gap: 1rem;">
<label class="radio-label">
<input type="radio" name="format" value="csv" checked> CSV
</label>
<label class="radio-label">
<input type="radio" name="format" value="pdf"> PDF
</label>
</div>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button" class="btn btn-secondary"
onclick="document.getElementById('file-modal').classList.add('hidden')">Cancel</button>
<button type="submit" class="btn btn-primary">Send Report & Close</button>
</div>
</form>
</div>
</div>
{{end}}
</main> </main>
</div> </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> </body>
</html> </html>

View file

@ -22,41 +22,7 @@
<div class="expense-item-amount"> <div class="expense-item-amount">
<span class="amount">{{printf "%.2f" .Amount}}</span> <span class="amount">{{printf "%.2f" .Amount}}</span>
<span class="currency">{{.Currency}}</span> <span class="currency">{{.Currency}}</span>
{{if .ConvertedAmount}}
<div style="font-size: 0.75rem; color: #166534;">
≈ {{printf "%.2f" .ConvertedAmount}} {{.BaseCurrency}}
</div> </div>
{{end}}
</div>
<div style="display: flex; align-items: center; gap: 0.25rem;">
{{if .ImagePath}}
<button class="btn btn-sm" style="background: none; border: 1px solid #475569; border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer;"
onclick="document.getElementById('img-{{.ID}}').classList.toggle('hidden')"
title="View receipt image">
🖼️
</button>
{{end}}
<button class="btn btn-sm" style="background: none; border: 1px solid #475569; border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer;"
hx-get="/expenses/{{.ID}}/edit"
hx-target="#receipt-form"
hx-swap="innerHTML"
title="Edit expense">
✏️
</button>
<button class="btn btn-sm" style="background: none; border: 1px solid #7f1d1d; border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; color: #fca5a5;"
onclick="if(confirm('Delete this expense?')) htmx.trigger('#del-{{.ID}}','click')"
title="Delete expense">
🗑️
</button>
<div hx-delete="/expenses/{{.ID}}" hx-target="#expense-list" hx-swap="outerHTML" id="del-{{.ID}}" style="display:none"></div>
</div>
{{if .ImagePath}}
<div id="img-{{.ID}}" class="hidden" style="position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 999; align-items: center; justify-content: center; cursor: pointer; padding: 1rem;"
onclick="this.classList.add('hidden')">
<span style="position: absolute; top: 1rem; right: 1rem; font-size: 2rem; color: #fff; line-height: 1; cursor: pointer; z-index: 1000;">&times;</span>
<img src="/storage/{{.ImagePath}}" alt="Receipt" style="max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 0.5rem;" onclick="event.stopPropagation()">
</div>
{{end}}
</div> </div>
{{end}} {{end}}
</div> </div>

View file

@ -6,25 +6,27 @@
<meta name="theme-color" content="#10b981"> <meta name="theme-color" content="#10b981">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default"> <meta name="apple-mobile-web-app-status-bar-style" content="default">
<title>NextExpense</title> <title>ExpenseFlow</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="alternate icon" href="/static/icons/icon-192.png">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-180.png"> <link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<link rel="stylesheet" href="/static/css/style.css?v=4"> <link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head> </head>
<body> <body>
<div class="login-page"> <div class="login-page">
<div class="card login-card"> <div class="card login-card">
<div class="login-brand"> <div class="login-brand">
<div class="login-icon"><img src="/static/favicon.svg" width="48" height="48" alt="Nx"></div> <div class="login-icon">
<h1>NextExpense</h1> <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> <p class="text-secondary">AI-Powered Expense Tracking</p>
</div> </div>
<div id="otp-form"> <div id="otp-form">
<form hx-post="/request-otp" hx-target="#otp-form" hx-swap="innerHTML"> <form hx-post="/request-otp" hx-target="#otp-form" hx-swap="outerHTML">
<div class="form-group"> <div class="form-group">
<label for="email">Email Address</label> <label for="email">Email Address</label>
<input type="email" id="email" name="email" placeholder="you@example.com" required autocomplete="email" inputmode="email"> <input type="email" id="email" name="email" placeholder="you@example.com" required autocomplete="email" inputmode="email">

View file

@ -1,159 +0,0 @@
<!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>{{.Month.Name}} - NextExpense</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="alternate icon" href="/static/icons/icon-192.png">
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/static/css/style.css?v=5">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<div class="app-shell">
<header class="app-header">
<a href="/dashboard" class="btn btn-secondary btn-sm"
hx-get="/dashboard" hx-target="body" hx-push-url="true">&larr; Dashboard</a>
<h1 class="app-title" style="font-size: 1.1rem;">{{.Month.Name}}</h1>
<button class="btn btn-secondary btn-sm" style="font-size: 0.75rem;"
hx-post="/logout" hx-target="body" hx-push-url="true">Logout</button>
</header>
<main class="main-content">
<!-- Breadcrumb -->
<div style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 1rem;">
<a href="/dashboard" style="color: var(--color-primary); text-decoration: none;"
hx-get="/dashboard" hx-target="body" hx-push-url="true">Dashboard</a>
&rsaquo; {{.Month.Name}}
</div>
<div class="dashboard-header">
<h2>Events</h2>
<button class="btn btn-primary btn-sm"
onclick="document.getElementById('create-event-form').classList.toggle('hidden')">
+ New
</button>
</div>
<div id="create-event-form" class="hidden" style="margin-bottom: 1rem;">
<div class="card" style="padding: 1rem;">
<h3 style="margin-bottom: 1rem;">New Event</h3>
<form hx-post="/months/{{.Month.ID}}/events" hx-target="body" hx-push-url="true">
<div class="form-group">
<label class="form-label">Event Name</label>
<input type="text" name="name" placeholder="Event name" required>
</div>
<div style="background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; padding: 1rem; margin-bottom: 0.5rem;">
<div style="font-size: 0.8rem; font-weight: 600; color: #6ee7b7; margin-bottom: 0.75rem;">Conversion Rate</div>
<div class="form-row" style="gap: 1rem;">
<div style="flex: 1;">
<label class="form-label">Claim Amount</label>
<input type="number" name="sample_claim_amount" step="0.01" min="0.01" placeholder="e.g. 7.73" required>
</div>
<div style="flex: 0 0 80px;">
<label class="form-label">Currency</label>
<input type="text" name="base_currency" value="USD" required maxlength="3" style="text-transform: uppercase;">
</div>
</div>
<div class="form-row" style="gap: 1rem; margin-top: 0.5rem;">
<div style="flex: 1;">
<label class="form-label">Local Amount</label>
<input type="number" name="sample_receipt_amount" step="0.01" min="0.01" placeholder="e.g. 1000" required>
</div>
<div style="flex: 0 0 80px;">
<label class="form-label">Currency</label>
<input type="text" name="sample_receipt_currency" value="KES" required maxlength="3" style="text-transform: uppercase;">
</div>
</div>
<div style="font-size: 0.7rem; color: var(--color-text-muted); margin-top: 0.5rem;">
Rate = Claim / Local
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">Create Event</button>
</form>
</div>
</div>
<div id="event-list">
{{if .Events}}
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
{{range .Events}}
<div style="display: flex; align-items: center; justify-content: space-between; background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 0.75rem 1rem;">
<div>
<div style="font-weight: 500; color: var(--color-text);">{{.Event.Name}}</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">
{{.Event.BaseCurrency}} · {{printf "%.6f" .Event.ExchangeRate}} rate
</div>
</div>
{{if .Total}}
<div style="text-align: right; margin-right: 0.75rem;">
<div style="font-size: 0.7rem; color: var(--color-text-muted);">claim</div>
<div style="font-weight: 600; color: var(--color-primary); font-size: 0.95rem;">{{printf "%.2f" .Total}} {{.Currency}}</div>
</div>
{{end}}
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary btn-sm"
hx-get="/months/{{$.Month.ID}}/events/{{.Event.ID}}/expenses" hx-target="body" hx-push-url="true">
Open
</button>
{{if eq .Event.Status "open"}}
<button class="btn btn-secondary btn-sm"
hx-get="/months/{{$.Month.ID}}/events/{{.Event.ID}}/edit"
hx-target="#create-event-form"
hx-swap="innerHTML"
onclick="document.getElementById('create-event-form').classList.remove('hidden')"
style="font-size: 0.75rem;">
Edit
</button>
{{end}}
{{if eq .Event.Status "closed"}}
<button class="btn btn-secondary btn-sm"
hx-put="/months/{{$.Month.ID}}/events/{{.Event.ID}}/reopen"
hx-target="body"
hx-push-url="true"
style="font-size: 0.75rem;">
Reopen
</button>
{{end}}
</div>
</div>
{{end}}
</div>
{{else}}
<div class="empty-state" style="text-align: center; padding: 3rem; color: #94a3b8;">
<p>No events yet. Create one to get started.</p>
</div>
{{end}}
</div>
<!-- Monthly Report Generation -->
{{if .Events}}
<div style="margin-top: 2rem; border-top: 1px solid var(--color-border); padding-top: 1.5rem;">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem;">Monthly Report</h3>
<div id="submit-error"></div>
<div id="report-section">
<form hx-post="/months/{{.Month.ID}}/generate" hx-target="#report-section" hx-swap="outerHTML"
hx-indicator="#generate-spinner">
<div class="form-group">
<label>Generate a complete report with all event expenses and receipt images.</label>
<div style="font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: 0.5rem;">
Includes {{len .Events}} event(s) with all receipts.
</div>
</div>
<div id="generate-spinner" class="htmx-indicator" style="text-align: center; padding: 0.5rem;">
<div class="spinner"></div>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-top: 0.25rem;">Packaging monthly report…</p>
</div>
<button type="submit" class="btn btn-primary btn-block" style="margin-top: 0.5rem;">
Generate Monthly Report
</button>
</form>
</div>
</div>
{{end}}
</main>
</div>
</body>
</html>

View file

@ -1,56 +0,0 @@
<!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>{{if .Editing}}Profile{{else}}Welcome{{end}} - NextExpense</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/style.css?v=4">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<div class="app-shell">
<header class="app-header">
{{if .Editing}}
<a href="/dashboard" class="btn btn-secondary btn-sm"
hx-get="/dashboard" hx-target="body" hx-push-url="true">&larr; Back</a>
<h1 class="app-title" style="font-size: 1.1rem;">Edit Profile</h1>
<span></span>
{{else}}
<h1 class="app-title" style="font-size: 1.1rem;">Welcome to NextExpense</h1>
{{end}}
</header>
<main class="main-content">
{{if .Editing}}
<p style="color: var(--color-text-muted); font-size: 0.9rem; margin-bottom: 1.5rem;">
Update your name or department. This appears on all expense reports.
</p>
{{else}}
<p style="color: var(--color-text-muted); font-size: 0.9rem; margin-bottom: 1.5rem;">
Let's set up your profile. This information will appear on your expense reports.
</p>
{{end}}
<div id="onboarding-error"></div>
<form hx-post="{{if .Editing}}/profile{{else}}/onboarding{{end}}" hx-target="#onboarding-error" hx-swap="innerHTML">
<div class="form-group">
<label class="form-label" for="name">Full Name</label>
<input type="text" id="name" name="name" placeholder="Your name as it should appear on reports"
value="{{.Name}}" required autofocus>
</div>
<div class="form-group">
<label class="form-label" for="department">Department / Employee ID</label>
<input type="text" id="department" name="department" placeholder="e.g. Finance, ENG-1234"
value="{{.Department}}">
</div>
<button type="submit" class="btn btn-primary btn-block" style="margin-top: 0.5rem;">
{{if .Editing}}Save Changes{{else}}Save & Continue{{end}}
</button>
</form>
</main>
</div>
</body>
</html>

View file

@ -1,3 +1,4 @@
<div id="receipt-form">
{{if .AIError}} {{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;"> <div class="error-message" style="background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">
{{.AIError}} {{.AIError}}
@ -14,15 +15,9 @@
<span class="badge badge-open" style="margin-left: auto;">AI Extracted</span> <span class="badge badge-open" style="margin-left: auto;">AI Extracted</span>
</div> </div>
{{if .EditID}}
<form hx-put="/expenses/{{.EditID}}" hx-target="#receipt-form" hx-swap="outerHTML"
hx-indicator="#save-indicator">
{{else}}
<form hx-post="/expenses" hx-target="#receipt-form" hx-swap="outerHTML" <form hx-post="/expenses" hx-target="#receipt-form" hx-swap="outerHTML"
hx-indicator="#save-indicator"> hx-indicator="#save-indicator">
{{end}}
<input type="hidden" name="image_path" value="{{.ImagePath}}"> <input type="hidden" name="image_path" value="{{.ImagePath}}">
{{if .EditID}}<input type="hidden" name="edit_id" value="{{.EditID}}">{{end}}
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
@ -32,8 +27,21 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="currency">Currency *</label> <label for="currency">Currency *</label>
<input type="text" id="currency" name="currency" placeholder="KES, USD, EUR…" <select id="currency" name="currency" required>
value="{{.Currency}}" required maxlength="3" style="text-transform: uppercase;"> <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> </div>
@ -48,22 +56,11 @@
<label for="category">Category *</label> <label for="category">Category *</label>
<select id="category" name="category" required> <select id="category" name="category" required>
<option value="">Select</option> <option value="">Select</option>
<option value="Airfare" {{if eq .Category "Airfare"}}selected{{end}}>Airfare</option> <option value="Food" {{if eq .Category "Food"}}selected{{end}}>Food</option>
<option value="Accommodation" {{if eq .Category "Accommodation"}}selected{{end}}>Accommodation</option> <option value="Travel" {{if eq .Category "Travel"}}selected{{end}}>Travel</option>
<option value="Meals Self" {{if eq .Category "Meals Self"}}selected{{end}}>Meals Self</option> <option value="Lodging" {{if eq .Category "Lodging"}}selected{{end}}>Lodging</option>
<option value="Staff Meal" {{if eq .Category "Staff Meal"}}selected{{end}}>Staff Meal</option> <option value="Software" {{if eq .Category "Software"}}selected{{end}}>Software</option>
<option value="Client Meal" {{if eq .Category "Client Meal"}}selected{{end}}>Client Meal</option> <option value="Other" {{if eq .Category "Other"}}selected{{end}}>Other</option>
<option value="Travel - Taxi" {{if eq .Category "Travel - Taxi"}}selected{{end}}>Travel - Taxi</option>
<option value="Travel - Phone" {{if eq .Category "Travel - Phone"}}selected{{end}}>Travel - Phone</option>
<option value="Misc Travel" {{if eq .Category "Misc Travel"}}selected{{end}}>Misc Travel</option>
<option value="Mobile / Office Phone" {{if eq .Category "Mobile / Office Phone"}}selected{{end}}>Mobile / Office Phone</option>
<option value="Office Supplies" {{if eq .Category "Office Supplies"}}selected{{end}}>Office Supplies</option>
<option value="Postage / Couriers" {{if eq .Category "Postage / Couriers"}}selected{{end}}>Postage / Couriers</option>
<option value="Other Expenses" {{if eq .Category "Other Expenses"}}selected{{end}}>Other Expenses</option>
<option value="Hotel" {{if eq .Category "Hotel"}}selected{{end}}>Hotel</option>
<option value="Per Diem" {{if eq .Category "Per Diem"}}selected{{end}}>Per Diem</option>
<option value="Visa Fees" {{if eq .Category "Visa Fees"}}selected{{end}}>Visa Fees</option>
<option value="Connectivity (internet connections)" {{if eq .Category "Connectivity (internet connections)"}}selected{{end}}>Connectivity (internet connections)</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -73,35 +70,14 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="description">Description *</label> <label for="description">Description</label>
<textarea id="description" name="description" placeholder="Required notes..." required>{{.Description}}</textarea> <textarea id="description" name="description" placeholder="Optional notes...">{{.Description}}</textarea>
</div> </div>
{{if .BaseCurrency}}
<div class="card" style="padding: 0.75rem; background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; margin-bottom: 1rem;">
<div style="font-size: 0.875rem; font-weight: 600; color: #6ee7b7; margin-bottom: 0.5rem;">
Claim Conversion
</div>
<div class="form-row">
<div class="form-group">
<label for="converted_amount">Converted Amount ({{.BaseCurrency}})</label>
<input type="number" id="converted_amount" name="converted_amount" step="0.01" min="0" placeholder="0.00"
value="{{.ConvertedAmount}}" inputmode="decimal">
</div>
<div class="form-group">
<label>Rate</label>
<input type="text" class="form-control" value="1 {{.Currency}} = {{printf "%.6f" .ExchangeRate}} {{.BaseCurrency}}" readonly style="background: var(--color-card); padding: 0.5rem; border: 1px solid var(--color-border); border-radius: 0.375rem; width: 100%; box-sizing: border-box; color: var(--color-text-muted);">
</div>
</div>
<input type="hidden" name="base_currency" value="{{.BaseCurrency}}">
<input type="hidden" name="exchange_rate" value="{{.ExchangeRate}}">
</div>
{{end}}
<div style="display: flex; gap: 0.5rem;"> <div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn btn-primary" id="save-indicator"> <button type="submit" class="btn btn-primary" id="save-indicator">
<span class="spinner htmx-indicator"></span> <span class="spinner htmx-indicator"></span>
{{if .EditID}}Update Expense{{else}}Save Expense{{end}} Save Expense
</button> </button>
<button type="button" class="btn btn-secondary" <button type="button" class="btn btn-secondary"
onclick="document.getElementById('receipt-form').innerHTML = ''"> onclick="document.getElementById('receipt-form').innerHTML = ''">
@ -110,3 +86,4 @@
</div> </div>
</form> </form>
</div> </div>
</div>