package invoice import ( "fmt" "os" "path/filepath" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/techxcar/backend/internal/auth" "github.com/techxcar/backend/internal/settings" "github.com/techxcar/backend/internal/workorder" "github.com/techxcar/backend/pkg/database" "github.com/techxcar/backend/pkg/pdf" ) const storageRoot = "/app/storage" func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { ro := []fiber.Handler{ auth.RequireAuth(secret), auth.RequireRole("tenant_admin", "manager", "technician"), auth.TenantMiddleware(db), } write := []fiber.Handler{ auth.RequireAuth(secret), auth.RequireRole("tenant_admin", "manager"), auth.TenantMiddleware(db), } app.Get("/api/v1/invoices", append(ro, listInvoicesH())...) app.Post("/api/v1/invoices", append(write, createInvoiceH())...) app.Get("/api/v1/invoices/:id/pdf", append(ro, downloadPDFH())...) } func listInvoicesH() fiber.Handler { return func(c *fiber.Ctx) error { conn := auth.GetConn(c) list, err := ListInvoices(c.Context(), conn) if err != nil { return fiber.NewError(500, "erro ao listar faturas") } if list == nil { list = []*Invoice{} } return c.JSON(fiber.Map{"data": list, "error": nil}) } } type createBody struct { WorkOrderID string `json:"work_order_id"` Type string `json:"type"` // "quote" or "invoice" } func tenantDir(c *fiber.Ctx) string { claims, _ := c.Locals("claims").(*auth.Claims) if claims == nil || claims.TenantID == "" { return "unknown" } return "tenant_" + strings.ReplaceAll(claims.TenantID, "-", "_") } func createInvoiceH() fiber.Handler { return func(c *fiber.Ctx) error { var b createBody if err := c.BodyParser(&b); err != nil { return fiber.NewError(400, "corpo do pedido inválido") } if b.WorkOrderID == "" { return fiber.NewError(400, "work_order_id obrigatório") } if b.Type != "quote" && b.Type != "invoice" { return fiber.NewError(400, "tipo deve ser 'quote' ou 'invoice'") } conn := auth.GetConn(c) detail, err := workorder.GetWorkOrderDetail(c.Context(), conn, b.WorkOrderID) if err != nil { return fiber.NewError(500, "erro ao obter ordem de trabalho") } if detail == nil { return fiber.NewError(404, "ordem de trabalho não encontrada") } if b.Type == "invoice" { if detail.PaymentMethod == "" { return fiber.NewError(400, "defina o meio de pagamento na OT antes de faturar") } var existingID string if err := conn.QueryRow(c.Context(), `SELECT id FROM invoices WHERE work_order_id=$1 AND type='invoice' ORDER BY issued_at DESC LIMIT 1`, b.WorkOrderID).Scan(&existingID); err == nil && existingID != "" { return fiber.NewError(409, "já existe uma fatura para esta OT") } else if err != nil && err != pgx.ErrNoRows { return fiber.NewError(500, "erro ao validar faturação existente") } } inv, err := CreateInvoice(c.Context(), conn, b.WorkOrderID, b.Type) if err != nil { return fiber.NewError(500, "erro ao criar fatura") } if b.Type == "invoice" { paymentMethod := detail.PaymentMethod paymentDate := "" if detail.PaymentDate != nil { paymentDate = detail.PaymentDate.Format("2006-01-02") } if paymentDate == "" { paymentDate = time.Now().Format("2006-01-02") } if _, err := workorder.TransitionStatus(c.Context(), conn, b.WorkOrderID, "invoiced", paymentMethod, paymentDate, ""); err != nil { _, _ = conn.Exec(c.Context(), `DELETE FROM invoices WHERE id=$1`, inv.ID) return fiber.NewError(500, "erro ao atualizar estado da ordem") } } outPath, err := regeneratePDF(c, conn, inv) if err != nil { return fiber.NewError(500, "erro ao gerar PDF") } inv.PDFPath = outPath return c.Status(201).JSON(fiber.Map{"data": inv, "error": nil}) } } func downloadPDFH() fiber.Handler { return func(c *fiber.Ctx) error { id := c.Params("id") conn := auth.GetConn(c) inv, err := GetInvoice(c.Context(), conn, id) if err != nil { return fiber.NewError(500, "erro interno") } if inv == nil { return fiber.NewError(404, "fatura não encontrada") } // Regenerate on download so layout/branding fixes are reflected on existing docs. outPath, err := regeneratePDF(c, conn, inv) if err != nil { return fiber.NewError(500, "erro ao gerar PDF") } if _, err := os.Stat(outPath); os.IsNotExist(err) { return fiber.NewError(404, "ficheiro PDF não encontrado") } c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="invoice_%d.pdf"`, inv.Number)) return c.SendFile(outPath) } } func regeneratePDF(c *fiber.Ctx, conn *pgxpool.Conn, inv *Invoice) (string, error) { detail, err := workorder.GetWorkOrderDetail(c.Context(), conn, inv.WorkOrderID) if err != nil { return "", fmt.Errorf("erro ao obter ordem de trabalho: %w", err) } if detail == nil { return "", fmt.Errorf("ordem de trabalho não encontrada") } sett, err := settings.GetSettings(c.Context(), conn) if err != nil { return "", fmt.Errorf("erro ao obter definições: %w", err) } var clientName, clientNIF, vehiclePlate string if detail.ClientID != nil { _ = conn.QueryRow(c.Context(), `SELECT name, COALESCE(nif,'') FROM clients WHERE id = $1`, *detail.ClientID).Scan(&clientName, &clientNIF) } if detail.VehicleID != nil { _ = conn.QueryRow(c.Context(), `SELECT plate FROM vehicles WHERE id = $1`, *detail.VehicleID).Scan(&vehiclePlate) } docType := "Orçamento" prefix := "ORC" if inv.Type == "invoice" { docType = "Fatura" prefix = "FAT" } outPath := filepath.Join(storageRoot, tenantDir(c), fmt.Sprintf("inv_%s.pdf", inv.ID)) meta := pdf.DocMeta{ CompanyName: sett["company_name"], CompanyNIF: sett["company_nif"], CompanyAddress: sett["company_address"], CompanyIBAN: sett["company_iban"], CompanyPhone: sett["company_phone"], CompanyEmail: sett["company_email"], CompanyLogo: sett["company_logo"], DocType: docType, DocNumber: fmt.Sprintf("%s/%d/%04d", prefix, inv.IssuedAt.Year(), inv.Number), IssuedAt: inv.IssuedAt.Format("02/01/2006"), ClientName: clientName, ClientNIF: clientNIF, VehiclePlate: vehiclePlate, } lineItems := make([]pdf.LineItem, len(detail.Items)) for i, item := range detail.Items { lineItems[i] = pdf.LineItem{ Description: item.Description, Qty: item.Qty, UnitPrice: item.UnitPrice, DiscountPct: item.DiscountPct, Total: item.Total, } } var staffTotal float64 for _, sh := range detail.StaffHours { staffTotal += sh.Total } if err := pdf.Generate(meta, lineItems, staffTotal, outPath); err != nil { return "", err } if err := SetPDFPath(c.Context(), conn, inv.ID, outPath); err != nil { return "", err } return outPath, nil }