Compare commits

...

111 commits
v1.0.0 ... main

Author SHA1 Message Date
22d2507d52 fix: CSV reports now include claim currency column, claim totals, and total claim header 2026-07-14 14:33:45 +00:00
f1b029949e feat: PDF — total claim in header, wider merchant column (52mm) 2026-07-14 13:48:31 +00:00
e9460a6a7d fix: UpdateEvent now saves the event name, not just currency/rate 2026-07-14 12:21:40 +00:00
47fb82d109 fix: PDF reports show local+claim columns, totals in claim currency, remove grand total 2026-07-14 12:17:42 +00:00
a7f7dca41d fix: auto-calculate claim conversion when converted_amount is empty using event exchange rate 2026-07-14 11:47:26 +00:00
2fcce08cff fix: remove nested #receipt-form wrapper causing duplicate IDs on edit 2026-07-14 11:33:24 +00:00
dfefd0208c fix: allow editing expenses in pre-migration events (empty month_id) 2026-07-14 11:24:24 +00:00
2f2fbf7630 fix: bump upload limit to 11MB, add .gitignore for build artifacts 2026-07-14 10:40:49 +00:00
949d4f277c fix: expense edit form — restore description value, use event's exchange rate 2026-07-14 10:26:28 +00:00
10e6b78940 feat: prepend user name to email report subjects (Claus Lohmar | Monthly Expense Report: ...) 2026-07-14 10:18:18 +00:00
6d641cf8c9 fix: correct curl pipe syntax in README 2026-07-14 10:11:28 +00:00
3bc3eddd5b chore: install.sh with --install/--update/--remove flags, help, update README 2026-07-14 10:09:50 +00:00
64844baa83 fix: landscape PDF reports, full text, wider columns, include CSV+PDF in monthly ZIP 2026-07-14 10:06:31 +00:00
d65c0cd5fa feat: add month hierarchy, AI categories, and UI polish
- Restructure hierarchy: Month → Event → Expense (new months table, FK)
- Add MonthHandler with CRUD, monthly reports, dropdown create form
- Events now scoped under months with extended ownership chain
- AI extraction: 16 specific expense categories (Airfare, Meals, etc.)
- UI: category dropdown, button-consistent cards, centered mobile shell on desktop
- Dashboard/month views show claim totals per card
- Description field now mandatory, forms simplified
- Months sorted by name chronologically (latest first)
2026-07-14 09:29:26 +00:00
1c9ab4554a chore: always include both CSV and PDF in report ZIP instead of picking one format 2026-07-13 09:10:12 +00:00
517e95adc2 chore: rebrand ReceiptNext to NextExpense
- Rename Go module from github.com/cclohmar/ReceiptNext to NextExpense
- Update all import paths across 7 Go source files
- Update templates (titles, headings, branding)
- Update static files (manifest.json, sw.js, CSS)
- Update config (Makefile, install.sh, .env.example)
- Update README with new name and URLs
- Rename service file receiptnext.service -> nextexpense.service
- Update install paths, service names, log paths in install.sh
2026-06-21 18:39:48 +00:00
ce08681d81 chore: add profile editor — edit name/department via dashboard 2026-06-17 15:15:23 +00:00
24451b4f4f chore: put currency labels in proper CSV/PDF columns, not jammed into number cells 2026-06-17 13:05:18 +00:00
95762cd1ae chore: add base currency to CSV totals row too 2026-06-17 12:58:33 +00:00
b50699fc0e chore: add base currency label to PDF totals row 2026-06-17 12:57:41 +00:00
7c84c0858f chore: add totals row to PDF reports (local + converted currency) 2026-06-17 12:54:16 +00:00
a943c258bb chore: use request Host header for download links, not BASE_URL 2026-06-17 12:41:38 +00:00
5e66e849fc chore: add Close Event button + cleanup download tokens on reopen 2026-06-17 12:38:21 +00:00
5aa5523e8b chore: remove eager token cleanup on regenerate to prevent breaking emailed links 2026-06-17 12:34:22 +00:00
64d7494e33 chore: flat ZIP structure + event-name download filenames (/dl/{token}/{name}.zip) 2026-06-17 12:28:20 +00:00
4bc9fe52ae chore: add onboarding flow — capture user name/department, inject into CSV/PDF reports 2026-06-17 12:23:21 +00:00
8759b31e47 chore: log addToZip errors instead of silently dropping 2026-06-17 12:14:37 +00:00
be3aa7a036 chore: add postbox — generate report package, download or email link 2026-06-17 12:13:34 +00:00
cc56d1ac9f chore: resize receipt images on upload (max 2048px, JPEG 85%) to prevent SMTP attachment size rejections 2026-06-17 11:50:46 +00:00
44309dc189 chore: surface SMTP error message to user on filing failure 2026-06-17 11:45:45 +00:00
290f803996 chore: fix upload error visibility + install.sh update mode ownership bug 2026-06-17 11:27:33 +00:00
422aaa08ab chore: fix invisible filing errors — add HTMX error feedback and loading indicator 2026-06-17 11:08:01 +00:00
90c9df6cce fix: receipt images missing from filed event ZIP + feat: delete individual expenses
- Fix createReceiptZip path traversal guard blocking new ImagePath entries
  (UploadReceipt stores bare filenames, guard required 'storage/' prefix)
- Add DeleteExpense DB function, handler with ownership verification,
  DELETE /expenses/{id} route, and 🗑️ button in both expense templates
