package main import ( "bytes" "fmt" "net/http" "strconv" "strings" "time" "codeberg.org/go-pdf/fpdf" "zeitkort-web/repository" ) // ExtraLine is a manual invoice line item (e.g. "1 stk" of something). type ExtraLine struct { Description string Quantity float64 Unit string // "tímar" or "stk" UnitPrice float64 } type invoiceInput struct { InvoiceNumber string Date time.Time DueDate time.Time DueDays int Comment string MVGPercent float64 FromName, FromAddress, FromPhone, FromEmail, FromWeb, FromVAT string BankInfo string Company repository.Company Assignment repository.Assignment HourlyRate float64 Sessions []repository.WorkSession ExtraLines []ExtraLine } // ── Handlers ───────────────────────────────────────────────────────────────── type invoiceFormView struct { CompanyID int64 AssignmentID int64 Company *repository.Company Assignment *repository.Assignment TotalHours float64 TotalAmount float64 InvoiceNumber string Today string DueDays int MVGPercent float64 Cfg Config } func (s *Server) handleInvoiceForm(w http.ResponseWriter, r *http.Request) { companyID := qInt64(r, "company_id") assignmentID := qInt64(r, "assignment_id") if assignmentID == 0 { s.renderMainWith(w, companyID, 0, "", "Select an assignment before making an invoice.") return } assignment, err := s.repo.GetAssignmentByID(assignmentID) if err != nil { s.renderMainWith(w, companyID, assignmentID, "", err.Error()) return } company, err := s.repo.GetCompanyByID(assignment.CompanyID) if err != nil { s.renderMainWith(w, companyID, assignmentID, "", err.Error()) return } hours, _ := s.repo.TotalHoursForAssignment(assignmentID) rate := assignment.HourlyRate if rate == 0 { rate = company.HourlyRate } now := time.Now() nextNum, _ := s.repo.NextInvoiceNumber(now) s.mu.Lock() cfg := s.Cfg s.mu.Unlock() s.render(w, "invoice_form", invoiceFormView{ CompanyID: assignment.CompanyID, AssignmentID: assignmentID, Company: company, Assignment: assignment, TotalHours: hours, TotalAmount: hours * rate, InvoiceNumber: nextNum, Today: now.Format("02-01-2006"), DueDays: cfg.PaymentTermsDays, MVGPercent: cfg.MVGPercent, Cfg: cfg, }) } // handleInvoiceLine returns one empty extra-line input row (htmx append). func (s *Server) handleInvoiceLine(w http.ResponseWriter, r *http.Request) { s.render(w, "invoice_line", nil) } func (s *Server) handleInvoiceGenerate(w http.ResponseWriter, r *http.Request) { r.ParseForm() companyID := formInt64(r, "company_id") assignmentID := formInt64(r, "assignment_id") assignment, err := s.repo.GetAssignmentByID(assignmentID) if err != nil { s.renderMainWith(w, companyID, assignmentID, "", err.Error()) return } company, err := s.repo.GetCompanyByID(assignment.CompanyID) if err != nil { s.renderMainWith(w, companyID, assignmentID, "", err.Error()) return } sessions, err := s.repo.WorkSessionsByAssignment(assignmentID, 1000) if err != nil { s.renderMainWith(w, companyID, assignmentID, "", err.Error()) return } rate := assignment.HourlyRate if rate == 0 { rate = company.HourlyRate } mvg, _ := strconv.ParseFloat(r.FormValue("mvg"), 64) dueDays, _ := strconv.Atoi(r.FormValue("due_days")) if dueDays <= 0 { dueDays = 8 } now := time.Now() in := invoiceInput{ InvoiceNumber: r.FormValue("invoice_number"), Date: now, DueDate: now.AddDate(0, 0, dueDays), DueDays: dueDays, Comment: r.FormValue("comment"), MVGPercent: mvg, FromName: r.FormValue("from_name"), FromAddress: r.FormValue("from_address"), FromPhone: r.FormValue("from_phone"), FromEmail: r.FormValue("from_email"), FromWeb: r.FormValue("from_web"), FromVAT: r.FormValue("from_vat"), BankInfo: r.FormValue("bank_info"), Company: *company, Assignment: *assignment, HourlyRate: rate, Sessions: sessions, ExtraLines: parseExtraLines(r), } pdfBytes, total, err := generateInvoicePDF(in) if err != nil { s.renderMainWith(w, companyID, assignmentID, "", "PDF generation failed: "+err.Error()) return } // Record the invoice so numbering keeps incrementing (best effort). if _, err := s.repo.InsertInvoice(repository.Invoice{ AssignmentID: assignmentID, InvoiceNumber: in.InvoiceNumber, TotalAmount: total, }); err != nil { s.errl.Printf("could not record invoice %s: %v", in.InvoiceNumber, err) } filename := fmt.Sprintf("faktura_%s_%s.pdf", in.InvoiceNumber, sanitize(assignment.Name)) w.Header().Set("Content-Type", "application/pdf") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) w.Header().Set("Content-Length", strconv.Itoa(len(pdfBytes))) w.Write(pdfBytes) } func parseExtraLines(r *http.Request) []ExtraLine { descs := r.Form["extra_desc"] qtys := r.Form["extra_qty"] units := r.Form["extra_unit"] prices := r.Form["extra_price"] var lines []ExtraLine for i := range descs { desc := strings.TrimSpace(descs[i]) if desc == "" { continue } qty, unit, price := 0.0, "stk", 0.0 if i < len(qtys) { qty, _ = strconv.ParseFloat(qtys[i], 64) } if i < len(units) && units[i] != "" { unit = units[i] } if i < len(prices) { price, _ = strconv.ParseFloat(prices[i], 64) } if qty == 0 || price == 0 { continue } lines = append(lines, ExtraLine{Description: desc, Quantity: qty, Unit: unit, UnitPrice: price}) } return lines } // ── PDF generation (ported from the desktop app) ───────────────────────────── // stripTaskNumber removes the "N - " prefix from task descriptions. // "1 - Frontend work" → "Frontend work" func stripTaskNumber(desc string) string { if idx := strings.Index(desc, " - "); idx >= 0 { prefix := desc[:idx] allDigits := true for _, c := range prefix { if c < '0' || c > '9' { allDigits = false break } } if allDigits { return desc[idx+3:] } } return desc } // generateInvoicePDF builds the invoice and returns the PDF bytes and the // grand total (incl. MVG) for record keeping. func generateInvoicePDF(d invoiceInput) ([]byte, float64, error) { pdf := fpdf.New("P", "mm", "A4", "") pdf.SetMargins(10, 15, 10) pdf.SetAutoPageBreak(true, 30) fontFamily := "Helvetica" txt := pdf.UnicodeTranslatorFromDescriptor("") footer1 := d.FromName if d.FromAddress != "" { footer1 += " / " + d.FromAddress } var footer2Parts []string if d.FromVAT != "" { footer2Parts = append(footer2Parts, "V-tal: "+d.FromVAT) } if d.FromPhone != "" { footer2Parts = append(footer2Parts, "tlf.: "+d.FromPhone) } if d.FromEmail != "" { footer2Parts = append(footer2Parts, "teldupostur: "+d.FromEmail) } if d.FromWeb != "" { footer2Parts = append(footer2Parts, "heimas\u00ed\u00f0a: "+d.FromWeb) } footer2 := strings.Join(footer2Parts, " / ") pdf.SetFooterFunc(func() { pdf.SetY(-20) pdf.SetTextColor(100, 100, 100) pdf.SetFont(fontFamily, "", 8) pdf.SetDrawColor(100, 100, 100) pdf.SetLineWidth(0.2) pdf.Line(10, pdf.GetY(), 200, pdf.GetY()) pdf.Ln(2) pdf.CellFormat(190, 4, txt(footer1), "", 0, "C", false, 0, "") pdf.Ln(-1) if footer2 != "" { pdf.CellFormat(190, 4, txt(footer2), "", 0, "C", false, 0, "") } }) pdf.AddPage() darkBlue := func() { pdf.SetTextColor(68, 84, 106) } black := func() { pdf.SetTextColor(0, 0, 0) } setFont := func(style string, size float64) { pdf.SetFont(fontFamily, style, size) } // Header darkBlue() setFont("B", 20) pdf.SetXY(10, 20) pdf.CellFormat(190, 10, txt(d.FromName), "", 0, "R", false, 0, "") // Customer info pdf.SetXY(10, 35) darkBlue() setFont("B", 11) pdf.Cell(190, 5, txt(d.Company.Name)) pdf.Ln(5) setFont("", 10) darkBlue() if d.Company.Address != "" { pdf.Cell(190, 5, txt(d.Company.Address)) pdf.Ln(5) } if d.Company.ContactEmail != "" { pdf.Cell(190, 5, txt(d.Company.ContactEmail)) pdf.Ln(5) } if d.Company.ContactPhone != "" { pdf.Cell(190, 5, txt(d.Company.ContactPhone)) pdf.Ln(5) } pdf.Ln(10) // Invoice meta yMeta := pdf.GetY() black() setFont("", 10) pdf.CellFormat(12, 5, txt("Dato: "), "", 0, "L", false, 0, "") setFont("B", 10) pdf.CellFormat(50, 5, d.Date.Format("02/01/2006"), "", 0, "L", false, 0, "") pdf.SetXY(140, yMeta) setFont("", 10) pdf.CellFormat(30, 5, txt("Fakturanr. "), "", 0, "R", false, 0, "") setFont("B", 10) pdf.CellFormat(20, 5, txt(d.InvoiceNumber), "", 0, "R", false, 0, "") pdf.SetXY(10, yMeta+6) if d.Comment != "" { setFont("", 10) pdf.MultiCell(190, 5, txt(d.Comment), "", "L", false) } pdf.Ln(10) // Title darkBlue() setFont("B", 16) pdf.Cell(190, 8, txt("Faktura")) pdf.Ln(10) // Table colW := []float64{80, 20, 20, 25, 20, 25} pdf.SetDrawColor(68, 84, 106) pdf.SetLineWidth(0.6) pdf.Line(10, pdf.GetY(), 200, pdf.GetY()) pdf.SetY(pdf.GetY() + 1) darkBlue() setFont("", 10) pdf.CellFormat(colW[0], 7, txt("Lýsing"), "", 0, "L", false, 0, "") pdf.CellFormat(colW[1], 7, txt("Nøgd"), "", 0, "R", false, 0, "") pdf.CellFormat(colW[2], 7, txt("Eind"), "", 0, "L", false, 0, "") pdf.CellFormat(colW[3], 7, txt("Stk. prísur"), "", 0, "R", false, 0, "") pdf.CellFormat(colW[4], 7, "", "", 0, "R", false, 0, "") pdf.CellFormat(colW[5], 7, txt("Upphædd"), "", 0, "R", false, 0, "") pdf.Ln(-1) pdf.Line(10, pdf.GetY(), 200, pdf.GetY()) pdf.SetY(pdf.GetY() + 1) // group sessions by task type taskLine struct { desc string hours float64 amount float64 } taskMap := make(map[int64]*taskLine) var taskOrder []int64 for _, ws := range d.Sessions { if ws.EndTime == nil { continue } hours := ws.DurationMinutes / 60.0 amount := hours * d.HourlyRate if tl, ok := taskMap[ws.TaskID]; ok { tl.hours += hours tl.amount += amount } else { taskMap[ws.TaskID] = &taskLine{desc: ws.TaskDescription, hours: hours, amount: amount} taskOrder = append(taskOrder, ws.TaskID) } } setFont("", 10) pdf.SetTextColor(50, 50, 50) subtotal := 0.0 for _, tid := range taskOrder { tl := taskMap[tid] desc := stripTaskNumber(tl.desc) if len(desc) > 45 { desc = desc[:42] + "..." } pdf.CellFormat(colW[0], 7, txt(desc), "", 0, "L", false, 0, "") pdf.CellFormat(colW[1], 7, fmtNum(tl.hours), "", 0, "R", false, 0, "") pdf.CellFormat(colW[2], 7, txt(" tímar"), "", 0, "L", false, 0, "") pdf.CellFormat(colW[3], 7, fmtNum(d.HourlyRate), "", 0, "R", false, 0, "") pdf.CellFormat(colW[4], 7, "", "", 0, "R", false, 0, "") pdf.CellFormat(colW[5], 7, fmtNum(tl.amount), "", 0, "R", false, 0, "") pdf.Ln(-1) subtotal += tl.amount } // Extra lines for _, el := range d.ExtraLines { desc := el.Description if len(desc) > 45 { desc = desc[:42] + "..." } amount := el.Quantity * el.UnitPrice pdf.CellFormat(colW[0], 7, txt(desc), "", 0, "L", false, 0, "") pdf.CellFormat(colW[1], 7, fmtNum(el.Quantity), "", 0, "R", false, 0, "") pdf.CellFormat(colW[2], 7, txt(" "+el.Unit), "", 0, "L", false, 0, "") pdf.CellFormat(colW[3], 7, fmtNum(el.UnitPrice), "", 0, "R", false, 0, "") pdf.CellFormat(colW[4], 7, "", "", 0, "R", false, 0, "") pdf.CellFormat(colW[5], 7, fmtNum(amount), "", 0, "R", false, 0, "") pdf.Ln(-1) subtotal += amount } pdf.Ln(5) // Totals offset := colW[0] + colW[1] + colW[2] totW := colW[3] + colW[4] + colW[5] vat := subtotal * (d.MVGPercent / 100.0) total := subtotal + vat darkBlue() setFont("", 10) pdf.SetX(10 + offset) pdf.Line(10+offset, pdf.GetY(), 200, pdf.GetY()) pdf.SetY(pdf.GetY() + 1) pdf.SetX(10 + offset) pdf.CellFormat(totW-colW[5], 6, txt("Í alt DKK uttan MVG"), "", 0, "L", false, 0, "") black() pdf.CellFormat(colW[5], 6, fmtNum(subtotal), "", 0, "R", false, 0, "") pdf.Ln(-1) darkBlue() pdf.SetX(10 + offset) pdf.CellFormat(totW-colW[5], 6, txt(fmt.Sprintf("%.0f%% MVG", d.MVGPercent)), "", 0, "L", false, 0, "") black() pdf.CellFormat(colW[5], 6, fmtNum(vat), "", 0, "R", false, 0, "") pdf.Ln(-1) darkBlue() setFont("B", 10) pdf.SetX(10 + offset) pdf.Line(10+offset, pdf.GetY(), 200, pdf.GetY()) pdf.SetY(pdf.GetY() + 1) pdf.SetX(10 + offset) pdf.CellFormat(totW-colW[5], 6, txt("Í alt DKK vid MVG"), "", 0, "L", false, 0, "") black() pdf.CellFormat(colW[5], 6, fmtNum(total), "", 0, "R", false, 0, "") pdf.Ln(-1) pdf.SetX(10 + offset) pdf.Line(10+offset, pdf.GetY(), 200, pdf.GetY()) pdf.Ln(20) // Payment terms darkBlue() setFont("", 9) pdf.Cell(190, 5, txt(fmt.Sprintf( "Gjaldstreytir: Netto %d dagar - Fellir á dato %s", d.DueDays, d.DueDate.Format("02/01/2006"), ))) pdf.Ln(8) if d.BankInfo != "" { pdf.Cell(190, 5, txt("Upphæddin skal setast inn á bankakontu:")) pdf.Ln(5) for _, line := range strings.Split(d.BankInfo, "\n") { pdf.Cell(190, 5, txt(strings.TrimRight(line, "\r"))) pdf.Ln(5) } } pdf.Cell(190, 5, txt(fmt.Sprintf("Biði verður um Fakturanr. %s við flytingini", d.InvoiceNumber))) pdf.Ln(8) pdf.Cell(190, 5, txt("Verður ikki goldið innan lokna gjaldsfrest, verður renta tilskriva á 0,70%, umframt eitt gjald uppá 100,00 DKK")) var buf bytes.Buffer if err := pdf.Output(&buf); err != nil { return nil, 0, err } return buf.Bytes(), total, nil } func fmtNum(v float64) string { return fmt.Sprintf("%.2f", v) } func sanitize(name string) string { var b strings.Builder for _, c := range name { if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' { b.WriteRune(c) } else if c == ' ' { b.WriteByte('_') } } return b.String() }