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) { (data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A) {
return "image/tiff" 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) // HEIC/HEIF (ftyp box at offset 4)
if len(data) >= 12 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70 { if len(data) >= 12 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70 {
brand := string(data[8:12]) 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. // 5. Validate content type by inspecting magic bytes.
ext := detectImageExtension(fileData) ext := detectImageExtension(fileData)
if ext == "" { if ext == "" {
log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported image type", log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported file type: %q",
time.Now().Format(time.RFC3339)) time.Now().Format(time.RFC3339), ext)
http.Error(w, "Only JPEG and PNG images are supported", http.StatusBadRequest) http.Error(w, "Unsupported file format. Please upload a receipt image (JPEG, PNG, HEIC) or PDF.", http.StatusBadRequest)
return return
} }
@ -551,6 +551,11 @@ func detectImageExtension(data []byte) string {
return "tiff" 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) // HEIC/HEIF/AVIF: .... 66 74 79 70 ... (ftyp box)
// The ftyp box starts at offset 4 with brand at offset 8. // 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 { 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. // Write header row.
var header []string var header []string
if hasConversion { 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 { } else {
header = []string{"Date", "Merchant", "Amount", "Currency", "Category", "Description"} header = []string{"#", "Date", "Merchant", "Amount", "Currency", "Category", "Description"}
} }
if err := writer.Write(header); err != nil { 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 with item number.
totalOrig := 0.0 totalOrig := 0.0
totalConv := 0.0 totalConv := 0.0
for _, exp := range expenses { for i, exp := range expenses {
itemNum := i + 1
var row []string var row []string
if hasConversion { if hasConversion {
row = []string{ row = []string{
fmt.Sprintf("%d", itemNum),
exp.Date, exp.Date,
exp.Merchant, exp.Merchant,
fmt.Sprintf("%.2f", exp.Amount), fmt.Sprintf("%.2f", exp.Amount),
@ -233,6 +235,7 @@ func generateCSV(eventName string, expenses []database.Expense) (*email.Attachme
} }
} else { } else {
row = []string{ row = []string{
fmt.Sprintf("%d", itemNum),
exp.Date, exp.Date,
exp.Merchant, exp.Merchant,
fmt.Sprintf("%.2f", exp.Amount), fmt.Sprintf("%.2f", exp.Amount),
@ -250,9 +253,9 @@ func generateCSV(eventName string, expenses []database.Expense) (*email.Attachme
// Write totals row. // Write totals row.
if hasConversion { 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 { } else {
writer.Write([]string{"TOTAL", "", fmt.Sprintf("%.2f", totalOrig), "", "", "", ""}) writer.Write([]string{"TOTAL", "", "", fmt.Sprintf("%.2f", totalOrig), "", "", "", ""})
} }
writer.Flush() writer.Flush()
@ -260,7 +263,7 @@ func generateCSV(eventName string, expenses []database.Expense) (*email.Attachme
return nil, fmt.Errorf("CSV writer flush: %w", err) 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{ return &email.Attachment{
Filename: filename, Filename: filename,
Content: buf.Bytes(), 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) pdf.SetFont("Helvetica", "B", 10)
var headers []string var headers []string
var colWidths []float64 var colWidths []float64
if hasConversion { if hasConversion {
headers = []string{"Date", "Merchant", "Amount", "Curr.", "Converted", "Claim", "Category"} headers = []string{"#", "Date", "Merchant", "Amount", "Curr.", "Converted", "Claim", "Category"}
colWidths = []float64{25, 40, 20, 12, 22, 14, 30} colWidths = []float64{8, 22, 38, 18, 12, 20, 14, 30}
} else { } else {
headers = []string{"Date", "Merchant", "Amount", "Currency", "Category"} headers = []string{"#", "Date", "Merchant", "Amount", "Currency", "Category"}
colWidths = []float64{30, 45, 25, 20, 45} colWidths = []float64{10, 28, 48, 22, 18, 40}
} }
for i, h := range headers { for i, h := range headers {
pdf.Cell(colWidths[i], 8, h) pdf.Cell(colWidths[i], 8, h)
} }
pdf.Ln(8) pdf.Ln(8)
// Table data rows. // Table data rows with item numbers.
pdf.SetFont("Helvetica", "", 9) pdf.SetFont("Helvetica", "", 9)
for _, exp := range expenses { for i, exp := range expenses {
itemNum := i + 1
if hasConversion { if hasConversion {
pdf.Cell(colWidths[0], 8, exp.Date) pdf.Cell(colWidths[0], 8, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 8, truncateString(exp.Merchant, 18)) pdf.Cell(colWidths[1], 8, exp.Date)
pdf.Cell(colWidths[2], 8, fmt.Sprintf("%.2f", exp.Amount)) pdf.Cell(colWidths[2], 8, truncateString(exp.Merchant, 18))
pdf.Cell(colWidths[3], 8, exp.Currency) pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 8, fmt.Sprintf("%.2f", exp.ConvertedAmount)) pdf.Cell(colWidths[4], 8, exp.Currency)
pdf.Cell(colWidths[5], 8, exp.BaseCurrency) pdf.Cell(colWidths[5], 8, fmt.Sprintf("%.2f", exp.ConvertedAmount))
pdf.Cell(colWidths[6], 8, truncateString(exp.Category, 12)) pdf.Cell(colWidths[6], 8, exp.BaseCurrency)
pdf.Cell(colWidths[7], 8, truncateString(exp.Category, 12))
} else { } else {
pdf.Cell(colWidths[0], 8, exp.Date) pdf.Cell(colWidths[0], 8, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 8, truncateString(exp.Merchant, 20)) pdf.Cell(colWidths[1], 8, exp.Date)
pdf.Cell(colWidths[2], 8, fmt.Sprintf("%.2f", exp.Amount)) pdf.Cell(colWidths[2], 8, truncateString(exp.Merchant, 20))
pdf.Cell(colWidths[3], 8, exp.Currency) pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 8, truncateString(exp.Category, 20)) pdf.Cell(colWidths[4], 8, exp.Currency)
pdf.Cell(colWidths[5], 8, truncateString(exp.Category, 20))
} }
pdf.Ln(8) pdf.Ln(8)
} }
@ -332,8 +338,9 @@ func generatePDF(eventName string, expenses []database.Expense) (*email.Attachme
return nil, fmt.Errorf("PDF output: %w", err) return nil, fmt.Errorf("PDF output: %w", err)
} }
filename := fmt.Sprintf("expense-%s-report.pdf", sanitiseFilename(eventName))
return &email.Attachment{ return &email.Attachment{
Filename: "report.pdf", Filename: filename,
Content: buf.Bytes(), Content: buf.Bytes(),
}, nil }, nil
} }
@ -397,7 +404,7 @@ func createReceiptZip(eventName string, expenses []database.Expense) (*email.Att
} }
return &email.Attachment{ return &email.Attachment{
Filename: fmt.Sprintf("%s-images.zip", safeName), Filename: fmt.Sprintf("expense-%s-images.zip", safeName),
Content: buf.Bytes(), Content: buf.Bytes(),
}, nil }, nil
} }

View file

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