Merge pull request #20 from rede5/codex/implement-logistics-and-delivery-module

Add shipping settings, calculation logic and API endpoints
This commit is contained in:
Tiago Yamamoto 2025-12-20 10:47:56 -03:00 committed by GitHub
commit 51ad574d72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 486 additions and 0 deletions

View file

@ -0,0 +1,44 @@
package domain
import (
"time"
"github.com/gofrs/uuid/v5"
)
// ShippingMethodType defines supported fulfillment modes.
type ShippingMethodType string
const (
ShippingMethodPickup ShippingMethodType = "pickup"
ShippingMethodOwnDelivery ShippingMethodType = "own_delivery"
ShippingMethodThirdParty ShippingMethodType = "third_party_delivery"
ShippingOptionTypePickup = "pickup"
ShippingOptionTypeDelivery = "delivery"
)
// ShippingMethod stores vendor configuration for pickup or delivery.
type ShippingMethod struct {
ID uuid.UUID `db:"id" json:"id"`
VendorID uuid.UUID `db:"vendor_id" json:"vendor_id"`
Type ShippingMethodType `db:"type" json:"type"`
Active bool `db:"active" json:"active"`
PreparationMinutes int `db:"preparation_minutes" json:"preparation_minutes"`
MaxRadiusKm float64 `db:"max_radius_km" json:"max_radius_km"`
MinFeeCents int64 `db:"min_fee_cents" json:"min_fee_cents"`
PricePerKmCents int64 `db:"price_per_km_cents" json:"price_per_km_cents"`
FreeShippingThresholdCents *int64 `db:"free_shipping_threshold_cents" json:"free_shipping_threshold_cents,omitempty"`
PickupAddress string `db:"pickup_address" json:"pickup_address"`
PickupHours string `db:"pickup_hours" json:"pickup_hours"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ShippingOption presents calculated options to the buyer.
type ShippingOption struct {
Type string `json:"type"`
ValueCents int64 `json:"value_cents"`
EstimatedMinutes int `json:"estimated_minutes"`
Description string `json:"description"`
DistanceKm float64 `json:"distance_km,omitempty"`
}

View file

@ -144,6 +144,31 @@ type updateStatusRequest struct {
Status string `json:"status"`
}
type shippingMethodRequest struct {
Type string `json:"type"`
Active bool `json:"active"`
PreparationMinutes int `json:"preparation_minutes"`
MaxRadiusKm float64 `json:"max_radius_km"`
MinFeeCents int64 `json:"min_fee_cents"`
PricePerKmCents int64 `json:"price_per_km_cents"`
FreeShippingThresholdCents *int64 `json:"free_shipping_threshold_cents,omitempty"`
PickupAddress string `json:"pickup_address,omitempty"`
PickupHours string `json:"pickup_hours,omitempty"`
}
type shippingSettingsRequest struct {
Methods []shippingMethodRequest `json:"methods"`
}
type shippingCalculateRequest struct {
VendorID uuid.UUID `json:"vendor_id"`
CartTotalCents int64 `json:"cart_total_cents"`
BuyerLatitude *float64 `json:"buyer_latitude,omitempty"`
BuyerLongitude *float64 `json:"buyer_longitude,omitempty"`
AddressID *uuid.UUID `json:"address_id,omitempty"`
PostalCode string `json:"postal_code,omitempty"`
}
// --- Utility Functions ---
func writeJSON(w http.ResponseWriter, status int, v any) {

View file

@ -21,6 +21,7 @@ type MockRepository struct {
products []domain.Product
users []domain.User
orders []domain.Order
shipping []domain.ShippingMethod
}
func NewMockRepository() *MockRepository {
@ -29,6 +30,7 @@ func NewMockRepository() *MockRepository {
products: make([]domain.Product, 0),
users: make([]domain.User, 0),
orders: make([]domain.Order, 0),
shipping: make([]domain.ShippingMethod, 0),
}
}
@ -232,6 +234,33 @@ func (m *MockRepository) AdminDashboard(ctx context.Context, since time.Time) (*
return &domain.AdminDashboard{}, nil
}
func (m *MockRepository) GetShippingMethodsByVendor(ctx context.Context, vendorID uuid.UUID) ([]domain.ShippingMethod, error) {
var methods []domain.ShippingMethod
for _, method := range m.shipping {
if method.VendorID == vendorID {
methods = append(methods, method)
}
}
return methods, nil
}
func (m *MockRepository) UpsertShippingMethods(ctx context.Context, methods []domain.ShippingMethod) error {
for _, method := range methods {
updated := false
for i, existing := range m.shipping {
if existing.VendorID == method.VendorID && existing.Type == method.Type {
m.shipping[i] = method
updated = true
break
}
}
if !updated {
m.shipping = append(m.shipping, method)
}
}
return nil
}
// MockPaymentGateway implements the PaymentGateway interface for testing
type MockPaymentGateway struct{}

View file

@ -0,0 +1,195 @@
package handler
import (
"errors"
"net/http"
"strings"
"github.com/gofrs/uuid/v5"
"github.com/saveinmed/backend-go/internal/domain"
)
// GetShippingSettings godoc
// @Summary Get vendor shipping settings
// @Description Returns pickup and delivery settings for a vendor.
// @Tags Shipping
// @Produce json
// @Param vendor_id path string true "Vendor ID"
// @Success 200 {array} domain.ShippingMethod
// @Failure 400 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/shipping/settings/{vendor_id} [get]
func (h *Handler) GetShippingSettings(w http.ResponseWriter, r *http.Request) {
vendorID, err := parseUUIDFromPath(r.URL.Path)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
requester, err := getRequester(r)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if !strings.EqualFold(requester.Role, "Admin") {
if requester.CompanyID == nil || *requester.CompanyID != vendorID {
writeError(w, http.StatusForbidden, errors.New("not allowed to view shipping settings"))
return
}
}
methods, err := h.svc.GetShippingMethods(r.Context(), vendorID)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, methods)
}
// UpsertShippingSettings godoc
// @Summary Update vendor shipping settings
// @Description Stores pickup and delivery settings for a vendor.
// @Tags Shipping
// @Accept json
// @Produce json
// @Param vendor_id path string true "Vendor ID"
// @Param payload body shippingSettingsRequest true "Shipping settings"
// @Success 200 {array} domain.ShippingMethod
// @Failure 400 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/shipping/settings/{vendor_id} [put]
func (h *Handler) UpsertShippingSettings(w http.ResponseWriter, r *http.Request) {
vendorID, err := parseUUIDFromPath(r.URL.Path)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
requester, err := getRequester(r)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if !strings.EqualFold(requester.Role, "Admin") {
if requester.CompanyID == nil || *requester.CompanyID != vendorID {
writeError(w, http.StatusForbidden, errors.New("not allowed to update shipping settings"))
return
}
}
var req shippingSettingsRequest
if err := decodeJSON(r.Context(), r, &req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if len(req.Methods) == 0 {
writeError(w, http.StatusBadRequest, errors.New("methods are required"))
return
}
methods := make([]domain.ShippingMethod, 0, len(req.Methods))
for _, method := range req.Methods {
methodType, err := parseShippingMethodType(method.Type)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if method.PreparationMinutes < 0 {
writeError(w, http.StatusBadRequest, errors.New("preparation_minutes must be >= 0"))
return
}
if methodType != domain.ShippingMethodPickup {
if method.MaxRadiusKm <= 0 {
writeError(w, http.StatusBadRequest, errors.New("max_radius_km must be > 0 for delivery methods"))
return
}
if method.MinFeeCents < 0 || method.PricePerKmCents < 0 {
writeError(w, http.StatusBadRequest, errors.New("delivery pricing must be >= 0"))
return
}
if method.FreeShippingThresholdCents != nil && *method.FreeShippingThresholdCents <= 0 {
writeError(w, http.StatusBadRequest, errors.New("free_shipping_threshold_cents must be > 0"))
return
}
}
if methodType == domain.ShippingMethodPickup && method.Active {
if strings.TrimSpace(method.PickupAddress) == "" || strings.TrimSpace(method.PickupHours) == "" {
writeError(w, http.StatusBadRequest, errors.New("pickup_address and pickup_hours are required for active pickup"))
return
}
}
methods = append(methods, domain.ShippingMethod{
Type: methodType,
Active: method.Active,
PreparationMinutes: method.PreparationMinutes,
MaxRadiusKm: method.MaxRadiusKm,
MinFeeCents: method.MinFeeCents,
PricePerKmCents: method.PricePerKmCents,
FreeShippingThresholdCents: method.FreeShippingThresholdCents,
PickupAddress: strings.TrimSpace(method.PickupAddress),
PickupHours: strings.TrimSpace(method.PickupHours),
})
}
updated, err := h.svc.UpsertShippingMethods(r.Context(), vendorID, methods)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, updated)
}
// CalculateShipping godoc
// @Summary Calculate shipping options
// @Description Calculates shipping or pickup options based on vendor config and buyer location.
// @Tags Shipping
// @Accept json
// @Produce json
// @Param payload body shippingCalculateRequest true "Calculation inputs"
// @Success 200 {array} domain.ShippingOption
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/shipping/calculate [post]
func (h *Handler) CalculateShipping(w http.ResponseWriter, r *http.Request) {
var req shippingCalculateRequest
if err := decodeJSON(r.Context(), r, &req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if req.VendorID == uuid.Nil {
writeError(w, http.StatusBadRequest, errors.New("vendor_id is required"))
return
}
if req.BuyerLatitude == nil || req.BuyerLongitude == nil {
if req.AddressID != nil || req.PostalCode != "" {
writeError(w, http.StatusBadRequest, errors.New("address_id or postal_code geocoding is not supported; provide buyer_latitude and buyer_longitude"))
return
}
writeError(w, http.StatusBadRequest, errors.New("buyer_latitude and buyer_longitude are required"))
return
}
options, err := h.svc.CalculateShippingOptions(r.Context(), req.VendorID, *req.BuyerLatitude, *req.BuyerLongitude, req.CartTotalCents)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, options)
}
func parseShippingMethodType(value string) (domain.ShippingMethodType, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case string(domain.ShippingMethodPickup):
return domain.ShippingMethodPickup, nil
case string(domain.ShippingMethodOwnDelivery):
return domain.ShippingMethodOwnDelivery, nil
case string(domain.ShippingMethodThirdParty):
return domain.ShippingMethodThirdParty, nil
default:
return "", errors.New("invalid shipping method type")
}
}

View file

@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS shipping_methods (
id UUID PRIMARY KEY,
vendor_id UUID NOT NULL REFERENCES companies(id),
type TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT FALSE,
preparation_minutes INT NOT NULL DEFAULT 0,
max_radius_km DOUBLE PRECISION NOT NULL DEFAULT 0,
min_fee_cents BIGINT NOT NULL DEFAULT 0,
price_per_km_cents BIGINT NOT NULL DEFAULT 0,
free_shipping_threshold_cents BIGINT,
pickup_address TEXT,
pickup_hours TEXT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
UNIQUE (vendor_id, type)
);

View file

@ -952,3 +952,55 @@ func (r *Repository) AdminDashboard(ctx context.Context, since time.Time) (*doma
return &domain.AdminDashboard{GMVCents: totalGMV, NewCompanies: newCompanies, WindowStartAt: since}, nil
}
func (r *Repository) GetShippingMethodsByVendor(ctx context.Context, vendorID uuid.UUID) ([]domain.ShippingMethod, error) {
query := `SELECT id, vendor_id, type, active, preparation_minutes, max_radius_km, min_fee_cents, price_per_km_cents, free_shipping_threshold_cents, pickup_address, pickup_hours, created_at, updated_at
FROM shipping_methods WHERE vendor_id = $1 ORDER BY type`
var methods []domain.ShippingMethod
if err := r.db.SelectContext(ctx, &methods, query, vendorID); err != nil {
return nil, err
}
return methods, nil
}
func (r *Repository) UpsertShippingMethods(ctx context.Context, methods []domain.ShippingMethod) error {
if len(methods) == 0 {
return nil
}
query := `INSERT INTO shipping_methods (id, vendor_id, type, active, preparation_minutes, max_radius_km, min_fee_cents, price_per_km_cents, free_shipping_threshold_cents, pickup_address, pickup_hours, created_at, updated_at)
VALUES (:id, :vendor_id, :type, :active, :preparation_minutes, :max_radius_km, :min_fee_cents, :price_per_km_cents, :free_shipping_threshold_cents, :pickup_address, :pickup_hours, :created_at, :updated_at)
ON CONFLICT (vendor_id, type) DO UPDATE
SET active = EXCLUDED.active,
preparation_minutes = EXCLUDED.preparation_minutes,
max_radius_km = EXCLUDED.max_radius_km,
min_fee_cents = EXCLUDED.min_fee_cents,
price_per_km_cents = EXCLUDED.price_per_km_cents,
free_shipping_threshold_cents = EXCLUDED.free_shipping_threshold_cents,
pickup_address = EXCLUDED.pickup_address,
pickup_hours = EXCLUDED.pickup_hours,
updated_at = EXCLUDED.updated_at`
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
now := time.Now().UTC()
for i := range methods {
methods[i].CreatedAt = now
methods[i].UpdatedAt = now
if _, err = tx.NamedExecContext(ctx, query, methods[i]); err != nil {
return err
}
}
if err = tx.Commit(); err != nil {
return err
}
return nil
}

View file

@ -110,6 +110,9 @@ func New(cfg config.Config) (*Server, error) {
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 /api/v1/shipping/settings/{vendor_id}", chain(http.HandlerFunc(h.GetShippingSettings), middleware.Logger, middleware.Gzip, auth))
mux.Handle("PUT /api/v1/shipping/settings/{vendor_id}", chain(http.HandlerFunc(h.UpsertShippingSettings), middleware.Logger, middleware.Gzip, auth))
mux.Handle("POST /api/v1/shipping/calculate", chain(http.HandlerFunc(h.CalculateShipping), middleware.Logger, middleware.Gzip))
mux.Handle("GET /docs/", httpSwagger.Handler(httpSwagger.URL("/docs/doc.json")))

View file

@ -3,6 +3,7 @@ package usecase
import (
"context"
"errors"
"math"
"strings"
"time"
@ -55,6 +56,8 @@ type Repository interface {
GetCompanyRating(ctx context.Context, companyID uuid.UUID) (*domain.CompanyRating, error)
SellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error)
AdminDashboard(ctx context.Context, since time.Time) (*domain.AdminDashboard, error)
GetShippingMethodsByVendor(ctx context.Context, vendorID uuid.UUID) ([]domain.ShippingMethod, error)
UpsertShippingMethods(ctx context.Context, methods []domain.ShippingMethod) error
}
// PaymentGateway abstracts Mercado Pago integration.
@ -108,6 +111,96 @@ func (s *Service) GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company
return s.repo.GetCompany(ctx, id)
}
func (s *Service) GetShippingMethods(ctx context.Context, vendorID uuid.UUID) ([]domain.ShippingMethod, error) {
return s.repo.GetShippingMethodsByVendor(ctx, vendorID)
}
func (s *Service) UpsertShippingMethods(ctx context.Context, vendorID uuid.UUID, methods []domain.ShippingMethod) ([]domain.ShippingMethod, error) {
if len(methods) == 0 {
return nil, errors.New("shipping methods are required")
}
for i := range methods {
if methods[i].Type == "" {
return nil, errors.New("shipping method type is required")
}
if methods[i].ID == uuid.Nil {
methods[i].ID = uuid.Must(uuid.NewV7())
}
methods[i].VendorID = vendorID
}
if err := s.repo.UpsertShippingMethods(ctx, methods); err != nil {
return nil, err
}
return s.repo.GetShippingMethodsByVendor(ctx, vendorID)
}
func (s *Service) CalculateShippingOptions(ctx context.Context, vendorID uuid.UUID, buyerLat, buyerLng float64, cartTotalCents int64) ([]domain.ShippingOption, error) {
company, err := s.repo.GetCompany(ctx, vendorID)
if err != nil {
return nil, err
}
if company == nil {
return nil, errors.New("vendor not found")
}
methods, err := s.repo.GetShippingMethodsByVendor(ctx, vendorID)
if err != nil {
return nil, err
}
distance := domain.HaversineDistance(company.Latitude, company.Longitude, buyerLat, buyerLng)
var options []domain.ShippingOption
for _, method := range methods {
if !method.Active {
continue
}
switch method.Type {
case domain.ShippingMethodPickup:
description := "Pickup at seller location"
if method.PickupAddress != "" {
description = "Pickup at " + method.PickupAddress
}
if method.PickupHours != "" {
description += " (" + method.PickupHours + ")"
}
options = append(options, domain.ShippingOption{
Type: domain.ShippingOptionTypePickup,
ValueCents: 0,
EstimatedMinutes: method.PreparationMinutes,
Description: description,
DistanceKm: distance,
})
case domain.ShippingMethodOwnDelivery, domain.ShippingMethodThirdParty:
if method.MaxRadiusKm > 0 && distance > method.MaxRadiusKm {
continue
}
variableCost := int64(math.Round(distance * float64(method.PricePerKmCents)))
price := method.MinFeeCents
if variableCost > price {
price = variableCost
}
if method.FreeShippingThresholdCents != nil && cartTotalCents >= *method.FreeShippingThresholdCents {
price = 0
}
estimatedMinutes := method.PreparationMinutes + int(math.Round(distance*5))
description := "Delivery via own fleet"
if method.Type == domain.ShippingMethodThirdParty {
description = "Delivery via third-party courier"
}
options = append(options, domain.ShippingOption{
Type: domain.ShippingOptionTypeDelivery,
ValueCents: price,
EstimatedMinutes: estimatedMinutes,
Description: description,
DistanceKm: distance,
})
}
}
return options, nil
}
func (s *Service) UpdateCompany(ctx context.Context, company *domain.Company) error {
return s.repo.UpdateCompany(ctx, company)
}

View file

@ -17,6 +17,7 @@ type MockRepository struct {
orders []domain.Order
cartItems []domain.CartItem
reviews []domain.Review
shipping []domain.ShippingMethod
}
func NewMockRepository() *MockRepository {
@ -27,6 +28,7 @@ func NewMockRepository() *MockRepository {
orders: make([]domain.Order, 0),
cartItems: make([]domain.CartItem, 0),
reviews: make([]domain.Review, 0),
shipping: make([]domain.ShippingMethod, 0),
}
}
@ -251,6 +253,33 @@ func (m *MockRepository) AdminDashboard(ctx context.Context, since time.Time) (*
return &domain.AdminDashboard{GMVCents: 1000000}, nil
}
func (m *MockRepository) GetShippingMethodsByVendor(ctx context.Context, vendorID uuid.UUID) ([]domain.ShippingMethod, error) {
var methods []domain.ShippingMethod
for _, method := range m.shipping {
if method.VendorID == vendorID {
methods = append(methods, method)
}
}
return methods, nil
}
func (m *MockRepository) UpsertShippingMethods(ctx context.Context, methods []domain.ShippingMethod) error {
for _, method := range methods {
updated := false
for i, existing := range m.shipping {
if existing.VendorID == method.VendorID && existing.Type == method.Type {
m.shipping[i] = method
updated = true
break
}
}
if !updated {
m.shipping = append(m.shipping, method)
}
}
return nil
}
// MockPaymentGateway for testing
type MockPaymentGateway struct{}