Merge pull request #8 from rede5/codex/implement-inventory-control-routes
Add inventory and cart endpoints
This commit is contained in:
commit
94c27ec7dc
5 changed files with 439 additions and 1 deletions
|
|
@ -59,6 +59,32 @@ type Product struct {
|
||||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
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.
|
// Order captures the status lifecycle and payment intent.
|
||||||
type Order struct {
|
type Order struct {
|
||||||
ID uuid.UUID `db:"id" json:"id"`
|
ID uuid.UUID `db:"id" json:"id"`
|
||||||
|
|
@ -101,3 +127,26 @@ const (
|
||||||
OrderStatusInvoiced OrderStatus = "Faturado"
|
OrderStatusInvoiced OrderStatus = "Faturado"
|
||||||
OrderStatusDelivered OrderStatus = "Entregue"
|
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"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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{
|
user := &domain.User{
|
||||||
CompanyID: req.CompanyID,
|
CompanyID: companyID,
|
||||||
Role: req.Role,
|
Role: req.Role,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
|
|
@ -222,6 +227,50 @@ func (h *Handler) ListProducts(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, products)
|
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
|
// CreateOrder godoc
|
||||||
// @Summary Criação de pedido com split
|
// @Summary Criação de pedido com split
|
||||||
// @Tags Pedidos
|
// @Tags Pedidos
|
||||||
|
|
@ -315,6 +364,68 @@ func (h *Handler) UpdateOrderStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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
|
// CreatePaymentPreference godoc
|
||||||
// @Summary Cria preferência de pagamento Mercado Pago com split nativo
|
// @Summary Cria preferência de pagamento Mercado Pago com split nativo
|
||||||
// @Tags Pagamentos
|
// @Tags Pagamentos
|
||||||
|
|
@ -579,6 +690,17 @@ type authResponse struct {
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
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 {
|
type updateUserRequest struct {
|
||||||
CompanyID *uuid.UUID `json:"company_id,omitempty"`
|
CompanyID *uuid.UUID `json:"company_id,omitempty"`
|
||||||
Role *string `json:"role,omitempty"`
|
Role *string `json:"role,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,15 @@ func (r *Repository) ListProducts(ctx context.Context) ([]domain.Product, error)
|
||||||
return products, nil
|
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 {
|
func (r *Repository) CreateOrder(ctx context.Context, order *domain.Order) error {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
order.CreatedAt = now
|
order.CreatedAt = now
|
||||||
|
|
@ -158,6 +167,80 @@ func (r *Repository) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status
|
||||||
return nil
|
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 {
|
func (r *Repository) CreateUser(ctx context.Context, user *domain.User) error {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
user.CreatedAt = now
|
user.CreatedAt = now
|
||||||
|
|
@ -256,6 +339,67 @@ func (r *Repository) DeleteUser(ctx context.Context, id uuid.UUID) error {
|
||||||
return nil
|
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.
|
// InitSchema applies a minimal schema for development environments.
|
||||||
func (r *Repository) InitSchema(ctx context.Context) error {
|
func (r *Repository) InitSchema(ctx context.Context) error {
|
||||||
schema := `
|
schema := `
|
||||||
|
|
@ -294,6 +438,14 @@ CREATE TABLE IF NOT EXISTS products (
|
||||||
updated_at TIMESTAMPTZ NOT NULL
|
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 (
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
buyer_id UUID NOT NULL REFERENCES companies(id),
|
buyer_id UUID NOT NULL REFERENCES companies(id),
|
||||||
|
|
@ -313,6 +465,19 @@ CREATE TABLE IF NOT EXISTS order_items (
|
||||||
batch TEXT NOT NULL,
|
batch TEXT NOT NULL,
|
||||||
expires_at DATE 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 {
|
if _, err := r.db.ExecContext(ctx, schema); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -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("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/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("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("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))
|
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("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("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")))
|
mux.Handle("GET /swagger/", httpSwagger.Handler(httpSwagger.URL("/swagger/doc.json")))
|
||||||
|
|
||||||
return &Server{cfg: cfg, db: db, mux: mux}, nil
|
return &Server{cfg: cfg, db: db, mux: mux}, nil
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ type Repository interface {
|
||||||
|
|
||||||
CreateProduct(ctx context.Context, product *domain.Product) error
|
CreateProduct(ctx context.Context, product *domain.Product) error
|
||||||
ListProducts(ctx context.Context) ([]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
|
CreateOrder(ctx context.Context, order *domain.Order) error
|
||||||
GetOrder(ctx context.Context, id uuid.UUID) (*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)
|
GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
|
||||||
UpdateUser(ctx context.Context, user *domain.User) error
|
UpdateUser(ctx context.Context, user *domain.User) error
|
||||||
DeleteUser(ctx context.Context, id uuid.UUID) 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.
|
// PaymentGateway abstracts Mercado Pago integration.
|
||||||
|
|
@ -74,6 +81,14 @@ func (s *Service) ListProducts(ctx context.Context) ([]domain.Product, error) {
|
||||||
return s.repo.ListProducts(ctx)
|
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 {
|
func (s *Service) CreateOrder(ctx context.Context, order *domain.Order) error {
|
||||||
order.ID = uuid.Must(uuid.NewV7())
|
order.ID = uuid.Must(uuid.NewV7())
|
||||||
order.Status = domain.OrderStatusPending
|
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)
|
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.
|
// 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 {
|
func (s *Service) RegisterAccount(ctx context.Context, company *domain.Company, user *domain.User, password string) error {
|
||||||
if company != nil {
|
if company != nil {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue