diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index 37958e1..41ac72d 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -59,6 +59,32 @@ type Product struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } +// InventoryItem exposes stock tracking tied to product batches. +type InventoryItem struct { + ProductID uuid.UUID `db:"product_id" json:"product_id"` + SellerID uuid.UUID `db:"seller_id" json:"seller_id"` + Name string `db:"name" json:"name"` + Batch string `db:"batch" json:"batch"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + Quantity int64 `db:"quantity" json:"quantity"` + PriceCents int64 `db:"price_cents" json:"price_cents"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +// InventoryFilter allows filtering by expiration window. +type InventoryFilter struct { + ExpiringBefore *time.Time +} + +// InventoryAdjustment records manual stock corrections. +type InventoryAdjustment struct { + ID uuid.UUID `db:"id" json:"id"` + ProductID uuid.UUID `db:"product_id" json:"product_id"` + Delta int64 `db:"delta" json:"delta"` + Reason string `db:"reason" json:"reason"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + // Order captures the status lifecycle and payment intent. type Order struct { ID uuid.UUID `db:"id" json:"id"` @@ -101,3 +127,26 @@ const ( OrderStatusInvoiced OrderStatus = "Faturado" OrderStatusDelivered OrderStatus = "Entregue" ) + +// CartItem stores buyer selections with unit pricing. +type CartItem struct { + ID uuid.UUID `db:"id" json:"id"` + BuyerID uuid.UUID `db:"buyer_id" json:"buyer_id"` + ProductID uuid.UUID `db:"product_id" json:"product_id"` + Quantity int64 `db:"quantity" json:"quantity"` + UnitCents int64 `db:"unit_cents" json:"unit_cents"` + ProductName string `db:"product_name" json:"product_name"` + Batch string `db:"batch" json:"batch"` + ExpiresAt time.Time `db:"expires_at" json:"expires_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +// CartSummary aggregates cart totals and discounts. +type CartSummary struct { + Items []CartItem `json:"items"` + SubtotalCents int64 `json:"subtotal_cents"` + DiscountCents int64 `json:"discount_cents"` + TotalCents int64 `json:"total_cents"` + DiscountReason string `json:"discount_reason,omitempty"` +} diff --git a/backend/internal/http/handler/handler.go b/backend/internal/http/handler/handler.go index bae39ce..1145f04 100644 --- a/backend/internal/http/handler/handler.go +++ b/backend/internal/http/handler/handler.go @@ -46,8 +46,13 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { } } + var companyID uuid.UUID + if req.CompanyID != nil { + companyID = *req.CompanyID + } + user := &domain.User{ - CompanyID: req.CompanyID, + CompanyID: companyID, Role: req.Role, Name: req.Name, Email: req.Email, @@ -222,6 +227,50 @@ func (h *Handler) ListProducts(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, products) } +// ListInventory exposes stock with expiring batch filters. +func (h *Handler) ListInventory(w http.ResponseWriter, r *http.Request) { + var filter domain.InventoryFilter + if days := r.URL.Query().Get("expires_in_days"); days != "" { + n, err := strconv.Atoi(days) + if err != nil || n < 0 { + writeError(w, http.StatusBadRequest, errors.New("invalid expires_in_days")) + return + } + expires := time.Now().Add(time.Duration(n) * 24 * time.Hour) + filter.ExpiringBefore = &expires + } + + inventory, err := h.svc.ListInventory(r.Context(), filter) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + writeJSON(w, http.StatusOK, inventory) +} + +// AdjustInventory handles manual stock corrections. +func (h *Handler) AdjustInventory(w http.ResponseWriter, r *http.Request) { + var req inventoryAdjustRequest + if err := decodeJSON(r.Context(), r, &req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + if req.Delta == 0 { + writeError(w, http.StatusBadRequest, errors.New("delta must be non-zero")) + return + } + + item, err := h.svc.AdjustInventory(r.Context(), req.ProductID, req.Delta, req.Reason) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + writeJSON(w, http.StatusOK, item) +} + // CreateOrder godoc // @Summary Criação de pedido com split // @Tags Pedidos @@ -315,6 +364,68 @@ func (h *Handler) UpdateOrderStatus(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// AddToCart appends an item to the authenticated buyer cart respecting stock. +func (h *Handler) AddToCart(w http.ResponseWriter, r *http.Request) { + claims, ok := middleware.GetClaims(r.Context()) + if !ok || claims.CompanyID == nil { + writeError(w, http.StatusBadRequest, errors.New("missing buyer context")) + return + } + + var req addCartItemRequest + if err := decodeJSON(r.Context(), r, &req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + summary, err := h.svc.AddItemToCart(r.Context(), *claims.CompanyID, req.ProductID, req.Quantity) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + writeJSON(w, http.StatusCreated, summary) +} + +// GetCart returns cart contents and totals for the authenticated buyer. +func (h *Handler) GetCart(w http.ResponseWriter, r *http.Request) { + claims, ok := middleware.GetClaims(r.Context()) + if !ok || claims.CompanyID == nil { + writeError(w, http.StatusBadRequest, errors.New("missing buyer context")) + return + } + + summary, err := h.svc.ListCart(r.Context(), *claims.CompanyID) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, summary) +} + +// DeleteCartItem removes a product from the cart and returns the updated totals. +func (h *Handler) DeleteCartItem(w http.ResponseWriter, r *http.Request) { + claims, ok := middleware.GetClaims(r.Context()) + if !ok || claims.CompanyID == nil { + writeError(w, http.StatusBadRequest, errors.New("missing buyer context")) + return + } + + id, err := parseUUIDFromPath(r.URL.Path) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + summary, err := h.svc.RemoveCartItem(r.Context(), *claims.CompanyID, id) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + writeJSON(w, http.StatusOK, summary) +} + // CreatePaymentPreference godoc // @Summary Cria preferência de pagamento Mercado Pago com split nativo // @Tags Pagamentos @@ -579,6 +690,17 @@ type authResponse struct { ExpiresAt time.Time `json:"expires_at"` } +type inventoryAdjustRequest struct { + ProductID uuid.UUID `json:"product_id"` + Delta int64 `json:"delta"` + Reason string `json:"reason"` +} + +type addCartItemRequest struct { + ProductID uuid.UUID `json:"product_id"` + Quantity int64 `json:"quantity"` +} + type updateUserRequest struct { CompanyID *uuid.UUID `json:"company_id,omitempty"` Role *string `json:"role,omitempty"` diff --git a/backend/internal/repository/postgres/postgres.go b/backend/internal/repository/postgres/postgres.go index 7dec15d..1247951 100644 --- a/backend/internal/repository/postgres/postgres.go +++ b/backend/internal/repository/postgres/postgres.go @@ -95,6 +95,15 @@ func (r *Repository) ListProducts(ctx context.Context) ([]domain.Product, error) return products, nil } +func (r *Repository) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) { + var product domain.Product + query := `SELECT id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at FROM products WHERE id = $1` + if err := r.db.GetContext(ctx, &product, query, id); err != nil { + return nil, err + } + return &product, nil +} + func (r *Repository) CreateOrder(ctx context.Context, order *domain.Order) error { now := time.Now().UTC() order.CreatedAt = now @@ -158,6 +167,80 @@ func (r *Repository) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status return nil } +func (r *Repository) AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error) { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + + var product domain.Product + if err := tx.GetContext(ctx, &product, `SELECT id, seller_id, name, batch, expires_at, price_cents, stock, updated_at FROM products WHERE id = $1 FOR UPDATE`, productID); err != nil { + _ = tx.Rollback() + return nil, err + } + + newStock := product.Stock + delta + if newStock < 0 { + _ = tx.Rollback() + return nil, errors.New("inventory cannot be negative") + } + + now := time.Now().UTC() + if _, err := tx.ExecContext(ctx, `UPDATE products SET stock = $1, updated_at = $2 WHERE id = $3`, newStock, now, productID); err != nil { + _ = tx.Rollback() + return nil, err + } + + adj := domain.InventoryAdjustment{ + ID: uuid.Must(uuid.NewV7()), + ProductID: productID, + Delta: delta, + Reason: reason, + CreatedAt: now, + } + + if _, err := tx.NamedExecContext(ctx, `INSERT INTO inventory_adjustments (id, product_id, delta, reason, created_at) VALUES (:id, :product_id, :delta, :reason, :created_at)`, &adj); err != nil { + _ = tx.Rollback() + return nil, err + } + + if err := tx.Commit(); err != nil { + return nil, err + } + + return &domain.InventoryItem{ + ProductID: productID, + SellerID: product.SellerID, + Name: product.Name, + Batch: product.Batch, + ExpiresAt: product.ExpiresAt, + Quantity: newStock, + PriceCents: product.PriceCents, + UpdatedAt: now, + }, nil +} + +func (r *Repository) ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, error) { + args := []any{} + clauses := []string{} + if filter.ExpiringBefore != nil { + clauses = append(clauses, fmt.Sprintf("expires_at <= $%d", len(args)+1)) + args = append(args, *filter.ExpiringBefore) + } + + where := "" + if len(clauses) > 0 { + where = " WHERE " + strings.Join(clauses, " AND ") + } + + query := fmt.Sprintf(`SELECT id AS product_id, seller_id, name, batch, expires_at, stock AS quantity, price_cents, updated_at FROM products%s ORDER BY expires_at ASC`, where) + var items []domain.InventoryItem + if err := r.db.SelectContext(ctx, &items, query, args...); err != nil { + return nil, err + } + return items, nil +} + func (r *Repository) CreateUser(ctx context.Context, user *domain.User) error { now := time.Now().UTC() user.CreatedAt = now @@ -256,6 +339,67 @@ func (r *Repository) DeleteUser(ctx context.Context, id uuid.UUID) error { return nil } +func (r *Repository) AddCartItem(ctx context.Context, item *domain.CartItem) (*domain.CartItem, error) { + now := time.Now().UTC() + item.CreatedAt = now + item.UpdatedAt = now + + query := `INSERT INTO cart_items (id, buyer_id, product_id, quantity, unit_cents, batch, expires_at, created_at, updated_at) +VALUES (:id, :buyer_id, :product_id, :quantity, :unit_cents, :batch, :expires_at, :created_at, :updated_at) +ON CONFLICT (buyer_id, product_id) DO UPDATE +SET quantity = cart_items.quantity + EXCLUDED.quantity, + unit_cents = EXCLUDED.unit_cents, + batch = EXCLUDED.batch, + expires_at = EXCLUDED.expires_at, + updated_at = EXCLUDED.updated_at +RETURNING id, buyer_id, product_id, quantity, unit_cents, batch, expires_at, created_at, updated_at` + + var saved domain.CartItem + rows, err := r.db.NamedQueryContext(ctx, query, item) + if err != nil { + return nil, err + } + defer rows.Close() + + if rows.Next() { + if err := rows.StructScan(&saved); err != nil { + return nil, err + } + return &saved, nil + } + return nil, errors.New("failed to persist cart item") +} + +func (r *Repository) ListCartItems(ctx context.Context, buyerID uuid.UUID) ([]domain.CartItem, error) { + query := `SELECT ci.id, ci.buyer_id, ci.product_id, ci.quantity, ci.unit_cents, ci.batch, ci.expires_at, ci.created_at, ci.updated_at, + p.name AS product_name +FROM cart_items ci +JOIN products p ON p.id = ci.product_id +WHERE ci.buyer_id = $1 +ORDER BY ci.created_at DESC` + + var items []domain.CartItem + if err := r.db.SelectContext(ctx, &items, query, buyerID); err != nil { + return nil, err + } + return items, nil +} + +func (r *Repository) DeleteCartItem(ctx context.Context, id uuid.UUID, buyerID uuid.UUID) error { + res, err := r.db.ExecContext(ctx, "DELETE FROM cart_items WHERE id = $1 AND buyer_id = $2", id, buyerID) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return errors.New("cart item not found") + } + return nil +} + // InitSchema applies a minimal schema for development environments. func (r *Repository) InitSchema(ctx context.Context) error { schema := ` @@ -294,6 +438,14 @@ CREATE TABLE IF NOT EXISTS products ( updated_at TIMESTAMPTZ NOT NULL ); +CREATE TABLE IF NOT EXISTS inventory_adjustments ( + id UUID PRIMARY KEY, + product_id UUID NOT NULL REFERENCES products(id), + delta BIGINT NOT NULL, + reason TEXT, + created_at TIMESTAMPTZ NOT NULL +); + CREATE TABLE IF NOT EXISTS orders ( id UUID PRIMARY KEY, buyer_id UUID NOT NULL REFERENCES companies(id), @@ -313,6 +465,19 @@ CREATE TABLE IF NOT EXISTS order_items ( batch TEXT NOT NULL, expires_at DATE NOT NULL ); + +CREATE TABLE IF NOT EXISTS cart_items ( + id UUID PRIMARY KEY, + buyer_id UUID NOT NULL REFERENCES companies(id), + product_id UUID NOT NULL REFERENCES products(id), + quantity BIGINT NOT NULL, + unit_cents BIGINT NOT NULL, + batch TEXT, + expires_at DATE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE (buyer_id, product_id) +); ` if _, err := r.db.ExecContext(ctx, schema); err != nil { diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 1892cec..aba9603 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -70,6 +70,9 @@ func New(cfg config.Config) (*Server, error) { mux.Handle("POST /api/products", chain(http.HandlerFunc(h.CreateProduct), middleware.Logger, middleware.Gzip)) mux.Handle("GET /api/products", chain(http.HandlerFunc(h.ListProducts), middleware.Logger, middleware.Gzip)) + mux.Handle("GET /api/v1/inventory", chain(http.HandlerFunc(h.ListInventory), middleware.Logger, middleware.Gzip, auth)) + mux.Handle("POST /api/v1/inventory/adjust", chain(http.HandlerFunc(h.AdjustInventory), middleware.Logger, middleware.Gzip, auth)) + mux.Handle("POST /api/orders", chain(http.HandlerFunc(h.CreateOrder), middleware.Logger, middleware.Gzip, auth)) mux.Handle("GET /api/orders/", chain(http.HandlerFunc(h.GetOrder), middleware.Logger, middleware.Gzip, auth)) mux.Handle("PATCH /api/orders/", chain(http.HandlerFunc(h.UpdateOrderStatus), middleware.Logger, middleware.Gzip, auth)) @@ -84,6 +87,10 @@ func New(cfg config.Config) (*Server, error) { mux.Handle("PUT /api/v1/users/", chain(http.HandlerFunc(h.UpdateUser), middleware.Logger, middleware.Gzip, auth)) mux.Handle("DELETE /api/v1/users/", chain(http.HandlerFunc(h.DeleteUser), middleware.Logger, middleware.Gzip, auth)) + mux.Handle("POST /api/v1/cart", chain(http.HandlerFunc(h.AddToCart), middleware.Logger, middleware.Gzip, auth)) + mux.Handle("GET /api/v1/cart", chain(http.HandlerFunc(h.GetCart), middleware.Logger, middleware.Gzip, auth)) + mux.Handle("DELETE /api/v1/cart/", chain(http.HandlerFunc(h.DeleteCartItem), middleware.Logger, middleware.Gzip, auth)) + mux.Handle("GET /swagger/", httpSwagger.Handler(httpSwagger.URL("/swagger/doc.json"))) return &Server{cfg: cfg, db: db, mux: mux}, nil diff --git a/backend/internal/usecase/usecase.go b/backend/internal/usecase/usecase.go index b05247a..fb477d0 100644 --- a/backend/internal/usecase/usecase.go +++ b/backend/internal/usecase/usecase.go @@ -22,6 +22,9 @@ type Repository interface { CreateProduct(ctx context.Context, product *domain.Product) error ListProducts(ctx context.Context) ([]domain.Product, error) + GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) + AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error) + ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, error) CreateOrder(ctx context.Context, order *domain.Order) error GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) @@ -33,6 +36,10 @@ type Repository interface { GetUserByEmail(ctx context.Context, email string) (*domain.User, error) UpdateUser(ctx context.Context, user *domain.User) error DeleteUser(ctx context.Context, id uuid.UUID) error + + AddCartItem(ctx context.Context, item *domain.CartItem) (*domain.CartItem, error) + ListCartItems(ctx context.Context, buyerID uuid.UUID) ([]domain.CartItem, error) + DeleteCartItem(ctx context.Context, id uuid.UUID, buyerID uuid.UUID) error } // PaymentGateway abstracts Mercado Pago integration. @@ -74,6 +81,14 @@ func (s *Service) ListProducts(ctx context.Context) ([]domain.Product, error) { return s.repo.ListProducts(ctx) } +func (s *Service) ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, error) { + return s.repo.ListInventory(ctx, filter) +} + +func (s *Service) AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error) { + return s.repo.AdjustInventory(ctx, productID, delta, reason) +} + func (s *Service) CreateOrder(ctx context.Context, order *domain.Order) error { order.ID = uuid.Must(uuid.NewV7()) order.Status = domain.OrderStatusPending @@ -147,6 +162,86 @@ func (s *Service) DeleteUser(ctx context.Context, id uuid.UUID) error { return s.repo.DeleteUser(ctx, id) } +// AddItemToCart validates stock, persists the item and returns the refreshed summary. +func (s *Service) AddItemToCart(ctx context.Context, buyerID, productID uuid.UUID, quantity int64) (*domain.CartSummary, error) { + if quantity <= 0 { + return nil, errors.New("quantity must be greater than zero") + } + + product, err := s.repo.GetProduct(ctx, productID) + if err != nil { + return nil, err + } + + cartItems, err := s.repo.ListCartItems(ctx, buyerID) + if err != nil { + return nil, err + } + + var currentQty int64 + for _, it := range cartItems { + if it.ProductID == productID { + currentQty += it.Quantity + } + } + + if product.Stock < currentQty+quantity { + return nil, errors.New("insufficient stock for requested quantity") + } + + _, err = s.repo.AddCartItem(ctx, &domain.CartItem{ + ID: uuid.Must(uuid.NewV7()), + BuyerID: buyerID, + ProductID: productID, + Quantity: quantity, + UnitCents: product.PriceCents, + Batch: product.Batch, + ExpiresAt: product.ExpiresAt, + }) + if err != nil { + return nil, err + } + + return s.cartSummary(ctx, buyerID) +} + +// ListCart returns the current cart with B2B discounts applied. +func (s *Service) ListCart(ctx context.Context, buyerID uuid.UUID) (*domain.CartSummary, error) { + return s.cartSummary(ctx, buyerID) +} + +// RemoveCartItem deletes a cart row and returns the refreshed cart summary. +func (s *Service) RemoveCartItem(ctx context.Context, buyerID, cartItemID uuid.UUID) (*domain.CartSummary, error) { + if err := s.repo.DeleteCartItem(ctx, cartItemID, buyerID); err != nil { + return nil, err + } + return s.cartSummary(ctx, buyerID) +} + +func (s *Service) cartSummary(ctx context.Context, buyerID uuid.UUID) (*domain.CartSummary, error) { + items, err := s.repo.ListCartItems(ctx, buyerID) + if err != nil { + return nil, err + } + + var subtotal int64 + for i := range items { + subtotal += items[i].UnitCents * items[i].Quantity + } + + summary := &domain.CartSummary{ + Items: items, + SubtotalCents: subtotal, + } + + if subtotal >= 100000 { // apply 5% B2B discount for large baskets + summary.DiscountCents = int64(float64(subtotal) * 0.05) + summary.DiscountReason = "5% B2B volume discount" + } + summary.TotalCents = subtotal - summary.DiscountCents + return summary, nil +} + // RegisterAccount creates a company when needed and persists a user bound to it. func (s *Service) RegisterAccount(ctx context.Context, company *domain.Company, user *domain.User, password string) error { if company != nil {