- Uses existing htmx.trigger confirm pattern for delete UX
2026-06-14 11:46:43 +00:00
09b2ddfc79 chore: final project handover — update README, AGENTS.md with project context
- AGENTS.md updated with full project architecture, structure, key decisions
- README.md: removed binary download section, build-from-source only
- README.md: updated build commands with -buildvcs=false
- README.md: updated install.sh description (installs Go if needed)
2026-06-05 15:01:11 +00:00
c83b2ccc83 refactor: install.sh always installs Go and builds from source — no binary downloads
- Removed all pre-built binary download logic from install.sh
- install.sh now installs Go (via apt/dnf/apk) if not present
- Always builds from source — ensures latest code, correct platform
- Removed dist/ binaries from local disk (not tracked in git)
2026-06-05 12:37:48 +00:00
ce2efd9be7 feat: persistent session + logout button + auto-redirect for authenticated users
- Logout button added to dashboard and event page headers
- Landing page checks for valid session cookie → auto-redirects to dashboard
- Session cookie persists 24h — closing and reopening browser keeps login
2026-06-05 11:24:05 +00:00
aa07ff3c50 feat: changing event exchange rate recalculates all expense converted amounts
- New RecalculateExpenses DB function updates all expenses for an event
- Expenses in different currency: converted = ROUND(amount * new_rate, 2)
- Expenses already in base currency: left unchanged
- Called from UpdateEvent handler after saving the new event rate
2026-06-03 08:24:46 +00:00
b0dfb5fad3 fix: reset ownership at start of update mode before any file operations
- Moves chown to the very beginning of the update
- Ensures app user can write to /opt/receiptnext/ before build
- Removes redundant chown later in update (already done upfront)
2026-06-03 08:20:23 +00:00
afd4b6a1cf fix: use sudo_if rm -f app — delete root-owned binary as app user 2026-06-03 08:18:17 +00:00
5abe8c49d9 fix: stop service before build so old binary can be removed
- Prevents 'text file busy' error when rebuilding running binary
- Service is started again after the build completes
2026-06-03 08:17:05 +00:00
f8d3cb6b33 fix: remove old binary before rebuild — prevents permission denied when overwriting root-owned file
- go build can't overwrite a root-owned binary as app user
- Added rm -f app before go build in both update and fresh install paths
- Also chown the app binary alongside .go cache
2026-06-03 08:15:58 +00:00
433b5a4ed8 fix: chown .go build cache before build to prevent permission errors
- Build cache may be root-owned from previous builds
- Added sudo_if chown before both update and fresh build steps
- Prevents 'permission denied' on go mod cache writes
2026-06-03 08:14:18 +00:00
67f1a6c845 fix: add -buildvcs=false to go build to prevent VCS errors
- .git directory may have ownership issues after sudo operations
- -buildvcs=false disables VCS stamping without affecting the binary
2026-06-03 08:11:53 +00:00
6e61740f0a fix: update mode chowns .go build cache to app user
- .go/ directory was root-owned from earlier builds
- Prevents 'permission denied' errors during go build
2026-06-03 08:10:57 +00:00
0d42a35cb8 refactor: remove duplicate edit from event page, add delete to edit form
- Event page now shows read-only metadata only (no edit/delete buttons)
- Edit works from dashboard (Edit button on event cards)
- Delete added to the edit form as a button alongside Save/Cancel
- htmx.trigger #delete div in the edit form fragment
2026-06-03 08:09:31 +00:00
b9721a9bab fix: event edit and delete now use htmx.trigger() instead of .submit() / hx.trigger()
- form.submit() triggered standard GET submission with query params
- hx.trigger() is not a valid function — htmx.trigger() is the correct API
- Both edit save and delete now properly fire HTMX requests
2026-06-03 08:03:47 +00:00
c815e022b5 fix: OTP email subject 'Your ExpenseFlow OTP' → 'Your ReceiptNext OTP' 2026-06-03 08:01:00 +00:00
e547eee57f fix: install.sh backup preserves database owner using stat
- Uses stat to get original owner:group before backup
- chown restores backup file to original owner
- Prevents database becoming readonly after update
2026-06-03 07:59:02 +00:00
91534ed8bd fix: renderOTPForm in auth.go now uses cached single-field template
- Was using its own inline template with 6 digit_0..digit_5 fields
- Now calls getTemplate('otp_form') which has the single 6-digit input
- templates.go already had the correct single-field version
2026-06-03 07:57:56 +00:00
cb42efa395 fix: database ownership preserved during backup — app user must be able to write
- Backup now uses chown --reference to preserve original owner
- Found: rsync changed database ownership to root, blocking writes
2026-06-03 07:52:23 +00:00
bb34528bf5 feat: single OTP field, event metadata on page, delete event
- OTP: 6 separate inputs → single 6-digit field with copy/paste support
- Event page: shows name, currency, exchange rate at top
- Edit toggle: unlock to edit fields, lock to save (via PUT)
- Delete button with confirmation popup
- DeleteEvent handler + DB function + route
- Backward compatible: collectOTP strips non-digits
2026-06-03 07:46:12 +00:00
0748af7c26 fix: EditEvent returns standalone form fragment, not full page
- Edit button now shows inline form with pre-filled values
- Cancel button hides the form again
- Computes sample claim from existing exchange rate
- No full-page rendering issues
2026-06-03 07:41:29 +00:00
0f39f3dc24 fix: Edit button shows hidden form — added onclick to remove hidden class
- Edit button now reveals the form before loading content
- Without this, the form was loaded into a hidden div and invisible
2026-06-03 07:38:52 +00:00
5f5f2be75b feat: event editing — change exchange rate and currency
- Added Edit button on open event cards in dashboard
- EditEvent handler returns dashboard.html with pre-filled form
- UpdateEvent handler (PUT /events/{id}) saves new currency + rate
- UpdateEvent in db.go updates base_currency and exchange_rate
- Form auto-switches between create (POST) and edit (PUT) mode
- Reuses the same conversion sample pattern as event creation
2026-06-03 07:33:37 +00:00
4ea3d63b29 feat: OTP inputs auto-advance to next field when digit is typed
- oninput moves focus to next field when digit entered
- onkeydown Backspace moves to previous field when current is empty
- Last field (digit_5) only handles backspace
2026-06-02 00:00:57 +00:00
7ab94a14d0 feat: add × close button to image lightbox
- Added visible close button (×) top-right of image overlay
- Image clicks no longer propagate (prevents accidental close)
- Overlay background click still closes
2026-06-01 23:57:17 +00:00
2d259a2b7c fix: add 'unsafe-inline' to script-src CSP so onclick handlers work 2026-06-01 23:51:39 +00:00
814cba1f53 fix: image lightbox overlay hidden not working — display:flex overrode hidden class
- Removed display:flex from inline style so the hidden class can apply display:none
- All image modals were visible and stacked on top of the page content
- This also blocked event creation (the transparent overlay covered the form)
2026-06-01 23:35:35 +00:00
0b55ac1fac fix: normalize ImagePath for old database entries with storage/ prefix
- Added normalizeImagePath() helper that strips legacy storage/ prefix
- Applied in ViewEventExpenses, SaveExpense, UpdateExpense, EditExpense
- Prevents double storage/storage/ in image URLs for old expenses
2026-06-01 23:30:56 +00:00
bb2c06fa3a fix: validate sudo access upfront in install.sh and update mode
- Added sudo -v at start of both fresh install and update mode
- Caches sudo credentials so subsequent sudo_if calls don't prompt
- If sudo is unavailable, shows clear error message
- Prevents 'Access denied' on systemctl restart
2026-06-01 23:27:04 +00:00
9c239e565b docs: update README — binary name, update process, config table, structure
- Binary name: receiptnext → app
- Update: no sudo, adds backups, templates/static sync
- Config table: added AI_MODEL, AI_BASE_URL, OPENAI_API_KEY
- Structure: added backups/ directory
- Security: .env is optional, not root-owned
2026-06-01 23:21:21 +00:00
5aca6c06a5 fix: update mode backs up database before making changes
- Creates timestamped backup at /opt/receiptnext/backups/expenses-YYYYMMDD-HHMMSS.db
- rsync only targets templates/ and static/ — database is never touched
2026-06-01 23:20:14 +00:00
c386b150c4 fix: update mode now copies templates, static assets, install.sh
- update mode previously only rebuilt the binary
- Now also rsyncs templates/ and static/ to install dir
- Copies updated install.sh to install dir
- Fixes ownership of new files
- Uses sudo_if for systemctl restart
2026-06-01 23:18:40 +00:00
8ff1a4175a feat: view receipt image from expense list
- Added 🖼️ button on each expense — opens full-screen lightbox
- Click anywhere on the overlay to close
- Images served via /storage/{filename} (auth-protected)
- Fixed ImagePath to store only filename (was storage/storage/...)
- Both expense_list.html fragment and event_expenses.html page updated
2026-06-01 23:16:35 +00:00
4593dd1f66 refactor: replace currency dropdowns with simple text inputs
- Currency ISO3 dropdowns replaced with <input type="text" maxlength="3">
- Users can type any currency code (KES, USD, EUR, etc.)
- No need to maintain a long list of currencies
- Exchange rate is user-defined so validation is unnecessary
- All currency inputs have text-transform: uppercase for consistency
2026-06-01 23:14:16 +00:00
e49b184be8 fix: install.sh installs git, curl, python3 if missing
- Checks for git before clone/pull — installs via apt/dnf/apk
- Checks for curl before downloading release binary
- Checks for python3 before parsing release JSON
- Multi-distro support (apt-get, dnf, apk)
2026-05-31 03:02:01 +00:00
92f070440f refactor: implement best-practice recommendations from code review
MUST FIX:
- M1: Fixed ignored errors in AI providers (json.Marshal, http.NewRequest, json.Unmarshal)
- M2: Template cache — pre-parse all templates once at startup, reuse via getTemplate()
- M3: Fixed silent ParseFloat error fallbacks — now returns HTTP 400 on invalid amounts
- M4: Wrapped readFile errors with context (fmt.Errorf with %w)
- M5: Deleted stale llm.go placeholder file
- M6: Renamed utils.New() to utils.NewUUID() for clarity
- M7: Validate current_event_id cookie UUID format, prevent tampering

SHOULD FIX:
- S4: Added utils.Timestamp() helper to replace repeated time.Now().Format() calls
- S6: Added request ID middleware for concurrent request log tracing
- S7: Increased DB pool from 1 to 4 connections (HTMX concurrency)
- S8: Graceful shutdown via http.Server.Shutdown() on SIGINT/SIGTERM
- S9: Storage served behind auth middleware with path traversal check

COULD FIX:
- C2: renderOTPForm uses cached template (not per-request Must)
- C3: CSP pinned to unpkg.com/htmx.org@1.9.10
- C4: Added ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout
- C7: PDF generation auto-adds page breaks when content overflows

