saveinmed/backend/internal/http/handler/product_handler.go
Tiago Yamamoto 12e2503244 feat: implement invisible 12% buyer fee
Business model:
- Seller registers R$10,00 → Seller sees R$10,00 → Seller receives R$10,00
- Buyer searches → sees R$11,20 (+12%) → pays R$11,20
- Marketplace keeps R$1,20 (12%)

Changes:
- config.go: Add BuyerFeeRate (default 0.12)
- handler.go: Add buyerFeeRate field to Handler struct
- product_handler.go: SearchProducts inflates prices for buyers
- server.go: Pass cfg.BuyerFeeRate to handler.New()
- handler_test.go: Fix 3 New() calls with fee rate

Env var: BUYER_FEE_RATE (default: 0.12)
2025-12-26 23:23:18 -03:00

327 lines
9.1 KiB
Go

package handler
import (
"errors"
"net/http"
"strconv"
"time"
"github.com/saveinmed/backend-go/internal/domain"
"github.com/saveinmed/backend-go/internal/http/middleware"
)
// CreateProduct godoc
// @Summary Cadastro de produto com rastreabilidade de lote
// @Tags Produtos
// @Accept json
// @Produce json
// @Param product body registerProductRequest true "Produto"
// @Success 201 {object} domain.Product
// @Router /api/v1/products [post]
func (h *Handler) CreateProduct(w http.ResponseWriter, r *http.Request) {
var req registerProductRequest
if err := decodeJSON(r.Context(), r, &req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
product := &domain.Product{
SellerID: req.SellerID,
Name: req.Name,
Description: req.Description,
Batch: req.Batch,
ExpiresAt: req.ExpiresAt,
PriceCents: req.PriceCents,
Stock: req.Stock,
}
if err := h.svc.RegisterProduct(r.Context(), product); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, product)
}
// ListProducts godoc
// @Summary Lista catálogo com lote e validade
// @Tags Produtos
// @Produce json
// @Success 200 {array} domain.Product
// @Router /api/v1/products [get]
func (h *Handler) ListProducts(w http.ResponseWriter, r *http.Request) {
page, pageSize := parsePagination(r)
filter := domain.ProductFilter{
Search: r.URL.Query().Get("search"),
}
result, err := h.svc.ListProducts(r.Context(), filter, page, pageSize)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, result)
}
// SearchProducts godoc
// @Summary Busca avançada de produtos com filtros e distância
// @Description Retorna produtos ordenados por validade, com distância aproximada. Vendedor anônimo até checkout.
// @Tags Produtos
// @Produce json
// @Param search query string false "Termo de busca"
// @Param min_price query integer false "Preço mínimo em centavos"
// @Param max_price query integer false "Preço máximo em centavos"
// @Param max_distance query number false "Distância máxima em km"
// @Param lat query number true "Latitude do comprador"
// @Param lng query number true "Longitude do comprador"
// @Param page query integer false "Página"
// @Param page_size query integer false "Itens por página"
// @Success 200 {object} domain.ProductSearchPage
// @Router /api/v1/products/search [get]
func (h *Handler) SearchProducts(w http.ResponseWriter, r *http.Request) {
page, pageSize := parsePagination(r)
filter := domain.ProductSearchFilter{
Search: r.URL.Query().Get("search"),
}
// Parse buyer location (required)
latStr := r.URL.Query().Get("lat")
lngStr := r.URL.Query().Get("lng")
if latStr == "" || lngStr == "" {
writeError(w, http.StatusBadRequest, errors.New("lat and lng query params are required"))
return
}
lat, err := strconv.ParseFloat(latStr, 64)
if err != nil {
writeError(w, http.StatusBadRequest, errors.New("invalid lat value"))
return
}
lng, err := strconv.ParseFloat(lngStr, 64)
if err != nil {
writeError(w, http.StatusBadRequest, errors.New("invalid lng value"))
return
}
filter.BuyerLat = lat
filter.BuyerLng = lng
// Parse optional price filters
if v := r.URL.Query().Get("min_price"); v != "" {
if price, err := strconv.ParseInt(v, 10, 64); err == nil {
filter.MinPriceCents = &price
}
}
if v := r.URL.Query().Get("max_price"); v != "" {
if price, err := strconv.ParseInt(v, 10, 64); err == nil {
filter.MaxPriceCents = &price
}
}
// Parse optional max distance
if v := r.URL.Query().Get("max_distance"); v != "" {
if dist, err := strconv.ParseFloat(v, 64); err == nil {
filter.MaxDistanceKm = &dist
}
}
// Parse optional expiration filter
if v := r.URL.Query().Get("expires_before"); v != "" {
if days, err := strconv.Atoi(v); err == nil && days > 0 {
expires := time.Now().AddDate(0, 0, days)
filter.ExpiresBefore = &expires
}
}
// Exclude products from the buyer's own company
if claims, ok := middleware.GetClaims(r.Context()); ok && claims.CompanyID != nil {
filter.ExcludeSellerID = claims.CompanyID
}
result, err := h.svc.SearchProducts(r.Context(), filter, page, pageSize)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
// Apply invisible buyer fee: inflate prices by buyerFeeRate (e.g., 12%)
// The buyer sees inflated prices, but the DB stores the original seller price
if h.buyerFeeRate > 0 {
for i := range result.Products {
originalPrice := result.Products[i].PriceCents
inflatedPrice := int64(float64(originalPrice) * (1 + h.buyerFeeRate))
result.Products[i].PriceCents = inflatedPrice
}
}
writeJSON(w, http.StatusOK, result)
}
// GetProduct godoc
// @Summary Obter produto
// @Tags Produtos
// @Produce json
// @Param id path string true "Product ID"
// @Success 200 {object} domain.Product
// @Failure 404 {object} map[string]string
// @Router /api/v1/products/{id} [get]
func (h *Handler) GetProduct(w http.ResponseWriter, r *http.Request) {
id, err := parseUUIDFromPath(r.URL.Path)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
product, err := h.svc.GetProduct(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, product)
}
// UpdateProduct godoc
// @Summary Atualizar produto
// @Tags Produtos
// @Accept json
// @Produce json
// @Param id path string true "Product ID"
// @Param payload body updateProductRequest true "Campos para atualização"
// @Success 200 {object} domain.Product
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/products/{id} [patch]
func (h *Handler) UpdateProduct(w http.ResponseWriter, r *http.Request) {
id, err := parseUUIDFromPath(r.URL.Path)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
var req updateProductRequest
if err := decodeJSON(r.Context(), r, &req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
product, err := h.svc.GetProduct(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
if req.SellerID != nil {
product.SellerID = *req.SellerID
}
if req.Name != nil {
product.Name = *req.Name
}
if req.Description != nil {
product.Description = *req.Description
}
if req.Batch != nil {
product.Batch = *req.Batch
}
if req.ExpiresAt != nil {
product.ExpiresAt = *req.ExpiresAt
}
if req.PriceCents != nil {
product.PriceCents = *req.PriceCents
}
if req.Stock != nil {
product.Stock = *req.Stock
}
if err := h.svc.UpdateProduct(r.Context(), product); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, product)
}
// DeleteProduct godoc
// @Summary Remover produto
// @Tags Produtos
// @Param id path string true "Product ID"
// @Success 204 ""
// @Failure 400 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/products/{id} [delete]
func (h *Handler) DeleteProduct(w http.ResponseWriter, r *http.Request) {
id, err := parseUUIDFromPath(r.URL.Path)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if err := h.svc.DeleteProduct(r.Context(), id); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ListInventory godoc
// @Summary Listar estoque
// @Tags Estoque
// @Security BearerAuth
// @Produce json
// @Param expires_in_days query int false "Dias para expiração"
// @Success 200 {array} domain.InventoryItem
// @Router /api/v1/inventory [get]
// ListInventory exposes stock with expiring batch filters.
func (h *Handler) ListInventory(w http.ResponseWriter, r *http.Request) {
page, pageSize := parsePagination(r)
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
}
result, err := h.svc.ListInventory(r.Context(), filter, page, pageSize)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, result)
}
// AdjustInventory godoc
// @Summary Ajustar estoque
// @Tags Estoque
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param payload body inventoryAdjustRequest true "Ajuste de estoque"
// @Success 200 {object} domain.InventoryItem
// @Failure 400 {object} map[string]string
// @Router /api/v1/inventory/adjust [post]
// 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)
}