This commit is contained in:
CFOU
2026-09-14 20:50:19 +02:00
commit 3091fb4020
135 changed files with 10262 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
package orders
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"backend/internal/modules/pricing"
"backend/internal/modules/products"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
type itemRequest struct {
ProductID uuid.UUID `json:"product_id" binding:"required"`
PriceTierID uuid.UUID `json:"price_tier_id" binding:"required"`
Multiplier int64 `json:"multiplier" binding:"required,gte=1"`
}
type createRequest struct {
CustomerName string `json:"customer_name" binding:"required"`
CustomerEmail string `json:"customer_email" binding:"required,email"`
CustomerPhone string `json:"customer_phone"`
Notes string `json:"notes"`
Items []itemRequest `json:"items" binding:"required,min=1,dive"`
}
type orderItemResponse struct {
ProductID *uuid.UUID `json:"product_id,omitempty"`
ProductName string `json:"product_name"`
UnitSymbol string `json:"unit_symbol"`
TierQuantity float64 `json:"tier_quantity"`
Multiplier int64 `json:"multiplier"`
UnitPriceCents int64 `json:"unit_price_cents"`
TotalCents int64 `json:"total_cents"`
}
type orderResponse struct {
ID uuid.UUID `json:"id"`
CustomerName string `json:"customer_name"`
CustomerEmail string `json:"customer_email"`
CustomerPhone string `json:"customer_phone"`
Status string `json:"status"`
TotalCents int64 `json:"total_cents"`
Notes string `json:"notes"`
Items []orderItemResponse `json:"items,omitempty"`
}
func toResponse(order *Order, items []*OrderItem) orderResponse {
resp := orderResponse{
ID: order.ID,
CustomerName: order.CustomerName,
CustomerEmail: order.CustomerEmail,
CustomerPhone: order.CustomerPhone,
Status: order.Status,
TotalCents: order.TotalCents,
Notes: order.Notes,
}
for _, item := range items {
resp.Items = append(resp.Items, orderItemResponse{
ProductID: item.ProductID,
ProductName: item.ProductName,
UnitSymbol: item.UnitSymbol,
TierQuantity: item.TierQuantity,
Multiplier: item.Multiplier,
UnitPriceCents: item.UnitPriceCents,
TotalCents: item.TotalCents,
})
}
return resp
}
// Create is public: a visitor (connected or not, per spec section 17) can
// place an order without an account.
func (h *Handler) Create(c *gin.Context) {
var req createRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
return
}
items := make([]ItemInput, 0, len(req.Items))
for _, item := range req.Items {
items = append(items, ItemInput{
ProductID: item.ProductID,
PriceTierID: item.PriceTierID,
Multiplier: item.Multiplier,
})
}
order, orderItems, err := h.service.Create(c.Request.Context(), CreateInput{
CustomerName: req.CustomerName,
CustomerEmail: req.CustomerEmail,
CustomerPhone: req.CustomerPhone,
Notes: req.Notes,
Items: items,
})
if err != nil {
h.respondCreateError(c, err)
return
}
c.JSON(http.StatusCreated, toResponse(order, orderItems))
}
func (h *Handler) respondCreateError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrEmptyOrder), errors.Is(err, ErrProductMismatch):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case errors.Is(err, pricing.ErrNotFound):
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown price tier"})
case errors.Is(err, products.ErrNotFound):
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown product"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create order"})
}
}
func (h *Handler) ListAdmin(c *gin.Context) {
list, err := h.service.List(c.Request.Context(), c.Query("status"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orders"})
return
}
resp := make([]orderResponse, 0, len(list))
for _, order := range list {
resp = append(resp, toResponse(order, nil))
}
c.JSON(http.StatusOK, gin.H{"orders": resp})
}
func (h *Handler) GetAdmin(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
order, items, err := h.service.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "order not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load order"})
return
}
c.JSON(http.StatusOK, toResponse(order, items))
}
type updateStatusRequest struct {
Status string `json:"status" binding:"required"`
}
func (h *Handler) UpdateStatus(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req updateStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
return
}
order, err := h.service.UpdateStatus(c.Request.Context(), id, req.Status)
if err != nil {
switch {
case errors.Is(err, ErrNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "order not found"})
case errors.Is(err, ErrInvalidStatus):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update order status"})
}
return
}
c.JSON(http.StatusOK, toResponse(order, nil))
}
+63
View File
@@ -0,0 +1,63 @@
// Package orders lets a customer (connected or not, per spec section 17-19)
// place an order directly from the storefront, and lets the admin track
// and update it. Pricing is never trusted from the client: each line is
// resolved against the pricing module's authoritative price tier at
// creation time.
package orders
import (
"time"
"github.com/google/uuid"
)
const (
StatusPending = "pending"
StatusConfirmed = "confirmed"
StatusPreparing = "preparing"
StatusShipped = "shipped"
StatusCompleted = "completed"
StatusCancelled = "cancelled"
)
var ValidStatuses = map[string]bool{
StatusPending: true,
StatusConfirmed: true,
StatusPreparing: true,
StatusShipped: true,
StatusCompleted: true,
StatusCancelled: true,
}
type Order struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
CustomerName string `gorm:"not null"`
CustomerEmail string `gorm:"not null"`
CustomerPhone string `gorm:"not null;default:''"`
Status string `gorm:"not null;default:pending"`
TotalCents int64 `gorm:"not null;default:0"`
Notes string `gorm:"not null;default:''"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (Order) TableName() string { return "orders" }
// OrderItem snapshots the product name, unit symbol and unit price at order
// time, so the order stays accurate even if the product/unit is later
// renamed or deleted.
type OrderItem struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
OrderID uuid.UUID `gorm:"type:uuid;not null;index"`
ProductID *uuid.UUID `gorm:"type:uuid"`
ProductName string `gorm:"not null"`
PriceTierID *uuid.UUID `gorm:"type:uuid"`
UnitSymbol string `gorm:"not null"`
TierQuantity float64 `gorm:"not null"`
Multiplier int64 `gorm:"not null;default:1"`
UnitPriceCents int64 `gorm:"not null"`
TotalCents int64 `gorm:"not null"`
CreatedAt time.Time
}
func (OrderItem) TableName() string { return "order_items" }
@@ -0,0 +1,85 @@
package orders
import (
"context"
"errors"
"github.com/google/uuid"
"gorm.io/gorm"
)
var ErrNotFound = errors.New("order not found")
type Repository interface {
Create(ctx context.Context, order *Order, items []*OrderItem) error
FindByID(ctx context.Context, id uuid.UUID) (*Order, []*OrderItem, error)
List(ctx context.Context, status string) ([]*Order, error)
UpdateStatus(ctx context.Context, id uuid.UUID, status string) (*Order, error)
}
type gormRepository struct {
db *gorm.DB
}
func NewRepository(db *gorm.DB) Repository {
return &gormRepository{db: db}
}
func (r *gormRepository) Create(ctx context.Context, order *Order, items []*OrderItem) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(order).Error; err != nil {
return err
}
for _, item := range items {
item.OrderID = order.ID
}
if len(items) > 0 {
if err := tx.Create(&items).Error; err != nil {
return err
}
}
return nil
})
}
func (r *gormRepository) FindByID(ctx context.Context, id uuid.UUID) (*Order, []*OrderItem, error) {
var order Order
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, ErrNotFound
}
return nil, nil, err
}
var items []*OrderItem
if err := r.db.WithContext(ctx).Where("order_id = ?", id).Order("created_at asc").Find(&items).Error; err != nil {
return nil, nil, err
}
return &order, items, nil
}
func (r *gormRepository) List(ctx context.Context, status string) ([]*Order, error) {
q := r.db.WithContext(ctx).Order("created_at desc")
if status != "" {
q = q.Where("status = ?", status)
}
var list []*Order
if err := q.Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func (r *gormRepository) UpdateStatus(ctx context.Context, id uuid.UUID, status string) (*Order, error) {
res := r.db.WithContext(ctx).Model(&Order{}).Where("id = ?", id).Update("status", status)
if res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return nil, ErrNotFound
}
var order Order
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&order).Error; err != nil {
return nil, err
}
return &order, nil
}
+17
View File
@@ -0,0 +1,17 @@
package orders
import "github.com/gin-gonic/gin"
// RegisterPublicRoutes exposes order creation to any visitor, connected or
// not (spec section 17-18). rateLimit throttles it to blunt spam/abuse
// since no authentication gates this endpoint.
func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler, rateLimit gin.HandlerFunc) {
rg.POST("/orders", rateLimit, h.Create)
}
func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
group := rg.Group("/admin/orders", requireAdmin)
group.GET("", h.ListAdmin)
group.GET("/:id", h.GetAdmin)
group.PATCH("/:id/status", h.UpdateStatus)
}
+171
View File
@@ -0,0 +1,171 @@
package orders
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"backend/internal/modules/pricing"
"backend/internal/modules/products"
"backend/internal/modules/units"
)
var (
ErrEmptyOrder = errors.New("order must contain at least one item")
ErrInvalidStatus = errors.New("invalid order status")
ErrProductMismatch = errors.New("price tier does not belong to the requested product")
)
// The dependencies below are the minimal slices of other modules' services
// this module needs, defined on the consumer side (Go idiom) so orders
// never has to import their concrete handler/repository types.
type ProductFinder interface {
Get(ctx context.Context, id uuid.UUID) (*products.Product, error)
}
type UnitFinder interface {
Get(ctx context.Context, id uuid.UUID) (*units.Unit, error)
}
type PriceResolver interface {
PriceForQuantity(ctx context.Context, tierID uuid.UUID, multiplier int64) (int64, *pricing.PriceTier, error)
}
// OrderNotifier is satisfied by the telegram module's Service (and, later,
// any other notification channel) without orders ever depending on it
// directly.
type OrderNotifier interface {
NotifyNewOrder(ctx context.Context, summary string)
NotifyOrderStatusChange(ctx context.Context, summary string)
}
type Service struct {
repo Repository
products ProductFinder
units UnitFinder
prices PriceResolver
notifier OrderNotifier
}
func NewService(repo Repository, products ProductFinder, units UnitFinder, prices PriceResolver, notifier OrderNotifier) *Service {
return &Service{repo: repo, products: products, units: units, prices: prices, notifier: notifier}
}
type ItemInput struct {
ProductID uuid.UUID
PriceTierID uuid.UUID
Multiplier int64
}
type CreateInput struct {
CustomerName string
CustomerEmail string
CustomerPhone string
Notes string
Items []ItemInput
}
func (s *Service) Create(ctx context.Context, in CreateInput) (*Order, []*OrderItem, error) {
if len(in.Items) == 0 {
return nil, nil, ErrEmptyOrder
}
order := &Order{
ID: uuid.New(),
CustomerName: in.CustomerName,
CustomerEmail: in.CustomerEmail,
CustomerPhone: in.CustomerPhone,
Status: StatusPending,
Notes: in.Notes,
}
items := make([]*OrderItem, 0, len(in.Items))
var total int64
for _, in := range in.Items {
multiplier := in.Multiplier
if multiplier < 1 {
multiplier = 1
}
lineTotal, tier, err := s.prices.PriceForQuantity(ctx, in.PriceTierID, multiplier)
if err != nil {
return nil, nil, fmt.Errorf("resolve price tier %s: %w", in.PriceTierID, err)
}
if tier.ProductID != in.ProductID {
return nil, nil, ErrProductMismatch
}
product, err := s.products.Get(ctx, in.ProductID)
if err != nil {
return nil, nil, fmt.Errorf("resolve product %s: %w", in.ProductID, err)
}
unit, err := s.units.Get(ctx, tier.UnitID)
if err != nil {
return nil, nil, fmt.Errorf("resolve unit %s: %w", tier.UnitID, err)
}
items = append(items, &OrderItem{
ID: uuid.New(),
ProductID: &product.ID,
ProductName: product.Name,
PriceTierID: &tier.ID,
UnitSymbol: unit.Symbol,
TierQuantity: tier.Quantity,
Multiplier: multiplier,
UnitPriceCents: tier.PriceCents,
TotalCents: lineTotal,
})
total += lineTotal
}
order.TotalCents = total
if err := s.repo.Create(ctx, order, items); err != nil {
return nil, nil, err
}
s.notifier.NotifyNewOrder(context.Background(), summarizeNewOrder(order, items))
return order, items, nil
}
func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Order, []*OrderItem, error) {
return s.repo.FindByID(ctx, id)
}
func (s *Service) List(ctx context.Context, status string) ([]*Order, error) {
return s.repo.List(ctx, status)
}
func (s *Service) UpdateStatus(ctx context.Context, id uuid.UUID, status string) (*Order, error) {
if !ValidStatuses[status] {
return nil, ErrInvalidStatus
}
order, err := s.repo.UpdateStatus(ctx, id, status)
if err != nil {
return nil, err
}
s.notifier.NotifyOrderStatusChange(context.Background(), fmt.Sprintf(
"Order %s status changed to %q (customer: %s)", order.ID, order.Status, order.CustomerName,
))
return order, nil
}
func summarizeNewOrder(order *Order, items []*OrderItem) string {
var b strings.Builder
fmt.Fprintf(&b, "New order from %s (%s)\n", order.CustomerName, order.CustomerEmail)
for _, item := range items {
fmt.Fprintf(&b, "- %dx %s (%.2f %s) = %.2f\n",
item.Multiplier, item.ProductName, item.TierQuantity, item.UnitSymbol, centsToUnits(item.TotalCents))
}
fmt.Fprintf(&b, "Total: %.2f", centsToUnits(order.TotalCents))
return b.String()
}
func centsToUnits(cents int64) float64 {
return float64(cents) / 100
}