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) {
|
(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])
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) -->
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue