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

View file

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

View file

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

View file

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

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.
// 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] + "…"
}

View file

@ -34,6 +34,25 @@
<label for="name">Event Name</label>
<input type="text" id="name" name="name" placeholder="e.g., WebSummit 2026" required>
</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>
</form>
</div>
@ -50,6 +69,7 @@
</div>
<div class="event-card-meta">
<span>Created: {{.CreatedAt}}</span>
<span>Claim in: {{.BaseCurrency}} @ {{printf "%.6f" .ExchangeRate}}</span>
</div>
<div class="event-card-actions">
{{if eq .Status "open"}}

View file

@ -48,7 +48,14 @@
<!-- 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}}
<div class="expense-list">
{{range .Expenses}}
@ -70,6 +77,11 @@
<div class="expense-item-amount">
<span class="amount">{{printf "%.2f" .Amount}}</span>
<span class="currency">{{.Currency}}</span>
{{if .ConvertedAmount}}
<div style="font-size: 0.75rem; color: #166534;">
≈ {{printf "%.2f" .ConvertedAmount}} {{.BaseCurrency}}
</div>
{{end}}
</div>
</div>
{{end}}

View file

@ -22,6 +22,11 @@
<div class="expense-item-amount">
<span class="amount">{{printf "%.2f" .Amount}}</span>
<span class="currency">{{.Currency}}</span>
{{if .ConvertedAmount}}
<div style="font-size: 0.75rem; color: #166534;">
≈ {{printf "%.2f" .ConvertedAmount}} {{.BaseCurrency}}
</div>
{{end}}
</div>
</div>
{{end}}

View file

@ -29,18 +29,29 @@
<label for="currency">Currency *</label>
<select id="currency" name="currency" required>
<option value="">Select</option>
<option value="USD" {{if eq .Currency "USD"}}selected{{end}}>USD</option>
<option value="EUR" {{if eq .Currency "EUR"}}selected{{end}}>EUR</option>
<option value="GBP" {{if eq .Currency "GBP"}}selected{{end}}>GBP</option>
<option value="JPY" {{if eq .Currency "JPY"}}selected{{end}}>JPY</option>
<option value="CHF" {{if eq .Currency "CHF"}}selected{{end}}>CHF</option>
<option value="SEK" {{if eq .Currency "SEK"}}selected{{end}}>SEK</option>
<option value="NOK" {{if eq .Currency "NOK"}}selected{{end}}>NOK</option>
<option value="DKK" {{if eq .Currency "DKK"}}selected{{end}}>DKK</option>
<option value="PLN" {{if eq .Currency "PLN"}}selected{{end}}>PLN</option>
<option value="CZK" {{if eq .Currency "CZK"}}selected{{end}}>CZK</option>
<option value="HUF" {{if eq .Currency "HUF"}}selected{{end}}>HUF</option>
<option value="RON" {{if eq .Currency "RON"}}selected{{end}}>RON</option>
<option value="KES" {{if eq .Currency "KES"}}selected{{end}}>KES - Kenyan Shilling</option>
<option value="USD" {{if eq .Currency "USD"}}selected{{end}}>USD - US Dollar</option>
<option value="EUR" {{if eq .Currency "EUR"}}selected{{end}}>EUR - Euro</option>
<option value="GBP" {{if eq .Currency "GBP"}}selected{{end}}>GBP - British Pound</option>
<option value="JPY" {{if eq .Currency "JPY"}}selected{{end}}>JPY - Japanese Yen</option>
<option value="CHF" {{if eq .Currency "CHF"}}selected{{end}}>CHF - Swiss Franc</option>
<option value="SEK" {{if eq .Currency "SEK"}}selected{{end}}>SEK - Swedish Krona</option>
<option value="NOK" {{if eq .Currency "NOK"}}selected{{end}}>NOK - Norwegian Krone</option>
<option value="DKK" {{if eq .Currency "DKK"}}selected{{end}}>DKK - Danish Krone</option>
<option value="PLN" {{if eq .Currency "PLN"}}selected{{end}}>PLN - Polish Zloty</option>
<option value="CZK" {{if eq .Currency "CZK"}}selected{{end}}>CZK - Czech Koruna</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>
</div>
</div>
@ -74,6 +85,27 @@
<textarea id="description" name="description" placeholder="Optional notes...">{{.Description}}</textarea>
</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;">
<button type="submit" class="btn btn-primary" id="save-indicator">
<span class="spinner htmx-indicator"></span>