feat: gallery upload + PDF receipt support

- Two upload buttons: Camera (capture) and Upload (gallery/PDF)
- PDF receipts from Uber/email now accepted and processed by Gemini Vision
- PDF detection via %PDF magic bytes in both handler and AI module
- Descriptions updated to reflect broader file support
This commit is contained in:
Claus Lohmar 2026-05-30 13:50:22 +00:00
parent dcb43b08a0
commit 646378df64
4 changed files with 78 additions and 46 deletions

View file

@ -208,6 +208,11 @@ func detectMimeType(data []byte) string {
(data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A) {
return "image/tiff"
}
// PDF: 25 50 44 46 (%PDF)
if len(data) >= 4 && data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46 {
return "application/pdf"
}
// HEIC/HEIF (ftyp box at offset 4)
if len(data) >= 12 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70 {
brand := string(data[8:12])

View file

@ -91,9 +91,9 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
// 5. Validate content type by inspecting magic bytes.
ext := detectImageExtension(fileData)
if ext == "" {
log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported image type",
time.Now().Format(time.RFC3339))
http.Error(w, "Only JPEG and PNG images are supported", http.StatusBadRequest)
log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported file type: %q",
time.Now().Format(time.RFC3339), ext)
http.Error(w, "Unsupported file format. Please upload a receipt image (JPEG, PNG, HEIC) or PDF.", http.StatusBadRequest)
return
}
@ -551,6 +551,11 @@ func detectImageExtension(data []byte) string {
return "tiff"
}
// PDF: 25 50 44 46 (%PDF)
if len(data) >= 4 && data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46 {
return "pdf"
}
// HEIC/HEIF/AVIF: .... 66 74 79 70 ... (ftyp box)
// The ftyp box starts at offset 4 with brand at offset 8.
if len(data) >= 12 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70 {

View file

@ -207,21 +207,23 @@ func generateCSV(eventName string, expenses []database.Expense) (*email.Attachme
// Write header row.
var header []string
if hasConversion {
header = []string{"Date", "Merchant", "Amount", "Currency", "Converted", "Claim Currency", "Category", "Description"}
header = []string{"#", "Date", "Merchant", "Amount", "Currency", "Converted", "Claim Currency", "Category", "Description"}
} else {
header = []string{"Date", "Merchant", "Amount", "Currency", "Category", "Description"}
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.
// Write one data row per expense with item number.
totalOrig := 0.0
totalConv := 0.0
for _, exp := range expenses {
for i, exp := range expenses {
itemNum := i + 1
var row []string
if hasConversion {
row = []string{
fmt.Sprintf("%d", itemNum),
exp.Date,
exp.Merchant,
fmt.Sprintf("%.2f", exp.Amount),
@ -233,6 +235,7 @@ func generateCSV(eventName string, expenses []database.Expense) (*email.Attachme
}
} else {
row = []string{
fmt.Sprintf("%d", itemNum),
exp.Date,
exp.Merchant,
fmt.Sprintf("%.2f", exp.Amount),
@ -250,9 +253,9 @@ func generateCSV(eventName string, expenses []database.Expense) (*email.Attachme
// Write totals row.
if hasConversion {
writer.Write([]string{"TOTAL", "", fmt.Sprintf("%.2f", totalOrig), "", fmt.Sprintf("%.2f", totalConv), "", "", ""})
writer.Write([]string{"TOTAL", "", "", fmt.Sprintf("%.2f", totalOrig), "", fmt.Sprintf("%.2f", totalConv), "", "", ""})
} else {
writer.Write([]string{"TOTAL", "", fmt.Sprintf("%.2f", totalOrig), "", "", "", ""})
writer.Write([]string{"TOTAL", "", "", fmt.Sprintf("%.2f", totalOrig), "", "", "", ""})
}
writer.Flush()
@ -260,7 +263,7 @@ func generateCSV(eventName string, expenses []database.Expense) (*email.Attachme
return nil, fmt.Errorf("CSV writer flush: %w", err)
}
filename := fmt.Sprintf("receiptnext-%s.csv", sanitiseFilename(eventName))
filename := fmt.Sprintf("expense-%s-report.csv", sanitiseFilename(eventName))
return &email.Attachment{
Filename: filename,
Content: buf.Bytes(),
@ -289,39 +292,42 @@ func generatePDF(eventName string, expenses []database.Expense) (*email.Attachme
}
}
// Table header row.
// Table header row with item number.
pdf.SetFont("Helvetica", "B", 10)
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}
headers = []string{"#", "Date", "Merchant", "Amount", "Curr.", "Converted", "Claim", "Category"}
colWidths = []float64{8, 22, 38, 18, 12, 20, 14, 30}
} else {
headers = []string{"Date", "Merchant", "Amount", "Currency", "Category"}
colWidths = []float64{30, 45, 25, 20, 45}
headers = []string{"#", "Date", "Merchant", "Amount", "Currency", "Category"}
colWidths = []float64{10, 28, 48, 22, 18, 40}
}
for i, h := range headers {
pdf.Cell(colWidths[i], 8, h)
}
pdf.Ln(8)
// Table data rows.
// Table data rows with item numbers.
pdf.SetFont("Helvetica", "", 9)
for _, exp := range expenses {
for i, exp := range expenses {
itemNum := i + 1
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))
pdf.Cell(colWidths[0], 8, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 8, exp.Date)
pdf.Cell(colWidths[2], 8, truncateString(exp.Merchant, 18))
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 8, exp.Currency)
pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", exp.ConvertedAmount))
pdf.Cell(colWidths[6], 8, exp.BaseCurrency)
pdf.Cell(colWidths[7], 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.Cell(colWidths[0], 8, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 8, exp.Date)
pdf.Cell(colWidths[2], 8, truncateString(exp.Merchant, 20))
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 8, exp.Currency)
pdf.Cell(colWidths[5], 8, truncateString(exp.Category, 20))
}
pdf.Ln(8)
}
@ -332,8 +338,9 @@ func generatePDF(eventName string, expenses []database.Expense) (*email.Attachme
return nil, fmt.Errorf("PDF output: %w", err)
}
filename := fmt.Sprintf("expense-%s-report.pdf", sanitiseFilename(eventName))
return &email.Attachment{
Filename: "report.pdf",
Filename: filename,
Content: buf.Bytes(),
}, nil
}
@ -397,7 +404,7 @@ func createReceiptZip(eventName string, expenses []database.Expense) (*email.Att
}
return &email.Attachment{
Filename: fmt.Sprintf("%s-images.zip", safeName),
Filename: fmt.Sprintf("expense-%s-images.zip", safeName),
Content: buf.Bytes(),
}, nil
}

View file

@ -61,23 +61,38 @@
<!-- Receipt Form (edit/add) -->
<div id="receipt-form" style="margin-top: 1.5rem;"></div>
<!-- Add Receipt Button -->
<div style="margin-top: 1.5rem; text-align: center;">
<label for="receipt-upload" class="btn btn-primary btn-block" style="display: block; text-align: center; cursor: pointer;">
+ Add Receipt
<!-- Add Receipt Buttons -->
<div style="margin-top: 1.5rem; display: flex; gap: 0.75rem;">
<label for="receipt-camera" class="btn btn-primary" style="flex: 1; text-align: center; cursor: pointer;">
📷 Camera
</label>
<input type="file" id="receipt-upload" name="receipt" accept="image/*" capture="environment" style="display: none;"
<label for="receipt-upload" class="btn btn-secondary" style="flex: 1; text-align: center; cursor: pointer;">
📁 Upload
</label>
</div>
<!-- Hidden file input: camera (mobile) -->
<input type="file" id="receipt-camera" name="receipt" accept="image/*" capture="environment" style="display: none;"
hx-post="/expenses/upload"
hx-encoding="multipart/form-data"
hx-target="#receipt-form"
hx-swap="innerHTML"
hx-indicator="#upload-indicator"
hx-trigger="change">
<!-- Hidden file input: gallery / PDF upload -->
<input type="file" id="receipt-upload" name="receipt" accept="image/*,.pdf" style="display: none;"
hx-post="/expenses/upload"
hx-encoding="multipart/form-data"
hx-target="#receipt-form"
hx-swap="innerHTML"
hx-indicator="#upload-indicator"
hx-trigger="change">
<div id="upload-indicator" class="htmx-indicator" style="text-align: center; padding: 1rem;">
<div class="spinner"></div>
<p>Analyzing receipt...</p>
</div>
</div>
<!-- Submit Event (only if open and has expenses) -->
{{if and (eq .Event.Status "open") .Expenses}}