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:
parent
dcb43b08a0
commit
646378df64
4 changed files with 78 additions and 46 deletions
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,22 +61,37 @@
|
|||
<!-- 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;"
|
||||
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>
|
||||
<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>
|
||||
|
||||
<!-- Submit Event (only if open and has expenses) -->
|
||||
|
|
|
|||
Loading…
Reference in a new issue