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.
# Run `bash install.sh` for interactive setup.
# --- AI Provider ---
# Choose one: gemini (default), openai
AI_PROVIDER=gemini
# SMTP Configuration
SMTP_HOST=smtp.openxchange.eu
SMTP_PORT=587
SMTP_USER=post@2-4-h.app
SMTP_PASS=D9AW8JP74r1V
# For AI_PROVIDER=gemini:
# GEMINI_API_KEY=your-gemini-api-key
# DeepSeek Vision API Key
DEEPSEEK_API_KEY=sk-e9362165d2694883a52a5142811aa422
# For AI_PROVIDER=openai (also works with Ollama, LocalAI, etc.):
# 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 for generating absolute links in emails
BASE_URL=http://localhost:8080

2
.gitignore vendored
View file

@ -30,5 +30,3 @@ expenseflow-*
# Go
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)

460
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
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
./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`.
> **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.
---
## 🔄 Updating
## 🔧 Building from Source
### Prerequisites
- **Go 1.22+** — [Download](https://go.dev/dl/)
- **GCC** (CGO is required for the SQLite driver)
```bash
# Debian/Ubuntu
sudo apt install build-essential
# macOS
xcode-select --install
# Alpine
apk add build-base
```
### Clone & Build
```bash
./install.sh --update
```
Update pulls the latest code, copies updated templates/static, rebuilds the binary, automatically backs up the database, and restarts the service.
## 🗑️ Uninstalling
```bash
./install.sh --remove
```
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
git clone https://git.lohmar.co.uk/cclohmar/ExpenseFlow.git
cd ExpenseFlow
# 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!)
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 now ready: ./expenseflow
```
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 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 |
|----------|----------|---------|-------------|
| `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 |
| **AI Provider** | | | |
| `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 |
| `PORT` | No | `8080` | HTTP server port |
> **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
```bash
# If installed via systemd:
systemctl start nextexpense
# Or run directly:
./app
./expenseflow
```
### 2. Open in Browser
Navigate to `http://YOUR_SERVER:8080`
Navigate to [http://localhost:8080](http://localhost:8080)
### 3. Full Acceptance Flow
```
1. Enter your email → click "Send Verification Code"
2. Check your inbox (or server log) for the 6-digit OTP
3. Enter OTP → click "Verify Code"
4. Create a month (e.g. "July 2026") → click "Open"
5. Click "+ New" to add an event → set claim currency + conversion sample
6. Click "Open" on the event
7. Tap "📷 Camera" or "📁 Upload" to add a receipt
8. AI extracts amount, merchant, category, date → form is pre-filled
9. Click "Save Expense"
10. Back on the month view, click "Generate Monthly Report"
11. Monthly ZIP contains CSV + PDF report + all receipt images
2. Check your inbox for the 6-digit OTP code
3. Enter the OTP → click "Verify Code"
4. Create an event (e.g., "WebSummit 2026")
5. Click "Add Expenses" on the event card
6. Click "Capture Receipt" → take a photo or select an image
7. AI extracts: amount, merchant, category, date → pre-fills the form
8. Review and click "Save Expense"
9. Click "File Event" → enter recipient email → choose CSV or PDF
10. Report is emailed and event status changes to "closed"
11. Click "Reopen" to re-open a closed event
```
---
## 📡 API Reference
### Public
### Public Endpoints (no authentication)
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/` | Landing page |
| `POST` | `/request-otp` | Request OTP code |
| `POST` | `/verify-otp` | Verify OTP and create session |
| `POST` | `/logout` | Clear session |
| `GET` | `/` | Landing page with email login form |
| `POST` | `/request-otp` | Request a 6-digit OTP code (sends email) |
| `POST` | `/verify-otp` | Verify OTP code and create session |
| `POST` | `/logout` | Clear session and redirect to login |
### Protected (requires session)
### Protected Endpoints (require session cookie)
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/dashboard` | Month list |
| `POST` | `/months` | Create month |
| `GET` | `/months/{mid}` | View month + events |
| `PUT` | `/months/{mid}` | Update month |
| `DELETE` | `/months/{mid}` | Delete month |
| `POST` | `/months/{mid}/events` | Create event |
| `PUT` | `/months/{mid}/events/{eid}` | Update event |
| `DELETE` | `/months/{mid}/events/{eid}` | Delete event |
| `GET` | `/months/{mid}/events/{eid}/expenses` | View event + expenses |
| `POST` | `/months/{mid}/events/{eid}/generate` | Generate event report |
| `POST` | `/months/{mid}/generate` | Generate monthly report |
| `POST` | `/expenses/upload` | Upload receipt image/PDF |
| `POST` | `/expenses` | Save expense |
| `GET` | `/expenses/{id}/edit` | Get edit form |
| `PUT` | `/expenses/{id}` | Update expense |
| `DELETE` | `/expenses/{id}` | Delete expense |
| `GET` | `/dashboard` | Event dashboard |
| `POST` | `/events` | Create a new event |
| `GET` | `/events/{id}/expenses` | View event with expense list |
| `PUT` | `/events/{id}/reopen` | Reopen a closed event |
| `POST` | `/expenses/upload` | Upload receipt image (multipart) |
| `POST` | `/expenses` | Save expense from form data |
| `POST` | `/events/{id}/file` | Generate report (CSV/PDF) and email it |
### Static Files
| Path | Description |
|------|-------------|
| `/static/css/style.css` | Application stylesheet |
| `/static/icons/icon-192.png` | PWA icon (192×192) |
| `/static/icons/icon-512.png` | PWA icon (512×512) |
| `/manifest.json` | PWA manifest |
| `/sw.js` | Service worker |
| `/storage/{filename}` | Uploaded receipt images |
---
## 🏗️ Project Structure
```
/opt/nextexpense/
├── app # Compiled binary
├── .env # Configuration (optional)
├── backups/ # Automatic DB backups (from -update)
├── templates/ # Go HTML templates
├── static/ # CSS, icons, favicon, service worker
├── templates/ # Go HTML templates
├── static/ # CSS, icons, favicon, service worker
│ ├── css/style.css
│ ├── favicon.svg # Rx logo
│ ├── manifest.json
│ ├── sw.js
│ └── icons/
├── storage/ # Uploaded receipt images (runtime)
├── install.sh # Installer script
├── Makefile # Build targets
└── contrib/
└── nextexpense.service # Systemd service file
ExpenseFlow/
├── main.go # Entry point, router, middleware, server
├── go.mod / go.sum # Go module definition
├── .env.example # Environment variable template
├── README.md # This file
├── internal/
│ ├── database/db.go # SQLite init, auto-migration, 11 query functions
│ ├── auth/
│ │ ├── otp.go # 6-digit OTP generation + 3-fail lockout
│ │ └── session.go # In-memory session store (crypto tokens, 24h TTL)
│ ├── handlers/
│ │ ├── auth.go # Auth endpoints + middleware
│ │ ├── events.go # Event CRUD + dashboard
│ │ ├── expenses.go # Receipt upload, AI extraction, save
│ │ └── file.go # CSV/PDF generation + email filing
│ ├── ai/deepseek.go # DeepSeek Vision API client
│ ├── email/smtp.go # SMTP sender (OTP + attachments)
│ └── 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
- Email-based 6-digit code, 5-minute expiry
- 3 failed attempts → 1-minute cooldown
- HTTP-only session cookie, 24h TTL
- Auto-creates user account on first login
- 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
- **Google Gemini** (default) — cloud vision API
- **OpenAI-compatible** — works with OpenAI, Perplexity, Groq, Together AI, etc.
- Supports: JPEG, PNG, WebP, HEIC, PDF (email receipts from Uber, etc.)
### 💱 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
- Upload receipt images (JPEG/PNG, max 10 MB)
- DeepSeek Vision API extracts: amount, currency, merchant, category, date
- Editable pre-filled form on failure or success
- Images stored locally in `./storage/`
### 📧 Email Reporting
- CSV or PDF report
- Receipt images bundled as ZIP (`expense-{event}-images.zip`)
- Filenames: `expense-{event}-report.csv`, `expense-{event}-report.pdf`
- Item numbers match between report rows and ZIP images
- Generate CSV (via `encoding/csv`) or PDF (via `gofpdf`)
- Automatic email delivery via SMTP with file attachment
- Event auto-closes after successful filing
- Multipart MIME support with proper content headers
### 📱 Progressive Web App
- Installable on mobile home screen
- Camera capture + gallery upload
- Dark "Terminal Mint" theme (`#0F172A` base)
- Rx favicon in emerald green
- Installable on mobile and desktop (manifest.json)
- Offline shell caching (service worker)
- Camera capture for receipts (`capture="environment"`)
- 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 |
|------|---------------|
| Sessions | `crypto/rand` tokens, in-memory, 24h TTL |
| OTP | `crypto/rand` codes, 5min expiry, lockout after 3 failures |
| Cookies | HTTP-only, SameSite=Lax, path-restricted |
| SQL | Parameterized queries everywhere |
| Uploads | Magic byte validation, max 10MB, UUID filenames |
| Config | `.env` is optional — app runs with defaults if absent |
| **Sessions** | Cryptographically random tokens (32 bytes, hex-encoded), in-memory store, 24h TTL |
| **OTP** | 6-digit codes from `crypto/rand`, 5-minute expiry, 3-fail lockout (1 minute) |
| **Cookies** | HTTP-only, SameSite=Lax, path restricted |
| **SQL Injection** | Parameterized queries on all database operations |
| **File Upload** | Magic byte validation (JPEG/PNG), 10 MB limit, sanitized filenames (UUID) |
| **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)
#
# Usage: ./build-all.sh
# Output: ./dist/app-{platform}
# Output: ./dist/expenseflow-{platform}
set -euo pipefail
@ -23,7 +23,7 @@ echo "==> Building ExpenseFlow $VERSION"
build() {
local GOOS="$1" GOARCH="$2" CC="$3" SUFFIX="$4"
local OUT="$OUTDIR/app-$SUFFIX"
local OUT="$OUTDIR/expenseflow-$SUFFIX"
echo " $SUFFIX ..."
GOOS="$GOOS" GOARCH="$GOARCH" CGO_ENABLED=1 CC="$CC" \
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 (
github.com/go-chi/chi/v5 v5.1.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/jung-kurt/gofpdf v1.16.2
golang.org/x/image v0.18.0
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
github.com/mattn/go-sqlite3 v1.14.22
)

49
go.sum
View file

@ -1,11 +1,7 @@
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/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/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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
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-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/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/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.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/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 (
"crypto/rand"
"crypto/subtle"
"fmt"
"sync"
"time"
@ -34,8 +33,7 @@ func ValidateOTP(provided, stored string, expiresAt time.Time) bool {
if time.Now().After(expiresAt) {
return false
}
// Use constant-time comparison to prevent timing side-channel attacks.
return subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1
return provided == stored
}
// attemptData stores the failure count and timestamp for a single email.

View file

@ -9,7 +9,7 @@ import (
"log"
"time"
_ "modernc.org/sqlite"
_ "github.com/mattn/go-sqlite3"
)
// DB is the shared database handle, initialized by Init().
@ -21,12 +21,9 @@ var DB *sql.DB
// User represents a row in the users table.
type User struct {
ID string
Email string
Name string
Department string
Onboarded bool
CreatedAt string
ID string
Email string
CreatedAt string
}
// OTP represents a row in the auth_otps table.
@ -36,50 +33,27 @@ type OTP struct {
ExpiresAt string
}
// Month represents a row in the months table.
type Month struct {
// Event represents a row in the events table.
type Event struct {
ID string
UserID string
Name string
Status string
CreatedAt string
}
// Event represents a row in the events table.
type Event struct {
ID string
UserID string
MonthID string
Name string
Status string
BaseCurrency string
ExchangeRate float64
CreatedAt string
}
// Expense represents a row in the expenses table.
type Expense struct {
ID string
EventID string
Amount float64
Currency string
ConvertedAmount float64
BaseCurrency string
Merchant string
Category string
Description string
Date string
ImagePath 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
ID string
EventID string
Amount float64
Currency string
Merchant string
Category string
Description string
Date string
ImagePath string
CreatedAt string
}
// ---------------------------------------------------------------------------
@ -91,41 +65,30 @@ type DownloadToken struct {
// It also sets the package-level DB variable for shared use.
func Init() (*sql.DB, error) {
var err error
DB, err = sql.Open("sqlite", "expenses.db")
DB, err = sql.Open("sqlite3", "expenses.db")
if err != nil {
log.Printf("ERROR [%s] database: failed to open: %v", time.Now().Format(time.RFC3339), err)
return nil, err
}
// modernc.org/sqlite supports concurrent reads.
// A small pool handles HTMX concurrent requests efficiently.
DB.SetMaxOpenConns(4)
DB.SetMaxIdleConns(2)
// SQLite does not support concurrent writes; limit to one connection.
DB.SetMaxOpenConns(1)
if err = createTables(DB); err != nil {
log.Printf("ERROR [%s] database: table creation failed: %v", time.Now().Format(time.RFC3339), err)
return nil, err
}
// 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))
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 {
statements := []string{
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
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
)`,
`CREATE TABLE IF NOT EXISTS auth_otps (
@ -133,32 +96,19 @@ func createTables(db *sql.DB) error {
otp_code TEXT 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 (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
month_id TEXT NOT NULL,
name TEXT NOT NULL,
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,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(month_id) REFERENCES months(id) ON DELETE CASCADE
FOREIGN KEY(user_id) REFERENCES users(id)
)`,
`CREATE TABLE IF NOT EXISTS expenses (
id TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT NOT NULL,
converted_amount REAL NOT NULL DEFAULT 0,
base_currency TEXT NOT NULL DEFAULT 'EUR',
merchant TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT,
@ -167,14 +117,6 @@ func createTables(db *sql.DB) error {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
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 {
@ -185,23 +127,6 @@ func createTables(db *sql.DB) error {
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
// ---------------------------------------------------------------------------
@ -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.
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{}
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 {
return nil, nil
}
@ -234,34 +159,6 @@ func GetUserByEmail(db *sql.DB, email string) (*User, error) {
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
// ---------------------------------------------------------------------------
@ -306,137 +203,19 @@ func DeleteOTP(db *sql.DB, email string) error {
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
// ---------------------------------------------------------------------------
// CreateEvent inserts a new event row with optional base currency and exchange rate.
func CreateEvent(db *sql.DB, id, userID, monthID, name, baseCurrency string, exchangeRate float64) error {
if baseCurrency == "" {
baseCurrency = "EUR"
}
if exchangeRate <= 0 {
exchangeRate = 1.0
}
// CreateEvent inserts a new event row.
func CreateEvent(db *sql.DB, id, userID, name string) error {
_, err := db.Exec(
"INSERT INTO events (id, user_id, month_id, name, base_currency, exchange_rate) VALUES (?, ?, ?, ?, ?, ?)",
id, userID, monthID, name, baseCurrency, exchangeRate,
"INSERT INTO events (id, user_id, name) VALUES (?, ?, ?)",
id, userID, name,
)
if err != nil {
log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s, %s, %s, %.4f): %v",
time.Now().Format(time.RFC3339), id, userID, monthID, name, baseCurrency, exchangeRate, err)
log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s): %v",
time.Now().Format(time.RFC3339), id, userID, name, 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.
func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
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,
)
if err != nil {
@ -457,7 +236,7 @@ func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
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 {
if err := rows.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.CreatedAt); err != nil {
log.Printf("ERROR [%s] database: GetEventsByUser scan: %v",
time.Now().Format(time.RFC3339), err)
return nil, err
@ -467,37 +246,11 @@ func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
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.
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{}
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 {
return nil, nil
}
@ -518,54 +271,17 @@ func UpdateEventStatus(db *sql.DB, id, status string) error {
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
// ---------------------------------------------------------------------------
// CreateExpense inserts a new expense row from the provided Expense struct.
// The expense's ID, EventID, and other fields must be set by the caller.
func CreateExpense(db *sql.DB, expense Expense) error {
_, err := db.Exec(
`INSERT INTO expenses (id, event_id, amount, currency, converted_amount, base_currency, merchant, category, description, date, image_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO expenses (id, event_id, amount, currency, merchant, category, description, date, image_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
expense.ID, expense.EventID, expense.Amount, expense.Currency,
expense.ConvertedAmount, expense.BaseCurrency,
expense.Merchant, expense.Category, expense.Description,
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.
func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) {
rows, err := db.Query(
`SELECT id, event_id, amount, currency, converted_amount, base_currency,
merchant, category, COALESCE(description, ''), date, image_path, created_at
`SELECT id, event_id, amount, currency, merchant, category,
COALESCE(description, ''), date, image_path, created_at
FROM expenses WHERE event_id = ? ORDER BY created_at DESC`,
eventID,
)
@ -595,8 +311,8 @@ func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) {
for rows.Next() {
var e Expense
if err := rows.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,
&e.ID, &e.EventID, &e.Amount, &e.Currency, &e.Merchant,
&e.Category, &e.Description, &e.Date, &e.ImagePath, &e.CreatedAt,
); err != nil {
log.Printf("ERROR [%s] database: GetExpensesByEvent scan: %v",
time.Now().Format(time.RFC3339), err)
@ -606,143 +322,3 @@ func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) {
}
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.
//
// 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.
// The email contains a standard subject line and the 6-digit verification code.
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)
msg := buildPlainMessage(s.from, to, subject, body)
@ -73,14 +73,15 @@ func (s *Sender) SendOTP(to, code string) error {
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
}
// SendReport sends an email with the given subject and body, attaching one or
// more files (report CSV/PDF + ZIP of receipt images).
func (s *Sender) SendReport(to, subject, body string, attachments []*Attachment) error {
msg, err := buildMultipartMessage(s.from, to, subject, body, attachments)
// SendReport sends an email with the given subject and body, attaching a CSV
// or PDF file. The attachment's Content-Type is inferred from its filename
// extension (text/csv for .csv, application/octet-stream otherwise).
func (s *Sender) SendReport(to, subject, body string, attachment *Attachment) error {
msg, err := buildMultipartMessage(s.from, to, subject, body, attachment)
if err != nil {
log.Printf("ERROR [%s] email: SendReport(%s): build failed: %v",
time.Now().Format(time.RFC3339), to, err)
@ -93,12 +94,8 @@ func (s *Sender) SendReport(to, subject, body string, attachments []*Attachment)
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)",
time.Now().Format(time.RFC3339), to, strings.Join(names, ", "))
time.Now().Format(time.RFC3339), to, attachment.Filename)
return nil
}
@ -187,15 +184,13 @@ func buildPlainMessage(from, to, subject, body string) []byte {
// buildMultipartMessage constructs an RFC 2046 multipart/mixed email with a
// text/plain body and a single attachment encoded as base64.
func buildMultipartMessage(from, to, subject, body string, attachments []*Attachment) ([]byte, error) {
func buildMultipartMessage(from, to, subject, body string, attachment *Attachment) ([]byte, error) {
var b strings.Builder
// Write the main SMTP headers with deliverability improvements.
// Write the main SMTP headers.
writeHeader(&b, "From", from)
writeHeader(&b, "To", to)
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.
mw := multipart.NewWriter(&b)
@ -214,21 +209,19 @@ func buildMultipartMessage(from, to, subject, body string, attachments []*Attach
return nil, fmt.Errorf("writing text part: %w", err)
}
// --- Attachment parts (report + receipt images zip) ---
for _, att := range attachments {
aw, err := mw.CreatePart(attachmentHeader(att.Filename))
if err != nil {
return nil, fmt.Errorf("creating attachment part %q: %w", att.Filename, err)
}
enc := base64.NewEncoder(base64.StdEncoding, aw)
if _, err := enc.Write(att.Content); err != nil {
enc.Close()
return nil, fmt.Errorf("writing attachment %q: %w", att.Filename, err)
}
enc.Close()
// --- Attachment part ---
aw, err := mw.CreatePart(attachmentHeader(attachment.Filename))
if err != nil {
return nil, fmt.Errorf("creating attachment part: %w", err)
}
enc := base64.NewEncoder(base64.StdEncoding, aw)
if _, err := enc.Write(attachment.Content); err != nil {
enc.Close()
return nil, fmt.Errorf("writing attachment content: %w", err)
}
enc.Close()
mw.Close()
return []byte(b.String()), nil
@ -275,8 +268,7 @@ func attachmentHeader(filename string) textproto.MIMEHeader {
func attachmentContentType(filename string) string {
switch {
case strings.HasSuffix(strings.ToLower(filename), ".csv"):
// Some providers block text/csv; use text/plain as fallback.
return "text/plain; charset=\"utf-8\""
return "text/csv; charset=\"utf-8\""
case strings.HasSuffix(strings.ToLower(filename), ".pdf"):
return "application/pdf"
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,
// and report generation endpoints.
package handlers
@ -9,15 +9,13 @@ import (
"html/template"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/cclohmar/NextExpense/internal/auth"
"github.com/cclohmar/NextExpense/internal/database"
"github.com/cclohmar/NextExpense/internal/email"
"github.com/cclohmar/NextExpense/internal/utils"
"github.com/expenseflow/internal/auth"
"github.com/expenseflow/internal/database"
"github.com/expenseflow/internal/email"
"github.com/expenseflow/internal/utils"
)
// ---------------------------------------------------------------------------
@ -37,8 +35,6 @@ type AuthHandler struct {
Sessions *auth.SessionStore
FailureTracker *auth.FailureTracker
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.
// It parses templates/index.html and executes it with no template data.
func (h *AuthHandler) LandingPage(w http.ResponseWriter, r *http.Request) {
// If the user already has a valid session, redirect to the dashboard.
if cookie, err := r.Cookie("session_token"); err == nil && cookie.Value != "" {
if _, ok := h.Sessions.Get(cookie.Value); ok {
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
return
}
tmpl, err := template.ParseFiles("templates/index.html")
if err != nil {
log.Printf("ERROR [%s] handlers: LandingPage parse template: %v", time.Now().Format(time.RFC3339), err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
tmpl := getTemplate("index.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, nil); err != nil {
log.Printf("ERROR [%s] handlers: LandingPage execute template: %v", time.Now().Format(time.RFC3339), err)
@ -94,7 +87,7 @@ func (h *AuthHandler) RequestOTP(w http.ResponseWriter, r *http.Request) {
return
}
if user == nil {
userID := utils.NewUUID()
userID := utils.New()
if err := database.CreateUser(h.DB, userID, emailAddr); err != nil {
log.Printf("ERROR [%s] handlers: RequestOTP CreateUser(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
renderError(w, "An error occurred. Please try again.")
@ -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 —
// during development the code is visible in server logs.
if h.EmailSender != 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)
}
} 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)
if err := h.EmailSender.SendOTP(emailAddr, code); err != nil {
log.Printf("ERROR [%s] handlers: RequestOTP SendOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
}
// Render the OTP verification form as an HTMX fragment.
renderOTPForm(w, emailAddr, "")
renderOTPForm(w, emailAddr)
}
// 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)
if emailAddr == "" || otpCode == "" {
renderOTPForm(w, emailAddr, "Email and OTP code are required.")
renderError(w, "Email and OTP code are required.")
return
}
@ -155,11 +143,11 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
stored, err := database.GetOTP(h.DB, emailAddr)
if err != nil {
log.Printf("ERROR [%s] handlers: VerifyOTP GetOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
renderOTPForm(w, emailAddr, "An error occurred. Please try again.")
renderError(w, "An error occurred. Please try again.")
return
}
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
}
@ -167,31 +155,29 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
expiresAt, err := time.Parse(time.RFC3339, stored.ExpiresAt)
if err != nil {
log.Printf("ERROR [%s] handlers: VerifyOTP parse expiry(%s): %v", time.Now().Format(time.RFC3339), stored.ExpiresAt, err)
renderOTPForm(w, emailAddr, "An error occurred. Please try again.")
renderError(w, "An error occurred. Please try again.")
return
}
// Validate + delete OTP atomically to prevent race-condition reuse.
h.otpMu.Lock()
// Validate the OTP code and expiry.
if !auth.ValidateOTP(otpCode, stored.OTPCode, expiresAt) {
h.otpMu.Unlock()
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
}
// Successful verification: delete OTP immediately (still under lock).
// Successful verification: clean up and create session.
h.FailureTracker.Reset(emailAddr)
if err := database.DeleteOTP(h.DB, emailAddr); err != nil {
log.Printf("ERROR [%s] handlers: VerifyOTP DeleteOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
// Non-fatal — the OTP is already validated.
}
h.otpMu.Unlock()
// Retrieve the user record to obtain the user ID.
user, err := database.GetUserByEmail(h.DB, emailAddr)
if err != nil || user == nil {
log.Printf("ERROR [%s] handlers: VerifyOTP GetUserByEmail(%s): err=%v", time.Now().Format(time.RFC3339), emailAddr, err)
renderOTPForm(w, emailAddr, "An error occurred. Please try again.")
renderError(w, "An error occurred. Please try again.")
return
}
@ -199,28 +185,22 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
token, err := h.Sessions.Generate(user.ID)
if err != nil {
log.Printf("ERROR [%s] handlers: VerifyOTP Session Generate(%s): %v", time.Now().Format(time.RFC3339), user.ID, err)
renderOTPForm(w, emailAddr, "An error occurred. Please try again.")
renderError(w, "An error occurred. Please try again.")
return
}
// Set the session cookie (HttpOnly, SameSite=Lax, Secure, 24h).
secure := strings.HasPrefix(os.Getenv("BASE_URL"), "https://")
// Set the HTTP-only session cookie with a 24-hour TTL.
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
Expires: time.Now().Add(24 * time.Hour),
})
// Redirect to the appropriate page — onboarding if first login, dashboard otherwise.
if user.Onboarded {
w.Header().Set("HX-Redirect", "/dashboard")
} else {
w.Header().Set("HX-Redirect", "/onboarding")
}
// Redirect to the dashboard via HTMX.
w.Header().Set("HX-Redirect", "/dashboard")
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.
func renderError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="error-message" style="color: #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
// using the cached otp_form template from templates.go.
func renderOTPForm(w http.ResponseWriter, email string, errMsg string) {
tmpl := getTemplate("otp_form")
// renderOTPForm writes the OTP verification form partial as an HTMX fragment.
// It renders 6 individual digit input boxes for a better mobile UX, plus a
// hidden email field. The handler combines the 6 digits server-side.
func renderOTPForm(w http.ResponseWriter, email string) {
tmpl := template.Must(template.New("otp_form").Parse(`
<form hx-post="/verify-otp" hx-target="#otp-form" hx-swap="outerHTML">
<input type="hidden" name="email" value="{{.Email}}">
<div style="display: flex; gap: 0.5rem; justify-content: center; margin: 1rem 0;">
<input type="text" name="digit_0" maxlength="1" pattern="[0-9]" inputmode="numeric" autocomplete="one-time-code" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
<input type="text" name="digit_1" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
<input type="text" name="digit_2" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
<input type="text" name="digit_3" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
<input type="text" name="digit_4" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
<input type="text" name="digit_5" maxlength="1" pattern="[0-9]" inputmode="numeric" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
</div>
<button type="submit" style="width: 100%; padding: 0.75rem; background-color: #10b981; color: white; border: none; border-radius: 0.5rem; font-size: 1rem; cursor: pointer;">
Verify Code
</button>
</form>
`))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, map[string]string{"Email": email, "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)
}
}
@ -282,171 +285,14 @@ func renderOTPForm(w http.ResponseWriter, email string, errMsg string) {
// collectOTP reads the 6 individual digit form values and concatenates them
// into a single 6-character OTP code string. Returns an empty string if any
// digit is missing.
// 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 {
code := r.FormValue("otp_code")
// Strip any non-digit characters (paste may include spaces/dashes).
code = strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
var b strings.Builder
for i := 0; i < 6; i++ {
digit := r.FormValue(fmt.Sprintf("digit_%d", i))
if digit == "" {
return ""
}
return -1
}, code)
if len(code) != 6 {
return ""
b.WriteString(digit)
}
return code
}
// ---------------------------------------------------------------------------
// 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))
return b.String()
}

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,
// reopening, closing, and expense viewing — all scoped under a parent month.
// This file implements event management endpoints including dashboard
// listing, event creation, reopening, and expense viewing.
package handlers
import (
@ -10,13 +10,10 @@ import (
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/cclohmar/NextExpense/internal/database"
"github.com/cclohmar/NextExpense/internal/utils"
"github.com/expenseflow/internal/database"
"github.com/expenseflow/internal/utils"
"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.
func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
if monthID == "" {
http.Error(w, "Missing month ID", http.StatusBadRequest)
return
}
// Dashboard renders the main dashboard page showing all events belonging
// to the authenticated user, along with the new event creation form.
func (h *EventHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: Dashboard: missing user ID", time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Verify month ownership.
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
events, err := database.GetEventsByUser(h.DB, userID)
if err != nil {
log.Printf("ERROR [%s] handlers: Dashboard: GetEventsByUser: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to load events", http.StatusInternalServerError)
return
}
tmpl, err := template.ParseFiles("templates/dashboard.html")
if err != nil {
log.Printf("ERROR [%s] handlers: Dashboard: parse template: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Events": events,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: Dashboard: execute template: %v",
time.Now().Format(time.RFC3339), err)
}
}
// ---------------------------------------------------------------------------
// POST /events — CreateEvent
// ---------------------------------------------------------------------------
// CreateEvent handles the creation of a new event for the authenticated user.
// It reads the event name from the form, generates a UUID, persists the
// event, and redirects to the dashboard via HX-Redirect.
func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: CreateEvent: missing user ID", time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
name := r.FormValue("name")
if name == "" {
log.Printf("ERROR [%s] handlers: CreateEvent: missing event name", time.Now().Format(time.RFC3339))
http.Error(w, "Event name is required", http.StatusBadRequest)
return
}
baseCurrency := r.FormValue("base_currency")
if baseCurrency == "" {
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 {
id := utils.New()
if err := database.CreateEvent(h.DB, id, userID, name); err != nil {
log.Printf("ERROR [%s] handlers: CreateEvent: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to create event", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/months/"+monthID)
w.Header().Set("HX-Redirect", "/dashboard")
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) {
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
if monthID == "" || eventID == "" {
http.Error(w, "Missing ID", http.StatusBadRequest)
eventID := chi.URLParam(r, "id")
if eventID == "" {
log.Printf("ERROR [%s] handlers: ReopenEvent: missing event ID",
time.Now().Format(time.RFC3339))
http.Error(w, "Missing event ID", http.StatusBadRequest)
return
}
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: ReopenEvent: missing user ID",
time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Verify month ownership.
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
event, err := database.GetEventByID(h.DB, eventID)
if err != nil {
log.Printf("ERROR [%s] handlers: ReopenEvent: GetEventByID(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil {
if event == nil {
log.Printf("ERROR [%s] handlers: ReopenEvent: event %s not found",
time.Now().Format(time.RFC3339), eventID)
http.Error(w, "Event not found", http.StatusNotFound)
return
}
if event.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)
return
}
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)
return
}
// Clean up stale download packages.
if oldFiles, err := database.DeleteDownloadTokensByEvent(h.DB, eventID); err == nil {
for _, fn := range oldFiles {
os.Remove(filepath.Join("storage", "postbox", fn))
}
}
w.Header().Set("HX-Redirect", "/months/"+monthID)
w.WriteHeader(http.StatusOK)
// Return HTMX fragment: green "open" badge targeting #status-badge-{id}.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<span id="status-badge-%s" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">open</span>`, eventID)
}
// ---------------------------------------------------------------------------
// POST /months/{mid}/events/{eid}/close — CloseEvent
// ---------------------------------------------------------------------------
// 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
// GET /events/{id}/expenses — ViewEventExpenses
// ---------------------------------------------------------------------------
// ViewEventExpenses displays the expense collection view for a specific event.
// It verifies event ownership, sets the current_event_id cookie, and renders
// the event_expenses.html template with the event and its expense list.
func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
if monthID == "" || eventID == "" {
http.Error(w, "Missing ID", http.StatusBadRequest)
eventID := chi.URLParam(r, "id")
if eventID == "" {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing event ID",
time.Now().Format(time.RFC3339))
http.Error(w, "Missing event ID", http.StatusBadRequest)
return
}
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing user ID",
time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Verify month ownership.
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
event, err := database.GetEventByID(h.DB, eventID)
if err != nil {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: GetEventByID(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil {
if event == nil {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: event %s not found",
time.Now().Format(time.RFC3339), eventID)
http.Error(w, "Event not found", http.StatusNotFound)
return
}
if event.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)
return
}
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
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)
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)
tmpl := getTemplate("event_expenses.html")
for i := range expenses {
expenses[i].ImagePath = normalizeImagePath(expenses[i].ImagePath)
tmpl, err := template.ParseFiles("templates/event_expenses.html")
if err != nil {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: parse template: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"Month": month,
"Event": event,
"Expenses": expenses,
}
@ -258,145 +238,3 @@ func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request)
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
// that drive the core receipt capture workflow using HTMX partial responses.
package handlers
import (
"bytes"
"database/sql"
"fmt"
"html/template"
"image"
"image/jpeg"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/image/draw"
"github.com/cclohmar/NextExpense/internal/ai"
"github.com/cclohmar/NextExpense/internal/database"
"github.com/cclohmar/NextExpense/internal/utils"
"github.com/expenseflow/internal/ai"
"github.com/expenseflow/internal/database"
"github.com/expenseflow/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
// ---------------------------------------------------------------------------
@ -61,10 +52,10 @@ func NewExpenseHandler(db *sql.DB) *ExpenseHandler {
// 5. Render templates/receipt_form.html with pre-filled fields or error banner
func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
// 1. Parse multipart form with 10 MB max memory.
if err := r.ParseMultipartForm(11 << 20); err != nil {
if err := r.ParseMultipartForm(10 << 20); err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: parse form: %v",
time.Now().Format(time.RFC3339), err)
renderUploadError(w, "Failed to parse upload form.")
http.Error(w, "Failed to parse upload form", http.StatusBadRequest)
return
}
defer r.MultipartForm.RemoveAll()
@ -74,7 +65,7 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: missing receipt field: %v",
time.Now().Format(time.RFC3339), err)
renderUploadError(w, "Missing receipt file.")
http.Error(w, "Missing receipt file", http.StatusBadRequest)
return
}
defer file.Close()
@ -83,7 +74,7 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
if header.Size > 10<<20 {
log.Printf("ERROR [%s] handlers: UploadReceipt: file too large: %d bytes",
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
}
@ -92,38 +83,27 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: read file: %v",
time.Now().Format(time.RFC3339), err)
renderUploadError(w, "Failed to read uploaded file.")
http.Error(w, "Failed to read uploaded file", http.StatusInternalServerError)
return
}
// 5. Validate content type by inspecting magic bytes.
ext := detectImageExtension(fileData)
if ext == "" {
log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported file type: %q",
time.Now().Format(time.RFC3339), ext)
renderUploadError(w, "Unsupported file format. Please upload a receipt image (JPEG, PNG, HEIC) or PDF.")
log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported image type",
time.Now().Format(time.RFC3339))
http.Error(w, "Only JPEG and PNG images are supported", http.StatusBadRequest)
return
}
// 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.
filename := utils.NewUUID() + "." + ext
filename := utils.New() + "." + ext
storagePath := filepath.Join("storage", filename)
if err := os.MkdirAll("storage", 0755); err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: mkdir storage: %v",
time.Now().Format(time.RFC3339), err)
renderUploadError(w, "Server error. Please try again.")
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
@ -131,48 +111,31 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
if err := os.WriteFile(storagePath, fileData, 0644); err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: write file: %v",
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
}
// 8. Fetch the event's base currency and exchange rate.
eventID := getCurrentEventID(r)
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
}
// 8. Call the DeepSeek Vision API for AI extraction.
receipt, aiErr := ai.ExtractReceipt(storagePath)
// 9. Call the Gemini Vision API for AI extraction (uses full disk path).
receipt, aiErr := ai.ExtractReceipt(filepath.Join("storage", filename))
// Strip the storage/ prefix so the template can build a proper URL: /storage/{file}
storagePath = filename
// 10. Render the receipt_form.html fragment.
tmpl := getTemplate("receipt_form.html")
// 9. Render the receipt_form.html fragment.
tmpl, err := template.ParseFiles("templates/receipt_form.html")
if err != nil {
log.Printf("ERROR [%s] handlers: UploadReceipt: parse template: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"ImagePath": storagePath,
"AIError": "",
"Amount": "",
"Currency": "",
"Merchant": "",
"Category": "",
"Date": "",
"Description": "",
"BaseCurrency": baseCurrency,
"ExchangeRate": exchangeRate,
"ConvertedAmount": "",
"ImagePath": storagePath,
"AIError": "",
"Amount": "",
"Currency": "",
"Merchant": "",
"Category": "",
"Date": "",
"Description": "",
}
if aiErr != nil {
@ -185,12 +148,6 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
data["Merchant"] = receipt.Merchant
data["Category"] = receipt.Category
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")
@ -240,10 +197,6 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
description := r.FormValue("description")
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.
var missing []string
if amountStr == "" {
@ -261,9 +214,6 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
if date == "" {
missing = append(missing, "date")
}
if description == "" {
missing = append(missing, "description")
}
if len(missing) > 0 {
log.Printf("ERROR [%s] handlers: SaveExpense: missing fields: %s",
time.Now().Format(time.RFC3339), strings.Join(missing, ", "))
@ -281,42 +231,17 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
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.
expense := database.Expense{
ID: utils.NewUUID(),
EventID: eventID,
Amount: amount,
Currency: currency,
ConvertedAmount: convertedAmount,
BaseCurrency: baseCurrency,
Merchant: merchant,
Category: category,
Description: description,
Date: date,
ImagePath: imagePath,
ID: utils.New(),
EventID: eventID,
Amount: amount,
Currency: currency,
Merchant: merchant,
Category: category,
Description: description,
Date: date,
ImagePath: imagePath,
}
if err := database.CreateExpense(h.DB, expense); err != nil {
@ -336,11 +261,13 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
}
// 7. Render the expense_list.html fragment.
// Normalize ImagePath for old DB entries that may have storage/ prefix.
for i := range expenses {
expenses[i].ImagePath = normalizeImagePath(expenses[i].ImagePath)
listTmpl, err := template.ParseFiles("templates/expense_list.html")
if err != nil {
log.Printf("ERROR [%s] handlers: SaveExpense: parse list template: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
listTmpl := getTemplate("expense_list.html")
var listBuf strings.Builder
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())
}
// ---------------------------------------------------------------------------
// 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)
// ---------------------------------------------------------------------------
@ -608,10 +298,6 @@ func getCurrentEventID(r *http.Request) string {
if err != nil {
return ""
}
// Validate UUID format to prevent cookie tampering.
if !uuidRe.MatchString(cookie.Value) {
return ""
}
return cookie.Value
}
@ -633,118 +319,20 @@ func setCurrentEventID(w http.ResponseWriter, eventID string) {
// 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
// determine its image format. Supports JPEG, PNG, WebP, GIF, BMP, TIFF,
// and HEIC/HEIF (common on iPhones). Returns the file extension (without
// dot) or an empty string if the format is not recognised.
// determine whether it is a JPEG or PNG image. Returns "jpg", "png", or
// an empty string if the format is not recognised.
func detectImageExtension(data []byte) string {
if len(data) < 4 {
return ""
}
// JPEG: FF D8 FF
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
// JPEG magic: 0xFF 0xD8 0xFF
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "jpg"
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
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 {
// PNG magic: 0x89 'P' 'N' 'G' 0x0D 0x0A 0x1A 0x0A
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
return "png"
}
// 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 ""
}
// ---------------------------------------------------------------------------
// 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
// expense reports and emailing them as attachments to a specified recipient.
package handlers
import (
"archive/zip"
"bytes"
"crypto/rand"
"database/sql"
"encoding/csv"
"encoding/hex"
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jung-kurt/gofpdf"
"github.com/cclohmar/NextExpense/internal/database"
"github.com/cclohmar/NextExpense/internal/email"
"github.com/expenseflow/internal/database"
"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
// 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
// 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) {
// 1. Get month and event IDs from the URL path parameters.
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
if monthID == "" || eventID == "" {
log.Printf("ERROR [%s] handlers: FileEvent: missing ID in URL",
// 1. Get event ID from the URL path parameter.
eventID := chi.URLParam(r, "id")
if eventID == "" {
log.Printf("ERROR [%s] handlers: FileEvent: missing event ID in URL",
time.Now().Format(time.RFC3339))
renderFileError(w, "Missing ID.")
http.Error(w, "Missing event ID", http.StatusBadRequest)
return
}
@ -63,30 +65,32 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: parse form: %v",
time.Now().Format(time.RFC3339), err)
renderFileError(w, "Cannot parse form data.")
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
return
}
to := r.FormValue("email")
format := r.FormValue("format")
if to == "" {
log.Printf("ERROR [%s] handlers: FileEvent: missing email field",
time.Now().Format(time.RFC3339))
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
}
// 3. Verify the authenticated user owns this event (via month).
// 3. Verify the authenticated user owns this event.
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: FileEvent: unauthenticated request",
time.Now().Format(time.RFC3339))
renderFileError(w, "Session expired. Please log in again.")
return
}
if !verifyMonthOwnership(h.DB, monthID, userID) {
renderFileError(w, "You do not have permission to file this event.")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
@ -94,19 +98,19 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: GetEventByID(%s): %v",
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
}
if event == nil {
log.Printf("ERROR [%s] handlers: FileEvent: event not found: %s",
time.Now().Format(time.RFC3339), eventID)
renderFileError(w, "Event not found.")
http.Error(w, "Event not found", http.StatusNotFound)
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",
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
}
@ -115,63 +119,32 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: GetExpensesByEvent(%s): %v",
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
}
// Fetch user info for report personalisation.
reportUser, _ := database.GetUserByID(h.DB, userID)
userName := ""
userDept := ""
if reportUser != nil {
userName = reportUser.Name
userDept = reportUser.Department
// 5. Generate the report in the requested format.
var attachment *email.Attachment
switch format {
case "csv":
attachment, err = generateCSV(event.Name, expenses)
case "pdf":
attachment, err = generatePDF(event.Name, expenses)
}
// 5. Generate both CSV and PDF reports.
csvAttachment, err := generateCSV(event.Name, expenses, userName, userDept)
if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: generate CSV: %v",
time.Now().Format(time.RFC3339), err)
renderFileError(w, "Failed to generate CSV report. Please try again.")
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.")
log.Printf("ERROR [%s] handlers: FileEvent: generate %s report: %v",
time.Now().Format(time.RFC3339), format, err)
http.Error(w, "Failed to generate report", http.StatusInternalServerError)
return
}
// 6. Create a ZIP of all receipt images.
zipAttachment, zipErr := createReceiptZip(event.Name, expenses)
// 7. Build the list of attachments (CSV + PDF + ZIP if available).
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 {
// 6. Send the report as an email attachment.
subject := "Expense report for event " + event.Name
body := "Please find attached the expense report."
if err := h.EmailSender.SendReport(to, subject, body, attachment); err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: SendReport(%s): %v",
time.Now().Format(time.RFC3339), to, err)
renderFileError(w, "Failed to send report: "+err.Error())
http.Error(w, "Failed to send report email", http.StatusInternalServerError)
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 {
log.Printf("ERROR [%s] handlers: FileEvent: UpdateEventStatus(%s): %v",
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
}
// 8. Redirect to the month view via HTMX.
w.Header().Set("HX-Redirect", "/months/"+monthID)
// 8. Redirect to the dashboard via HTMX.
w.Header().Set("HX-Redirect", "/dashboard")
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
// ---------------------------------------------------------------------------
// generateCSV creates a CSV attachment from the provided expenses.
// The CSV includes a header row and one data row per expense.
// If the expenses use a different currency than the base currency, both
// original and converted amounts are included.
func generateCSV(eventName string, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
func generateCSV(eventName string, expenses []database.Expense) (*email.Attachment, error) {
var buf bytes.Buffer
writer := csv.NewWriter(&buf)
// Write user metadata row.
if userName != "" {
metaLine := fmt.Sprintf("Prepared by: %s", userName)
if userDept != "" && userDept != "-" {
metaLine += fmt.Sprintf(" | Department: %s", userDept)
}
writer.Write([]string{metaLine})
writer.Write([]string{""})
// Write header row.
if err := writer.Write([]string{"Date", "Merchant", "Amount", "Currency", "Category", "Description"}); err != nil {
return nil, fmt.Errorf("write CSV header: %w", err)
}
// Total claim summary.
var totalClaim float64
claimCur := ""
// Write one data row per expense.
for _, exp := range expenses {
cAmt := exp.ConvertedAmount
if cAmt <= 0 {
cAmt = exp.Amount
}
totalClaim += cAmt
if claimCur == "" && exp.BaseCurrency != "" {
claimCur = exp.BaseCurrency
if err := writer.Write([]string{
exp.Date,
exp.Merchant,
fmt.Sprintf("%.2f", exp.Amount),
exp.Currency,
exp.Category,
exp.Description,
}); err != nil {
return nil, fmt.Errorf("write CSV row: %w", err)
}
}
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()
if err := writer.Error(); err != nil {
return nil, fmt.Errorf("CSV writer flush: %w", err)
}
filename := fmt.Sprintf("expense-%s-report.csv", sanitiseFilename(eventName))
return &email.Attachment{
Filename: filename,
Filename: "report.csv",
Content: buf.Bytes(),
}, nil
}
// generatePDF creates a PDF attachment from the provided expenses using gofpdf.
// The PDF contains a title row, a header row, and one data row per expense.
// If the expenses use a different currency than the base currency, both
// original and converted amounts are included.
func generatePDF(eventName string, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
pdf := gofpdf.New("L", "mm", "A4", "")
func generatePDF(eventName string, expenses []database.Expense) (*email.Attachment, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
// Title.
pdf.SetFont("Helvetica", "B", 14)
// Title: "Expense Report: <event name>"
pdf.SetFont("Helvetica", "B", 16)
pdf.Cell(0, 10, "Expense Report: "+eventName)
pdf.Ln(15)
// Table header row.
pdf.SetFont("Helvetica", "B", 10)
headers := []string{"Date", "Merchant", "Amount", "Currency", "Category"}
for _, h := range headers {
pdf.Cell(35, 8, h)
}
pdf.Ln(8)
// 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.
pdf.SetFont("Helvetica", "", 8)
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 := ""
pdf.SetFont("Helvetica", "", 10)
for _, exp := range expenses {
if exp.BaseCurrency != "" {
claimTotalCur = exp.BaseCurrency
break
}
pdf.Cell(35, 8, exp.Date)
pdf.Cell(35, 8, exp.Merchant)
pdf.Cell(20, 8, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(20, 8, exp.Currency)
pdf.Cell(35, 8, exp.Category)
pdf.Ln(8)
}
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
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("PDF output: %w", err)
}
filename := fmt.Sprintf("expense-%s-report.pdf", sanitiseFilename(eventName))
return &email.Attachment{
Filename: filename,
Filename: "report.pdf",
Content: buf.Bytes(),
}, 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
import (
"time"
"github.com/google/uuid"
)
// NewUUID generates a new UUID v4 string.
func NewUUID() string {
// New generates a new UUID v4 string.
func 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)
}

195
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
// 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:
// Copy .env.example to .env and fill in credentials, then:
@ -13,25 +13,19 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/joho/godotenv"
"github.com/cclohmar/NextExpense/internal/auth"
"github.com/cclohmar/NextExpense/internal/database"
"github.com/cclohmar/NextExpense/internal/email"
"github.com/cclohmar/NextExpense/internal/handlers"
"github.com/cclohmar/NextExpense/internal/utils"
"github.com/expenseflow/internal/auth"
"github.com/expenseflow/internal/database"
"github.com/expenseflow/internal/email"
"github.com/expenseflow/internal/handlers"
)
func main() {
@ -41,7 +35,8 @@ func main() {
// Load environment variables from .env file (if present).
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")
@ -55,13 +50,16 @@ func main() {
smtpUser := os.Getenv("SMTP_USER")
smtpPass := os.Getenv("SMTP_PASS")
// DeepSeek API key is read directly by the ai package.
_ = os.Getenv("DEEPSEEK_API_KEY")
// -----------------------------------------------------------------------
// Database
// -----------------------------------------------------------------------
db, err := database.Init()
if err != nil {
log.Fatalf("FATAL main: database init: %v", err)
log.Fatalf("FATAL [%s] main: database init: %v", time.Now().Format(time.RFC3339), err)
}
defer db.Close()
@ -72,21 +70,15 @@ func main() {
sessionStore := auth.NewSessionStore()
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.
var emailSender *email.Sender
if smtpHost != "" && smtpPort != "" && smtpUser != "" && smtpPass != "" {
emailSender = email.NewSender(smtpHost, smtpPort, smtpUser, smtpPass, smtpUser)
log.Printf("INFO main: SMTP sender configured (%s:%s)", smtpHost, smtpPort)
emailSender = email.NewSender(smtpHost, smtpPort, smtpUser, smtpPass, "post@2-4-h.app")
log.Printf("INFO [%s] main: SMTP sender configured (%s:%s)",
time.Now().Format(time.RFC3339), smtpHost, smtpPort)
} else {
log.Printf("WARN 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,
}
monthHandler := handlers.NewMonthHandler(db)
monthHandler.EmailSender = emailSender
eventHandler := handlers.NewEventHandler(db)
expenseHandler := handlers.NewExpenseHandler(db)
fileHandler := &handlers.FileHandler{
@ -110,9 +99,6 @@ func main() {
EmailSender: emailSender,
}
// Start background cleanup of expired download packages.
fileHandler.StartDownloadCleanup()
// -----------------------------------------------------------------------
// Router
// -----------------------------------------------------------------------
@ -124,38 +110,6 @@ func main() {
r.Use(middleware.Recoverer)
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.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -182,16 +136,8 @@ func main() {
http.ServeFile(w, r, "static/manifest.json")
}))
// iOS PWA / Safari root-level icon requests.
r.Get("/apple-touch-icon.png", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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")
}))
// Serve uploaded receipt images.
r.Get("/storage/*", http.StripPrefix("/storage/", http.FileServer(http.Dir("storage"))).ServeHTTP)
// ---- Public routes (no auth required) ----
@ -199,66 +145,38 @@ func main() {
r.Post("/request-otp", authHandler.RequestOTP)
r.Post("/verify-otp", authHandler.VerifyOTP)
// Download link (token-based auth, no login required).
r.Get("/dl/{token}", fileHandler.ServeDownload)
r.Get("/dl/{token}/{name}", fileHandler.ServeDownload)
// ---- Logout ----
// ---- Logout (invalidates server-side session + clears cookie) ----
r.Post("/logout", authHandler.Logout)
r.Post("/logout", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Delete the session cookie.
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusOK)
}))
// ---- Protected routes (auth required) ----
r.Group(func(r chi.Router) {
r.Use(authHandler.RequireAuth)
// Dashboard (months).
r.Get("/dashboard", monthHandler.ListMonths)
r.Get("/onboarding", authHandler.OnboardingPage)
r.Post("/onboarding", authHandler.SaveOnboarding)
r.Get("/profile", authHandler.ProfilePage)
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)
// Events.
r.Get("/dashboard", eventHandler.Dashboard)
r.Post("/events", eventHandler.CreateEvent)
r.Put("/events/{id}/reopen", eventHandler.ReopenEvent)
r.Get("/events/{id}/expenses", eventHandler.ViewEventExpenses)
// Expenses.
r.Post("/expenses/upload", expenseHandler.UploadReceipt)
r.Post("/expenses", expenseHandler.SaveExpense)
r.Get("/expenses/{id}/edit", expenseHandler.EditExpense)
r.Put("/expenses/{id}", expenseHandler.UpdateExpense)
r.Delete("/expenses/{id}", expenseHandler.DeleteExpense)
// Filing (event-level).
r.Post("/months/{mid}/events/{eid}/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))
})
// Filing.
r.Post("/events/{id}/file", fileHandler.FileEvent)
})
// -----------------------------------------------------------------------
@ -266,33 +184,12 @@ func main() {
// -----------------------------------------------------------------------
addr := ":" + port
srv := &http.Server{
Addr: addr,
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
log.Printf("INFO [%s] main: ExpenseFlow server starting on %s",
time.Now().Format(time.RFC3339), addr)
log.Printf("INFO [%s] main: open http://localhost%s in your browser",
time.Now().Format(time.RFC3339), addr)
if err := http.ListenAndServe(addr, r); err != nil {
log.Fatalf("FATAL [%s] main: server error: %v", time.Now().Format(time.RFC3339), err)
}
// Graceful shutdown on SIGINT / SIGTERM.
go func() {
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
"The Terminal Mint" dark palette | Mobile-first responsive
ExpenseFlow PWA Expense Tracker Stylesheet
Mobile-first responsive | Plain CSS | System-ui font
========================================================================== */
/* --------------------------------------------------------------------------
0. CSS Custom Properties (Design Tokens)
-------------------------------------------------------------------------- */
:root {
/* Colors — "The Terminal Mint" Dark Palette */
/* Colors */
--color-primary: #10b981;
--color-primary-hover: #34d399;
--color-primary-light: #064e3b;
--color-primary-hover: #059669;
--color-primary-light: #d1fae5;
--color-primary-dark: #047857;
--color-secondary: #334155;
--color-secondary-hover: #475569;
--color-secondary: #1e293b;
--color-secondary-hover: #334155;
--color-bg: #0f172a;
--color-card: #1e293b;
--color-text: #f8fafc;
--color-text-muted: #94a3b8;
--color-text-light: #64748b;
--color-bg: #f8fafc;
--color-card: #ffffff;
--color-text: #0f172a;
--color-text-muted: #64748b;
--color-text-light: #94a3b8;
--color-border: #334155;
--color-border: #e2e8f0;
--color-border-focus: #10b981;
--color-danger: #ef4444;
--color-danger-hover: #f87171;
--color-danger-light: #450a0a;
--color-danger-hover: #dc2626;
--color-danger-light: #fef2f2;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-open-bg: #064e3b;
--color-open-text: #6ee7b7;
--color-closed-bg: #334155;
--color-closed-text: #cbd5e1;
--color-open-bg: #d1fae5;
--color-open-text: #065f46;
--color-closed-bg: #f1f5f9;
--color-closed-text: #475569;
/* Shadows — more pronounced on dark bg */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.2);
--shadow-xl: 0 10px 25px rgba(0, 0, 0, 0.4);
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 10px 25px rgba(0, 0, 0, 0.08);
/* Border radius */
--radius-sm: 6px;
@ -129,23 +129,6 @@ body {
-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 {
max-width: 100%;
height: auto;
@ -330,15 +313,11 @@ small, .text-sm {
}
.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(100dvh - var(--header-height));
}
.main-content {
padding: var(--space-5) var(--space-4) var(--space-10);
}
/* --------------------------------------------------------------------------
5. Cards
-------------------------------------------------------------------------- */
@ -390,9 +369,9 @@ small, .text-sm {
/* Login card — centered single-column */
.login-card {
width: 100%;
max-width: 420px;
max-width: 400px;
margin: var(--space-12) auto;
padding: var(--space-10) var(--space-8);
padding: var(--space-8) var(--space-6);
border-radius: var(--radius-xl);
}
@ -522,7 +501,7 @@ small, .text-sm {
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
padding: var(--space-2) var(--space-4);
font-family: inherit;
font-size: var(--text-sm);
font-weight: var(--font-medium);
@ -682,17 +661,15 @@ small, .text-sm {
9. Form Inputs
-------------------------------------------------------------------------- */
.form-group {
margin-bottom: var(--space-5);
margin-bottom: var(--space-4);
}
.form-label {
display: block;
font-size: var(--text-xs);
font-weight: var(--font-semibold);
color: var(--color-text-muted);
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--color-text);
margin-bottom: var(--space-1);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.form-label--required::after {
@ -700,44 +677,15 @@ small, .text-sm {
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-select,
.form-textarea {
display: block;
width: 100%;
padding: var(--space-4) var(--space-4);
padding: var(--space-3) var(--space-3);
font-family: inherit;
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--color-text);
background-color: var(--color-card);
border: 1px solid var(--color-border);
@ -747,27 +695,19 @@ small, .text-sm {
box-shadow var(--transition-fast);
-webkit-appearance: none;
appearance: none;
box-sizing: border-box;
}
.form-input::placeholder,
.form-textarea::placeholder {
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-select:hover,
.form-textarea:hover {
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-select:focus,
.form-textarea:focus {
@ -793,24 +733,11 @@ small, .text-sm {
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 {
min-height: 100px;
resize: vertical;
}
.form-group 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-repeat: no-repeat;
@ -821,9 +748,6 @@ small, .text-sm {
/* Prevent zoom on mobile for inputs */
@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-select,
.form-textarea {
@ -1919,13 +1843,3 @@ small, .text-sm {
.animate-stagger > *:nth-child(4) { animation-delay: 180ms; }
.animate-stagger > *:nth-child(5) { animation-delay: 240ms; }
.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",
"short_name": "NextExpense",
"name": "ExpenseFlow",
"short_name": "ExpenseFlow",
"start_url": "/",
"display": "standalone",
"theme_color": "#10b981",
"background_color": "#0f172a",
"background_color": "#ffffff",
"icons": [
{
"src": "/static/icons/icon-192.png",

View file

@ -1,17 +1,16 @@
/* ============================================================
* NextExpense Service Worker
* Version: 2.0.0
* Cache name: nextexpense-v2
* ExpenseFlow Service Worker
* Version: 1.0.0
* Cache name: expenseflow-v1
* 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
const SHELL_ASSETS = [
'/',
'/static/css/style.css',
'/static/favicon.svg',
'https://unpkg.com/htmx.org@1.9.10'
];
@ -195,7 +194,7 @@ self.addEventListener('fetch', event => {
// Return a minimal offline fallback for navigations
if (request.mode === 'navigate') {
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,
statusText: 'Service Unavailable',

View file

@ -4,115 +4,92 @@
<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>NextExpense</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="alternate icon" href="/static/icons/icon-192.png">
<title>Dashboard - ExpenseFlow</title>
<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>
</head>
<body>
<div class="app-shell">
<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>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-secondary btn-sm" style="font-size: 0.75rem;"
hx-get="/profile" hx-target="body" hx-push-url="true">Profile</button>
<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>
<h1 class="app-title">ExpenseFlow</h1>
<a href="/" class="btn btn-secondary btn-sm"
hx-post="/logout" hx-target="body" hx-push-url="true"
hx-confirm="Are you sure you want to logout?">Logout</a>
</header>
<main class="main-content">
<div class="dashboard-header">
<h2>My Months</h2>
<button class="btn btn-primary btn-sm"
onclick="document.getElementById('create-form').classList.toggle('hidden')">
+ New
<h2>My Events</h2>
<button class="btn btn-primary"
onclick="document.getElementById('create-event-form').classList.toggle('hidden')">
+ New Event
</button>
</div>
<div id="create-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;">
<h3 style="margin-bottom: 1rem;">New Month</h3>
<form hx-post="/months" hx-target="body" hx-push-url="true">
<div class="form-row" style="gap: 1rem;">
<div style="flex: 2;">
<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 style="flex: 1;">
<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>
<form hx-post="/events" hx-target="body" hx-push-url="true">
<div class="form-group">
<label for="name">Event Name</label>
<input type="text" id="name" name="name" placeholder="e.g., WebSummit 2026" required>
</div>
<button type="submit" class="btn btn-primary btn-block">Create Month</button>
<button type="submit" class="btn btn-primary btn-block">Create Event</button>
</form>
</div>
</div>
<div id="month-list">
{{if .Months}}
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
{{range .Months}}
<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>
<div style="font-weight: 500; color: var(--color-text);">{{.Month.Name}}</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">{{.Month.CreatedAt}}</div>
<div id="event-list">
{{if .Events}}
<div class="event-grid">
{{range .Events}}
<div class="card event-card">
<div class="event-card-header">
<h3 class="event-card-name">{{.Name}}</h3>
<span id="status-badge-{{.ID}}" class="badge badge-{{.Status}}">{{.Status}}</span>
</div>
{{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}}</div>
<div class="event-card-meta">
<span>Created: {{.CreatedAt}}</span>
</div>
{{end}}
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary btn-sm"
hx-get="/months/{{.Month.ID}}" hx-target="body" hx-push-url="true">
Open
</button>
<div class="event-card-actions">
{{if eq .Status "open"}}
<a href="/events/{{.ID}}/expenses" class="btn btn-primary btn-sm"
hx-get="/events/{{.ID}}/expenses" hx-target="body" hx-push-url="true">
Add Expenses
</a>
{{else}}
<button class="btn btn-secondary btn-sm"
hx-get="/months/{{.Month.ID}}/edit"
hx-target="#create-form"
hx-swap="innerHTML"
onclick="document.getElementById('create-form').classList.remove('hidden')"
style="font-size: 0.75rem;">
Edit
hx-put="/events/{{.ID}}/reopen"
hx-target="#status-badge-{{.ID}}"
hx-swap="outerHTML">
Reopen
</button>
{{end}}
</div>
</div>
{{end}}
</div>
{{else}}
<div class="empty-state" style="text-align: center; padding: 3rem; color: #94a3b8;">
<p>No months yet. Create one to get started.</p>
<div class="empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="12"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
<h3>No Events Yet</h3>
<p>Create your first event to start tracking expenses.</p>
</div>
{{end}}
</div>
</main>
</div>
<script>
// Toggle hidden class
document.querySelector('button[onclick]')?.addEventListener('click', function() {
// handled inline
});
</script>
</body>
</html>

View file

@ -4,135 +4,143 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#10b981">
<title>{{.Event.Name}} - NextExpense</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="alternate icon" href="/static/icons/icon-192.png">
<title>{{.Event.Name}} - ExpenseFlow</title>
<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>
</head>
<body>
<div class="app-shell">
<header class="app-header">
<a href="/months/{{.Month.ID}}" class="btn btn-secondary btn-sm"
hx-get="/months/{{.Month.ID}}" hx-target="body" hx-push-url="true">&larr; {{.Month.Name}}</a>
<h1 class="app-title" style="font-size: 1.1rem;">{{.Event.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>
<a href="/dashboard" class="btn btn-secondary btn-sm"
hx-get="/dashboard" hx-target="body" hx-push-url="true">
&larr; Back
</a>
<h1 class="app-title">{{.Event.Name}}</h1>
<span class="badge badge-{{.Event.Status}}">{{.Event.Status}}</span>
</header>
<main class="main-content">
<!-- 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; <a href="/months/{{.Month.ID}}" style="color: var(--color-primary); text-decoration: none;"
hx-get="/months/{{.Month.ID}}" hx-target="body" hx-push-url="true">{{.Month.Name}}</a>
&rsaquo; {{.Event.Name}}
</div>
<!-- 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>
<!-- Capture Receipt Button -->
<div style="margin-bottom: 1.5rem;">
<label for="receipt-upload" class="btn btn-primary btn-block" style="display: inline-block; text-align: center; cursor: pointer;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 0.5rem;">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
Capture Receipt
</label>
<input type="file" id="receipt-upload" accept="image/*" capture="environment" style="display: none;"
hx-post="/expenses/upload"
hx-encoding="multipart/form-data"
hx-target="#receipt-form"
hx-swap="innerHTML"
hx-indicator="#upload-indicator">
<div id="upload-indicator" class="htmx-indicator" style="text-align: center; padding: 1rem;">
<div class="spinner"></div>
<p>Analyzing receipt...</p>
</div>
</div>
<!-- Receipt 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>
<!-- 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 style="display: flex; flex-direction: column; gap: 0.5rem;">
<div class="expense-list">
{{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 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 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>
<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 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 class="expense-item-amount">
<span class="amount">{{printf "%.2f" .Amount}}</span>
<span class="currency">{{.Currency}}</span>
</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 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>
<!-- 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 for="receipt-upload" class="btn btn-secondary" style="flex: 1; text-align: center; cursor: pointer;">
📁 Upload
</label>
<!-- 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>
<!-- 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-encoding="multipart/form-data"
hx-target="#receipt-form"
hx-swap="innerHTML"
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 class="spinner"></div>
<p>Analyzing receipt...</p>
<!-- File Event Modal -->
<div id="file-modal" class="modal-overlay hidden">
<div class="modal-content">
<h3>File Event Report</h3>
<p class="text-secondary">Generate and email the expense report for "{{.Event.Name}}".</p>
<form hx-post="/events/{{.Event.ID}}/file" hx-target="body" hx-push-url="true">
<div class="form-group">
<label for="file-email">Send to</label>
<input type="email" id="file-email" name="email" placeholder="recipient@example.com" required>
</div>
<div class="form-group">
<label>Format</label>
<div style="display: flex; gap: 1rem;">
<label class="radio-label">
<input type="radio" name="format" value="csv" checked> CSV
</label>
<label class="radio-label">
<input type="radio" name="format" value="pdf"> PDF
</label>
</div>
</div>
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button type="button" class="btn btn-secondary"
onclick="document.getElementById('file-modal').classList.add('hidden')">Cancel</button>
<button type="submit" class="btn btn-primary">Send Report & Close</button>
</div>
</form>
</div>
</div>
{{end}}
</main>
</div>
<script>
// Auto-trigger file input on label click
document.querySelector('label[for="receipt-upload"]')?.addEventListener('click', function() {
document.getElementById('receipt-upload').click();
});
// Close modal on overlay click
document.querySelector('.modal-overlay')?.addEventListener('click', function(e) {
if (e.target === this) {
this.classList.add('hidden');
}
});
</script>
</body>
</html>

View file

@ -22,41 +22,7 @@
<div class="expense-item-amount">
<span class="amount">{{printf "%.2f" .Amount}}</span>
<span class="currency">{{.Currency}}</span>
{{if .ConvertedAmount}}
<div style="font-size: 0.75rem; color: #166534;">
≈ {{printf "%.2f" .ConvertedAmount}} {{.BaseCurrency}}
</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>
{{end}}
</div>

View file

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

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,8 +1,9 @@
{{if .AIError}}
<div class="error-message" style="background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">
{{.AIError}}
</div>
{{end}}
<div id="receipt-form">
{{if .AIError}}
<div class="error-message" style="background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">
{{.AIError}}
</div>
{{end}}
<div class="card" style="padding: 1rem;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;">
@ -14,15 +15,9 @@
<span class="badge badge-open" style="margin-left: auto;">AI Extracted</span>
</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"
hx-indicator="#save-indicator">
{{end}}
<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-group">
@ -32,8 +27,21 @@
</div>
<div class="form-group">
<label for="currency">Currency *</label>
<input type="text" id="currency" name="currency" placeholder="KES, USD, EUR…"
value="{{.Currency}}" required maxlength="3" style="text-transform: uppercase;">
<select id="currency" name="currency" required>
<option value="">Select</option>
<option value="USD" {{if eq .Currency "USD"}}selected{{end}}>USD</option>
<option value="EUR" {{if eq .Currency "EUR"}}selected{{end}}>EUR</option>
<option value="GBP" {{if eq .Currency "GBP"}}selected{{end}}>GBP</option>
<option value="JPY" {{if eq .Currency "JPY"}}selected{{end}}>JPY</option>
<option value="CHF" {{if eq .Currency "CHF"}}selected{{end}}>CHF</option>
<option value="SEK" {{if eq .Currency "SEK"}}selected{{end}}>SEK</option>
<option value="NOK" {{if eq .Currency "NOK"}}selected{{end}}>NOK</option>
<option value="DKK" {{if eq .Currency "DKK"}}selected{{end}}>DKK</option>
<option value="PLN" {{if eq .Currency "PLN"}}selected{{end}}>PLN</option>
<option value="CZK" {{if eq .Currency "CZK"}}selected{{end}}>CZK</option>
<option value="HUF" {{if eq .Currency "HUF"}}selected{{end}}>HUF</option>
<option value="RON" {{if eq .Currency "RON"}}selected{{end}}>RON</option>
</select>
</div>
</div>
@ -48,22 +56,11 @@
<label for="category">Category *</label>
<select id="category" name="category" required>
<option value="">Select</option>
<option value="Airfare" {{if eq .Category "Airfare"}}selected{{end}}>Airfare</option>
<option value="Accommodation" {{if eq .Category "Accommodation"}}selected{{end}}>Accommodation</option>
<option value="Meals Self" {{if eq .Category "Meals Self"}}selected{{end}}>Meals Self</option>
<option value="Staff Meal" {{if eq .Category "Staff Meal"}}selected{{end}}>Staff Meal</option>
<option value="Client Meal" {{if eq .Category "Client Meal"}}selected{{end}}>Client Meal</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>
<option value="Food" {{if eq .Category "Food"}}selected{{end}}>Food</option>
<option value="Travel" {{if eq .Category "Travel"}}selected{{end}}>Travel</option>
<option value="Lodging" {{if eq .Category "Lodging"}}selected{{end}}>Lodging</option>
<option value="Software" {{if eq .Category "Software"}}selected{{end}}>Software</option>
<option value="Other" {{if eq .Category "Other"}}selected{{end}}>Other</option>
</select>
</div>
<div class="form-group">
@ -73,35 +70,14 @@
</div>
<div class="form-group">
<label for="description">Description *</label>
<textarea id="description" name="description" placeholder="Required notes..." required>{{.Description}}</textarea>
<label for="description">Description</label>
<textarea id="description" name="description" placeholder="Optional notes...">{{.Description}}</textarea>
</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;">
<button type="submit" class="btn btn-primary" id="save-indicator">
<span class="spinner htmx-indicator"></span>
{{if .EditID}}Update Expense{{else}}Save Expense{{end}}
Save Expense
</button>
<button type="button" class="btn btn-secondary"
onclick="document.getElementById('receipt-form').innerHTML = ''">
@ -110,3 +86,4 @@
</div>
</form>
</div>
</div>