ADDITIONAL:
- Pass config to AI provider constructors (newGeminiProvider, newOpenAIProvider)
- Value receivers on geminiProvider/openaiProvider (empty structs)
- Added envOrDefault() helper in ai/receipt.go
- Session cleanup goroutine started in main.go
- Removed duplicate imports and unused html/template from handlers
2026-05-31 02:13:07 +00:00
6a902f0b85 chore: remove last DeepSeek reference from main.go header 2026-05-31 02:02:02 +00:00
a7381bde0c fix: final vulnerability sweep — storage auth, security headers, body limits, cookie flags
- Storage route moved behind auth middleware (was publicly accessible)
- Security headers: X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy
- Request body size limit: 10 MB on all endpoints via MaxBytesReader
- Session cookie now sets Secure flag when BASE_URL uses HTTPS
- readFile() returns proper errors for dirs & oversized files (was nil,nil)
- Removed dead DEEPSEEK_API_KEY code from main.go
- Added fmt import to ai/receipt.go for error formatting
2026-05-31 02:01:12 +00:00
e831fcf617 fix: resolve 7 critical security findings from code review
CR-1: Path traversal in createReceiptZip — validate image_path is within storage/
CR-2: Missing authz on EditExpense/UpdateExpense — verify event ownership
CR-3: OTP timing side-channel — use crypto/subtle.ConstantTimeCompare
CR-4: Logout doesn't invalidate session — moved to AuthHandler with Sessions.Delete()
CR-5: OTP reuse race condition — mutex lock around validate+delete
CR-6: Live credentials on disk — removed .env from disk entirely
CR-7: No TLS — documented as expected behind-proxy deployment

Additional:
- Removed stale github.com/expenseflow import path from auth.go
- Made EnvironmentFile optional (prefix with -) so .env is not required
- App runs and starts clean without any .env file
2026-05-31 01:50:08 +00:00
2f26abfb2f docs: update README — remove Ollama references, update config table 2026-05-30 17:44:13 +00:00
9f52198647 fix: remove orphaned fallthrough after ollama removal 2026-05-30 17:40:40 +00:00
3327fd4fac feat: use glm-ocr as default Ollama model (specialized OCR, not a general LLM)
- glm-ocr is a 1.1B parameter model built specifically for OCR
- No reasoning overhead, no thinking field issues
- Faster inference than qwen3.5 on CPU
- Removed old qwen3.5 models (2B + 0.8B) to free ~4GB disk
- Updated install.sh, ollama.go, .env.example defaults
2026-05-30 17:17:06 +00:00
1eca4ae777 fix: qwen3.5 reasoning model outputs to thinking field not content
- ollamaResponse now reads both content and thinking fields
- Falls back to thinking if content is empty
- Install script adds model warm-up prompt to preload into memory
- Warm-up uses OLLAMA_HOST for correct user context
- Makes first real receipt analysis faster
2026-05-30 16:51:43 +00:00
59f73830fb fix: chown /usr/share/ollama after install so ollama user can write to it
- The Ollama installer run via sudo leaves /usr/share/ollama owned by root
- The ollama service runs as the ollama user and needs write access
- Added chown after installation to fix permissions
2026-05-30 16:45:00 +00:00
51c5d9a23e fix: set GOCACHE alongside GOMODCACHE for users without home write access
- Go build cache was trying to write to /app/.cache/go-build (unwritable)
- Now uses /opt/receiptnext/.go/build instead
2026-05-30 16:13:26 +00:00
de29841341 docs: remove sudo from local clone install command 2026-05-30 16:11:18 +00:00
0ab9f1e1be fix: go build module cache permission + README sudo
- Set GOMODCACHE + GOPATH to /opt/receiptnext/.go (writable location)
- Removes sudo from README one-liner — script self-elevates
- Build now works for users whose home directory isn't writable
2026-05-30 16:10:42 +00:00
b7fddf0069 fix: set OLLAMA_HOST explicitly for model pull
- ollama pull failed because the CLI didn't know where the server was
- Uses OLLAMA_HOST=http://127.0.0.1:11434 to connect regardless of user
2026-05-30 16:06:03 +00:00
bc5a5172dc fix: git safe.directory + ownership after clone/pull
- Adds safe.directory exception so git doesn't reject the repo
- chown directory to invoking user after clone/pull so build works
2026-05-30 16:02:44 +00:00
bb11a7f32c fix: wait for Ollama API before pulling model
- Wait loop now checks HTTP endpoint (127.0.0.1:11434/api/tags)
  instead of just the CLI version
- Increased retries from 10 to 15 (30s → 30s with 2s intervals)
- Uses sudo -u ollama for pull so models go to correct user directory
2026-05-30 16:00:08 +00:00
378c8e5b98 docs: update help text — no global sudo needed 2026-05-30 15:56:41 +00:00
e977907da3 fix: install.sh syntax errors from sed replacements
- Fixed stray dots and missing quotes in download URLs
- Fixed stale receiptnext binary references (now app)
- Fixed missing closing quote on info message
2026-05-30 15:55:28 +00:00
e20a484ebc chore: rename binary from receiptnext to app
- Binary output changed from 'receiptnext' to 'app'
- Release assets renamed to app-linux-amd64 / app-linux-arm64
- install.sh, Makefile, README.md updated to reference 'app'
- contrib/receiptnext.service uses /opt/receiptnext/app
- Systemd service updated to ExecStart=/opt/receiptnext/app
2026-05-30 15:53:24 +00:00
f9a7df1580 feat: greenfield deployment — no Go required
- install.sh detects if Go is installed
- If Go available: builds from source (current behavior)
- If Go missing: downloads pre-built binary from latest release
- Detects amd64/arm64 architecture automatically
- Same logic applies to -update mode
- Removed hard dependency on ollama.service in systemd unit
2026-05-30 15:49:05 +00:00
59788ba0c8 fix: app runs as dedicated user, not root
- install.sh detects the real user (SUDO_USER or whoami)
- If root, creates receiptnext system user
- If regular user, uses that user for the service
- User is added to ollama group for CLI access
- Systemd service uses User=receiptnext (or detected user)
- All file ownership set to the app user
2026-05-30 15:44:10 +00:00
b6c2c3f99d fix: Ollama timeout + image compression + selective sudo in install.sh 2026-05-30 15:41:47 +00:00
357483cfd2 fix: install zstd dependency before Ollama installer 2026-05-30 15:22:03 +00:00
57f501e0c8 docs: update one-liner install command in README 2026-05-30 15:21:25 +00:00
1aa5ec456e chore: install.sh asks for domain name instead of raw URL
- Prompts for domain and HTTPS preference
- Builds proper BASE_URL from domain + protocol
- Defaults to http://localhost:8080
2026-05-30 15:08:26 +00:00
c0d3002e22 fix: remove hardcoded credentials from .env.example and source
- .env.example: placeholder values only
- gemini.go: error on missing API key instead of fallback
- main.go: dynamic from address from SMTP_USER
- Security: old credentials removed from active codebase
2026-05-30 15:08:01 +00:00
7fdfba8095 chore: change install dir from /opt/rx to /opt/receiptnext 2026-05-30 15:06:20 +00:00
39fa7f6a12 chore: rewrite install.sh with AI provider choice + -update flag + pure Go
- install.sh now:
  • Clones repo to /opt/rx
  • Builds pure-Go binary (no CGO)
  • Prompts AI choice: Gemini / OpenAI / Ollama
  • If Ollama: auto-installs Ollama + pulls qwen3.5:2b
  • Creates .env interactively
  • Creates systemd service
  • Supports -update flag (pull, rebuild, restart)
- README.md fully rewritten for new install flow
- All binaries removed from git tracking (dist/ in .gitignore)
2026-05-30 15:03:39 +00:00
6de9c27f9c chore: switch from mattn/go-sqlite3 (CGO) to modernc.org/sqlite (pure Go)
- Zero CGO dependencies — builds with CGO_ENABLED=0
- Fully static binaries, no libc required
- Cross-compilation now works without any GCC cross-compilers
- ldd confirms: 'not a dynamic executable'
- Updates go.mod to Go 1.23 (required by modernc.org/sqlite)
2026-05-30 15:02:07 +00:00
b780ac1e1f chore: interactive install.sh — prompts user for credentials, installs to /opt/rx
- install.sh copies binary + assets to /opt/rx/
- Prompts for SMTP, Gemini, port, URL interactively
- Creates .env file with chmod 600
- Creates systemd service pointing to /opt/rx/
- Handles both pre-built and dist/ binaries
2026-05-30 14:23:42 +00:00
f50048e833 chore: rename to ReceiptNext + deployment scripts
- Full rename: module path, imports, directory structure
- install.sh: one-command bare-metal deployment
- contrib/receiptnext.service: systemd service file
- Makefile: build, install, manage service
- .gitenv updated to ReceiptNext
- New Releases page on git.lohmar.co.uk/cclohmar/ReceiptNext
2026-05-30 14:20:49 +00:00
5ca3ff7555 feat: configurable AI provider system (Gemini, OpenAI, Ollama)
- New provider architecture with common interface
- Provider selected via AI_PROVIDER env var (gemini/openai/ollama)
- Gemini (default): existing implementation, uses GEMINI_API_KEY
- OpenAI-compatible: uses OPENAI_API_KEY + AI_MODEL + AI_BASE_URL
  - Works with OpenAI, Perplexity, Together AI, Groq, etc.
