diff --git a/veza-backend-api/go.mod b/veza-backend-api/go.mod index f8f28699e..6a3806827 100644 --- a/veza-backend-api/go.mod +++ b/veza-backend-api/go.mod @@ -68,7 +68,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/boombuler/barcode v1.0.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect @@ -94,6 +94,7 @@ require ( github.com/go-openapi/jsonreference v0.19.6 // indirect github.com/go-openapi/spec v0.20.4 // indirect github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-pdf/fpdf v0.9.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/goccy/go-json v0.10.2 // indirect diff --git a/veza-backend-api/go.sum b/veza-backend-api/go.sum index c2c2bc0f3..106970ae6 100644 --- a/veza-backend-api/go.sum +++ b/veza-backend-api/go.sum @@ -62,6 +62,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -137,6 +139,8 @@ github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7 github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= +github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= diff --git a/veza-backend-api/internal/api/routes_marketplace.go b/veza-backend-api/internal/api/routes_marketplace.go index 2c5de49b6..3a277874f 100644 --- a/veza-backend-api/internal/api/routes_marketplace.go +++ b/veza-backend-api/internal/api/routes_marketplace.go @@ -67,6 +67,7 @@ func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) { protected.GET("/orders", marketHandler.ListOrders) protected.GET("/orders/:id", marketHandler.GetOrder) + protected.GET("/orders/:id/invoice", marketHandler.GetOrderInvoice) protected.POST("/orders", marketHandler.CreateOrder) protected.GET("/download/:product_id", marketHandler.GetDownloadURL) protected.GET("/licenses/mine", marketHandler.GetMyLicenses) diff --git a/veza-backend-api/internal/core/marketplace/invoice.go b/veza-backend-api/internal/core/marketplace/invoice.go new file mode 100644 index 000000000..7862a86cb --- /dev/null +++ b/veza-backend-api/internal/core/marketplace/invoice.go @@ -0,0 +1,111 @@ +// Package marketplace - v0.403 F1: Invoice PDF generation +package marketplace + +import ( + "bytes" + "context" + "fmt" + + "github.com/go-pdf/fpdf" + "github.com/google/uuid" + "gorm.io/gorm" + + "veza-backend-api/internal/models" +) + +// GenerateInvoice generates a PDF invoice for an order (v0.403 F1) +func (s *Service) GenerateInvoice(ctx context.Context, orderID, buyerID uuid.UUID) ([]byte, error) { + var order Order + if err := s.db.WithContext(ctx).Preload("Items").First(&order, "id = ?", orderID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, ErrOrderNotFound + } + return nil, err + } + if order.BuyerID != buyerID { + return nil, ErrOrderNotFound + } + var buyer models.User + if err := s.db.WithContext(ctx).First(&buyer, "id = ?", order.BuyerID).Error; err != nil { + return nil, fmt.Errorf("buyer not found: %w", err) + } + productTitles := make(map[uuid.UUID]string) + for _, item := range order.Items { + var p Product + if err := s.db.WithContext(ctx).First(&p, "id = ?", item.ProductID).Error; err == nil { + productTitles[item.ProductID] = p.Title + } else { + productTitles[item.ProductID] = "Product" + } + } + return buildInvoicePDF(&order, &buyer, productTitles) +} + +func buildInvoicePDF(order *Order, buyer *models.User, productTitles map[uuid.UUID]string) ([]byte, error) { + pdf := fpdf.New("P", "mm", "A4", "") + pdf.SetMargins(15, 15, 15) + pdf.SetAutoPageBreak(true, 15) + pdf.AddPage() + + pdf.SetFont("Helvetica", "B", 20) + pdf.CellFormat(0, 10, "INVOICE", "", 1, "L", false, 0, "") + pdf.Ln(4) + + pdf.SetFont("Helvetica", "", 10) + pdf.CellFormat(0, 6, fmt.Sprintf("Order #%s", order.ID.String()[:8]), "", 1, "L", false, 0, "") + pdf.CellFormat(0, 6, fmt.Sprintf("Date: %s", order.CreatedAt.Format("2006-01-02")), "", 1, "L", false, 0, "") + pdf.Ln(6) + + pdf.SetFont("Helvetica", "B", 11) + pdf.CellFormat(0, 6, "Bill To:", "", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 10) + pdf.CellFormat(0, 6, buyer.Username, "", 1, "L", false, 0, "") + if buyer.Email != "" { + pdf.CellFormat(0, 6, buyer.Email, "", 1, "L", false, 0, "") + } + pdf.Ln(8) + + pdf.SetFont("Helvetica", "B", 10) + pdf.CellFormat(80, 7, "Product", "1", 0, "L", false, 0, "") + pdf.CellFormat(40, 7, "Price", "1", 0, "R", false, 0, "") + pdf.CellFormat(0, 7, "", "1", 1, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 10) + + subtotal := 0.0 + for _, item := range order.Items { + title := productTitles[item.ProductID] + pdf.CellFormat(80, 7, truncate(title, 45), "0", 0, "L", false, 0, "") + pdf.CellFormat(40, 7, fmt.Sprintf("%.2f %s", item.Price, order.Currency), "0", 0, "R", false, 0, "") + pdf.CellFormat(0, 7, "", "0", 1, "L", false, 0, "") + subtotal += item.Price + } + + pdf.Ln(4) + if order.DiscountAmountCents > 0 { + pdf.CellFormat(80, 6, "Discount", "0", 0, "L", false, 0, "") + pdf.CellFormat(40, 6, fmt.Sprintf("-%.2f %s", float64(order.DiscountAmountCents)/100, order.Currency), "0", 0, "R", false, 0, "") + pdf.CellFormat(0, 6, "", "0", 1, "L", false, 0, "") + } + pdf.SetFont("Helvetica", "B", 10) + pdf.CellFormat(80, 7, "Total", "1", 0, "L", false, 0, "") + pdf.CellFormat(40, 7, fmt.Sprintf("%.2f %s", order.TotalAmount, order.Currency), "1", 0, "R", false, 0, "") + pdf.CellFormat(0, 7, "", "1", 1, "L", false, 0, "") + + pdf.Ln(10) + pdf.SetFont("Helvetica", "", 8) + pdf.CellFormat(0, 5, "Thank you for your purchase.", "", 1, "L", false, 0, "") + pdf.CellFormat(0, 5, "Veza Marketplace", "", 1, "L", false, 0, "") + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-3] + "..." +} diff --git a/veza-backend-api/internal/core/marketplace/service.go b/veza-backend-api/internal/core/marketplace/service.go index 9f71d6186..a2c7bd555 100644 --- a/veza-backend-api/internal/core/marketplace/service.go +++ b/veza-backend-api/internal/core/marketplace/service.go @@ -89,6 +89,9 @@ type MarketplaceService interface { // v0.403 R1: Product reviews CreateReview(ctx context.Context, productID uuid.UUID, buyerID uuid.UUID, rating int, comment string) (*ProductReview, error) ListReviews(ctx context.Context, productID uuid.UUID, limit, offset int) ([]ProductReview, error) + + // v0.403 F1: Invoices + GenerateInvoice(ctx context.Context, orderID, buyerID uuid.UUID) ([]byte, error) } // ProductImageInput represents input for adding/updating product images diff --git a/veza-backend-api/internal/handlers/marketplace.go b/veza-backend-api/internal/handlers/marketplace.go index daab4ef45..d1b8d1c92 100644 --- a/veza-backend-api/internal/handlers/marketplace.go +++ b/veza-backend-api/internal/handlers/marketplace.go @@ -741,6 +741,31 @@ func (h *MarketplaceHandler) GetOrder(c *gin.Context) { response.Success(c, order) } +// GetOrderInvoice returns a PDF invoice for an order (v0.403 F1) +func (h *MarketplaceHandler) GetOrderInvoice(c *gin.Context) { + userID, ok := GetUserIDUUID(c) + if !ok { + return + } + orderID, err := uuid.Parse(c.Param("id")) + if err != nil { + response.BadRequest(c, "Invalid order id") + return + } + pdf, err := h.service.GenerateInvoice(c.Request.Context(), orderID, userID) + if err != nil { + if err == marketplace.ErrOrderNotFound { + response.NotFound(c, "Order not found") + return + } + response.InternalServerError(c, "Failed to generate invoice") + return + } + c.Header("Content-Type", "application/pdf") + c.Header("Content-Disposition", "attachment; filename=\"invoice-"+orderID.String()[:8]+".pdf\"") + c.Data(200, "application/pdf", pdf) +} + // GetMyLicenses returns all licenses purchased by the authenticated user (v0.401 M2) func (h *MarketplaceHandler) GetMyLicenses(c *gin.Context) { userID, ok := GetUserIDUUID(c)