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:
Claus Lohmar 2026-05-30 12:23:31 +00:00
parent 46c78ef507
commit 4895b3d608
9 changed files with 307 additions and 84 deletions

View file

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

View file

@ -35,25 +35,29 @@ type OTP struct {
// Event represents a row in the events table. // Event represents a row in the events table.
type Event struct { type Event struct {
ID string ID string
UserID string UserID string
Name string Name string
Status string Status string
CreatedAt string BaseCurrency string
ExchangeRate float64
CreatedAt string
} }
// Expense represents a row in the expenses table. // Expense represents a row in the expenses table.
type Expense struct { type Expense struct {
ID string ID string
EventID string EventID string
Amount float64 Amount float64
Currency string Currency string
Merchant string ConvertedAmount float64
Category string BaseCurrency string
Description string Merchant string
Date string Category string
ImagePath string Description string
CreatedAt string Date string
ImagePath string
CreatedAt 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)

View file

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

View file

@ -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",
@ -128,14 +145,17 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
} }
data := map[string]interface{}{ data := map[string]interface{}{
"ImagePath": storagePath, "ImagePath": storagePath,
"AIError": "", "AIError": "",
"Amount": "", "Amount": "",
"Currency": "", "Currency": "",
"Merchant": "", "Merchant": "",
"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,17 +261,33 @@ 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,
Merchant: merchant, ConvertedAmount: convertedAmount,
Category: category, BaseCurrency: baseCurrency,
Description: description, Merchant: merchant,
Date: date, Category: category,
ImagePath: imagePath, Description: description,
Date: date,
ImagePath: imagePath,
} }
if err := database.CreateExpense(h.DB, expense); err != nil { if err := database.CreateExpense(h.DB, expense); err != nil {

View file

@ -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
exp.Date, if hasConversion {
exp.Merchant, row = []string{
fmt.Sprintf("%.2f", exp.Amount), exp.Date,
exp.Currency, exp.Merchant,
exp.Category, fmt.Sprintf("%.2f", exp.Amount),
exp.Description, exp.Currency,
}); err != nil { 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) 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] + "…"
}

View file

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

View file

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

View file

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

View file

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