- Ollama: local LLM, uses AI_BASE_URL + AI_MODEL
  - Supports llava, bakllava, and other vision models
- deepseek.go renamed to llm.go (cleanup)
- .env.example updated with all AI provider options
2026-05-30 14:05:19 +00:00
646378df64 feat: gallery upload + PDF receipt support
- Two upload buttons: Camera (capture) and Upload (gallery/PDF)
- PDF receipts from Uber/email now accepted and processed by Gemini Vision
- PDF detection via %PDF magic bytes in both handler and AI module
- Descriptions updated to reflect broader file support
2026-05-30 13:50:22 +00:00
dcb43b08a0 feat: send receipt images as ZIP attachment with report
- Email now includes both report (CSV/PDF) + ZIP of all receipt images
- ZIP images named {event-name}-{index}.{ext} matching list order
- Uses Go's archive/zip (stdlib, no external deps)
- Sender.SendReport now accepts []*Attachment for multiple files
- Gracefully skips missing image files with warnings
2026-05-30 13:38:23 +00:00
e22bd56169 fix: form fields too tight - increased spacing and padding
- Input padding increased from 12px to 16px
- Label-to-input gap increased from 4px to 8px
- Form group spacing increased from 16px to 20px
- Buttons padding increased from 8px/16px to 12px/20px
- Login card padding increased for more breathing room
- Added .form-row with responsive flex gap layout
- Added .main-content class with generous padding
2026-05-30 13:08:28 +00:00
67f5ed5624 feat: rebrand to ReceiptNext with 'The Terminal Mint' dark palette
- New favicon: Rx symbol in emerald green square (prescription/receipt)
- Dark theme: #0F172A base, #1E293B cards, #F8FAFC text
- Emerald #10B981 primary accent throughout
- Dashboard simplified to list view with event name + Open button
- Event page: receipt list with edit, Add Receipt button, Submit Event form
- Submit form shows total claim amount + email + format selection
- PWA manifest + service worker updated for ReceiptNext branding
- Variables-based theming for easy palette switching
2026-05-30 13:05:58 +00:00
ded4954c72 feat: add expense editing - view and edit all existing receipts
- Added ✏️ edit button on every expense in the list
- Clicking loads the receipt form pre-filled with the expense data
- Changes are saved via PUT /expenses/{id}
- Receipt form auto-switches between create (POST) and edit (PUT) mode
- Full HTMX multi-target response: form resets + list refreshes
2026-05-30 12:43:33 +00:00
e00c3373cb feat: replace abstract exchange rate with sample-based conversion
- Instead of entering a hard-to-calculate rate like 0.00773,
  users now enter a real sample (e.g. receipt=1000 KES, claimed=7.73 USD)
- The system computes the rate automatically: 7.73 / 1000 = 0.00773
- Users can get the sample values from their payment app notification
- Much more intuitive, especially for currencies with small exchange rates
2026-05-30 12:33:48 +00:00
4895b3d608 feat: add KES support + base currency + exchange rate for expense claims
- Added KES and 12+ additional currencies to receipt form
- Events now have base_currency (claim currency) and exchange_rate fields
- Receipts show original amount + auto-computed converted amount
- Converted amounts stored per expense in database
- CSV and PDF reports include both original and converted amounts
- Dashboard shows claim currency per event card
2026-05-30 12:23:31 +00:00
46c78ef507 chore: use gemini-2.0-flash-lite (most cost-effective model) 2026-05-30 12:14:46 +00:00
127046c9b7 feat: switch AI provider from DeepSeek to Google Gemini Vision API
- DeepSeek API does not support vision (only chat UI supports images)
- Google Gemini Vision supports native image analysis via inline_data
- Images are sent directly as base64 with proper MIME type detection
- No more OCR pipeline needed - Gemini sees the image directly
- Supports: JPEG, PNG, WebP, GIF, BMP, TIFF, HEIC, AVIF
- Updated .env.example to use GEMINI_API_KEY instead of DEEPSEEK_API_KEY
- Also: log OTP code in server log for easier debugging
2026-05-30 12:11:20 +00:00
fecf4af8a2 fix: AI receipt extraction now uses OCR (Tesseract) + LLM (DeepSeek) pipeline
- Problem: deepseek-v4-flash is text-only, cannot process base64 images - hallucinated fake data
- Solution: Two-step pipeline that actually extracts real data:
  1. Tesseract OCR extracts raw text from the receipt image
  2. DeepSeek v4 parses the OCR text into structured JSON
- Benefits: works with any image format, fast, accurate, no hallucinated data
- Properly handles HEIC/HEIF via heif-convert before OCR
2026-05-30 12:00:40 +00:00
ebe06082cf fix: support HEIC/HEIF photos from iPhone + broaden accepted image formats
- detectImageExtension now handles: JPEG, PNG, WebP, GIF, BMP, TIFF, HEIC, AVIF
- Added heif-convert + ImageMagick fallback for decoding unsupported formats
- AI extraction properly converts HEIC to JPEG before analysis
- Model: deepseek-v4-flash (confirmed working)
2026-05-30 11:54:31 +00:00
dc407fbf06 fix: DeepSeek Vision API changed - use text-embedded base64 + image compression instead of image_url content type
- DeepSeek no longer supports image_url in chat completions
- Images are now resized (max 300px) and JPEG-compressed (quality 50)
- Base64 data embedded directly in text prompt for processing
- Increased API timeout to 120s for larger prompts
- Also fixed mobile receipt capture (missing name attribute on file input)
- Also fixed OTP htmx:targetError (outerHTML → innerHTML swap)
2026-05-30 11:46:49 +00:00
64f5b9a65b fix: mobile receipt capture not working - missing name attribute on file input + htmx targetError on OTP verification 2026-05-30 11:34:36 +00:00
6b6196a59a fix: nil pointer panic when EmailSender is not configured 2026-05-29 20:05:01 +00:00
39 changed files with 5246 additions and 1187 deletions

View file

@ -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
View file

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

85
Makefile Normal file
View 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)

460
README.md
View file

@ -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:
```bash
./install.sh # Show help
./install.sh --help # Same
```
The installer will:
1. Clone the repo to `/opt/nextexpense/`
2. Build the binary (pure Go, no CGO, no dependencies)
3. Ask which AI provider to use:
```
1) Google Gemini (cloud API, needs API key)
2) OpenAI / Compatible (OpenAI, Perplexity, Groq, etc.)
```
4. Prompt for SMTP settings (for OTP emails and report delivery)
5. Create `/opt/nextexpense/.env` with all configuration
6. Set up a systemd service that auto-starts on boot
7. Start NextExpense
**Result:** A fully configured, always-running expense tracker at `http://YOUR_SERVER:8080`.
---
## 🔧 Building from Source
### Prerequisites
- **Go 1.22+** — [Download](https://go.dev/dl/)
- **GCC** (CGO is required for the SQLite driver)
```bash
# Debian/Ubuntu
sudo apt install build-essential
# macOS
xcode-select --install
# Alpine
apk add build-base
```
### Clone & Build
## 🔄 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 ❤️*

View file

@ -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" .

View 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
View file

@ -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
View file

@ -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
View 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

View file

@ -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
View 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
View 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
View 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 ""
}

View file

@ -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.

View file

@ -9,7 +9,7 @@ import (
"log"
"time"
_ "github.com/mattn/go-sqlite3"
_ "modernc.org/sqlite"
)
// DB is the shared database handle, initialized by Init().
@ -21,9 +21,12 @@ var DB *sql.DB
// User represents a row in the users table.
type User struct {
ID string
Email string
CreatedAt string
ID string
Email string
Name string
Department string
Onboarded bool
CreatedAt string
}
// OTP represents a row in the auth_otps table.
@ -33,27 +36,50 @@ type OTP struct {
ExpiresAt string
}
// Event represents a row in the events table.
type Event struct {
// Month represents a row in the months table.
type Month struct {
ID string
UserID string
Name string
Status string
CreatedAt string
}
// Event represents a row in the events table.
type Event struct {
ID string
UserID string
MonthID string
Name string
Status string
BaseCurrency string
ExchangeRate float64
CreatedAt string
}
// Expense represents a row in the expenses table.
type Expense struct {
ID string
EventID string
Amount float64
Currency string
Merchant string
Category string
Description string
Date string
ImagePath string
CreatedAt string
ID string
EventID string
Amount float64
Currency string
ConvertedAmount float64
BaseCurrency string
Merchant string
Category string
Description string
Date string
ImagePath string
CreatedAt string
}
// DownloadToken represents a download token for a generated report package.
type DownloadToken struct {
Token string
EventID string
Filename string
CreatedAt string
ExpiresAt string
Accessed bool
}
// ---------------------------------------------------------------------------
@ -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
}

View file

@ -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))
if err != nil {
return nil, fmt.Errorf("creating attachment part: %w", err)
}
// --- Attachment parts (report + receipt images zip) ---
for _, att := range attachments {
aw, err := mw.CreatePart(attachmentHeader(att.Filename))
if err != nil {
return nil, fmt.Errorf("creating attachment part %q: %w", att.Filename, err)
}
enc := base64.NewEncoder(base64.StdEncoding, aw)
if _, err := enc.Write(attachment.Content); err != nil {
enc := base64.NewEncoder(base64.StdEncoding, aw)
if _, err := enc.Write(att.Content); err != nil {
enc.Close()
return nil, fmt.Errorf("writing attachment %q: %w", att.Filename, err)
}
enc.Close()
return nil, fmt.Errorf("writing attachment content: %w", 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:

View file

@ -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)
return
// 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 err := h.EmailSender.SendOTP(emailAddr, code); err != nil {
log.Printf("ERROR [%s] handlers: RequestOTP SendOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err)
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.
w.Header().Set("HX-Redirect", "/dashboard")
// 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.
func collectOTP(r *http.Request) string {
var b strings.Builder
for i := 0; i < 6; i++ {
digit := r.FormValue(fmt.Sprintf("digit_%d", i))
if digit == "" {
return ""
}
b.WriteString(digit)
// 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)
}
return b.String()
// Clear the cookie on the client side.
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
w.Header().Set("HX-Redirect", "/")
w.WriteHeader(http.StatusOK)
}
func collectOTP(r *http.Request) string {
code := r.FormValue("otp_code")
// Strip any non-digit characters (paste may include spaces/dashes).
code = strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, code)
if len(code) != 6 {
return ""
}
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))
}

View file

@ -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)
}

View file

@ -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,31 +131,48 @@ 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,
"AIError": "",
"Amount": "",
"Currency": "",
"Merchant": "",
"Category": "",
"Date": "",
"Description": "",
"ImagePath": storagePath,
"AIError": "",
"Amount": "",
"Currency": "",
"Merchant": "",
"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,17 +281,42 @@ 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(),
EventID: eventID,
Amount: amount,
Currency: currency,
Merchant: merchant,
Category: category,
Description: description,
Date: date,
ImagePath: imagePath,
ID: utils.NewUUID(),
EventID: eventID,
Amount: amount,
Currency: currency,
ConvertedAmount: convertedAmount,
BaseCurrency: baseCurrency,
Merchant: merchant,
Category: category,
Description: description,
Date: date,
ImagePath: imagePath,
}
if err := database.CreateExpense(h.DB, expense); err != nil {
@ -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
}

View file

@ -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 &amp; %d receipt images packaged.</p>
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
<a href="/dl/%s/%s" class="btn btn-primary" style="flex:1; text-align:center; text-decoration:none; font-size:0.85rem;" download> Download Now</a>
</div>
<div style="border-top: 1px solid #065f46; padding-top: 0.75rem;">
<p style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 0.5rem;">Or send a download link via email (tiny email, no attachment limits):</p>
<form hx-post="/months/%s/events/%s/send-link" hx-target="#send-link-result" hx-indicator="#send-link-spinner" style="display: flex; gap: 0.5rem;">
<input type="hidden" name="token" value="%s">
<input type="email" name="email" placeholder="finance@company.com" required style="flex:1; padding:0.5rem; border:1px solid #475569; border-radius:0.375rem; background:#1e293b; color:#f8fafc; font-size:0.85rem;">
<button type="submit" class="btn btn-secondary" style="font-size:0.85rem; white-space:nowrap;">Send Link</button>
</form>
<div id="send-link-spinner" class="htmx-indicator" style="text-align:center; padding:0.5rem;"><div class="spinner"></div></div>
<div id="send-link-result"></div>
</div>
</div>`,
len(expenses),
template.HTMLEscapeString(token), template.HTMLEscapeString(dlName),
template.HTMLEscapeString(monthID), template.HTMLEscapeString(eventID), template.HTMLEscapeString(token))
}
// ---------------------------------------------------------------------------
// POST /months/{mid}/events/{eid}/send-link — SendDownloadLink
// ---------------------------------------------------------------------------
// SendDownloadLink emails a download link for a previously generated report
// package to the specified recipient.
func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to parse form.</div>`)
return
}
token := strings.TrimSpace(r.FormValue("token"))
to := strings.TrimSpace(r.FormValue("email"))
if token == "" || to == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Token and email are required.</div>`)
return
}
// Verify token exists and belongs to user's event.
dt, err := database.GetDownloadTokenByToken(h.DB, token)
if err != nil || dt == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Invalid or expired download token.</div>`)
return
}
event, err := database.GetEventByID(h.DB, dt.EventID)
if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Permission denied.</div>`)
return
}
if h.EmailSender == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">SMTP not configured.</div>`)
return
}
// Build the download URL using the request's Host header (most reliable),
// falling back to BASE_URL env var.
scheme := "https"
host := r.Host
baseURL := os.Getenv("BASE_URL")
if host == "" && baseURL != "" {
// Parse scheme and host from BASE_URL as fallback.
if strings.HasPrefix(baseURL, "https://") {
host = strings.TrimPrefix(baseURL, "https://")
} else if strings.HasPrefix(baseURL, "http://") {
scheme = "http"
host = strings.TrimPrefix(baseURL, "http://")
}
}
if host == "" {
host = "localhost:8080"
}
safeName := sanitiseFilename(event.Name)
if safeName == "" {
safeName = "report"
}
link := fmt.Sprintf("%s://%s/dl/%s/%s.zip", scheme, host, token, safeName)
// Fetch user name for the subject line.
repUser, _ := database.GetUserByID(h.DB, getUserID(r))
userName := ""
if repUser != nil {
userName = repUser.Name
}
subject := fmt.Sprintf("%s | Expense report: %s", userName, event.Name)
if userName == "" {
subject = "Expense report: " + event.Name
}
body := fmt.Sprintf("Expense report for %s is ready.\n\nDownload: %s\n\nThis link expires in 24 hours.", event.Name, link)
if err := h.EmailSender.SendReport(to, subject, body, nil); err != nil {
log.Printf("ERROR [%s] handlers: SendDownloadLink: %v",
time.Now().Format(time.RFC3339), err)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to send: %s</div>`,
template.HTMLEscapeString(err.Error()))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#6ee7b7; font-size:0.8rem; margin-top:0.5rem;">Download link sent to %s.</div>`,
template.HTMLEscapeString(to))
}
// ---------------------------------------------------------------------------
// GET /dl/{token} — ServeDownload
// ---------------------------------------------------------------------------
// ServeDownload streams a previously generated report package to the client.
// Access is controlled via the crypto-random token in the URL — no login
// required. The token is valid for 24 hours from creation.
// The optional filename suffix in the URL (e.g. /dl/{token}/Lagos-report.zip)
// is used for the Content-Disposition header but does not affect access control.
func (h *FileHandler) ServeDownload(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
if token == "" {
http.NotFound(w, r)
return
}
dt, err := database.GetDownloadTokenByToken(h.DB, token)
if err != nil || dt == nil {
http.NotFound(w, r)
return
}
// Check expiry.
expiresAt, err := time.Parse(time.RFC3339, dt.ExpiresAt)
if err != nil || time.Now().After(expiresAt) {
http.NotFound(w, r)
return
}
pkgPath := filepath.Join("storage", "postbox", dt.Filename)
if _, err := os.Stat(pkgPath); os.IsNotExist(err) {
http.NotFound(w, r)
return
}
// Mark as accessed.
database.MarkDownloadTokenAccessed(h.DB, token)
// Build a friendly download filename from the URL suffix, falling back to the token.
dlName := dt.Filename
if name := chi.URLParam(r, "name"); name != "" {
dlName = filepath.Base(name) // prevent path traversal in the suffix
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, dlName))
http.ServeFile(w, r, pkgPath)
}
// ---------------------------------------------------------------------------
// StartDownloadCleanup
// ---------------------------------------------------------------------------
// StartDownloadCleanup runs a background goroutine that periodically deletes
// expired download tokens and their associated files from disk.
func (h *FileHandler) StartDownloadCleanup() {
go func() {
for {
time.Sleep(1 * time.Hour)
filenames, err := database.DeleteExpiredDownloadTokens(h.DB)
if err != nil {
log.Printf("ERROR [%s] handlers: download cleanup: %v",
time.Now().Format(time.RFC3339), err)
continue
}
for _, fn := range filenames {
path := filepath.Join("storage", "postbox", fn)
if err := os.Remove(path); err != nil {
log.Printf("WARN [%s] handlers: download cleanup: remove %s: %v",
time.Now().Format(time.RFC3339), path, err)
}
}
if len(filenames) > 0 {
log.Printf("INFO [%s] handlers: download cleanup: removed %d expired packages",
time.Now().Format(time.RFC3339), len(filenames))
}
}
}()
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// addToZip adds a file to a zip.Writer. Errors are logged but not returned
// since a missing image in the ZIP is non-fatal — the report is the priority.
func addToZip(zw *zip.Writer, name string, data []byte) {
f, err := zw.Create(name)
if err != nil {
log.Printf("WARN [%s] handlers: addToZip: create %q: %v",
time.Now().Format(time.RFC3339), name, err)
return
}
if _, err := f.Write(data); err != nil {
log.Printf("WARN [%s] handlers: addToZip: write %q: %v",
time.Now().Format(time.RFC3339), name, err)
}
}
// ---------------------------------------------------------------------------
// Report generation helpers
// ---------------------------------------------------------------------------
// generateCSV creates a CSV attachment from the provided expenses.
// The CSV includes a header row and one data row per expense.
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)
// 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)
// 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)
}
// Write the PDF document to a memory buffer.
// Total claim summary.
var totalClaim float64
claimCur := ""
for _, exp := range expenses {
cAmt := exp.ConvertedAmount
if cAmt <= 0 {
cAmt = exp.Amount
}
totalClaim += cAmt
if claimCur == "" && exp.BaseCurrency != "" {
claimCur = exp.BaseCurrency
}
}
if claimCur == "" && len(expenses) > 0 {
claimCur = expenses[0].Currency
}
pdf.SetFont("Helvetica", "B", 10)
pdf.Cell(0, 8, fmt.Sprintf("Total Claim: %.2f %s", totalClaim, claimCur))
pdf.Ln(12)
// Table header.
pdf.SetFont("Helvetica", "B", 8)
headers := []string{"#", "Date", "Merchant", "Local Amt", "Cur", "Claim Amt", "Claim", "Category", "Description"}
colWidths := []float64{7, 22, 52, 18, 12, 18, 12, 36, 100}
for i, h := range headers {
pdf.Cell(colWidths[i], 7, h)
}
pdf.Ln(7)
// Table data rows.
pdf.SetFont("Helvetica", "", 8)
marginBottom := 18.0
var totalLocal, tableClaim float64
for i, exp := range expenses {
if pdf.GetY() > 210-marginBottom {
pdf.AddPage()
pdf.SetFont("Helvetica", "B", 8)
for j, h := range headers {
pdf.Cell(colWidths[j], 7, h)
}
pdf.Ln(7)
pdf.SetFont("Helvetica", "", 8)
}
// Compute claim amount (auto-calc if not set).
claimAmt := exp.ConvertedAmount
claimCur := exp.BaseCurrency
if claimAmt <= 0 {
claimAmt = exp.Amount
}
if claimCur == "" {
claimCur = exp.Currency
}
itemNum := i + 1
pdf.Cell(colWidths[0], 6, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 6, exp.Date)
pdf.Cell(colWidths[2], 6, exp.Merchant)
pdf.Cell(colWidths[3], 6, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 6, exp.Currency)
pdf.Cell(colWidths[5], 6, fmt.Sprintf("%.2f", claimAmt))
pdf.Cell(colWidths[6], 6, claimCur)
pdf.Cell(colWidths[7], 6, exp.Category)
pdf.Cell(colWidths[8], 6, exp.Description)
pdf.Ln(6)
totalLocal += exp.Amount
tableClaim += claimAmt
}
// Totals row in claim currency.
pdf.SetDrawColor(71, 85, 105)
pdf.Line(10, pdf.GetY()+1, 287, pdf.GetY()+1)
pdf.Ln(3)
pdf.SetFont("Helvetica", "B", 9)
claimTotalCur := ""
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), "-")
}

