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