chore(backend): add PDF library for invoices

feat(marketplace): add invoice generation service and download endpoint
This commit is contained in:
senke 2026-02-22 16:11:42 +01:00
parent e6797481cf
commit 166acc6069
6 changed files with 146 additions and 1 deletions

View file

@ -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

View file

@ -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=

View file

@ -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)

View file

@ -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] + "..."
}

View file

@ -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

View file

@ -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)