View 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
View 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 &amp; %d events, %d receipt images packaged.</p>
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
<a href="/dl/%s/%s" class="btn btn-primary" style="flex:1; text-align:center; text-decoration:none; font-size:0.85rem;" download> Download Now</a>
</div>
<div style="border-top: 1px solid #065f46; padding-top: 0.75rem;">
<p style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 0.5rem;">Or send a download link via email:</p>
<form hx-post="/months/%s/send-link" hx-target="#send-link-result" hx-indicator="#send-link-spinner" style="display: flex; gap: 0.5rem;">
<input type="hidden" name="token" value="%s">
<input type="email" name="email" placeholder="finance@company.com" required style="flex:1; padding:0.5rem; border:1px solid #475569; border-radius:0.375rem; background:#1e293b; color:#f8fafc; font-size:0.85rem;">
<button type="submit" class="btn btn-secondary" style="font-size:0.85rem; white-space:nowrap;">Send Link</button>
</form>
<div id="send-link-spinner" class="htmx-indicator" style="text-align:center; padding:0.5rem;"><div class="spinner"></div></div>
<div id="send-link-result"></div>
</div>
</div>`,
len(events), imgIdx,
template.HTMLEscapeString(token), template.HTMLEscapeString(dlName),
template.HTMLEscapeString(monthID), template.HTMLEscapeString(token))
}
// ---------------------------------------------------------------------------
// POST /months/{mid}/send-link — SendMonthlyDownloadLink
// ---------------------------------------------------------------------------
// SendMonthlyDownloadLink emails a download link for a previously generated
// monthly report package.
func (h *MonthHandler) SendMonthlyDownloadLink(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to parse form.</div>`)
return
}
token := strings.TrimSpace(r.FormValue("token"))
to := strings.TrimSpace(r.FormValue("email"))
if token == "" || to == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Token and email are required.</div>`)
return
}
// Verify token exists.
dt, err := database.GetDownloadTokenByToken(h.DB, token)
if err != nil || dt == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Invalid or expired download token.</div>`)
return
}
// For monthly reports, the token's event_id stores the month_id.
// Verify the month belongs to the user.
month, err := database.GetMonthByID(h.DB, dt.EventID)
if err != nil || month == nil || month.UserID != getUserID(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Permission denied.</div>`)
return
}
if h.EmailSender == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">SMTP not configured.</div>`)
return
}
scheme := "https"
host := r.Host
baseURL := os.Getenv("BASE_URL")
if host == "" && baseURL != "" {
if strings.HasPrefix(baseURL, "https://") {
host = strings.TrimPrefix(baseURL, "https://")
} else if strings.HasPrefix(baseURL, "http://") {
scheme = "http"
host = strings.TrimPrefix(baseURL, "http://")
}
}
if host == "" {
host = "localhost:8080"
}
safeName := sanitiseFilename(month.Name)
if safeName == "" {
safeName = "monthly-report"
}
link := fmt.Sprintf("%s://%s/dl/%s/%s.zip", scheme, host, token, safeName)
// Fetch user name for the subject line.
user, _ := database.GetUserByID(h.DB, getUserID(r))
userName := ""
if user != nil {
userName = user.Name
}
subject := fmt.Sprintf("%s | Monthly Expense Report: %s", userName, month.Name)
if userName == "" {
subject = "Monthly Expense Report: " + month.Name
}
body := fmt.Sprintf("Monthly expense report for %s is ready.\n\nDownload: %s\n\nThis link expires in 24 hours.", month.Name, link)
if err := h.EmailSender.SendReport(to, subject, body, nil); err != nil {
log.Printf("ERROR [%s] handlers: SendMonthlyDownloadLink: %v",
time.Now().Format(time.RFC3339), err)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to send: %s</div>`,
template.HTMLEscapeString(err.Error()))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#6ee7b7; font-size:0.8rem; margin-top:0.5rem;">Download link sent to %s.</div>`,
template.HTMLEscapeString(to))
}
// ---------------------------------------------------------------------------
// Monthly report generation helpers
// ---------------------------------------------------------------------------
// generateMonthlyCSV creates a CSV attachment aggregating expenses across
// all events in a month. Each event is prefixed with a section header.
func generateMonthlyCSV(monthName string, events []database.Event, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
var buf bytes.Buffer
buf.WriteString(fmt.Sprintf("Monthly Expense Report: %s\r\n", monthName))
if userName != "" {
metaLine := fmt.Sprintf("Prepared by: %s", userName)
if userDept != "" && userDept != "-" {
metaLine += fmt.Sprintf(" | Department: %s", userDept)
}
buf.WriteString(metaLine + "\r\n")
}
buf.WriteString("\r\n")
// Total claim summary.
var totalClaim float64
claimCur := ""
for _, exp := range expenses {
cAmt := exp.ConvertedAmount
if cAmt <= 0 {
cAmt = exp.Amount
}
totalClaim += cAmt
if claimCur == "" && exp.BaseCurrency != "" {
claimCur = exp.BaseCurrency
}
}
if claimCur == "" && len(expenses) > 0 {
claimCur = expenses[0].Currency
}
buf.WriteString(fmt.Sprintf("Total Claim: %.2f %s\r\n", totalClaim, claimCur))
buf.WriteString("\r\n")
// Group expenses by event.
expensesByEvent := make(map[string][]database.Expense)
eventNames := make(map[string]string)
for _, evt := range events {
eventNames[evt.ID] = evt.Name
}
for _, exp := range expenses {
expensesByEvent[exp.EventID] = append(expensesByEvent[exp.EventID], exp)
}
itemNum := 1
var grandLocal, grandClaim float64
for _, evt := range events {
evtExpenses := expensesByEvent[evt.ID]
if len(evtExpenses) == 0 {
continue
}
buf.WriteString(fmt.Sprintf("\r\n--- %s ---\r\n", eventNames[evt.ID]))
buf.WriteString("#,Date,Merchant,Local Amt,Currency,Claim Amt,Claim Curr,Category,Description\r\n")
var evtLocal, evtClaim float64
for _, exp := range evtExpenses {
claimAmt := exp.ConvertedAmount
claimCur := exp.BaseCurrency
if claimAmt <= 0 {
claimAmt = exp.Amount
}
if claimCur == "" {
claimCur = exp.Currency
}
buf.WriteString(fmt.Sprintf("%d,%s,%s,%.2f,%s,%.2f,%s,%s,%s\r\n",
itemNum, exp.Date, exp.Merchant, exp.Amount, exp.Currency, claimAmt, claimCur, exp.Category, exp.Description))
evtLocal += exp.Amount
evtClaim += claimAmt
itemNum++
}
buf.WriteString(fmt.Sprintf("Event Total,,,%.2f,,%.2f,,\r\n", evtLocal, evtClaim))
grandLocal += evtLocal
grandClaim += evtClaim
}
buf.WriteString(fmt.Sprintf("\r\nGrand Total,,,%.2f,,%.2f,,\r\n", grandLocal, grandClaim))
filename := fmt.Sprintf("monthly-%s-report.csv", sanitiseFilename(monthName))
return &email.Attachment{
Filename: filename,
Content: []byte(buf.String()),
}, nil
}
// generateMonthlyPDF creates a landscape PDF attachment aggregating expenses
// across all events in a month, with a section per event. Full text, no truncation.
func generateMonthlyPDF(monthName string, events []database.Event, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
pdf := gofpdf.New("L", "mm", "A4", "")
pdf.AddPage()
// Title.
pdf.SetFont("Helvetica", "B", 14)
pdf.Cell(0, 10, "Monthly Expense Report: "+monthName)
pdf.Ln(8)
// User info.
if userName != "" {
pdf.SetFont("Helvetica", "", 9)
infoLine := fmt.Sprintf("Prepared by: %s", userName)
if userDept != "" && userDept != "-" {
infoLine += fmt.Sprintf(" | Department: %s", userDept)
}
pdf.Cell(0, 6, infoLine)
pdf.Ln(6)
}
// Total claim summary.
var totalClaim float64
claimCur := ""
for _, exp := range expenses {
cAmt := exp.ConvertedAmount
if cAmt <= 0 {
cAmt = exp.Amount
}
totalClaim += cAmt
if claimCur == "" && exp.BaseCurrency != "" {
claimCur = exp.BaseCurrency
}
}
if claimCur == "" && len(expenses) > 0 {
claimCur = expenses[0].Currency
}
pdf.SetFont("Helvetica", "B", 10)
pdf.Cell(0, 8, fmt.Sprintf("Total Claim: %.2f %s", totalClaim, claimCur))
pdf.Ln(12)
// Group expenses by event.
expensesByEvent := make(map[string][]database.Expense)
eventNames := make(map[string]string)
for _, evt := range events {
eventNames[evt.ID] = evt.Name
}
for _, exp := range expenses {
expensesByEvent[exp.EventID] = append(expensesByEvent[exp.EventID], exp)
}
itemNum := 1
// Landscape A4: 297mm wide, 10mm margins → 277mm usable.
colWidths := []float64{7, 22, 52, 18, 12, 18, 12, 36, 100}
headers := []string{"#", "Date", "Merchant", "Local Amt", "Cur", "Claim Amt", "Claim", "Category", "Description"}
marginBottom := 18.0
for _, evt := range events {
evtExpenses := expensesByEvent[evt.ID]
if len(evtExpenses) == 0 {
continue
}
// Event section header.
if pdf.GetY() > 180 {
pdf.AddPage()
}
pdf.SetFont("Helvetica", "B", 10)
pdf.Cell(0, 8, eventNames[evt.ID])
pdf.Ln(9)
// Column headers.
pdf.SetFont("Helvetica", "B", 8)
for j, h := range headers {
pdf.Cell(colWidths[j], 7, h)
}
pdf.Ln(7)
var evtLocal, evtClaim float64
pdf.SetFont("Helvetica", "", 8)
for _, exp := range evtExpenses {
if pdf.GetY() > 210-marginBottom {
pdf.AddPage()
pdf.SetFont("Helvetica", "B", 8)
for j, h := range headers {
pdf.Cell(colWidths[j], 7, h)
}
pdf.Ln(7)
pdf.SetFont("Helvetica", "", 8)
}
claimAmt := exp.ConvertedAmount
claimCur := exp.BaseCurrency
if claimAmt <= 0 {
claimAmt = exp.Amount
}
if claimCur == "" {
claimCur = exp.Currency
}
pdf.Cell(colWidths[0], 6, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 6, exp.Date)
pdf.Cell(colWidths[2], 6, exp.Merchant)
pdf.Cell(colWidths[3], 6, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 6, exp.Currency)
pdf.Cell(colWidths[5], 6, fmt.Sprintf("%.2f", claimAmt))
pdf.Cell(colWidths[6], 6, claimCur)
pdf.Cell(colWidths[7], 6, exp.Category)
pdf.Cell(colWidths[8], 6, exp.Description)
pdf.Ln(6)
evtLocal += exp.Amount
evtClaim += claimAmt
itemNum++
}
// Event subtotal in both local and claim currency.
pdf.SetDrawColor(71, 85, 105)
pdf.Line(10, pdf.GetY()+1, 287, pdf.GetY()+1)
pdf.Ln(3)
pdf.SetFont("Helvetica", "B", 9)
pdf.Cell(colWidths[0], 8, "")
pdf.Cell(colWidths[1], 8, "")
pdf.Cell(colWidths[2], 8, fmt.Sprintf("%s Total", eventNames[evt.ID]))
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", evtLocal))
pdf.Cell(colWidths[4], 8, "")
pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", evtClaim))
pdf.Cell(colWidths[6], 8, "")
pdf.Cell(colWidths[7], 8, "")
pdf.Cell(colWidths[8], 8, "")
pdf.Ln(10)
}
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("PDF output: %w", err)
}
filename := fmt.Sprintf("monthly-%s-report.pdf", sanitiseFilename(monthName))
return &email.Attachment{
Filename: filename,
Content: buf.Bytes(),
}, nil
}
// parseMonthName extracts year and month number from a name like "July 2026".
// Returns (0, 0) if parsing fails.
func parseMonthName(name string) (year, month int) {
parts := strings.Fields(name)
if len(parts) < 2 {
return 0, 0
}
months := map[string]int{
"january": 1, "february": 2, "march": 3, "april": 4,
"may": 5, "june": 6, "july": 7, "august": 8,
"september": 9, "october": 10, "november": 11, "december": 12,
}
m, ok := months[strings.ToLower(parts[0])]
if !ok {
return 0, 0
}
y, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0
}
return y, m
}

