first
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
type tierResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
UnitID uuid.UUID `json:"unit_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
PriceCents int64 `json:"price_cents"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
func toResponse(t *PriceTier) tierResponse {
|
||||
return tierResponse{
|
||||
ID: t.ID,
|
||||
ProductID: t.ProductID,
|
||||
UnitID: t.UnitID,
|
||||
Quantity: t.Quantity,
|
||||
PriceCents: t.PriceCents,
|
||||
Position: t.Position,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ListByProduct(c *gin.Context) {
|
||||
productID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product id"})
|
||||
return
|
||||
}
|
||||
list, err := h.service.ListByProduct(c.Request.Context(), productID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list price tiers"})
|
||||
return
|
||||
}
|
||||
resp := make([]tierResponse, 0, len(list))
|
||||
for _, t := range list {
|
||||
resp = append(resp, toResponse(t))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"price_tiers": resp})
|
||||
}
|
||||
|
||||
type upsertRequest struct {
|
||||
UnitID uuid.UUID `json:"unit_id" binding:"required"`
|
||||
Quantity float64 `json:"quantity" binding:"required,gt=0"`
|
||||
PriceCents int64 `json:"price_cents" binding:"gte=0"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
productID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product id"})
|
||||
return
|
||||
}
|
||||
var req upsertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
t, err := h.service.Create(c.Request.Context(), productID, req.UnitID, req.Quantity, req.PriceCents, req.Position)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidReference) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "product or unit does not exist"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create price tier"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, toResponse(t))
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("tierId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var req upsertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
t, err := h.service.Update(c.Request.Context(), id, req.UnitID, req.Quantity, req.PriceCents, req.Position)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "price tier not found"})
|
||||
case errors.Is(err, ErrInvalidReference):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "product or unit does not exist"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update price tier"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(t))
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("tierId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := h.service.Delete(c.Request.Context(), id); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "price tier not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete price tier"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Package pricing implements quantity-based pricing for products (spec
|
||||
// section 16): a product can have several price tiers, each pairing a
|
||||
// quantity with a unit (from the units module) and a price. Pricing logic
|
||||
// (computing an order line's total from a chosen tier) is centralized here
|
||||
// in the backend -- the frontend only ever displays tiers and sends back a
|
||||
// tier ID + multiplier; it never sends a price the backend has to trust.
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PriceTier struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||
UnitID uuid.UUID `gorm:"type:uuid;not null"`
|
||||
Quantity float64 `gorm:"not null"`
|
||||
PriceCents int64 `gorm:"not null"`
|
||||
Position int `gorm:"not null;default:0"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (PriceTier) TableName() string { return "price_tiers" }
|
||||
@@ -0,0 +1,86 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("price tier not found")
|
||||
ErrInvalidReference = errors.New("product or unit does not exist")
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, t *PriceTier) error
|
||||
FindByID(ctx context.Context, id uuid.UUID) (*PriceTier, error)
|
||||
ListByProduct(ctx context.Context, productID uuid.UUID) ([]*PriceTier, error)
|
||||
Update(ctx context.Context, t *PriceTier) error
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
}
|
||||
|
||||
type gormRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &gormRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *gormRepository) Create(ctx context.Context, t *PriceTier) error {
|
||||
if err := r.db.WithContext(ctx).Create(t).Error; err != nil {
|
||||
if isForeignKeyViolation(err) {
|
||||
return ErrInvalidReference
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*PriceTier, error) {
|
||||
var t PriceTier
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&t).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) ListByProduct(ctx context.Context, productID uuid.UUID) ([]*PriceTier, error) {
|
||||
var list []*PriceTier
|
||||
err := r.db.WithContext(ctx).Where("product_id = ?", productID).Order("position asc, quantity asc").Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) Update(ctx context.Context, t *PriceTier) error {
|
||||
err := r.db.WithContext(ctx).Save(t).Error
|
||||
if isForeignKeyViolation(err) {
|
||||
return ErrInvalidReference
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *gormRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
res := r.db.WithContext(ctx).Delete(&PriceTier{}, "id = ?", id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isForeignKeyViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23503"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package pricing
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// RegisterAdminRoutes nests price-tier management under a product, e.g.
|
||||
// PUT /api/admin/products/:id/price-tiers/:tierId, so tiers are always
|
||||
// managed in the context of the product they belong to.
|
||||
func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
|
||||
group := rg.Group("/admin/products/:id/price-tiers", requireAdmin)
|
||||
group.GET("", h.ListByProduct)
|
||||
group.POST("", h.Create)
|
||||
group.PUT("/:tierId", h.Update)
|
||||
group.DELETE("/:tierId", h.Delete)
|
||||
}
|
||||
|
||||
// RegisterPublicRoutes exposes read-only tiers so the storefront can render
|
||||
// the available quantity/price options for a product. Kept as its own
|
||||
// top-level path (rather than nested under /products/:slug) because the
|
||||
// products module's public detail route uses a :slug wildcard at that same
|
||||
// position -- gin does not allow two different wildcard names at the same
|
||||
// path segment.
|
||||
func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) {
|
||||
rg.GET("/price-tiers/by-product/:id", h.ListByProduct)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package pricing
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) ListByProduct(ctx context.Context, productID uuid.UUID) ([]*PriceTier, error) {
|
||||
return s.repo.ListByProduct(ctx, productID)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id uuid.UUID) (*PriceTier, error) {
|
||||
return s.repo.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, productID, unitID uuid.UUID, quantity float64, priceCents int64, position int) (*PriceTier, error) {
|
||||
t := &PriceTier{
|
||||
ID: uuid.New(),
|
||||
ProductID: productID,
|
||||
UnitID: unitID,
|
||||
Quantity: quantity,
|
||||
PriceCents: priceCents,
|
||||
Position: position,
|
||||
}
|
||||
if err := s.repo.Create(ctx, t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id uuid.UUID, unitID uuid.UUID, quantity float64, priceCents int64, position int) (*PriceTier, error) {
|
||||
t, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.UnitID = unitID
|
||||
t.Quantity = quantity
|
||||
t.PriceCents = priceCents
|
||||
t.Position = position
|
||||
if err := s.repo.Update(ctx, t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// PriceForQuantity is the centralized pricing calculation used by the
|
||||
// orders module: given a chosen tier and how many of that tier package the
|
||||
// customer wants, it returns the authoritative total in cents. The
|
||||
// frontend never gets to supply a price directly.
|
||||
func (s *Service) PriceForQuantity(ctx context.Context, tierID uuid.UUID, multiplier int64) (totalCents int64, tier *PriceTier, err error) {
|
||||
t, err := s.repo.FindByID(ctx, tierID)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if multiplier < 1 {
|
||||
multiplier = 1
|
||||
}
|
||||
return t.PriceCents * multiplier, t, nil
|
||||
}
|
||||
Reference in New Issue
Block a user