Compare commits
111 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22d2507d52 | |||
| f1b029949e | |||
| e9460a6a7d | |||
| 47fb82d109 | |||
| a7f7dca41d | |||
| 2fcce08cff | |||
| dfefd0208c | |||
| 2f2fbf7630 | |||
| 949d4f277c | |||
| 10e6b78940 | |||
| 6d641cf8c9 | |||
| 3bc3eddd5b | |||
| 64844baa83 | |||
| d65c0cd5fa | |||
| 1c9ab4554a | |||
| 517e95adc2 | |||
| ce08681d81 | |||
| 24451b4f4f | |||
| 95762cd1ae | |||
| b50699fc0e | |||
| 7c84c0858f | |||
| a943c258bb | |||
| 5e66e849fc | |||
| 5aa5523e8b | |||
| 64d7494e33 | |||
| 4bc9fe52ae | |||
| 8759b31e47 | |||
| be3aa7a036 | |||
| cc56d1ac9f | |||
| 44309dc189 | |||
| 290f803996 | |||
| 422aaa08ab | |||
| 90c9df6cce | |||
| 09b2ddfc79 | |||
| c83b2ccc83 | |||
| ce2efd9be7 | |||
| aa07ff3c50 | |||
| b0dfb5fad3 | |||
| afd4b6a1cf | |||
| 5abe8c49d9 | |||
| f8d3cb6b33 | |||
| 433b5a4ed8 | |||
| 67f1a6c845 | |||
| 6e61740f0a | |||
| 0d42a35cb8 | |||
| b9721a9bab | |||
| c815e022b5 | |||
| e547eee57f | |||
| 91534ed8bd | |||
| cb42efa395 | |||
| bb34528bf5 | |||
| 0748af7c26 | |||
| 0f39f3dc24 | |||
| 5f5f2be75b | |||
| 4ea3d63b29 | |||
| 7ab94a14d0 | |||
| 2d259a2b7c | |||
| 814cba1f53 | |||
| 0b55ac1fac | |||
| bb2c06fa3a | |||
| 9c239e565b | |||
| 5aca6c06a5 | |||
| c386b150c4 | |||
| 8ff1a4175a | |||
| 4593dd1f66 | |||
| e49b184be8 | |||
| 92f070440f | |||
| 6a902f0b85 | |||
| a7381bde0c | |||
| e831fcf617 | |||
| 2f26abfb2f | |||
| 9f52198647 | |||
| 3327fd4fac | |||
| 1eca4ae777 | |||
| 59f73830fb | |||
| 51c5d9a23e | |||
| de29841341 | |||
| 0ab9f1e1be | |||
| b7fddf0069 | |||
| bc5a5172dc | |||
| bb11a7f32c | |||
| 378c8e5b98 | |||
| e977907da3 | |||
| e20a484ebc | |||
| f9a7df1580 | |||
| 59788ba0c8 | |||
| b6c2c3f99d | |||
| 357483cfd2 | |||
| 57f501e0c8 | |||
| 1aa5ec456e | |||
| c0d3002e22 | |||
| 7fdfba8095 | |||
| 39fa7f6a12 | |||
| 6de9c27f9c | |||
| b780ac1e1f | |||
| f50048e833 | |||
| 5ca3ff7555 | |||
| 646378df64 | |||
| dcb43b08a0 | |||
| e22bd56169 | |||
| 67f5ed5624 | |||
| ded4954c72 | |||
| e00c3373cb | |||
| 4895b3d608 | |||
| 46c78ef507 | |||
| 127046c9b7 | |||
| fecf4af8a2 | |||
| ebe06082cf | |||
| dc407fbf06 | |||
| 64f5b9a65b | |||
| 6b6196a59a |
39 changed files with 5246 additions and 1187 deletions
30
.env.example
30
.env.example
|
|
@ -1,14 +1,26 @@
|
|||
# ExpenseFlow Configuration
|
||||
# NextExpense Configuration
|
||||
# Copy this file to .env and fill in your credentials.
|
||||
# Run `bash install.sh` for interactive setup.
|
||||
|
||||
# SMTP Configuration
|
||||
SMTP_HOST=smtp.openxchange.eu
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=post@2-4-h.app
|
||||
SMTP_PASS=D9AW8JP74r1V
|
||||
# --- AI Provider ---
|
||||
# Choose one: gemini (default), openai
|
||||
AI_PROVIDER=gemini
|
||||
|
||||
# DeepSeek Vision API Key
|
||||
DEEPSEEK_API_KEY=sk-e9362165d2694883a52a5142811aa422
|
||||
# For AI_PROVIDER=gemini:
|
||||
# GEMINI_API_KEY=your-gemini-api-key
|
||||
|
||||
# Base URL for generating absolute links in emails
|
||||
# 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=http://localhost:8080
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -30,3 +30,5 @@ expenseflow-*
|
|||
|
||||
# Go
|
||||
vendor/
|
||||
.go/
|
||||
app
|
||||
|
|
|
|||
85
Makefile
Normal file
85
Makefile
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# NextExpense — AI-Powered Expense Tracker
|
||||
# Makefile for build, install, and deployment
|
||||
|
||||
BINARY = app
|
||||
OUTDIR = dist
|
||||
VERSION = v1.0.0
|
||||
LDFLAGS = -s -w
|
||||
|
||||
.PHONY: all build clean install uninstall run stop restart logs
|
||||
|
||||
all: build
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Build
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
build:
|
||||
go build -ldflags="$(LDFLAGS)" -o $(BINARY) .
|
||||
|
||||
build-linux-amd64:
|
||||
GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(OUTDIR)/$(BINARY)-linux-amd64 .
|
||||
|
||||
build-linux-arm64:
|
||||
GOOS=linux GOARCH=arm64 CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc go build -ldflags="$(LDFLAGS)" -o $(OUTDIR)/$(BINARY)-linux-arm64 .
|
||||
|
||||
build-all: build-linux-amd64 build-linux-arm64
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Install (systemd service)
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
install: build
|
||||
@echo "==> Installing NextExpense..."
|
||||
cp $(BINARY) /usr/local/bin/$(BINARY)
|
||||
@if [ ! -f /etc/$(BINARY)/.env ]; then \
|
||||
mkdir -p /etc/$(BINARY); \
|
||||
cp .env.example /etc/$(BINARY)/.env; \
|
||||
echo "==> Created /etc/$(BINARY)/.env — edit it with your credentials"; \
|
||||
fi
|
||||
@if [ ! -f /etc/systemd/system/$(BINARY).service ]; then \
|
||||
cp contrib/$(BINARY).service /etc/systemd/system/; \
|
||||
systemctl daemon-reload; \
|
||||
systemctl enable $(BINARY); \
|
||||
echo "==> Systemd service installed"; \
|
||||
fi
|
||||
@echo "==> Run 'systemctl start $(BINARY)' to start"
|
||||
@echo "==> Run 'systemctl status $(BINARY)' to check status"
|
||||
|
||||
uninstall:
|
||||
-systemctl stop $(BINARY) 2>/dev/null
|
||||
-systemctl disable $(BINARY) 2>/dev/null
|
||||
-rm -f /etc/systemd/system/$(BINARY).service
|
||||
-systemctl daemon-reload
|
||||
-rm -f /usr/local/bin/$(BINARY)
|
||||
@echo "==> NextExpense uninstalled"
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Service management
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
run:
|
||||
./$(BINARY)
|
||||
|
||||
start:
|
||||
systemctl start $(BINARY)
|
||||
|
||||
stop:
|
||||
systemctl stop $(BINARY)
|
||||
|
||||
restart:
|
||||
systemctl restart $(BINARY)
|
||||
|
||||
logs:
|
||||
journalctl -u $(BINARY) -f
|
||||
|
||||
status:
|
||||
systemctl status $(BINARY)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Clean
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
rm -rf $(OUTDIR)
|
||||
452
README.md
452
README.md
|
|
@ -1,121 +1,113 @@
|
|||
# ExpenseFlow — AI-Powered Expense Tracker
|
||||
# NextExpense — AI-Powered Expense Tracker
|
||||
|
||||
> A production-ready, mobile-first Progressive Web App (PWA) that uses passwordless email OTP login, event-based expense tracking, AI receipt extraction (DeepSeek Vision), and event filing (CSV/PDF via email).
|
||||
> 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.
|
||||
|
||||
**Tech Stack:** Go 1.22+ · HTMX · SQLite · DeepSeek Vision API · PWA
|
||||
**Tech Stack:** Go 1.23+ · HTMX · SQLite (pure Go) · Google Gemini / OpenAI · PWA
|
||||
|
||||
---
|
||||
|
||||
## 📦 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
|
||||
## 🚀 One-Command Install
|
||||
|
||||
```bash
|
||||
# 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
|
||||
curl -fsSL https://git.lohmar.co.uk/cclohmar/NextExpense/raw/branch/main/install.sh | bash -s -- --install
|
||||
```
|
||||
|
||||
The server starts on `http://localhost:8080` by default. Set `PORT=3000` to change the port.
|
||||
Or from a local clone:
|
||||
|
||||
> **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.
|
||||
```bash
|
||||
./install.sh --install
|
||||
```
|
||||
|
||||
---
|
||||
Running without flags shows help:
|
||||
|
||||
## 🔧 Building from Source
|
||||
```bash
|
||||
./install.sh # Show help
|
||||
./install.sh --help # Same
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
The installer will:
|
||||
|
||||
- **Go 1.22+** — [Download](https://go.dev/dl/)
|
||||
- **GCC** (CGO is required for the SQLite driver)
|
||||
1. Clone the repo to `/opt/nextexpense/`
|
||||
2. Build the binary (pure Go, no CGO, no dependencies)
|
||||
3. Ask which AI provider to use:
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo apt install build-essential
|
||||
|
||||
# macOS
|
||||
xcode-select --install
|
||||
|
||||
# Alpine
|
||||
apk add build-base
|
||||
```
|
||||
1) Google Gemini (cloud API, needs API key)
|
||||
2) OpenAI / Compatible (OpenAI, Perplexity, Groq, etc.)
|
||||
```
|
||||
|
||||
### Clone & Build
|
||||
4. Prompt for SMTP settings (for OTP emails and report delivery)
|
||||
5. Create `/opt/nextexpense/.env` with all configuration
|
||||
6. Set up a systemd service that auto-starts on boot
|
||||
7. Start NextExpense
|
||||
|
||||
**Result:** A fully configured, always-running expense tracker at `http://YOUR_SERVER:8080`.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Updating
|
||||
|
||||
```bash
|
||||
git clone https://git.lohmar.co.uk/cclohmar/ExpenseFlow.git
|
||||
cd ExpenseFlow
|
||||
./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
|
||||
|
||||
# Build for your current platform
|
||||
go build -o expenseflow .
|
||||
CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w" -o app .
|
||||
|
||||
# The binary is now ready: ./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 .
|
||||
```
|
||||
|
||||
### 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`.
|
||||
The binary is fully static — zero runtime dependencies, no CGO, no libc.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Copy `.env.example` to `.env` and fill in your credentials:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
Configuration is via environment variables in `.env`. The install script builds this for you interactively.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `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 |
|
||||
| `PORT` | No | `8080` | HTTP server port |
|
||||
| `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 |
|
||||
|
||||
*\* 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.
|
||||
> **Note:** SMTP is optional — without it, OTP codes are logged to the server console for testing and reports cannot be emailed.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -124,208 +116,128 @@ cp .env.example .env
|
|||
### 1. Start the Server
|
||||
|
||||
```bash
|
||||
./expenseflow
|
||||
# If installed via systemd:
|
||||
systemctl start nextexpense
|
||||
|
||||
# Or run directly:
|
||||
./app
|
||||
```
|
||||
|
||||
### 2. Open in Browser
|
||||
|
||||
Navigate to [http://localhost:8080](http://localhost:8080)
|
||||
Navigate to `http://YOUR_SERVER:8080`
|
||||
|
||||
### 3. Full Acceptance Flow
|
||||
|
||||
```
|
||||
1. Enter your email → click "Send Verification Code"
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📡 API Reference
|
||||
|
||||
### Public Endpoints (no authentication)
|
||||
### Public
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `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 |
|
||||
| `GET` | `/` | Landing page |
|
||||
| `POST` | `/request-otp` | Request OTP code |
|
||||
| `POST` | `/verify-otp` | Verify OTP and create session |
|
||||
| `POST` | `/logout` | Clear session |
|
||||
|
||||
### Protected Endpoints (require session cookie)
|
||||
### Protected (requires session)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Project Structure
|
||||
|
||||
```
|
||||
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)
|
||||
/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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Features in Detail
|
||||
## 🧩 Features
|
||||
|
||||
### 🔐 Passwordless OTP Authentication
|
||||
- Email-based 6-digit code, 5-minute expiry
|
||||
- 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
|
||||
- 3 failed attempts → 1-minute cooldown
|
||||
- HTTP-only session cookie, 24h TTL
|
||||
|
||||
### 🤖 AI Receipt Extraction
|
||||
- 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/`
|
||||
- **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
|
||||
|
||||
### 📧 Email Reporting
|
||||
- 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
|
||||
- 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
|
||||
|
||||
### 📱 Progressive Web App
|
||||
- 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+
|
||||
- Installable on mobile home screen
|
||||
- Camera capture + gallery upload
|
||||
- Dark "Terminal Mint" theme (`#0F172A` base)
|
||||
- Rx favicon in emerald green
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Database Schema (SQLite)
|
||||
## 🗄️ Database
|
||||
|
||||
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
|
||||
```
|
||||
SQLite, auto-created on first run — 5 tables: `users`, `auth_otps`, `months`, `events`, `expenses`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -333,57 +245,13 @@ docker run -p 8080:8080 -v $(pwd)/.env:/app/.env -v $(pwd)/storage:/app/storage
|
|||
|
||||
| Area | Implementation |
|
||||
|------|---------------|
|
||||
| **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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 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 ❤️*
|
||||
*Built with Go, HTMX, SQLite and ❤️*
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
# darwin/arm64: aarch64-apple-darwin-clang (via osxcross)
|
||||
#
|
||||
# Usage: ./build-all.sh
|
||||
# Output: ./dist/expenseflow-{platform}
|
||||
# Output: ./dist/app-{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/expenseflow-$SUFFIX"
|
||||
local OUT="$OUTDIR/app-$SUFFIX"
|
||||
echo " $SUFFIX ..."
|
||||
GOOS="$GOOS" GOARCH="$GOARCH" CGO_ENABLED=1 CC="$CC" \
|
||||
go build -ldflags="$LDFLAGS" -o "$OUT" .
|
||||
|
|
|
|||
25
contrib/nextexpense.service
Normal file
25
contrib/nextexpense.service
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[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
19
go.mod
|
|
@ -1,11 +1,24 @@
|
|||
module github.com/expenseflow
|
||||
module github.com/cclohmar/NextExpense
|
||||
|
||||
go 1.22
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/jung-kurt/gofpdf v1.16.2
|
||||
github.com/mattn/go-sqlite3 v1.14.22
|
||||
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
|
||||
)
|
||||
|
|
|
|||
49
go.sum
49
go.sum
|
|
@ -1,7 +1,11 @@
|
|||
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=
|
||||
|
|
@ -9,12 +13,53 @@ 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-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/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/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=
|
||||
|
|
|
|||
550
install.sh
Executable file
550
install.sh
Executable file
|
|
@ -0,0 +1,550 @@
|
|||
#!/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
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
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
|
||||
}
|
||||
151
internal/ai/gemini.go
Normal file
151
internal/ai/gemini.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
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
|
||||
}
|
||||
145
internal/ai/openai.go
Normal file
145
internal/ai/openai.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
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
|
||||
}
|
||||
164
internal/ai/receipt.go
Normal file
164
internal/ai/receipt.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// 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 ""
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ package auth
|
|||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -33,7 +34,8 @@ func ValidateOTP(provided, stored string, expiresAt time.Time) bool {
|
|||
if time.Now().After(expiresAt) {
|
||||
return false
|
||||
}
|
||||
return provided == stored
|
||||
// Use constant-time comparison to prevent timing side-channel attacks.
|
||||
return subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1
|
||||
}
|
||||
|
||||
// attemptData stores the failure count and timestamp for a single email.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"log"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// DB is the shared database handle, initialized by Init().
|
||||
|
|
@ -23,6 +23,9 @@ var DB *sql.DB
|
|||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
Department string
|
||||
Onboarded bool
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
|
|
@ -33,12 +36,23 @@ type OTP struct {
|
|||
ExpiresAt string
|
||||
}
|
||||
|
||||
// Month represents a row in the months table.
|
||||
type Month struct {
|
||||
ID string
|
||||
UserID string
|
||||
Name string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// Event represents a row in the events table.
|
||||
type Event struct {
|
||||
ID string
|
||||
UserID string
|
||||
MonthID string
|
||||
Name string
|
||||
Status string
|
||||
BaseCurrency string
|
||||
ExchangeRate float64
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
|
|
@ -48,6 +62,8 @@ type Expense struct {
|
|||
EventID string
|
||||
Amount float64
|
||||
Currency string
|
||||
ConvertedAmount float64
|
||||
BaseCurrency string
|
||||
Merchant string
|
||||
Category string
|
||||
Description string
|
||||
|
|
@ -56,6 +72,16 @@ type Expense struct {
|
|||
CreatedAt string
|
||||
}
|
||||
|
||||
// DownloadToken represents a download token for a generated report package.
|
||||
type DownloadToken struct {
|
||||
Token string
|
||||
EventID string
|
||||
Filename string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
Accessed bool
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -65,30 +91,41 @@ type Expense struct {
|
|||
// It also sets the package-level DB variable for shared use.
|
||||
func Init() (*sql.DB, error) {
|
||||
var err error
|
||||
DB, err = sql.Open("sqlite3", "expenses.db")
|
||||
DB, err = sql.Open("sqlite", "expenses.db")
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] database: failed to open: %v", time.Now().Format(time.RFC3339), err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// SQLite does not support concurrent writes; limit to one connection.
|
||||
DB.SetMaxOpenConns(1)
|
||||
// modernc.org/sqlite supports concurrent reads.
|
||||
// A small pool handles HTMX concurrent requests efficiently.
|
||||
DB.SetMaxOpenConns(4)
|
||||
DB.SetMaxIdleConns(2)
|
||||
|
||||
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 four tables.
|
||||
// createTables executes the DDL statements for all 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 (
|
||||
|
|
@ -96,19 +133,32 @@ func createTables(db *sql.DB) error {
|
|||
otp_code TEXT NOT NULL,
|
||||
expires_at DATETIME NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS events (
|
||||
`CREATE TABLE IF NOT EXISTS months (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT CHECK(status IN ('open', 'closed')) DEFAULT 'open',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS 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
|
||||
)`,
|
||||
`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,
|
||||
|
|
@ -117,6 +167,14 @@ 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 {
|
||||
|
|
@ -127,6 +185,23 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -146,9 +221,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, created_at FROM users WHERE email = ?", email)
|
||||
row := db.QueryRow("SELECT id, email, name, department, onboarded, created_at FROM users WHERE email = ?", email)
|
||||
u := &User{}
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.CreatedAt); err != nil {
|
||||
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
|
||||
}
|
||||
|
|
@ -159,6 +234,34 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -204,26 +307,144 @@ func DeleteOTP(db *sql.DB, email string) error {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event queries
|
||||
// Month queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreateEvent inserts a new event row.
|
||||
func CreateEvent(db *sql.DB, id, userID, name string) error {
|
||||
// CreateMonth inserts a new month row.
|
||||
func CreateMonth(db *sql.DB, id, userID, name string) error {
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO events (id, user_id, name) VALUES (?, ?, ?)",
|
||||
"INSERT INTO months (id, user_id, name) VALUES (?, ?, ?)",
|
||||
id, userID, name,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s): %v",
|
||||
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
|
||||
}
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO events (id, user_id, month_id, name, base_currency, exchange_rate) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
id, userID, monthID, name, baseCurrency, exchangeRate,
|
||||
)
|
||||
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)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// GetEventsByUser returns all events belonging to a user, ordered by creation date descending.
|
||||
func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
|
||||
rows, err := db.Query(
|
||||
"SELECT id, user_id, name, status, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC",
|
||||
"SELECT id, user_id, month_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC",
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -236,7 +457,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.Name, &e.Status, &e.CreatedAt); err != nil {
|
||||
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: GetEventsByUser scan: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
return nil, err
|
||||
|
|
@ -246,11 +467,37 @@ 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, name, status, created_at FROM events WHERE id = ?", id)
|
||||
row := db.QueryRow("SELECT id, user_id, month_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE id = ?", id)
|
||||
e := &Event{}
|
||||
if err := row.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.CreatedAt); err != nil {
|
||||
if err := row.Scan(&e.ID, &e.UserID, &e.MonthID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -271,17 +518,54 @@ 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, merchant, category, description, date, image_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO expenses (id, event_id, amount, currency, converted_amount, base_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,
|
||||
)
|
||||
|
|
@ -295,8 +579,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, merchant, category,
|
||||
COALESCE(description, ''), date, image_path, created_at
|
||||
`SELECT id, event_id, amount, currency, converted_amount, base_currency,
|
||||
merchant, category, COALESCE(description, ''), date, image_path, created_at
|
||||
FROM expenses WHERE event_id = ? ORDER BY created_at DESC`,
|
||||
eventID,
|
||||
)
|
||||
|
|
@ -311,8 +595,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.Merchant,
|
||||
&e.Category, &e.Description, &e.Date, &e.ImagePath, &e.CreatedAt,
|
||||
&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 {
|
||||
log.Printf("ERROR [%s] database: GetExpensesByEvent scan: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
|
|
@ -322,3 +606,143 @@ 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Package email provides SMTP email sending for ExpenseFlow, including OTP
|
||||
// Package email provides SMTP email sending for NextExpense, 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 ExpenseFlow OTP"
|
||||
subject := "Your NextExpense OTP"
|
||||
body := fmt.Sprintf("Your verification code is: %s", code)
|
||||
|
||||
msg := buildPlainMessage(s.from, to, subject, body)
|
||||
|
|
@ -73,15 +73,14 @@ func (s *Sender) SendOTP(to, code string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
log.Printf("INFO [%s] email: OTP sent to %s", time.Now().Format(time.RFC3339), to)
|
||||
log.Printf("INFO [%s] email: OTP code %s sent to %s", time.Now().Format(time.RFC3339), code, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendReport sends an email with the given subject and body, attaching a CSV
|
||||
// or PDF file. The attachment's Content-Type is inferred from its filename
|
||||
// extension (text/csv for .csv, application/octet-stream otherwise).
|
||||
func (s *Sender) SendReport(to, subject, body string, attachment *Attachment) error {
|
||||
msg, err := buildMultipartMessage(s.from, to, subject, body, attachment)
|
||||
// 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)
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] email: SendReport(%s): build failed: %v",
|
||||
time.Now().Format(time.RFC3339), to, err)
|
||||
|
|
@ -94,8 +93,12 @@ func (s *Sender) SendReport(to, subject, body string, attachment *Attachment) er
|
|||
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, attachment.Filename)
|
||||
time.Now().Format(time.RFC3339), to, strings.Join(names, ", "))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -184,13 +187,15 @@ 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, attachment *Attachment) ([]byte, error) {
|
||||
func buildMultipartMessage(from, to, subject, body string, attachments []*Attachment) ([]byte, error) {
|
||||
var b strings.Builder
|
||||
|
||||
// Write the main SMTP headers.
|
||||
// Write the main SMTP headers with deliverability improvements.
|
||||
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)
|
||||
|
|
@ -209,18 +214,20 @@ func buildMultipartMessage(from, to, subject, body string, attachment *Attachmen
|
|||
return nil, fmt.Errorf("writing text part: %w", err)
|
||||
}
|
||||
|
||||
// --- Attachment part ---
|
||||
aw, err := mw.CreatePart(attachmentHeader(attachment.Filename))
|
||||
// --- 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: %w", err)
|
||||
return nil, fmt.Errorf("creating attachment part %q: %w", att.Filename, err)
|
||||
}
|
||||
|
||||
enc := base64.NewEncoder(base64.StdEncoding, aw)
|
||||
if _, err := enc.Write(attachment.Content); err != nil {
|
||||
if _, err := enc.Write(att.Content); err != nil {
|
||||
enc.Close()
|
||||
return nil, fmt.Errorf("writing attachment content: %w", err)
|
||||
return nil, fmt.Errorf("writing attachment %q: %w", att.Filename, err)
|
||||
}
|
||||
enc.Close()
|
||||
}
|
||||
|
||||
mw.Close()
|
||||
|
||||
|
|
@ -268,7 +275,8 @@ func attachmentHeader(filename string) textproto.MIMEHeader {
|
|||
func attachmentContentType(filename string) string {
|
||||
switch {
|
||||
case strings.HasSuffix(strings.ToLower(filename), ".csv"):
|
||||
return "text/csv; charset=\"utf-8\""
|
||||
// Some providers block text/csv; use text/plain as fallback.
|
||||
return "text/plain; charset=\"utf-8\""
|
||||
case strings.HasSuffix(strings.ToLower(filename), ".pdf"):
|
||||
return "application/pdf"
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Package handlers implements HTTP handlers for ExpenseFlow, providing
|
||||
// Package handlers implements HTTP handlers for NextExpense, providing
|
||||
// passwordless email OTP authentication, event management, expense tracking,
|
||||
// and report generation endpoints.
|
||||
package handlers
|
||||
|
|
@ -9,13 +9,15 @@ import (
|
|||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/expenseflow/internal/auth"
|
||||
"github.com/expenseflow/internal/database"
|
||||
"github.com/expenseflow/internal/email"
|
||||
"github.com/expenseflow/internal/utils"
|
||||
"github.com/cclohmar/NextExpense/internal/auth"
|
||||
"github.com/cclohmar/NextExpense/internal/database"
|
||||
"github.com/cclohmar/NextExpense/internal/email"
|
||||
"github.com/cclohmar/NextExpense/internal/utils"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -35,6 +37,8 @@ type AuthHandler struct {
|
|||
Sessions *auth.SessionStore
|
||||
FailureTracker *auth.FailureTracker
|
||||
EmailSender *email.Sender
|
||||
|
||||
otpMu sync.Mutex // prevents OTP reuse via race conditions
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -44,13 +48,16 @@ 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) {
|
||||
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)
|
||||
// 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 := 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)
|
||||
|
|
@ -87,7 +94,7 @@ func (h *AuthHandler) RequestOTP(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
if user == nil {
|
||||
userID := utils.New()
|
||||
userID := utils.NewUUID()
|
||||
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.")
|
||||
|
|
@ -114,12 +121,17 @@ 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)
|
||||
}
|
||||
|
||||
// Render the OTP verification form as an HTMX fragment.
|
||||
renderOTPForm(w, emailAddr)
|
||||
renderOTPForm(w, emailAddr, "")
|
||||
}
|
||||
|
||||
// VerifyOTP handles OTP code verification and session creation.
|
||||
|
|
@ -135,7 +147,7 @@ func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) {
|
|||
otpCode := collectOTP(r)
|
||||
|
||||
if emailAddr == "" || otpCode == "" {
|
||||
renderError(w, "Email and OTP code are required.")
|
||||
renderOTPForm(w, emailAddr, "Email and OTP code are required.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -143,11 +155,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)
|
||||
renderError(w, "An error occurred. Please try again.")
|
||||
renderOTPForm(w, emailAddr, "An error occurred. Please try again.")
|
||||
return
|
||||
}
|
||||
if stored == nil {
|
||||
renderError(w, "No OTP found for this email. Please request a new code.")
|
||||
renderOTPForm(w, emailAddr, "No OTP found for this email. Please request a new code.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -155,29 +167,31 @@ 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)
|
||||
renderError(w, "An error occurred. Please try again.")
|
||||
renderOTPForm(w, emailAddr, "An error occurred. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the OTP code and expiry.
|
||||
// Validate + delete OTP atomically to prevent race-condition reuse.
|
||||
h.otpMu.Lock()
|
||||
if !auth.ValidateOTP(otpCode, stored.OTPCode, expiresAt) {
|
||||
h.otpMu.Unlock()
|
||||
h.FailureTracker.RecordFailure(emailAddr)
|
||||
renderError(w, "Invalid or expired OTP code. Please try again.")
|
||||
renderOTPForm(w, emailAddr, "Invalid or expired OTP code. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
// Successful verification: clean up and create session.
|
||||
// Successful verification: delete OTP immediately (still under lock).
|
||||
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)
|
||||
renderError(w, "An error occurred. Please try again.")
|
||||
renderOTPForm(w, emailAddr, "An error occurred. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -185,22 +199,28 @@ 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)
|
||||
renderError(w, "An error occurred. Please try again.")
|
||||
renderOTPForm(w, emailAddr, "An error occurred. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
// Set the HTTP-only session cookie with a 24-hour TTL.
|
||||
// Set the session cookie (HttpOnly, SameSite=Lax, Secure, 24h).
|
||||
secure := strings.HasPrefix(os.Getenv("BASE_URL"), "https://")
|
||||
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 dashboard via HTMX.
|
||||
// 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")
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
|
|
@ -246,38 +266,15 @@ 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: #dc2626; margin-bottom: 1rem;">%s</div>`, template.HTMLEscapeString(message))
|
||||
fmt.Fprintf(w, `<div class="error-message" style="color: #fca5a5; margin-bottom: 1rem;">%s</div>`, template.HTMLEscapeString(message))
|
||||
}
|
||||
|
||||
// renderOTPForm writes the OTP verification form partial as an HTMX fragment.
|
||||
// It renders 6 individual digit input boxes for a better mobile UX, plus a
|
||||
// hidden email field. The handler combines the 6 digits server-side.
|
||||
func renderOTPForm(w http.ResponseWriter, email string) {
|
||||
tmpl := template.Must(template.New("otp_form").Parse(`
|
||||
<form hx-post="/verify-otp" hx-target="#otp-form" hx-swap="outerHTML">
|
||||
<input type="hidden" name="email" value="{{.Email}}">
|
||||
<div style="display: flex; gap: 0.5rem; justify-content: center; margin: 1rem 0;">
|
||||
<input type="text" name="digit_0" maxlength="1" pattern="[0-9]" inputmode="numeric" autocomplete="one-time-code" required
|
||||
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
|
||||
<input type="text" name="digit_1" maxlength="1" pattern="[0-9]" inputmode="numeric" required
|
||||
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
|
||||
<input type="text" name="digit_2" maxlength="1" pattern="[0-9]" inputmode="numeric" required
|
||||
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
|
||||
<input type="text" name="digit_3" maxlength="1" pattern="[0-9]" inputmode="numeric" required
|
||||
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
|
||||
<input type="text" name="digit_4" maxlength="1" pattern="[0-9]" inputmode="numeric" required
|
||||
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
|
||||
<input type="text" name="digit_5" maxlength="1" pattern="[0-9]" inputmode="numeric" required
|
||||
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">
|
||||
</div>
|
||||
<button type="submit" style="width: 100%; padding: 0.75rem; background-color: #10b981; color: white; border: none; border-radius: 0.5rem; font-size: 1rem; cursor: pointer;">
|
||||
Verify Code
|
||||
</button>
|
||||
</form>
|
||||
`))
|
||||
|
||||
// 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")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, map[string]string{"Email": email}); err != nil {
|
||||
if err := tmpl.Execute(w, map[string]string{"Email": email, "Error": errMsg}); err != nil {
|
||||
log.Printf("ERROR [%s] handlers: renderOTPForm execute: %v", time.Now().Format(time.RFC3339), err)
|
||||
}
|
||||
}
|
||||
|
|
@ -285,14 +282,171 @@ func renderOTPForm(w http.ResponseWriter, email 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 {
|
||||
var b strings.Builder
|
||||
for i := 0; i < 6; i++ {
|
||||
digit := r.FormValue(fmt.Sprintf("digit_%d", i))
|
||||
if digit == "" {
|
||||
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
|
||||
}
|
||||
return -1
|
||||
}, code)
|
||||
if len(code) != 6 {
|
||||
return ""
|
||||
}
|
||||
b.WriteString(digit)
|
||||
}
|
||||
return b.String()
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Package handlers provides HTTP request handlers for ExpenseFlow.
|
||||
// Package handlers provides HTTP request handlers for NextExpense.
|
||||
//
|
||||
// This file implements event management endpoints including dashboard
|
||||
// listing, event creation, reopening, and expense viewing.
|
||||
// This file implements event management endpoints including event creation,
|
||||
// reopening, closing, and expense viewing — all scoped under a parent month.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
|
|
@ -10,10 +10,13 @@ import (
|
|||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/expenseflow/internal/database"
|
||||
"github.com/expenseflow/internal/utils"
|
||||
"github.com/cclohmar/NextExpense/internal/database"
|
||||
"github.com/cclohmar/NextExpense/internal/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
|
|
@ -33,201 +36,218 @@ func NewEventHandler(db *sql.DB) *EventHandler {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /dashboard — Dashboard
|
||||
// POST /months/{mid}/events — CreateEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Dashboard renders the main dashboard page showing all events belonging
|
||||
// to the authenticated user, along with the new event creation form.
|
||||
func (h *EventHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
userID := getUserID(r)
|
||||
if userID == "" {
|
||||
log.Printf("ERROR [%s] handlers: Dashboard: missing user ID", time.Now().Format(time.RFC3339))
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
events, err := database.GetEventsByUser(h.DB, userID)
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] handlers: Dashboard: GetEventsByUser: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
http.Error(w, "Failed to load events", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := template.ParseFiles("templates/dashboard.html")
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] handlers: Dashboard: parse template: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Events": events,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
log.Printf("ERROR [%s] handlers: Dashboard: execute template: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /events — CreateEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreateEvent handles the creation of a new event for the authenticated user.
|
||||
// It reads the event name from the form, generates a UUID, persists the
|
||||
// event, and redirects to the dashboard via HX-Redirect.
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
log.Printf("ERROR [%s] handlers: CreateEvent: missing event name", time.Now().Format(time.RFC3339))
|
||||
http.Error(w, "Event name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id := utils.New()
|
||||
if err := database.CreateEvent(h.DB, id, userID, name); err != nil {
|
||||
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 {
|
||||
log.Printf("ERROR [%s] handlers: CreateEvent: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
http.Error(w, "Failed to create event", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("HX-Redirect", "/dashboard")
|
||||
w.Header().Set("HX-Redirect", "/months/"+monthID)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /events/{id}/reopen — ReopenEvent
|
||||
// PUT /months/{mid}/events/{eid}/reopen — ReopenEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ReopenEvent sets an event's status back to "open" and returns an HTMX
|
||||
// fragment replacing the event's status badge with a green "open" badge.
|
||||
// It verifies that the requesting user owns the event.
|
||||
// ReopenEvent sets an event's status back to "open". Verifies month and event ownership.
|
||||
func (h *EventHandler) ReopenEvent(w http.ResponseWriter, r *http.Request) {
|
||||
eventID := chi.URLParam(r, "id")
|
||||
if eventID == "" {
|
||||
log.Printf("ERROR [%s] handlers: ReopenEvent: missing event ID",
|
||||
time.Now().Format(time.RFC3339))
|
||||
http.Error(w, "Missing event ID", http.StatusBadRequest)
|
||||
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 == "" {
|
||||
log.Printf("ERROR [%s] handlers: ReopenEvent: missing user ID",
|
||||
time.Now().Format(time.RFC3339))
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
event, err := database.GetEventByID(h.DB, eventID)
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] handlers: ReopenEvent: GetEventByID(%s): %v",
|
||||
time.Now().Format(time.RFC3339), eventID, err)
|
||||
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
|
||||
// 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
|
||||
}
|
||||
if event == nil {
|
||||
log.Printf("ERROR [%s] handlers: ReopenEvent: event %s not found",
|
||||
time.Now().Format(time.RFC3339), eventID)
|
||||
|
||||
event, err := database.GetEventByID(h.DB, eventID)
|
||||
if err != nil || event == nil {
|
||||
http.Error(w, "Event not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if event.UserID != userID {
|
||||
log.Printf("ERROR [%s] handlers: ReopenEvent: ownership mismatch for event %s",
|
||||
time.Now().Format(time.RFC3339), eventID)
|
||||
if event.MonthID != monthID || event.UserID != userID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateEventStatus(h.DB, eventID, "open"); err != nil {
|
||||
log.Printf("ERROR [%s] handlers: ReopenEvent: UpdateEventStatus(%s): %v",
|
||||
time.Now().Format(time.RFC3339), eventID, err)
|
||||
log.Printf("ERROR [%s] handlers: ReopenEvent: %v", time.Now().Format(time.RFC3339), err)
|
||||
http.Error(w, "Failed to reopen event", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return HTMX fragment: green "open" badge targeting #status-badge-{id}.
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<span id="status-badge-%s" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">open</span>`, eventID)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /events/{id}/expenses — ViewEventExpenses
|
||||
// POST /months/{mid}/events/{eid}/close — CloseEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ViewEventExpenses displays the expense collection view for a specific event.
|
||||
// It verifies event ownership, sets the current_event_id cookie, and renders
|
||||
// the event_expenses.html template with the event and its expense list.
|
||||
func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request) {
|
||||
eventID := chi.URLParam(r, "id")
|
||||
if eventID == "" {
|
||||
log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing event ID",
|
||||
time.Now().Format(time.RFC3339))
|
||||
http.Error(w, "Missing event ID", http.StatusBadRequest)
|
||||
// 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 == "" {
|
||||
log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing user ID",
|
||||
time.Now().Format(time.RFC3339))
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
event, err := database.GetEventByID(h.DB, eventID)
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] handlers: ViewEventExpenses: GetEventByID(%s): %v",
|
||||
time.Now().Format(time.RFC3339), eventID, err)
|
||||
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
|
||||
// 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
|
||||
}
|
||||
if event == nil {
|
||||
log.Printf("ERROR [%s] handlers: ViewEventExpenses: event %s not found",
|
||||
time.Now().Format(time.RFC3339), eventID)
|
||||
|
||||
event, err := database.GetEventByID(h.DB, eventID)
|
||||
if err != nil || event == nil {
|
||||
http.Error(w, "Event not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if event.UserID != userID {
|
||||
log.Printf("ERROR [%s] handlers: ViewEventExpenses: ownership mismatch for event %s",
|
||||
time.Now().Format(time.RFC3339), eventID)
|
||||
if event.MonthID != monthID || event.UserID != userID {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil {
|
||||
log.Printf("ERROR [%s] handlers: CloseEvent: %v", time.Now().Format(time.RFC3339), err)
|
||||
http.Error(w, "Failed to close event", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("HX-Redirect", "/months/"+monthID)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /months/{mid}/events/{eid}/expenses — ViewEventExpenses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ViewEventExpenses displays the expense collection view for a specific event.
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] handlers: ViewEventExpenses: GetExpensesByEvent(%s): %v",
|
||||
time.Now().Format(time.RFC3339), eventID, err)
|
||||
log.Printf("ERROR [%s] handlers: ViewEventExpenses: %v", time.Now().Format(time.RFC3339), err)
|
||||
http.Error(w, "Failed to load expenses", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the current_event_id cookie so subsequent expense operations
|
||||
// (SaveExpense, UploadReceipt) know which event to associate with.
|
||||
// Set current_event_id cookie for expense operations.
|
||||
setCurrentEventID(w, eventID)
|
||||
|
||||
tmpl, err := template.ParseFiles("templates/event_expenses.html")
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] handlers: ViewEventExpenses: parse template: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
tmpl := getTemplate("event_expenses.html")
|
||||
|
||||
for i := range expenses {
|
||||
expenses[i].ImagePath = normalizeImagePath(expenses[i].ImagePath)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Month": month,
|
||||
"Event": event,
|
||||
"Expenses": expenses,
|
||||
}
|
||||
|
|
@ -238,3 +258,145 @@ 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,36 @@
|
|||
// Package handlers provides HTTP request handlers for ExpenseFlow.
|
||||
// Package handlers provides HTTP request handlers for NextExpense.
|
||||
//
|
||||
// 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/expenseflow/internal/ai"
|
||||
"github.com/expenseflow/internal/database"
|
||||
"github.com/expenseflow/internal/utils"
|
||||
"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"
|
||||
)
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -52,10 +61,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(10 << 20); err != nil {
|
||||
if err := r.ParseMultipartForm(11 << 20); err != nil {
|
||||
log.Printf("ERROR [%s] handlers: UploadReceipt: parse form: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
http.Error(w, "Failed to parse upload form", http.StatusBadRequest)
|
||||
renderUploadError(w, "Failed to parse upload form.")
|
||||
return
|
||||
}
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
|
|
@ -65,7 +74,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)
|
||||
http.Error(w, "Missing receipt file", http.StatusBadRequest)
|
||||
renderUploadError(w, "Missing receipt file.")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
|
@ -74,7 +83,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)
|
||||
http.Error(w, "File too large. Maximum size is 10 MB.", http.StatusBadRequest)
|
||||
renderUploadError(w, "File too large. Maximum size is 10 MB.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -83,27 +92,38 @@ 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)
|
||||
http.Error(w, "Failed to read uploaded file", http.StatusInternalServerError)
|
||||
renderUploadError(w, "Failed to read uploaded file.")
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Validate content type by inspecting magic bytes.
|
||||
ext := detectImageExtension(fileData)
|
||||
if ext == "" {
|
||||
log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported image type",
|
||||
time.Now().Format(time.RFC3339))
|
||||
http.Error(w, "Only JPEG and PNG images are supported", http.StatusBadRequest)
|
||||
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.")
|
||||
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.New() + "." + ext
|
||||
filename := utils.NewUUID() + "." + ext
|
||||
storagePath := filepath.Join("storage", filename)
|
||||
|
||||
if err := os.MkdirAll("storage", 0755); err != nil {
|
||||
log.Printf("ERROR [%s] handlers: UploadReceipt: mkdir storage: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
||||
renderUploadError(w, "Server error. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -111,21 +131,35 @@ 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)
|
||||
http.Error(w, "Failed to save receipt image", http.StatusInternalServerError)
|
||||
renderUploadError(w, "Failed to save receipt image. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
// 8. Call the DeepSeek Vision API for AI extraction.
|
||||
receipt, aiErr := ai.ExtractReceipt(storagePath)
|
||||
|
||||
// 9. Render the receipt_form.html fragment.
|
||||
tmpl, err := template.ParseFiles("templates/receipt_form.html")
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] handlers: UploadReceipt: parse template: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||
return
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
data := map[string]interface{}{
|
||||
"ImagePath": storagePath,
|
||||
|
|
@ -136,6 +170,9 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
|
|||
"Category": "",
|
||||
"Date": "",
|
||||
"Description": "",
|
||||
"BaseCurrency": baseCurrency,
|
||||
"ExchangeRate": exchangeRate,
|
||||
"ConvertedAmount": "",
|
||||
}
|
||||
|
||||
if aiErr != nil {
|
||||
|
|
@ -148,6 +185,12 @@ 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")
|
||||
|
|
@ -197,6 +240,10 @@ 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 == "" {
|
||||
|
|
@ -214,6 +261,9 @@ 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, ", "))
|
||||
|
|
@ -231,12 +281,37 @@ 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.New(),
|
||||
ID: utils.NewUUID(),
|
||||
EventID: eventID,
|
||||
Amount: amount,
|
||||
Currency: currency,
|
||||
ConvertedAmount: convertedAmount,
|
||||
BaseCurrency: baseCurrency,
|
||||
Merchant: merchant,
|
||||
Category: category,
|
||||
Description: description,
|
||||
|
|
@ -261,13 +336,11 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// 7. Render the expense_list.html fragment.
|
||||
listTmpl, err := template.ParseFiles("templates/expense_list.html")
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] handlers: SaveExpense: parse list template: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||
return
|
||||
// Normalize ImagePath for old DB entries that may have storage/ prefix.
|
||||
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{}{
|
||||
|
|
@ -287,6 +360,243 @@ 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -298,6 +608,10 @@ 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
|
||||
}
|
||||
|
||||
|
|
@ -319,20 +633,118 @@ 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 whether it is a JPEG or PNG image. Returns "jpg", "png", or
|
||||
// an empty string if the format is not recognised.
|
||||
// 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.
|
||||
func detectImageExtension(data []byte) string {
|
||||
if len(data) < 4 {
|
||||
return ""
|
||||
}
|
||||
// JPEG magic: 0xFF 0xD8 0xFF
|
||||
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||
return "jpg"
|
||||
}
|
||||
// PNG magic: 0x89 'P' 'N' 'G' 0x0D 0x0A 0x1A 0x0A
|
||||
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
|
||||
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,30 @@
|
|||
// Package handlers implements HTTP request handlers for ExpenseFlow.
|
||||
// Package handlers implements HTTP request handlers for NextExpense.
|
||||
//
|
||||
// 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/expenseflow/internal/database"
|
||||
"github.com/expenseflow/internal/email"
|
||||
"github.com/cclohmar/NextExpense/internal/database"
|
||||
"github.com/cclohmar/NextExpense/internal/email"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -40,24 +47,15 @@ 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
|
||||
// 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
|
||||
// month view via the HX-Redirect header.
|
||||
func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
|
||||
// 1. Get event ID from the URL path parameter.
|
||||
eventID := chi.URLParam(r, "id")
|
||||
if eventID == "" {
|
||||
log.Printf("ERROR [%s] handlers: FileEvent: missing event ID in URL",
|
||||
// 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",
|
||||
time.Now().Format(time.RFC3339))
|
||||
http.Error(w, "Missing event ID", http.StatusBadRequest)
|
||||
renderFileError(w, "Missing ID.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -65,32 +63,30 @@ 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)
|
||||
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
|
||||
renderFileError(w, "Cannot parse form data.")
|
||||
return
|
||||
}
|
||||
|
||||
to := r.FormValue("email")
|
||||
format := r.FormValue("format")
|
||||
|
||||
if to == "" {
|
||||
log.Printf("ERROR [%s] handlers: FileEvent: missing email field",
|
||||
time.Now().Format(time.RFC3339))
|
||||
http.Error(w, "Email address is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if format != "csv" && format != "pdf" {
|
||||
log.Printf("ERROR [%s] handlers: FileEvent: invalid format %q",
|
||||
time.Now().Format(time.RFC3339), format)
|
||||
http.Error(w, "Format must be 'csv' or 'pdf'", http.StatusBadRequest)
|
||||
renderFileError(w, "Email address is required.")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Verify the authenticated user owns this event.
|
||||
// 3. Verify the authenticated user owns this event (via month).
|
||||
userID := getUserID(r)
|
||||
if userID == "" {
|
||||
log.Printf("ERROR [%s] handlers: FileEvent: unauthenticated request",
|
||||
time.Now().Format(time.RFC3339))
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
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.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -98,19 +94,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)
|
||||
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
|
||||
renderFileError(w, "Failed to retrieve event. Please try again.")
|
||||
return
|
||||
}
|
||||
if event == nil {
|
||||
log.Printf("ERROR [%s] handlers: FileEvent: event not found: %s",
|
||||
time.Now().Format(time.RFC3339), eventID)
|
||||
http.Error(w, "Event not found", http.StatusNotFound)
|
||||
renderFileError(w, "Event not found.")
|
||||
return
|
||||
}
|
||||
if event.UserID != userID {
|
||||
if event.MonthID != monthID || event.UserID != userID {
|
||||
log.Printf("ERROR [%s] handlers: FileEvent: user %s does not own event %s",
|
||||
time.Now().Format(time.RFC3339), userID, eventID)
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
renderFileError(w, "You do not have permission to file this event.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -119,32 +115,63 @@ 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)
|
||||
http.Error(w, "Failed to retrieve expenses", http.StatusInternalServerError)
|
||||
renderFileError(w, "Failed to retrieve expenses. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Generate the report in the requested format.
|
||||
var attachment *email.Attachment
|
||||
switch format {
|
||||
case "csv":
|
||||
attachment, err = generateCSV(event.Name, expenses)
|
||||
case "pdf":
|
||||
attachment, err = generatePDF(event.Name, expenses)
|
||||
// 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 both CSV and PDF reports.
|
||||
csvAttachment, err := generateCSV(event.Name, expenses, userName, userDept)
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] handlers: FileEvent: generate %s report: %v",
|
||||
time.Now().Format(time.RFC3339), format, err)
|
||||
http.Error(w, "Failed to generate report", http.StatusInternalServerError)
|
||||
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.")
|
||||
return
|
||||
}
|
||||
|
||||
// 6. Send the report as an email attachment.
|
||||
subject := "Expense report for event " + event.Name
|
||||
body := "Please find attached the expense report."
|
||||
if err := h.EmailSender.SendReport(to, subject, body, attachment); err != nil {
|
||||
// 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 {
|
||||
log.Printf("ERROR [%s] handlers: FileEvent: SendReport(%s): %v",
|
||||
time.Now().Format(time.RFC3339), to, err)
|
||||
http.Error(w, "Failed to send report email", http.StatusInternalServerError)
|
||||
renderFileError(w, "Failed to send report: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -152,93 +179,698 @@ 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)
|
||||
http.Error(w, "Failed to close event", http.StatusInternalServerError)
|
||||
renderFileError(w, "Report sent but failed to close the event. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
// 8. Redirect to the dashboard via HTMX.
|
||||
w.Header().Set("HX-Redirect", "/dashboard")
|
||||
// 8. Redirect to the month view via HTMX.
|
||||
w.Header().Set("HX-Redirect", "/months/"+monthID)
|
||||
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 & %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.
|
||||
func generateCSV(eventName string, expenses []database.Expense) (*email.Attachment, error) {
|
||||
// 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) {
|
||||
var buf bytes.Buffer
|
||||
writer := csv.NewWriter(&buf)
|
||||
|
||||
// Write header row.
|
||||
if err := writer.Write([]string{"Date", "Merchant", "Amount", "Currency", "Category", "Description"}); err != nil {
|
||||
return nil, fmt.Errorf("write CSV header: %w", err)
|
||||
// Write 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 one data row per expense.
|
||||
// Total claim summary.
|
||||
var totalClaim float64
|
||||
claimCur := ""
|
||||
for _, exp := range expenses {
|
||||
if err := writer.Write([]string{
|
||||
exp.Date,
|
||||
exp.Merchant,
|
||||
fmt.Sprintf("%.2f", exp.Amount),
|
||||
exp.Currency,
|
||||
exp.Category,
|
||||
exp.Description,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("write CSV row: %w", err)
|
||||
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
|
||||
}
|
||||
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: "report.csv",
|
||||
Filename: filename,
|
||||
Content: buf.Bytes(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generatePDF creates a PDF attachment from the provided expenses using gofpdf.
|
||||
// The PDF contains a title row, a header row, and one data row per expense.
|
||||
func generatePDF(eventName string, expenses []database.Expense) (*email.Attachment, error) {
|
||||
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||
// 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", "")
|
||||
pdf.AddPage()
|
||||
|
||||
// Title: "Expense Report: <event name>"
|
||||
pdf.SetFont("Helvetica", "B", 16)
|
||||
// Title.
|
||||
pdf.SetFont("Helvetica", "B", 14)
|
||||
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", "", 10)
|
||||
for _, exp := range expenses {
|
||||
pdf.Cell(35, 8, exp.Date)
|
||||
pdf.Cell(35, 8, exp.Merchant)
|
||||
pdf.Cell(20, 8, fmt.Sprintf("%.2f", exp.Amount))
|
||||
pdf.Cell(20, 8, exp.Currency)
|
||||
pdf.Cell(35, 8, exp.Category)
|
||||
pdf.Ln(8)
|
||||
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
|
||||
}
|
||||
|
||||
// Write the PDF document to a memory buffer.
|
||||
// Totals row in claim currency.
|
||||
pdf.SetDrawColor(71, 85, 105)
|
||||
pdf.Line(10, pdf.GetY()+1, 287, pdf.GetY()+1)
|
||||
pdf.Ln(3)
|
||||
pdf.SetFont("Helvetica", "B", 9)
|
||||
claimTotalCur := ""
|
||||
for _, exp := range expenses {
|
||||
if exp.BaseCurrency != "" {
|
||||
claimTotalCur = exp.BaseCurrency
|
||||
break
|
||||
}
|
||||
}
|
||||
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.
|
||||
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: "report.pdf",
|
||||
Filename: filename,
|
||||
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), "-")
|
||||
}
|
||||
|
|
|
|||
29
internal/handlers/helpers.go
Normal file
29
internal/handlers/helpers.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
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
|
||||
}
|
||||
818
internal/handlers/months.go
Normal file
818
internal/handlers/months.go
Normal file
|
|
@ -0,0 +1,818 @@
|
|||
// 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 & %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
|
||||
}
|
||||
63
internal/handlers/templates.go
Normal file
63
internal/handlers/templates.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
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>`
|
||||
|
|
@ -1,11 +1,18 @@
|
|||
// Package utils provides common utility functions for ExpenseFlow.
|
||||
// Package utils provides common utility functions for NextExpense.
|
||||
package utils
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// New generates a new UUID v4 string.
|
||||
func New() string {
|
||||
// NewUUID generates a new UUID v4 string.
|
||||
func NewUUID() 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
195
main.go
|
|
@ -1,8 +1,8 @@
|
|||
// ExpenseFlow — AI-Powered Expense Tracker
|
||||
// NextExpense — AI-Powered Expense Tracker
|
||||
//
|
||||
// A production-ready, mobile-first Progressive Web App (PWA) that uses
|
||||
// passwordless email OTP login, event-based expense tracking, AI receipt
|
||||
// extraction (DeepSeek Vision), and event filing (CSV/PDF via email).
|
||||
// extraction (Gemini / OpenAI), and event filing (CSV/PDF via email).
|
||||
//
|
||||
// Usage:
|
||||
// Copy .env.example to .env and fill in credentials, then:
|
||||
|
|
@ -13,19 +13,25 @@
|
|||
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/expenseflow/internal/auth"
|
||||
"github.com/expenseflow/internal/database"
|
||||
"github.com/expenseflow/internal/email"
|
||||
"github.com/expenseflow/internal/handlers"
|
||||
"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"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
@ -35,8 +41,7 @@ func main() {
|
|||
|
||||
// Load environment variables from .env file (if present).
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Printf("INFO [%s] main: no .env file found, using system environment",
|
||||
time.Now().Format(time.RFC3339))
|
||||
log.Printf("INFO main: no .env file found, using system environment")
|
||||
}
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
|
|
@ -50,16 +55,13 @@ 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 [%s] main: database init: %v", time.Now().Format(time.RFC3339), err)
|
||||
log.Fatalf("FATAL main: database init: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
|
|
@ -70,15 +72,21 @@ 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, "post@2-4-h.app")
|
||||
log.Printf("INFO [%s] main: SMTP sender configured (%s:%s)",
|
||||
time.Now().Format(time.RFC3339), smtpHost, smtpPort)
|
||||
emailSender = email.NewSender(smtpHost, smtpPort, smtpUser, smtpPass, smtpUser)
|
||||
log.Printf("INFO main: SMTP sender configured (%s:%s)", smtpHost, smtpPort)
|
||||
} else {
|
||||
log.Printf("WARN [%s] main: SMTP not configured — OTP emails will not be sent",
|
||||
time.Now().Format(time.RFC3339))
|
||||
log.Printf("WARN main: SMTP not configured — OTP emails will not be sent")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -92,6 +100,9 @@ func main() {
|
|||
EmailSender: emailSender,
|
||||
}
|
||||
|
||||
monthHandler := handlers.NewMonthHandler(db)
|
||||
monthHandler.EmailSender = emailSender
|
||||
|
||||
eventHandler := handlers.NewEventHandler(db)
|
||||
expenseHandler := handlers.NewExpenseHandler(db)
|
||||
fileHandler := &handlers.FileHandler{
|
||||
|
|
@ -99,6 +110,9 @@ func main() {
|
|||
EmailSender: emailSender,
|
||||
}
|
||||
|
||||
// Start background cleanup of expired download packages.
|
||||
fileHandler.StartDownloadCleanup()
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Router
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -110,6 +124,38 @@ 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) {
|
||||
|
|
@ -136,8 +182,16 @@ func main() {
|
|||
http.ServeFile(w, r, "static/manifest.json")
|
||||
}))
|
||||
|
||||
// Serve uploaded receipt images.
|
||||
r.Get("/storage/*", http.StripPrefix("/storage/", http.FileServer(http.Dir("storage"))).ServeHTTP)
|
||||
// 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")
|
||||
}))
|
||||
|
||||
// ---- Public routes (no auth required) ----
|
||||
|
||||
|
|
@ -145,38 +199,66 @@ func main() {
|
|||
r.Post("/request-otp", authHandler.RequestOTP)
|
||||
r.Post("/verify-otp", authHandler.VerifyOTP)
|
||||
|
||||
// ---- Logout ----
|
||||
// Download link (token-based auth, no login required).
|
||||
r.Get("/dl/{token}", fileHandler.ServeDownload)
|
||||
r.Get("/dl/{token}/{name}", fileHandler.ServeDownload)
|
||||
|
||||
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)
|
||||
}))
|
||||
// ---- Logout (invalidates server-side session + clears cookie) ----
|
||||
|
||||
r.Post("/logout", authHandler.Logout)
|
||||
|
||||
// ---- Protected routes (auth required) ----
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authHandler.RequireAuth)
|
||||
|
||||
// Events.
|
||||
r.Get("/dashboard", eventHandler.Dashboard)
|
||||
r.Post("/events", eventHandler.CreateEvent)
|
||||
r.Put("/events/{id}/reopen", eventHandler.ReopenEvent)
|
||||
r.Get("/events/{id}/expenses", eventHandler.ViewEventExpenses)
|
||||
// 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)
|
||||
|
||||
// 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.
|
||||
r.Post("/events/{id}/file", fileHandler.FileEvent)
|
||||
// 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))
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -184,12 +266,33 @@ func main() {
|
|||
// -----------------------------------------------------------------------
|
||||
|
||||
addr := ":" + port
|
||||
log.Printf("INFO [%s] main: ExpenseFlow server starting on %s",
|
||||
time.Now().Format(time.RFC3339), addr)
|
||||
log.Printf("INFO [%s] main: open http://localhost%s in your browser",
|
||||
time.Now().Format(time.RFC3339), addr)
|
||||
|
||||
if err := http.ListenAndServe(addr, r); err != nil {
|
||||
log.Fatalf("FATAL [%s] main: server error: %v", time.Now().Format(time.RFC3339), err)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,47 +1,47 @@
|
|||
/* ==========================================================================
|
||||
ExpenseFlow — PWA Expense Tracker Stylesheet
|
||||
Mobile-first responsive | Plain CSS | System-ui font
|
||||
NextExpense — PWA Expense Tracker Stylesheet
|
||||
"The Terminal Mint" dark palette | Mobile-first responsive
|
||||
========================================================================== */
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
0. CSS Custom Properties (Design Tokens)
|
||||
-------------------------------------------------------------------------- */
|
||||
:root {
|
||||
/* Colors */
|
||||
/* Colors — "The Terminal Mint" Dark Palette */
|
||||
--color-primary: #10b981;
|
||||
--color-primary-hover: #059669;
|
||||
--color-primary-light: #d1fae5;
|
||||
--color-primary-hover: #34d399;
|
||||
--color-primary-light: #064e3b;
|
||||
--color-primary-dark: #047857;
|
||||
|
||||
--color-secondary: #1e293b;
|
||||
--color-secondary-hover: #334155;
|
||||
--color-secondary: #334155;
|
||||
--color-secondary-hover: #475569;
|
||||
|
||||
--color-bg: #f8fafc;
|
||||
--color-card: #ffffff;
|
||||
--color-text: #0f172a;
|
||||
--color-text-muted: #64748b;
|
||||
--color-text-light: #94a3b8;
|
||||
--color-bg: #0f172a;
|
||||
--color-card: #1e293b;
|
||||
--color-text: #f8fafc;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-text-light: #64748b;
|
||||
|
||||
--color-border: #e2e8f0;
|
||||
--color-border: #334155;
|
||||
--color-border-focus: #10b981;
|
||||
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-hover: #dc2626;
|
||||
--color-danger-light: #fef2f2;
|
||||
--color-danger-hover: #f87171;
|
||||
--color-danger-light: #450a0a;
|
||||
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
|
||||
--color-open-bg: #d1fae5;
|
||||
--color-open-text: #065f46;
|
||||
--color-closed-bg: #f1f5f9;
|
||||
--color-closed-text: #475569;
|
||||
--color-open-bg: #064e3b;
|
||||
--color-open-text: #6ee7b7;
|
||||
--color-closed-bg: #334155;
|
||||
--color-closed-text: #cbd5e1;
|
||||
|
||||
/* 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);
|
||||
/* 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);
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 6px;
|
||||
|
|
@ -129,6 +129,23 @@ 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;
|
||||
|
|
@ -313,11 +330,15 @@ small, .text-sm {
|
|||
}
|
||||
|
||||
.app-main {
|
||||
padding: var(--space-4) 0 var(--space-8);
|
||||
padding: var(--space-6) var(--space-4) var(--space-10);
|
||||
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
|
||||
-------------------------------------------------------------------------- */
|
||||
|
|
@ -369,9 +390,9 @@ small, .text-sm {
|
|||
/* Login card — centered single-column */
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
max-width: 420px;
|
||||
margin: var(--space-12) auto;
|
||||
padding: var(--space-8) var(--space-6);
|
||||
padding: var(--space-10) var(--space-8);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
|
|
@ -501,7 +522,7 @@ small, .text-sm {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
|
|
@ -661,15 +682,17 @@ small, .text-sm {
|
|||
9. Form Inputs
|
||||
-------------------------------------------------------------------------- */
|
||||
.form-group {
|
||||
margin-bottom: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.form-label--required::after {
|
||||
|
|
@ -677,15 +700,16 @@ small, .text-sm {
|
|||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
/* 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-3) var(--space-3);
|
||||
padding: var(--space-4) var(--space-4);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
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);
|
||||
|
|
@ -695,19 +719,55 @@ small, .text-sm {
|
|||
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);
|
||||
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-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 {
|
||||
|
|
@ -733,11 +793,24 @@ 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;
|
||||
|
|
@ -748,6 +821,9 @@ 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 {
|
||||
|
|
@ -1843,3 +1919,13 @@ 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
4
static/favicon.svg
Normal file
4
static/favicon.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 317 B |
BIN
static/icons/icon-180.png
Normal file
BIN
static/icons/icon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 593 B After Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 48 KiB |
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"name": "ExpenseFlow",
|
||||
"short_name": "ExpenseFlow",
|
||||
"name": "NextExpense",
|
||||
"short_name": "NextExpense",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#10b981",
|
||||
"background_color": "#ffffff",
|
||||
"background_color": "#0f172a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-192.png",
|
||||
|
|
|
|||
11
static/sw.js
11
static/sw.js
|
|
@ -1,16 +1,17 @@
|
|||
/* ============================================================
|
||||
* ExpenseFlow — Service Worker
|
||||
* Version: 1.0.0
|
||||
* Cache name: expenseflow-v1
|
||||
* NextExpense — Service Worker
|
||||
* Version: 2.0.0
|
||||
* Cache name: nextexpense-v2
|
||||
* Strategy: Cache-first for shell assets, network-only for API
|
||||
* ============================================================ */
|
||||
|
||||
const CACHE_NAME = 'expenseflow-v1';
|
||||
const CACHE_NAME = 'nextexpense-v2';
|
||||
|
||||
// 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'
|
||||
];
|
||||
|
||||
|
|
@ -194,7 +195,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 — 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>',
|
||||
'<!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>',
|
||||
{
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
|
|
|
|||
|
|
@ -4,92 +4,115 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#10b981">
|
||||
<title>Dashboard - ExpenseFlow</title>
|
||||
<title>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">
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="dashboard-header">
|
||||
<h2>My Events</h2>
|
||||
<button class="btn btn-primary"
|
||||
onclick="document.getElementById('create-event-form').classList.toggle('hidden')">
|
||||
+ New Event
|
||||
<h2>My Months</h2>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
onclick="document.getElementById('create-form').classList.toggle('hidden')">
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="create-event-form" class="hidden" style="margin-bottom: 1.5rem;">
|
||||
<div id="create-form" class="hidden" style="margin-bottom: 1rem;">
|
||||
<div class="card" style="padding: 1rem;">
|
||||
<form hx-post="/events" hx-target="body" hx-push-url="true">
|
||||
<div class="form-group">
|
||||
<label for="name">Event Name</label>
|
||||
<input type="text" id="name" name="name" placeholder="e.g., WebSummit 2026" required>
|
||||
<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>
|
||||
<button type="submit" class="btn btn-primary btn-block">Create Event</button>
|
||||
<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>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Create Month</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="event-list">
|
||||
{{if .Events}}
|
||||
<div class="event-grid">
|
||||
{{range .Events}}
|
||||
<div class="card event-card">
|
||||
<div class="event-card-header">
|
||||
<h3 class="event-card-name">{{.Name}}</h3>
|
||||
<span id="status-badge-{{.ID}}" class="badge badge-{{.Status}}">{{.Status}}</span>
|
||||
<div 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>
|
||||
<div class="event-card-meta">
|
||||
<span>Created: {{.CreatedAt}}</span>
|
||||
{{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>
|
||||
<div class="event-card-actions">
|
||||
{{if eq .Status "open"}}
|
||||
<a href="/events/{{.ID}}/expenses" class="btn btn-primary btn-sm"
|
||||
hx-get="/events/{{.ID}}/expenses" hx-target="body" hx-push-url="true">
|
||||
Add Expenses
|
||||
</a>
|
||||
{{else}}
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
hx-put="/events/{{.ID}}/reopen"
|
||||
hx-target="#status-badge-{{.ID}}"
|
||||
hx-swap="outerHTML">
|
||||
Reopen
|
||||
{{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>
|
||||
<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
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="12" y1="18" x2="12" y2="12"/>
|
||||
<line x1="9" y1="15" x2="15" y2="15"/>
|
||||
</svg>
|
||||
<h3>No Events Yet</h3>
|
||||
<p>Create your first event to start tracking expenses.</p>
|
||||
<div class="empty-state" style="text-align: center; padding: 3rem; color: #94a3b8;">
|
||||
<p>No months yet. Create one to get started.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Toggle hidden class
|
||||
document.querySelector('button[onclick]')?.addEventListener('click', function() {
|
||||
// handled inline
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -4,143 +4,135 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta name="theme-color" content="#10b981">
|
||||
<title>{{.Event.Name}} - ExpenseFlow</title>
|
||||
<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">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<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">
|
||||
← Back
|
||||
</a>
|
||||
<h1 class="app-title">{{.Event.Name}}</h1>
|
||||
<span class="badge badge-{{.Event.Status}}">{{.Event.Status}}</span>
|
||||
<a href="/months/{{.Month.ID}}" class="btn btn-secondary btn-sm"
|
||||
hx-get="/months/{{.Month.ID}}" hx-target="body" hx-push-url="true">← {{.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>
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
<!-- Capture Receipt Button -->
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<label for="receipt-upload" class="btn btn-primary btn-block" style="display: inline-block; text-align: center; cursor: pointer;">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 0.5rem;">
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
|
||||
<circle cx="12" cy="13" r="4"/>
|
||||
</svg>
|
||||
Capture Receipt
|
||||
<!-- 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>
|
||||
› <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>
|
||||
› {{.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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receipt List -->
|
||||
<div id="expense-list">
|
||||
<h3 style="margin-bottom: 1rem; font-size: 1rem; font-weight: 600;">
|
||||
Receipts
|
||||
{{if .Event.BaseCurrency}}
|
||||
<span style="font-weight: 400; color: var(--color-text-muted);">(claim in {{.Event.BaseCurrency}})</span>
|
||||
{{end}}
|
||||
</h3>
|
||||
|
||||
{{if .Expenses}}
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
{{range .Expenses}}
|
||||
<div style="display: flex; align-items: center; background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 0.75rem;">
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="font-weight: 500; color: var(--color-text);">{{.Merchant}}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--color-text-muted);">{{.Date}} · {{.Category}} {{if .Description}}· {{.Description}}{{end}}</div>
|
||||
</div>
|
||||
<div style="text-align: right; margin-right: 0.75rem;">
|
||||
<div style="font-weight: 600; color: var(--color-text);">{{printf "%.2f" .Amount}} {{.Currency}}</div>
|
||||
{{if .ConvertedAmount}}
|
||||
<div style="font-size: 0.75rem; color: var(--color-primary);">{{printf "%.2f" .ConvertedAmount}} {{.BaseCurrency}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.25rem;">
|
||||
{{if .ImagePath}}
|
||||
<button class="btn btn-sm" style="background: none; border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: var(--color-text);"
|
||||
onclick="document.getElementById('img-{{.ID}}').classList.toggle('hidden')"
|
||||
title="View receipt image">🖼️</button>
|
||||
{{end}}
|
||||
<button class="btn btn-sm" style="background: none; border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: var(--color-text);"
|
||||
hx-get="/expenses/{{.ID}}/edit"
|
||||
hx-target="#receipt-form"
|
||||
hx-swap="innerHTML"
|
||||
title="Edit receipt">✏️</button>
|
||||
<button class="btn btn-sm" style="background: none; border: 1px solid #7f1d1d; border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: #fca5a5;"
|
||||
onclick="if(confirm('Delete this receipt?')) htmx.trigger('#del-{{.ID}}','click')"
|
||||
title="Delete receipt">🗑️</button>
|
||||
<div hx-delete="/expenses/{{.ID}}" hx-target="#expense-list" hx-swap="outerHTML" id="del-{{.ID}}" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{if .ImagePath}}
|
||||
<div id="img-{{.ID}}" class="hidden" style="position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 999; align-items: center; justify-content: center; cursor: pointer; padding: 1rem;"
|
||||
onclick="this.classList.add('hidden')">
|
||||
<span style="position: absolute; top: 1rem; right: 1rem; font-size: 2rem; color: #fff; line-height: 1; cursor: pointer; z-index: 1000;">×</span>
|
||||
<img src="/storage/{{.ImagePath}}" alt="Receipt" style="max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 0.5rem;" onclick="event.stopPropagation()">
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div style="text-align: center; padding: 2rem; color: var(--color-text-muted); font-size: 0.875rem;">
|
||||
<p>No receipts yet.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Receipt Form (edit/add) -->
|
||||
<div id="receipt-form" style="margin-top: 1.5rem;"></div>
|
||||
|
||||
<!-- Add Receipt Buttons -->
|
||||
<div style="margin-top: 1.5rem; display: flex; gap: 0.75rem;">
|
||||
<label for="receipt-camera" class="btn btn-primary" style="flex: 1; text-align: center; cursor: pointer;">
|
||||
📷 Camera
|
||||
</label>
|
||||
<input type="file" id="receipt-upload" accept="image/*" capture="environment" style="display: none;"
|
||||
<label for="receipt-upload" class="btn btn-secondary" style="flex: 1; text-align: center; cursor: pointer;">
|
||||
📁 Upload
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input: camera (mobile) -->
|
||||
<input type="file" id="receipt-camera" name="receipt" accept="image/*" capture="environment" style="display: none;"
|
||||
hx-post="/expenses/upload"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-target="#receipt-form"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#upload-indicator">
|
||||
hx-indicator="#upload-indicator"
|
||||
hx-trigger="change">
|
||||
|
||||
<!-- Hidden file input: gallery / PDF upload -->
|
||||
<input type="file" id="receipt-upload" name="receipt" accept="image/*,.pdf" style="display: none;"
|
||||
hx-post="/expenses/upload"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-target="#receipt-form"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#upload-indicator"
|
||||
hx-trigger="change">
|
||||
|
||||
<div id="upload-indicator" class="htmx-indicator" style="text-align: center; padding: 1rem;">
|
||||
<div class="spinner"></div>
|
||||
<p>Analyzing receipt...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receipt Form (filled by AI or empty) -->
|
||||
<div id="receipt-form"></div>
|
||||
|
||||
<!-- Expense List -->
|
||||
<div id="expense-list">
|
||||
<h3 style="margin-bottom: 1rem;">Expenses</h3>
|
||||
{{if .Expenses}}
|
||||
<div class="expense-list">
|
||||
{{range .Expenses}}
|
||||
<div class="expense-item">
|
||||
<div class="expense-item-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<polyline points="21 15 16 10 5 21"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="expense-item-info">
|
||||
<div class="expense-item-merchant">{{.Merchant}}</div>
|
||||
<div class="expense-item-meta">{{.Date}} · {{.Category}}</div>
|
||||
{{if .Description}}
|
||||
<div class="expense-item-desc">{{.Description}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="expense-item-amount">
|
||||
<span class="amount">{{printf "%.2f" .Amount}}</span>
|
||||
<span class="currency">{{.Currency}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state" style="padding: 2rem;">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="12" y1="18" x2="12" y2="18"/>
|
||||
<line x1="9" y1="15" x2="15" y2="15"/>
|
||||
</svg>
|
||||
<h3>No Expenses Yet</h3>
|
||||
<p>Capture a receipt to get started.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- File Event Button (only for open events) -->
|
||||
{{if eq .Event.Status "open"}}
|
||||
<div style="margin-top: 2rem; text-align: center;">
|
||||
<button class="btn btn-secondary"
|
||||
onclick="document.getElementById('file-modal').classList.toggle('hidden')">
|
||||
File Event
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- File Event Modal -->
|
||||
<div id="file-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content">
|
||||
<h3>File Event Report</h3>
|
||||
<p class="text-secondary">Generate and email the expense report for "{{.Event.Name}}".</p>
|
||||
<form hx-post="/events/{{.Event.ID}}/file" hx-target="body" hx-push-url="true">
|
||||
<div class="form-group">
|
||||
<label for="file-email">Send to</label>
|
||||
<input type="email" id="file-email" name="email" placeholder="recipient@example.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Format</label>
|
||||
<div style="display: flex; gap: 1rem;">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="format" value="csv" checked> CSV
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="format" value="pdf"> PDF
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.5rem; justify-content: flex-end;">
|
||||
<button type="button" class="btn btn-secondary"
|
||||
onclick="document.getElementById('file-modal').classList.add('hidden')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Send Report & Close</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-trigger file input on label click
|
||||
document.querySelector('label[for="receipt-upload"]')?.addEventListener('click', function() {
|
||||
document.getElementById('receipt-upload').click();
|
||||
});
|
||||
|
||||
// Close modal on overlay click
|
||||
document.querySelector('.modal-overlay')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
this.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,41 @@
|
|||
<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;">×</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>
|
||||
|
|
|
|||
|
|
@ -6,27 +6,25 @@
|
|||
<meta name="theme-color" content="#10b981">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<title>ExpenseFlow</title>
|
||||
<title>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="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-180.png">
|
||||
<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="login-page">
|
||||
<div class="card login-card">
|
||||
<div class="login-brand">
|
||||
<div class="login-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>ExpenseFlow</h1>
|
||||
<div class="login-icon"><img src="/static/favicon.svg" width="48" height="48" alt="Nx"></div>
|
||||
<h1>NextExpense</h1>
|
||||
<p class="text-secondary">AI-Powered Expense Tracking</p>
|
||||
</div>
|
||||
|
||||
<div id="otp-form">
|
||||
<form hx-post="/request-otp" hx-target="#otp-form" hx-swap="outerHTML">
|
||||
<form hx-post="/request-otp" hx-target="#otp-form" hx-swap="innerHTML">
|
||||
<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">
|
||||
|
|
|
|||
159
templates/month_events.html
Normal file
159
templates/month_events.html
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<!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">← 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>
|
||||
› {{.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>
|
||||
56
templates/onboarding.html
Normal file
56
templates/onboarding.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<!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">← 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>
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
<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;">
|
||||
{{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>
|
||||
{{end}}
|
||||
|
||||
<div class="card" style="padding: 1rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;">
|
||||
|
|
@ -15,9 +14,15 @@
|
|||
<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">
|
||||
|
|
@ -27,21 +32,8 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<label for="currency">Currency *</label>
|
||||
<select id="currency" name="currency" required>
|
||||
<option value="">Select</option>
|
||||
<option value="USD" {{if eq .Currency "USD"}}selected{{end}}>USD</option>
|
||||
<option value="EUR" {{if eq .Currency "EUR"}}selected{{end}}>EUR</option>
|
||||
<option value="GBP" {{if eq .Currency "GBP"}}selected{{end}}>GBP</option>
|
||||
<option value="JPY" {{if eq .Currency "JPY"}}selected{{end}}>JPY</option>
|
||||
<option value="CHF" {{if eq .Currency "CHF"}}selected{{end}}>CHF</option>
|
||||
<option value="SEK" {{if eq .Currency "SEK"}}selected{{end}}>SEK</option>
|
||||
<option value="NOK" {{if eq .Currency "NOK"}}selected{{end}}>NOK</option>
|
||||
<option value="DKK" {{if eq .Currency "DKK"}}selected{{end}}>DKK</option>
|
||||
<option value="PLN" {{if eq .Currency "PLN"}}selected{{end}}>PLN</option>
|
||||
<option value="CZK" {{if eq .Currency "CZK"}}selected{{end}}>CZK</option>
|
||||
<option value="HUF" {{if eq .Currency "HUF"}}selected{{end}}>HUF</option>
|
||||
<option value="RON" {{if eq .Currency "RON"}}selected{{end}}>RON</option>
|
||||
</select>
|
||||
<input type="text" id="currency" name="currency" placeholder="KES, USD, EUR…"
|
||||
value="{{.Currency}}" required maxlength="3" style="text-transform: uppercase;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -56,11 +48,22 @@
|
|||
<label for="category">Category *</label>
|
||||
<select id="category" name="category" required>
|
||||
<option value="">Select</option>
|
||||
<option value="Food" {{if eq .Category "Food"}}selected{{end}}>Food</option>
|
||||
<option value="Travel" {{if eq .Category "Travel"}}selected{{end}}>Travel</option>
|
||||
<option value="Lodging" {{if eq .Category "Lodging"}}selected{{end}}>Lodging</option>
|
||||
<option value="Software" {{if eq .Category "Software"}}selected{{end}}>Software</option>
|
||||
<option value="Other" {{if eq .Category "Other"}}selected{{end}}>Other</option>
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
|
@ -70,14 +73,35 @@
|
|||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" placeholder="Optional notes...">{{.Description}}</textarea>
|
||||
<label for="description">Description *</label>
|
||||
<textarea id="description" name="description" placeholder="Required notes..." required>{{.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>
|
||||
Save Expense
|
||||
{{if .EditID}}Update Expense{{else}}Save Expense{{end}}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary"
|
||||
onclick="document.getElementById('receipt-form').innerHTML = ''">
|
||||
|
|
@ -86,4 +110,3 @@
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue