From fd60888706e2a2a33b91b47776283b4f4bdd97a1 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Sat, 20 Dec 2025 10:47:37 -0300 Subject: [PATCH] Add shipping settings and calculation --- backend/internal/domain/shipping.go | 44 ++++ backend/internal/http/handler/dto.go | 25 +++ backend/internal/http/handler/handler_test.go | 29 +++ .../internal/http/handler/shipping_handler.go | 195 ++++++++++++++++++ .../migrations/0002_shipping_methods.sql | 16 ++ .../internal/repository/postgres/postgres.go | 52 +++++ backend/internal/server/server.go | 3 + backend/internal/usecase/usecase.go | 93 +++++++++ backend/internal/usecase/usecase_test.go | 29 +++ 9 files changed, 486 insertions(+) create mode 100644 backend/internal/domain/shipping.go create mode 100644 backend/internal/http/handler/shipping_handler.go create mode 100644 backend/internal/repository/postgres/migrations/0002_shipping_methods.sql diff --git a/backend/internal/domain/shipping.go b/backend/internal/domain/shipping.go new file mode 100644 index 0000000..442c488 --- /dev/null +++ b/backend/internal/domain/shipping.go @@ -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"` +} diff --git a/backend/internal/http/handler/dto.go b/backend/internal/http/handler/dto.go index 0a53f80..fd6dca6 100644 --- a/backend/internal/http/handler/dto.go +++ b/backend/internal/http/handler/dto.go @@ -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) { diff --git a/backend/internal/http/handler/handler_test.go b/backend/internal/http/handler/handler_test.go index ab29617..8285372 100644 --- a/backend/internal/http/handler/handler_test.go +++ b/backend/internal/http/handler/handler_test.go @@ -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{} diff --git a/backend/internal/http/handler/shipping_handler.go b/backend/internal/http/handler/shipping_handler.go new file mode 100644 index 0000000..ff2c1fd --- /dev/null +++ b/backend/internal/http/handler/shipping_handler.go @@ -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") + } +} diff --git a/backend/internal/repository/postgres/migrations/0002_shipping_methods.sql b/backend/internal/repository/postgres/migrations/0002_shipping_methods.sql new file mode 100644 index 0000000..001bb13 --- /dev/null +++ b/backend/internal/repository/postgres/migrations/0002_shipping_methods.sql @@ -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) +); diff --git a/backend/internal/repository/postgres/postgres.go b/backend/internal/repository/postgres/postgres.go index c37d657..c794d3b 100644 --- a/backend/internal/repository/postgres/postgres.go +++ b/backend/internal/repository/postgres/postgres.go @@ -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 +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 12af33f..3520a6c 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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"))) diff --git a/backend/internal/usecase/usecase.go b/backend/internal/usecase/usecase.go index 09187ca..faf2e71 100644 --- a/backend/internal/usecase/usecase.go +++ b/backend/internal/usecase/usecase.go @@ -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) } diff --git a/backend/internal/usecase/usecase_test.go b/backend/internal/usecase/usecase_test.go index c760674..0bcc77d 100644 --- a/backend/internal/usecase/usecase_test.go +++ b/backend/internal/usecase/usecase_test.go @@ -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{}