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
This commit is contained in:
parent
46c78ef507
commit
4895b3d608
9 changed files with 307 additions and 84 deletions
|
|
@ -24,7 +24,7 @@ type ReceiptData struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
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
|
requestTimeout = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ type Event struct {
|
||||||
UserID string
|
UserID string
|
||||||
Name string
|
Name string
|
||||||
Status string
|
Status string
|
||||||
|
BaseCurrency string
|
||||||
|
ExchangeRate float64
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,6 +50,8 @@ type Expense struct {
|
||||||
EventID string
|
EventID string
|
||||||
Amount float64
|
Amount float64
|
||||||
Currency string
|
Currency string
|
||||||
|
ConvertedAmount float64
|
||||||
|
BaseCurrency string
|
||||||
Merchant string
|
Merchant string
|
||||||
Category string
|
Category string
|
||||||
Description string
|
Description string
|
||||||
|
|
@ -101,6 +105,8 @@ func createTables(db *sql.DB) error {
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
status TEXT CHECK(status IN ('open', 'closed')) DEFAULT 'open',
|
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,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
)`,
|
)`,
|
||||||
|
|
@ -109,6 +115,8 @@ func createTables(db *sql.DB) error {
|
||||||
event_id TEXT NOT NULL,
|
event_id TEXT NOT NULL,
|
||||||
amount REAL NOT NULL,
|
amount REAL NOT NULL,
|
||||||
currency TEXT NOT NULL,
|
currency TEXT NOT NULL,
|
||||||
|
converted_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
base_currency TEXT NOT NULL DEFAULT 'EUR',
|
||||||
merchant TEXT NOT NULL,
|
merchant TEXT NOT NULL,
|
||||||
category TEXT NOT NULL,
|
category TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
|
|
@ -207,15 +215,21 @@ func DeleteOTP(db *sql.DB, email string) error {
|
||||||
// Event queries
|
// Event queries
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// CreateEvent inserts a new event row.
|
// CreateEvent inserts a new event row with optional base currency and exchange rate.
|
||||||
func CreateEvent(db *sql.DB, id, userID, name string) error {
|
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(
|
_, err := db.Exec(
|
||||||
"INSERT INTO events (id, user_id, name) VALUES (?, ?, ?)",
|
"INSERT INTO events (id, user_id, name, base_currency, exchange_rate) VALUES (?, ?, ?, ?, ?)",
|
||||||
id, userID, name,
|
id, userID, name, baseCurrency, exchangeRate,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s): %v",
|
log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s, %s, %.4f): %v",
|
||||||
time.Now().Format(time.RFC3339), id, userID, name, err)
|
time.Now().Format(time.RFC3339), id, userID, name, baseCurrency, exchangeRate, err)
|
||||||
}
|
}
|
||||||
return 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.
|
// GetEventsByUser returns all events belonging to a user, ordered by creation date descending.
|
||||||
func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
|
func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
|
||||||
rows, err := db.Query(
|
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,
|
userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -236,7 +250,7 @@ func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
|
||||||
var events []Event
|
var events []Event
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e Event
|
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",
|
log.Printf("ERROR [%s] database: GetEventsByUser scan: %v",
|
||||||
time.Now().Format(time.RFC3339), err)
|
time.Now().Format(time.RFC3339), err)
|
||||||
return nil, 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.
|
// GetEventByID returns a single event by ID, or nil if not found.
|
||||||
func GetEventByID(db *sql.DB, id string) (*Event, error) {
|
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{}
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
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.
|
// 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 {
|
func CreateExpense(db *sql.DB, expense Expense) error {
|
||||||
_, err := db.Exec(
|
_, err := db.Exec(
|
||||||
`INSERT INTO expenses (id, event_id, amount, currency, merchant, category, description, date, image_path)
|
`INSERT INTO expenses (id, event_id, amount, currency, converted_amount, base_currency, merchant, category, description, date, image_path)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
expense.ID, expense.EventID, expense.Amount, expense.Currency,
|
expense.ID, expense.EventID, expense.Amount, expense.Currency,
|
||||||
|
expense.ConvertedAmount, expense.BaseCurrency,
|
||||||
expense.Merchant, expense.Category, expense.Description,
|
expense.Merchant, expense.Category, expense.Description,
|
||||||
expense.Date, expense.ImagePath,
|
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.
|
// GetExpensesByEvent returns all expenses for a given event, ordered by creation date descending.
|
||||||
func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) {
|
func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) {
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
`SELECT id, event_id, amount, currency, merchant, category,
|
`SELECT id, event_id, amount, currency, converted_amount, base_currency,
|
||||||
COALESCE(description, ''), date, image_path, created_at
|
merchant, category, COALESCE(description, ''), date, image_path, created_at
|
||||||
FROM expenses WHERE event_id = ? ORDER BY created_at DESC`,
|
FROM expenses WHERE event_id = ? ORDER BY created_at DESC`,
|
||||||
eventID,
|
eventID,
|
||||||
)
|
)
|
||||||
|
|
@ -311,8 +325,8 @@ func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e Expense
|
var e Expense
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&e.ID, &e.EventID, &e.Amount, &e.Currency, &e.Merchant,
|
&e.ID, &e.EventID, &e.Amount, &e.Currency, &e.ConvertedAmount, &e.BaseCurrency,
|
||||||
&e.Category, &e.Description, &e.Date, &e.ImagePath, &e.CreatedAt,
|
&e.Merchant, &e.Category, &e.Description, &e.Date, &e.ImagePath, &e.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.Printf("ERROR [%s] database: GetExpensesByEvent scan: %v",
|
log.Printf("ERROR [%s] database: GetExpensesByEvent scan: %v",
|
||||||
time.Now().Format(time.RFC3339), err)
|
time.Now().Format(time.RFC3339), err)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/expenseflow/internal/database"
|
"github.com/expenseflow/internal/database"
|
||||||
|
|
@ -95,8 +96,20 @@ func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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()
|
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",
|
log.Printf("ERROR [%s] handlers: CreateEvent: %v",
|
||||||
time.Now().Format(time.RFC3339), err)
|
time.Now().Format(time.RFC3339), err)
|
||||||
http.Error(w, "Failed to create event", http.StatusInternalServerError)
|
http.Error(w, "Failed to create event", http.StatusInternalServerError)
|
||||||
|
|
|
||||||
|
|
@ -115,10 +115,27 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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)
|
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")
|
tmpl, err := template.ParseFiles("templates/receipt_form.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ERROR [%s] handlers: UploadReceipt: parse template: %v",
|
log.Printf("ERROR [%s] handlers: UploadReceipt: parse template: %v",
|
||||||
|
|
@ -136,6 +153,9 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
|
||||||
"Category": "",
|
"Category": "",
|
||||||
"Date": "",
|
"Date": "",
|
||||||
"Description": "",
|
"Description": "",
|
||||||
|
"BaseCurrency": baseCurrency,
|
||||||
|
"ExchangeRate": exchangeRate,
|
||||||
|
"ConvertedAmount": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
if aiErr != nil {
|
if aiErr != nil {
|
||||||
|
|
@ -148,6 +168,12 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
|
||||||
data["Merchant"] = receipt.Merchant
|
data["Merchant"] = receipt.Merchant
|
||||||
data["Category"] = receipt.Category
|
data["Category"] = receipt.Category
|
||||||
data["Date"] = receipt.Date
|
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")
|
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")
|
description := r.FormValue("description")
|
||||||
imagePath := r.FormValue("image_path")
|
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.
|
// 3. Validate required fields.
|
||||||
var missing []string
|
var missing []string
|
||||||
if amountStr == "" {
|
if amountStr == "" {
|
||||||
|
|
@ -231,12 +261,28 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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.
|
// 5. Build and save the expense record.
|
||||||
expense := database.Expense{
|
expense := database.Expense{
|
||||||
ID: utils.New(),
|
ID: utils.New(),
|
||||||
EventID: eventID,
|
EventID: eventID,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
Currency: currency,
|
Currency: currency,
|
||||||
|
ConvertedAmount: convertedAmount,
|
||||||
|
BaseCurrency: baseCurrency,
|
||||||
Merchant: merchant,
|
Merchant: merchant,
|
||||||
Category: category,
|
Category: category,
|
||||||
Description: description,
|
Description: description,
|
||||||
|
|
|
||||||
|
|
@ -173,27 +173,70 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// generateCSV creates a CSV attachment from the provided expenses.
|
// generateCSV creates a CSV attachment from the provided expenses.
|
||||||
// The CSV includes a header row and one data row per expense.
|
// 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) {
|
func generateCSV(eventName string, expenses []database.Expense) (*email.Attachment, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
writer := csv.NewWriter(&buf)
|
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.
|
// 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)
|
return nil, fmt.Errorf("write CSV header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write one data row per expense.
|
// Write one data row per expense.
|
||||||
|
totalOrig := 0.0
|
||||||
|
totalConv := 0.0
|
||||||
for _, exp := range expenses {
|
for _, exp := range expenses {
|
||||||
if err := writer.Write([]string{
|
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.Date,
|
||||||
exp.Merchant,
|
exp.Merchant,
|
||||||
fmt.Sprintf("%.2f", exp.Amount),
|
fmt.Sprintf("%.2f", exp.Amount),
|
||||||
exp.Currency,
|
exp.Currency,
|
||||||
exp.Category,
|
exp.Category,
|
||||||
exp.Description,
|
exp.Description,
|
||||||
}); err != nil {
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Write(row); err != nil {
|
||||||
return nil, fmt.Errorf("write CSV row: %w", err)
|
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()
|
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.
|
// 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.
|
// 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) {
|
func generatePDF(eventName string, expenses []database.Expense) (*email.Attachment, error) {
|
||||||
pdf := gofpdf.New("P", "mm", "A4", "")
|
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||||
pdf.AddPage()
|
pdf.AddPage()
|
||||||
|
|
@ -218,22 +263,49 @@ func generatePDF(eventName string, expenses []database.Expense) (*email.Attachme
|
||||||
pdf.Cell(0, 10, "Expense Report: "+eventName)
|
pdf.Cell(0, 10, "Expense Report: "+eventName)
|
||||||
pdf.Ln(15)
|
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.
|
// Table header row.
|
||||||
pdf.SetFont("Helvetica", "B", 10)
|
pdf.SetFont("Helvetica", "B", 10)
|
||||||
headers := []string{"Date", "Merchant", "Amount", "Currency", "Category"}
|
var headers []string
|
||||||
for _, h := range headers {
|
var colWidths []float64
|
||||||
pdf.Cell(35, 8, h)
|
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)
|
pdf.Ln(8)
|
||||||
|
|
||||||
// Table data rows.
|
// Table data rows.
|
||||||
pdf.SetFont("Helvetica", "", 10)
|
pdf.SetFont("Helvetica", "", 9)
|
||||||
for _, exp := range expenses {
|
for _, exp := range expenses {
|
||||||
pdf.Cell(35, 8, exp.Date)
|
if hasConversion {
|
||||||
pdf.Cell(35, 8, exp.Merchant)
|
pdf.Cell(colWidths[0], 8, exp.Date)
|
||||||
pdf.Cell(20, 8, fmt.Sprintf("%.2f", exp.Amount))
|
pdf.Cell(colWidths[1], 8, truncateString(exp.Merchant, 18))
|
||||||
pdf.Cell(20, 8, exp.Currency)
|
pdf.Cell(colWidths[2], 8, fmt.Sprintf("%.2f", exp.Amount))
|
||||||
pdf.Cell(35, 8, exp.Category)
|
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)
|
pdf.Ln(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,3 +320,12 @@ func generatePDF(eventName string, expenses []database.Expense) (*email.Attachme
|
||||||
Content: buf.Bytes(),
|
Content: buf.Bytes(),
|
||||||
}, nil
|
}, 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] + "…"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,25 @@
|
||||||
<label for="name">Event Name</label>
|
<label for="name">Event Name</label>
|
||||||
<input type="text" id="name" name="name" placeholder="e.g., WebSummit 2026" required>
|
<input type="text" id="name" name="name" placeholder="e.g., WebSummit 2026" required>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="base_currency">Claim Currency</label>
|
||||||
|
<select id="base_currency" name="base_currency" required>
|
||||||
|
<option value="EUR">EUR - Euro</option>
|
||||||
|
<option value="USD">USD - US Dollar</option>
|
||||||
|
<option value="GBP">GBP - British Pound</option>
|
||||||
|
<option value="KES" selected>KES - Kenyan Shilling</option>
|
||||||
|
<option value="CHF">CHF - Swiss Franc</option>
|
||||||
|
<option value="SEK">SEK - Swedish Krona</option>
|
||||||
|
<option value="NOK">NOK - Norwegian Krone</option>
|
||||||
|
<option value="PLN">PLN - Polish Zloty</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="exchange_rate">Exchange Rate (1 {receipt currency} = ? {claim currency})</label>
|
||||||
|
<input type="number" id="exchange_rate" name="exchange_rate" step="0.000001" min="0.000001" value="1.0" required inputmode="decimal">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-block">Create Event</button>
|
<button type="submit" class="btn btn-primary btn-block">Create Event</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -50,6 +69,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="event-card-meta">
|
<div class="event-card-meta">
|
||||||
<span>Created: {{.CreatedAt}}</span>
|
<span>Created: {{.CreatedAt}}</span>
|
||||||
|
<span>Claim in: {{.BaseCurrency}} @ {{printf "%.6f" .ExchangeRate}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="event-card-actions">
|
<div class="event-card-actions">
|
||||||
{{if eq .Status "open"}}
|
{{if eq .Status "open"}}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,14 @@
|
||||||
|
|
||||||
<!-- Expense List -->
|
<!-- Expense List -->
|
||||||
<div id="expense-list">
|
<div id="expense-list">
|
||||||
<h3 style="margin-bottom: 1rem;">Expenses</h3>
|
<h3 style="margin-bottom: 1rem;">
|
||||||
|
Expenses
|
||||||
|
{{if and .Event.BaseCurrency (ne .Event.BaseCurrency "")}}
|
||||||
|
<span style="font-size: 0.875rem; font-weight: 400; color: #6b7280;">
|
||||||
|
(claim in {{.Event.BaseCurrency}})
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
</h3>
|
||||||
{{if .Expenses}}
|
{{if .Expenses}}
|
||||||
<div class="expense-list">
|
<div class="expense-list">
|
||||||
{{range .Expenses}}
|
{{range .Expenses}}
|
||||||
|
|
@ -70,6 +77,11 @@
|
||||||
<div class="expense-item-amount">
|
<div class="expense-item-amount">
|
||||||
<span class="amount">{{printf "%.2f" .Amount}}</span>
|
<span class="amount">{{printf "%.2f" .Amount}}</span>
|
||||||
<span class="currency">{{.Currency}}</span>
|
<span class="currency">{{.Currency}}</span>
|
||||||
|
{{if .ConvertedAmount}}
|
||||||
|
<div style="font-size: 0.75rem; color: #166534;">
|
||||||
|
≈ {{printf "%.2f" .ConvertedAmount}} {{.BaseCurrency}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@
|
||||||
<div class="expense-item-amount">
|
<div class="expense-item-amount">
|
||||||
<span class="amount">{{printf "%.2f" .Amount}}</span>
|
<span class="amount">{{printf "%.2f" .Amount}}</span>
|
||||||
<span class="currency">{{.Currency}}</span>
|
<span class="currency">{{.Currency}}</span>
|
||||||
|
{{if .ConvertedAmount}}
|
||||||
|
<div style="font-size: 0.75rem; color: #166534;">
|
||||||
|
≈ {{printf "%.2f" .ConvertedAmount}} {{.BaseCurrency}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -29,18 +29,29 @@
|
||||||
<label for="currency">Currency *</label>
|
<label for="currency">Currency *</label>
|
||||||
<select id="currency" name="currency" required>
|
<select id="currency" name="currency" required>
|
||||||
<option value="">Select</option>
|
<option value="">Select</option>
|
||||||
<option value="USD" {{if eq .Currency "USD"}}selected{{end}}>USD</option>
|
<option value="KES" {{if eq .Currency "KES"}}selected{{end}}>KES - Kenyan Shilling</option>
|
||||||
<option value="EUR" {{if eq .Currency "EUR"}}selected{{end}}>EUR</option>
|
<option value="USD" {{if eq .Currency "USD"}}selected{{end}}>USD - US Dollar</option>
|
||||||
<option value="GBP" {{if eq .Currency "GBP"}}selected{{end}}>GBP</option>
|
<option value="EUR" {{if eq .Currency "EUR"}}selected{{end}}>EUR - Euro</option>
|
||||||
<option value="JPY" {{if eq .Currency "JPY"}}selected{{end}}>JPY</option>
|
<option value="GBP" {{if eq .Currency "GBP"}}selected{{end}}>GBP - British Pound</option>
|
||||||
<option value="CHF" {{if eq .Currency "CHF"}}selected{{end}}>CHF</option>
|
<option value="JPY" {{if eq .Currency "JPY"}}selected{{end}}>JPY - Japanese Yen</option>
|
||||||
<option value="SEK" {{if eq .Currency "SEK"}}selected{{end}}>SEK</option>
|
<option value="CHF" {{if eq .Currency "CHF"}}selected{{end}}>CHF - Swiss Franc</option>
|
||||||
<option value="NOK" {{if eq .Currency "NOK"}}selected{{end}}>NOK</option>
|
<option value="SEK" {{if eq .Currency "SEK"}}selected{{end}}>SEK - Swedish Krona</option>
|
||||||
<option value="DKK" {{if eq .Currency "DKK"}}selected{{end}}>DKK</option>
|
<option value="NOK" {{if eq .Currency "NOK"}}selected{{end}}>NOK - Norwegian Krone</option>
|
||||||
<option value="PLN" {{if eq .Currency "PLN"}}selected{{end}}>PLN</option>
|
<option value="DKK" {{if eq .Currency "DKK"}}selected{{end}}>DKK - Danish Krone</option>
|
||||||
<option value="CZK" {{if eq .Currency "CZK"}}selected{{end}}>CZK</option>
|
<option value="PLN" {{if eq .Currency "PLN"}}selected{{end}}>PLN - Polish Zloty</option>
|
||||||
<option value="HUF" {{if eq .Currency "HUF"}}selected{{end}}>HUF</option>
|
<option value="CZK" {{if eq .Currency "CZK"}}selected{{end}}>CZK - Czech Koruna</option>
|
||||||
<option value="RON" {{if eq .Currency "RON"}}selected{{end}}>RON</option>
|
<option value="HUF" {{if eq .Currency "HUF"}}selected{{end}}>HUF - Hungarian Forint</option>
|
||||||
|
<option value="RON" {{if eq .Currency "RON"}}selected{{end}}>RON - Romanian Leu</option>
|
||||||
|
<option value="ZAR" {{if eq .Currency "ZAR"}}selected{{end}}>ZAR - South African Rand</option>
|
||||||
|
<option value="NGN" {{if eq .Currency "NGN"}}selected{{end}}>NGN - Nigerian Naira</option>
|
||||||
|
<option value="EGP" {{if eq .Currency "EGP"}}selected{{end}}>EGP - Egyptian Pound</option>
|
||||||
|
<option value="TZS" {{if eq .Currency "TZS"}}selected{{end}}>TZS - Tanzanian Shilling</option>
|
||||||
|
<option value="UGX" {{if eq .Currency "UGX"}}selected{{end}}>UGX - Ugandan Shilling</option>
|
||||||
|
<option value="RWF" {{if eq .Currency "RWF"}}selected{{end}}>RWF - Rwandan Franc</option>
|
||||||
|
<option value="AED" {{if eq .Currency "AED"}}selected{{end}}>AED - UAE Dirham</option>
|
||||||
|
<option value="CNY" {{if eq .Currency "CNY"}}selected{{end}}>CNY - Chinese Yuan</option>
|
||||||
|
<option value="INR" {{if eq .Currency "INR"}}selected{{end}}>INR - Indian Rupee</option>
|
||||||
|
<option value="BRL" {{if eq .Currency "BRL"}}selected{{end}}>BRL - Brazilian Real</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -74,6 +85,27 @@
|
||||||
<textarea id="description" name="description" placeholder="Optional notes...">{{.Description}}</textarea>
|
<textarea id="description" name="description" placeholder="Optional notes...">{{.Description}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .BaseCurrency}}
|
||||||
|
<div class="card" style="padding: 0.75rem; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 0.5rem; margin-bottom: 1rem;">
|
||||||
|
<div style="font-size: 0.875rem; font-weight: 600; color: #166534; 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: #f9fafb; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem; width: 100%; box-sizing: border-box;">
|
||||||
|
</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;">
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
<button type="submit" class="btn btn-primary" id="save-indicator">
|
<button type="submit" class="btn btn-primary" id="save-indicator">
|
||||||
<span class="spinner htmx-indicator"></span>
|
<span class="spinner htmx-indicator"></span>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue