Files
zeitkort-web-golang/invoice.go
T
2026-06-18 21:41:47 +01:00

510 lines
14 KiB
Go

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()
}