diff --git a/internal/ai/deepseek.go b/internal/ai/deepseek.go index 6c1528e..503a704 100644 --- a/internal/ai/deepseek.go +++ b/internal/ai/deepseek.go @@ -24,7 +24,7 @@ type ReceiptData struct { } const ( - geminiAPIURL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite:generateContent" + geminiAPIURL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite:generateContent" requestTimeout = 30 * time.Second ) diff --git a/internal/database/db.go b/internal/database/db.go index 8d895d3..0c739ea 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -35,25 +35,29 @@ type OTP struct { // Event represents a row in the events table. type Event struct { - ID string - UserID string - Name string - Status string - CreatedAt string + ID string + UserID 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 } // --------------------------------------------------------------------------- @@ -101,6 +105,8 @@ func createTables(db *sql.DB) error { user_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) )`, @@ -109,6 +115,8 @@ func createTables(db *sql.DB) error { 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, @@ -207,15 +215,21 @@ func DeleteOTP(db *sql.DB, email string) error { // Event queries // --------------------------------------------------------------------------- -// CreateEvent inserts a new event row. -func CreateEvent(db *sql.DB, id, userID, name string) error { +// CreateEvent inserts a new event row with optional base currency and exchange rate. +func CreateEvent(db *sql.DB, id, userID, 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, name) VALUES (?, ?, ?)", - id, userID, name, + "INSERT INTO events (id, user_id, name, base_currency, exchange_rate) VALUES (?, ?, ?, ?, ?)", + id, userID, name, baseCurrency, exchangeRate, ) if err != nil { - log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s): %v", - time.Now().Format(time.RFC3339), id, userID, name, err) + log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s, %s, %.4f): %v", + time.Now().Format(time.RFC3339), id, userID, name, baseCurrency, exchangeRate, err) } return err } @@ -223,7 +237,7 @@ func CreateEvent(db *sql.DB, id, userID, name string) error { // 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, name, status, base_currency, exchange_rate, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC", userID, ) if err != nil { @@ -236,7 +250,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.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 @@ -248,9 +262,9 @@ func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) { // 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, 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.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil { if err == sql.ErrNoRows { return nil, nil } @@ -276,12 +290,12 @@ func UpdateEventStatus(db *sql.DB, id, status string) error { // --------------------------------------------------------------------------- // 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 +309,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 +325,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) diff --git a/internal/handlers/events.go b/internal/handlers/events.go index 5a68163..fd77d81 100644 --- a/internal/handlers/events.go +++ b/internal/handlers/events.go @@ -10,6 +10,7 @@ import ( "html/template" "log" "net/http" + "strconv" "time" "github.com/expenseflow/internal/database" @@ -95,8 +96,20 @@ func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) { return } + baseCurrency := r.FormValue("base_currency") + if baseCurrency == "" { + baseCurrency = "EUR" + } + + exchangeRate := 1.0 + if v := r.FormValue("exchange_rate"); v != "" { + if parsed, err := strconv.ParseFloat(v, 64); err == nil && parsed > 0 { + exchangeRate = parsed + } + } + id := utils.New() - if err := database.CreateEvent(h.DB, id, userID, name); err != nil { + if err := database.CreateEvent(h.DB, id, userID, 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) diff --git a/internal/handlers/expenses.go b/internal/handlers/expenses.go index 6382308..215e460 100644 --- a/internal/handlers/expenses.go +++ b/internal/handlers/expenses.go @@ -115,10 +115,27 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) { return } - // 8. Call the DeepSeek Vision API for AI extraction. + // 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. receipt, aiErr := ai.ExtractReceipt(storagePath) - // 9. Render the receipt_form.html fragment. + // 10. 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", @@ -128,14 +145,17 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) { } 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 +168,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 +223,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 == "" { @@ -231,17 +261,33 @@ 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) + } + if baseCurrency == "" || baseCurrency == currency { + baseCurrency = currency + convertedAmount = amount + } + if convertedAmount <= 0 { + convertedAmount = amount + baseCurrency = currency + } + // 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.New(), + 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 { diff --git a/internal/handlers/file.go b/internal/handlers/file.go index 632f794..76fc291 100644 --- a/internal/handlers/file.go +++ b/internal/handlers/file.go @@ -173,27 +173,70 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) { // generateCSV creates a CSV attachment from the provided expenses. // The CSV includes a header row and one data row per expense. +// If the expenses use a different currency than the base currency, both +// original and converted amounts are included. func generateCSV(eventName string, expenses []database.Expense) (*email.Attachment, error) { var buf bytes.Buffer writer := csv.NewWriter(&buf) + // Determine if we need conversion columns. + hasConversion := false + for _, exp := range expenses { + if exp.ConvertedAmount > 0 && exp.BaseCurrency != "" && exp.BaseCurrency != exp.Currency { + hasConversion = true + break + } + } + // Write header row. - if err := writer.Write([]string{"Date", "Merchant", "Amount", "Currency", "Category", "Description"}); err != nil { + var header []string + if hasConversion { + header = []string{"Date", "Merchant", "Amount", "Currency", "Converted", "Claim Currency", "Category", "Description"} + } else { + header = []string{"Date", "Merchant", "Amount", "Currency", "Category", "Description"} + } + if err := writer.Write(header); err != nil { return nil, fmt.Errorf("write CSV header: %w", err) } // Write one data row per expense. + totalOrig := 0.0 + totalConv := 0.0 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 { + var row []string + if hasConversion { + row = []string{ + exp.Date, + exp.Merchant, + fmt.Sprintf("%.2f", exp.Amount), + exp.Currency, + fmt.Sprintf("%.2f", exp.ConvertedAmount), + exp.BaseCurrency, + exp.Category, + exp.Description, + } + } else { + row = []string{ + exp.Date, + exp.Merchant, + fmt.Sprintf("%.2f", exp.Amount), + exp.Currency, + exp.Category, + exp.Description, + } + } + if err := writer.Write(row); err != nil { return nil, fmt.Errorf("write CSV row: %w", err) } + totalOrig += exp.Amount + totalConv += exp.ConvertedAmount + } + + // Write totals row. + if hasConversion { + writer.Write([]string{"TOTAL", "", fmt.Sprintf("%.2f", totalOrig), "", fmt.Sprintf("%.2f", totalConv), "", "", ""}) + } else { + writer.Write([]string{"TOTAL", "", fmt.Sprintf("%.2f", totalOrig), "", "", "", ""}) } writer.Flush() @@ -209,6 +252,8 @@ func generateCSV(eventName string, expenses []database.Expense) (*email.Attachme // generatePDF creates a PDF attachment from the provided expenses using gofpdf. // The PDF contains a title row, a header row, and one data row per expense. +// If the expenses use a different currency than the base currency, both +// original and converted amounts are included. func generatePDF(eventName string, expenses []database.Expense) (*email.Attachment, error) { pdf := gofpdf.New("P", "mm", "A4", "") pdf.AddPage() @@ -218,22 +263,49 @@ func generatePDF(eventName string, expenses []database.Expense) (*email.Attachme pdf.Cell(0, 10, "Expense Report: "+eventName) pdf.Ln(15) + // Determine if we need conversion columns. + hasConversion := false + for _, exp := range expenses { + if exp.ConvertedAmount > 0 && exp.BaseCurrency != "" && exp.BaseCurrency != exp.Currency { + hasConversion = true + break + } + } + // Table header row. pdf.SetFont("Helvetica", "B", 10) - headers := []string{"Date", "Merchant", "Amount", "Currency", "Category"} - for _, h := range headers { - pdf.Cell(35, 8, h) + var headers []string + var colWidths []float64 + if hasConversion { + headers = []string{"Date", "Merchant", "Amount", "Curr.", "Converted", "Claim", "Category"} + colWidths = []float64{25, 40, 20, 12, 22, 14, 30} + } else { + headers = []string{"Date", "Merchant", "Amount", "Currency", "Category"} + colWidths = []float64{30, 45, 25, 20, 45} + } + for i, h := range headers { + pdf.Cell(colWidths[i], 8, h) } pdf.Ln(8) // Table data rows. - pdf.SetFont("Helvetica", "", 10) + pdf.SetFont("Helvetica", "", 9) 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) + if hasConversion { + pdf.Cell(colWidths[0], 8, exp.Date) + pdf.Cell(colWidths[1], 8, truncateString(exp.Merchant, 18)) + pdf.Cell(colWidths[2], 8, fmt.Sprintf("%.2f", exp.Amount)) + pdf.Cell(colWidths[3], 8, exp.Currency) + pdf.Cell(colWidths[4], 8, fmt.Sprintf("%.2f", exp.ConvertedAmount)) + pdf.Cell(colWidths[5], 8, exp.BaseCurrency) + pdf.Cell(colWidths[6], 8, truncateString(exp.Category, 12)) + } else { + pdf.Cell(colWidths[0], 8, exp.Date) + pdf.Cell(colWidths[1], 8, truncateString(exp.Merchant, 20)) + pdf.Cell(colWidths[2], 8, fmt.Sprintf("%.2f", exp.Amount)) + pdf.Cell(colWidths[3], 8, exp.Currency) + pdf.Cell(colWidths[4], 8, truncateString(exp.Category, 20)) + } pdf.Ln(8) } @@ -248,3 +320,12 @@ func generatePDF(eventName string, expenses []database.Expense) (*email.Attachme Content: buf.Bytes(), }, nil } + +// 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] + "…" +} diff --git a/templates/dashboard.html b/templates/dashboard.html index 6c36b62..3f43480 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -34,6 +34,25 @@ +