chore(backend): add PDF library for invoices
feat(marketplace): add invoice generation service and download endpoint
This commit is contained in:
parent
e6797481cf
commit
166acc6069
6 changed files with 146 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
111
veza-backend-api/internal/core/marketplace/invoice.go
Normal file
111
veza-backend-api/internal/core/marketplace/invoice.go
Normal 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] + "..."
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue