diff --git a/internal/ai/deepseek.go b/internal/ai/deepseek.go index 503a704..2bc913c 100644 --- a/internal/ai/deepseek.go +++ b/internal/ai/deepseek.go @@ -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]) diff --git a/internal/handlers/expenses.go b/internal/handlers/expenses.go index 4faf68e..a948aaa 100644 --- a/internal/handlers/expenses.go +++ b/internal/handlers/expenses.go @@ -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 { diff --git a/internal/handlers/file.go b/internal/handlers/file.go index 4d027f7..0e224ae 100644 --- a/internal/handlers/file.go +++ b/internal/handlers/file.go @@ -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 } diff --git a/templates/event_expenses.html b/templates/event_expenses.html index e0b97e9..f35f03d 100644 --- a/templates/event_expenses.html +++ b/templates/event_expenses.html @@ -61,22 +61,37 @@
- -
-