- Passwordless email OTP authentication - Event-based expense tracking with HTMX UI - AI receipt extraction via DeepSeek Vision API - CSV/PDF report generation with email filing - PWA with service worker and manifest - Mobile-first responsive design - SQLite database with auto-migration
195 lines
6 KiB
Go
195 lines
6 KiB
Go
// ExpenseFlow — AI-Powered Expense Tracker
|
|
//
|
|
// A production-ready, mobile-first Progressive Web App (PWA) that uses
|
|
// passwordless email OTP login, event-based expense tracking, AI receipt
|
|
// extraction (DeepSeek Vision), and event filing (CSV/PDF via email).
|
|
//
|
|
// Usage:
|
|
// Copy .env.example to .env and fill in credentials, then:
|
|
// go run main.go
|
|
//
|
|
// The server starts on the port specified by the PORT env var (default 8080).
|
|
|
|
package main
|
|
|
|
import (
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/joho/godotenv"
|
|
|
|
"github.com/expenseflow/internal/auth"
|
|
"github.com/expenseflow/internal/database"
|
|
"github.com/expenseflow/internal/email"
|
|
"github.com/expenseflow/internal/handlers"
|
|
)
|
|
|
|
func main() {
|
|
// -----------------------------------------------------------------------
|
|
// Configuration
|
|
// -----------------------------------------------------------------------
|
|
|
|
// Load environment variables from .env file (if present).
|
|
if err := godotenv.Load(); err != nil {
|
|
log.Printf("INFO [%s] main: no .env file found, using system environment",
|
|
time.Now().Format(time.RFC3339))
|
|
}
|
|
|
|
port := os.Getenv("PORT")
|
|
if port == "" {
|
|
port = "8080"
|
|
}
|
|
|
|
// SMTP configuration.
|
|
smtpHost := os.Getenv("SMTP_HOST")
|
|
smtpPort := os.Getenv("SMTP_PORT")
|
|
smtpUser := os.Getenv("SMTP_USER")
|
|
smtpPass := os.Getenv("SMTP_PASS")
|
|
|
|
// DeepSeek API key is read directly by the ai package.
|
|
_ = os.Getenv("DEEPSEEK_API_KEY")
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Database
|
|
// -----------------------------------------------------------------------
|
|
|
|
db, err := database.Init()
|
|
if err != nil {
|
|
log.Fatalf("FATAL [%s] main: database init: %v", time.Now().Format(time.RFC3339), err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Services
|
|
// -----------------------------------------------------------------------
|
|
|
|
sessionStore := auth.NewSessionStore()
|
|
failureTracker := auth.NewFailureTracker()
|
|
|
|
// Create the email sender only if SMTP credentials are configured.
|
|
var emailSender *email.Sender
|
|
if smtpHost != "" && smtpPort != "" && smtpUser != "" && smtpPass != "" {
|
|
emailSender = email.NewSender(smtpHost, smtpPort, smtpUser, smtpPass, "post@2-4-h.app")
|
|
log.Printf("INFO [%s] main: SMTP sender configured (%s:%s)",
|
|
time.Now().Format(time.RFC3339), smtpHost, smtpPort)
|
|
} else {
|
|
log.Printf("WARN [%s] main: SMTP not configured — OTP emails will not be sent",
|
|
time.Now().Format(time.RFC3339))
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Handlers
|
|
// -----------------------------------------------------------------------
|
|
|
|
authHandler := &handlers.AuthHandler{
|
|
DB: db,
|
|
Sessions: sessionStore,
|
|
FailureTracker: failureTracker,
|
|
EmailSender: emailSender,
|
|
}
|
|
|
|
eventHandler := handlers.NewEventHandler(db)
|
|
expenseHandler := handlers.NewExpenseHandler(db)
|
|
fileHandler := &handlers.FileHandler{
|
|
DB: db,
|
|
EmailSender: emailSender,
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Router
|
|
// -----------------------------------------------------------------------
|
|
|
|
r := chi.NewRouter()
|
|
|
|
// Middleware.
|
|
r.Use(middleware.Logger)
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(middleware.RealIP)
|
|
|
|
// PWA headers for service worker.
|
|
r.Use(func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/sw.js" {
|
|
w.Header().Set("Service-Worker-Allowed", "/")
|
|
w.Header().Set("Content-Type", "application/javascript")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
})
|
|
|
|
// Static file serving.
|
|
fileServer := http.FileServer(http.Dir("static"))
|
|
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
|
|
|
|
// Serve the manifest.json and sw.js from the root for PWA compliance.
|
|
r.Get("/sw.js", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Service-Worker-Allowed", "/")
|
|
w.Header().Set("Content-Type", "application/javascript")
|
|
http.ServeFile(w, r, "static/sw.js")
|
|
}))
|
|
r.Get("/manifest.json", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
http.ServeFile(w, r, "static/manifest.json")
|
|
}))
|
|
|
|
// Serve uploaded receipt images.
|
|
r.Get("/storage/*", http.StripPrefix("/storage/", http.FileServer(http.Dir("storage"))).ServeHTTP)
|
|
|
|
// ---- Public routes (no auth required) ----
|
|
|
|
r.Get("/", authHandler.LandingPage)
|
|
r.Post("/request-otp", authHandler.RequestOTP)
|
|
r.Post("/verify-otp", authHandler.VerifyOTP)
|
|
|
|
// ---- Logout ----
|
|
|
|
r.Post("/logout", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Delete the session cookie.
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: "session_token",
|
|
Value: "",
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
MaxAge: -1,
|
|
})
|
|
w.Header().Set("HX-Redirect", "/")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
// ---- Protected routes (auth required) ----
|
|
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(authHandler.RequireAuth)
|
|
|
|
// Events.
|
|
r.Get("/dashboard", eventHandler.Dashboard)
|
|
r.Post("/events", eventHandler.CreateEvent)
|
|
r.Put("/events/{id}/reopen", eventHandler.ReopenEvent)
|
|
r.Get("/events/{id}/expenses", eventHandler.ViewEventExpenses)
|
|
|
|
// Expenses.
|
|
r.Post("/expenses/upload", expenseHandler.UploadReceipt)
|
|
r.Post("/expenses", expenseHandler.SaveExpense)
|
|
|
|
// Filing.
|
|
r.Post("/events/{id}/file", fileHandler.FileEvent)
|
|
})
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Startup
|
|
// -----------------------------------------------------------------------
|
|
|
|
addr := ":" + port
|
|
log.Printf("INFO [%s] main: ExpenseFlow server starting on %s",
|
|
time.Now().Format(time.RFC3339), addr)
|
|
log.Printf("INFO [%s] main: open http://localhost%s in your browser",
|
|
time.Now().Format(time.RFC3339), addr)
|
|
|
|
if err := http.ListenAndServe(addr, r); err != nil {
|
|
log.Fatalf("FATAL [%s] main: server error: %v", time.Now().Format(time.RFC3339), err)
|
|
}
|
|
}
|