View file

@ -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>`

View file

@ -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
View file

@ -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")
}

View file

@ -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
View 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

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

View file

@ -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",

View file

@ -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',

View file

@ -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>
<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 Event</button>
<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>

View file

@ -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">
&larr; 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">&larr; {{.Month.Name}}</a>
<h1 class="app-title" style="font-size: 1.1rem;">{{.Event.Name}}</h1>
<button class="btn btn-secondary btn-sm" style="font-size: 0.75rem;"
hx-post="/logout" hx-target="body" hx-push-url="true">Logout</button>
</header>
<main class="main-content">
<!-- Capture Receipt Button -->
<div style="margin-bottom: 1.5rem;">
<label for="receipt-upload" class="btn btn-primary btn-block" style="display: inline-block; text-align: center; cursor: pointer;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 0.5rem;">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
Capture Receipt
</label>
<input type="file" id="receipt-upload" accept="image/*" capture="environment" style="display: none;"
hx-post="/expenses/upload"
hx-encoding="multipart/form-data"
hx-target="#receipt-form"
hx-swap="innerHTML"
hx-indicator="#upload-indicator">
<div id="upload-indicator" class="htmx-indicator" style="text-align: center; padding: 1rem;">
<div class="spinner"></div>
<p>Analyzing receipt...</p>
<!-- Breadcrumb -->
<div style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 1rem;">
<a href="/dashboard" style="color: var(--color-primary); text-decoration: none;"
hx-get="/dashboard" hx-target="body" hx-push-url="true">Dashboard</a>
&rsaquo; <a href="/months/{{.Month.ID}}" style="color: var(--color-primary); text-decoration: none;"
hx-get="/months/{{.Month.ID}}" hx-target="body" hx-push-url="true">{{.Month.Name}}</a>
&rsaquo; {{.Event.Name}}
</div>
<!-- Event Metadata -->
<div style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<div style="font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem;">Event Details</div>
<div class="form-row" style="margin:0;">
<div style="flex:2; font-size:0.9rem; color:var(--color-text);">{{.Event.Name}}</div>
<div style="flex:1; font-size:0.9rem; color:var(--color-text);">{{.Event.BaseCurrency}}</div>
<div style="flex:1; font-size:0.9rem; color:var(--color-text-muted);">{{printf "%.6f" .Event.ExchangeRate}}</div>
</div>
</div>
<!-- Receipt Form (filled by AI or empty) -->
<div id="receipt-form"></div>
<!-- Expense List -->
<!-- Receipt List -->
<div id="expense-list">
<h3 style="margin-bottom: 1rem;">Expenses</h3>
<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 class="expense-list">
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
{{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 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 class="expense-item-info">
<div class="expense-item-merchant">{{.Merchant}}</div>
<div class="expense-item-meta">{{.Date}} &middot; {{.Category}}</div>
{{if .Description}}
<div class="expense-item-desc">{{.Description}}</div>
<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 class="expense-item-amount">
<span class="amount">{{printf "%.2f" .Amount}}</span>
<span class="currency">{{.Currency}}</span>
<div style="display: flex; gap: 0.25rem;">
{{if .ImagePath}}
<button class="btn btn-sm" style="background: none; border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: var(--color-text);"
onclick="document.getElementById('img-{{.ID}}').classList.toggle('hidden')"
title="View receipt image">🖼️</button>
{{end}}
<button class="btn btn-sm" style="background: none; border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: var(--color-text);"
hx-get="/expenses/{{.ID}}/edit"
hx-target="#receipt-form"
hx-swap="innerHTML"
title="Edit receipt">✏️</button>
<button class="btn btn-sm" style="background: none; border: 1px solid #7f1d1d; border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: #fca5a5;"
onclick="if(confirm('Delete this receipt?')) htmx.trigger('#del-{{.ID}}','click')"
title="Delete receipt">🗑️</button>
<div hx-delete="/expenses/{{.ID}}" hx-target="#expense-list" hx-swap="outerHTML" id="del-{{.ID}}" style="display:none"></div>
</div>
</div>
{{if .ImagePath}}
<div id="img-{{.ID}}" class="hidden" style="position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 999; align-items: center; justify-content: center; cursor: pointer; padding: 1rem;"
onclick="this.classList.add('hidden')">
<span style="position: absolute; top: 1rem; right: 1rem; font-size: 2rem; color: #fff; line-height: 1; cursor: pointer; z-index: 1000;">&times;</span>
<img src="/storage/{{.ImagePath}}" alt="Receipt" style="max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 0.5rem;" onclick="event.stopPropagation()">
</div>
{{end}}
{{end}}
</div>
{{else}}
<div 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 style="text-align: center; padding: 2rem; color: var(--color-text-muted); font-size: 0.875rem;">
<p>No receipts yet.</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>
<!-- Receipt Form (edit/add) -->
<div id="receipt-form" style="margin-top: 1.5rem;"></div>
<!-- Add Receipt Buttons -->
<div style="margin-top: 1.5rem; display: flex; gap: 0.75rem;">
<label for="receipt-camera" class="btn btn-primary" style="flex: 1; text-align: center; cursor: pointer;">
📷 Camera
</label>
<label for="receipt-upload" class="btn btn-secondary" style="flex: 1; text-align: center; cursor: pointer;">
📁 Upload
</label>
</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>
<!-- Hidden file input: camera (mobile) -->
<input type="file" id="receipt-camera" name="receipt" accept="image/*" capture="environment" style="display: none;"
hx-post="/expenses/upload"
hx-encoding="multipart/form-data"
hx-target="#receipt-form"
hx-swap="innerHTML"
hx-indicator="#upload-indicator"
hx-trigger="change">
<!-- Hidden file input: gallery / PDF upload -->
<input type="file" id="receipt-upload" name="receipt" accept="image/*,.pdf" style="display: none;"
hx-post="/expenses/upload"
hx-encoding="multipart/form-data"
hx-target="#receipt-form"
hx-swap="innerHTML"
hx-indicator="#upload-indicator"
hx-trigger="change">
<div id="upload-indicator" class="htmx-indicator" style="text-align: center; padding: 1rem;">
<div class="spinner"></div>
<p>Analyzing receipt...</p>
</div>
{{end}}
</main>
</div>
<script>
// Auto-trigger file input on label click
document.querySelector('label[for="receipt-upload"]')?.addEventListener('click', function() {
document.getElementById('receipt-upload').click();
});
// Close modal on overlay click
document.querySelector('.modal-overlay')?.addEventListener('click', function(e) {
if (e.target === this) {
this.classList.add('hidden');
}
});
</script>
</body>
</html>

View file

@ -22,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;">&times;</span>
<img src="/storage/{{.ImagePath}}" alt="Receipt" style="max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 0.5rem;" onclick="event.stopPropagation()">
</div>
{{end}}
</div>
{{end}}
</div>

View file

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

56
templates/onboarding.html Normal file
View 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">&larr; Back</a>
<h1 class="app-title" style="font-size: 1.1rem;">Edit Profile</h1>
<span></span>
{{else}}
<h1 class="app-title" style="font-size: 1.1rem;">Welcome to NextExpense</h1>
{{end}}
</header>
<main class="main-content">
{{if .Editing}}
<p style="color: var(--color-text-muted); font-size: 0.9rem; margin-bottom: 1.5rem;">
Update your name or department. This appears on all expense reports.
</p>
{{else}}
<p style="color: var(--color-text-muted); font-size: 0.9rem; margin-bottom: 1.5rem;">
Let's set up your profile. This information will appear on your expense reports.
</p>
{{end}}
<div id="onboarding-error"></div>
<form hx-post="{{if .Editing}}/profile{{else}}/onboarding{{end}}" hx-target="#onboarding-error" hx-swap="innerHTML">
<div class="form-group">
<label class="form-label" for="name">Full Name</label>
<input type="text" id="name" name="name" placeholder="Your name as it should appear on reports"
value="{{.Name}}" required autofocus>
</div>
<div class="form-group">
<label class="form-label" for="department">Department / Employee ID</label>
<input type="text" id="department" name="department" placeholder="e.g. Finance, ENG-1234"
value="{{.Department}}">
</div>
<button type="submit" class="btn btn-primary btn-block" style="margin-top: 0.5rem;">
{{if .Editing}}Save Changes{{else}}Save & Continue{{end}}
</button>
</form>
</main>
</div>
</body>
</html>

View file

@ -1,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;">
{{.AIError}}
</div>
{{end}}
{{if .AIError}}
<div class="error-message" style="background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">
{{.AIError}}
</div>
{{end}}
<div class="card" style="padding: 1rem;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;">
@ -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>