Tenant Model: - Renamed Company→Tenant (Company alias for compatibility) - Added: lat/lng, city, state, category - Updated: postgres, handlers, DTOs, schema SQL Seeder (cmd/seeder): - Generates 400 pharmacies in Anápolis/GO - 20-500 products per tenant - Haversine distance variation ±5km from center Product Search: - GET /products/search with advanced filters - Filters: price (min/max), expiration, distance - Haversine distance calculation (approx km) - Anonymous seller (only city/state shown until checkout) - Ordered by expiration date (nearest first) New domain types: - ProductWithDistance, ProductSearchFilter, ProductSearchPage - HaversineDistance function Updated tests for Category instead of Role
530 lines
17 KiB
Go
530 lines
17 KiB
Go
package usecase
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/gofrs/uuid/v5"
|
|
|
|
"github.com/saveinmed/backend-go/internal/domain"
|
|
)
|
|
|
|
// Repository defines DB contract for the core entities.
|
|
type Repository interface {
|
|
CreateCompany(ctx context.Context, company *domain.Company) error
|
|
ListCompanies(ctx context.Context, filter domain.CompanyFilter) ([]domain.Company, int64, error)
|
|
GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error)
|
|
UpdateCompany(ctx context.Context, company *domain.Company) error
|
|
DeleteCompany(ctx context.Context, id uuid.UUID) error
|
|
|
|
CreateProduct(ctx context.Context, product *domain.Product) error
|
|
ListProducts(ctx context.Context, filter domain.ProductFilter) ([]domain.Product, int64, error)
|
|
SearchProducts(ctx context.Context, filter domain.ProductSearchFilter) ([]domain.ProductWithDistance, int64, error)
|
|
GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error)
|
|
UpdateProduct(ctx context.Context, product *domain.Product) error
|
|
DeleteProduct(ctx context.Context, id uuid.UUID) error
|
|
AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error)
|
|
ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, int64, error)
|
|
|
|
CreateOrder(ctx context.Context, order *domain.Order) error
|
|
ListOrders(ctx context.Context, filter domain.OrderFilter) ([]domain.Order, int64, error)
|
|
|
|
GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error)
|
|
UpdateOrderStatus(ctx context.Context, id uuid.UUID, status domain.OrderStatus) error
|
|
DeleteOrder(ctx context.Context, id uuid.UUID) error
|
|
CreateShipment(ctx context.Context, shipment *domain.Shipment) error
|
|
GetShipmentByOrderID(ctx context.Context, orderID uuid.UUID) (*domain.Shipment, error)
|
|
|
|
CreateUser(ctx context.Context, user *domain.User) error
|
|
ListUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error)
|
|
GetUser(ctx context.Context, id uuid.UUID) (*domain.User, error)
|
|
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
|
|
|
|
CreateReview(ctx context.Context, review *domain.Review) error
|
|
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)
|
|
}
|
|
|
|
// PaymentGateway abstracts Mercado Pago integration.
|
|
type PaymentGateway interface {
|
|
CreatePreference(ctx context.Context, order *domain.Order) (*domain.PaymentPreference, error)
|
|
}
|
|
|
|
type Service struct {
|
|
repo Repository
|
|
pay PaymentGateway
|
|
jwtSecret []byte
|
|
tokenTTL time.Duration
|
|
marketplaceCommission float64
|
|
passwordPepper string
|
|
}
|
|
|
|
// NewService wires use cases together.
|
|
func NewService(repo Repository, pay PaymentGateway, commissionPct float64, jwtSecret string, tokenTTL time.Duration, passwordPepper string) *Service {
|
|
return &Service{
|
|
repo: repo,
|
|
pay: pay,
|
|
jwtSecret: []byte(jwtSecret),
|
|
tokenTTL: tokenTTL,
|
|
marketplaceCommission: commissionPct,
|
|
passwordPepper: passwordPepper,
|
|
}
|
|
}
|
|
|
|
func (s *Service) RegisterCompany(ctx context.Context, company *domain.Company) error {
|
|
company.ID = uuid.Must(uuid.NewV7())
|
|
return s.repo.CreateCompany(ctx, company)
|
|
}
|
|
|
|
func (s *Service) ListCompanies(ctx context.Context, filter domain.CompanyFilter, page, pageSize int) (*domain.CompanyPage, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
filter.Limit = pageSize
|
|
filter.Offset = (page - 1) * pageSize
|
|
companies, total, err := s.repo.ListCompanies(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &domain.CompanyPage{Companies: companies, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
func (s *Service) GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error) {
|
|
return s.repo.GetCompany(ctx, id)
|
|
}
|
|
|
|
func (s *Service) UpdateCompany(ctx context.Context, company *domain.Company) error {
|
|
return s.repo.UpdateCompany(ctx, company)
|
|
}
|
|
|
|
func (s *Service) DeleteCompany(ctx context.Context, id uuid.UUID) error {
|
|
return s.repo.DeleteCompany(ctx, id)
|
|
}
|
|
|
|
func (s *Service) RegisterProduct(ctx context.Context, product *domain.Product) error {
|
|
product.ID = uuid.Must(uuid.NewV7())
|
|
return s.repo.CreateProduct(ctx, product)
|
|
}
|
|
|
|
func (s *Service) ListProducts(ctx context.Context, filter domain.ProductFilter, page, pageSize int) (*domain.ProductPage, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
filter.Limit = pageSize
|
|
filter.Offset = (page - 1) * pageSize
|
|
products, total, err := s.repo.ListProducts(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &domain.ProductPage{Products: products, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
// SearchProducts returns products with distance, ordered by expiration date.
|
|
// Seller info is anonymized until checkout.
|
|
func (s *Service) SearchProducts(ctx context.Context, filter domain.ProductSearchFilter, page, pageSize int) (*domain.ProductSearchPage, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
filter.Limit = pageSize
|
|
filter.Offset = (page - 1) * pageSize
|
|
products, total, err := s.repo.SearchProducts(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &domain.ProductSearchPage{Products: products, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
func (s *Service) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) {
|
|
return s.repo.GetProduct(ctx, id)
|
|
}
|
|
|
|
func (s *Service) UpdateProduct(ctx context.Context, product *domain.Product) error {
|
|
return s.repo.UpdateProduct(ctx, product)
|
|
}
|
|
|
|
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
|
|
return s.repo.DeleteProduct(ctx, id)
|
|
}
|
|
|
|
func (s *Service) ListInventory(ctx context.Context, filter domain.InventoryFilter, page, pageSize int) (*domain.InventoryPage, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
filter.Limit = pageSize
|
|
filter.Offset = (page - 1) * pageSize
|
|
items, total, err := s.repo.ListInventory(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &domain.InventoryPage{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
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
|
|
return s.repo.CreateOrder(ctx, order)
|
|
}
|
|
|
|
func (s *Service) ListOrders(ctx context.Context, filter domain.OrderFilter, page, pageSize int) (*domain.OrderPage, error) {
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
filter.Limit = pageSize
|
|
filter.Offset = (page - 1) * pageSize
|
|
orders, total, err := s.repo.ListOrders(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &domain.OrderPage{Orders: orders, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
func (s *Service) GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) {
|
|
return s.repo.GetOrder(ctx, id)
|
|
}
|
|
|
|
func (s *Service) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status domain.OrderStatus) error {
|
|
return s.repo.UpdateOrderStatus(ctx, id, status)
|
|
}
|
|
|
|
func (s *Service) DeleteOrder(ctx context.Context, id uuid.UUID) error {
|
|
return s.repo.DeleteOrder(ctx, id)
|
|
}
|
|
|
|
// CreateShipment persists a freight label for an order if not already present.
|
|
func (s *Service) CreateShipment(ctx context.Context, shipment *domain.Shipment) error {
|
|
if _, err := s.repo.GetOrder(ctx, shipment.OrderID); err != nil {
|
|
return err
|
|
}
|
|
|
|
shipment.ID = uuid.Must(uuid.NewV7())
|
|
shipment.Status = "Label gerada"
|
|
return s.repo.CreateShipment(ctx, shipment)
|
|
}
|
|
|
|
// GetShipmentByOrderID returns freight details for an order.
|
|
func (s *Service) GetShipmentByOrderID(ctx context.Context, orderID uuid.UUID) (*domain.Shipment, error) {
|
|
return s.repo.GetShipmentByOrderID(ctx, orderID)
|
|
}
|
|
|
|
func (s *Service) CreatePaymentPreference(ctx context.Context, id uuid.UUID) (*domain.PaymentPreference, error) {
|
|
order, err := s.repo.GetOrder(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.pay.CreatePreference(ctx, order)
|
|
}
|
|
|
|
// HandlePaymentWebhook processes Mercado Pago notifications ensuring split consistency.
|
|
func (s *Service) HandlePaymentWebhook(ctx context.Context, event domain.PaymentWebhookEvent) (*domain.PaymentSplitResult, error) {
|
|
order, err := s.repo.GetOrder(ctx, event.OrderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
expectedMarketplaceFee := int64(float64(order.TotalCents) * (s.marketplaceCommission / 100))
|
|
marketplaceFee := event.MarketplaceFee
|
|
if marketplaceFee == 0 {
|
|
marketplaceFee = expectedMarketplaceFee
|
|
}
|
|
|
|
sellerReceivable := order.TotalCents - marketplaceFee
|
|
if event.SellerAmount > 0 {
|
|
sellerReceivable = event.SellerAmount
|
|
}
|
|
|
|
if strings.EqualFold(event.Status, "approved") || strings.EqualFold(event.Status, "paid") {
|
|
if err := s.repo.UpdateOrderStatus(ctx, order.ID, domain.OrderStatusPaid); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return &domain.PaymentSplitResult{
|
|
OrderID: order.ID,
|
|
PaymentID: event.PaymentID,
|
|
Status: event.Status,
|
|
MarketplaceFee: marketplaceFee,
|
|
SellerReceivable: sellerReceivable,
|
|
TotalPaidAmount: event.TotalPaidAmount,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) CreateUser(ctx context.Context, user *domain.User, password string) error {
|
|
hashed, err := bcrypt.GenerateFromPassword([]byte(s.pepperPassword(password)), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
user.ID = uuid.Must(uuid.NewV7())
|
|
user.PasswordHash = string(hashed)
|
|
|
|
return s.repo.CreateUser(ctx, user)
|
|
}
|
|
|
|
func (s *Service) ListUsers(ctx context.Context, filter domain.UserFilter, page, pageSize int) (*domain.UserPage, error) {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if pageSize <= 0 {
|
|
pageSize = 20
|
|
}
|
|
|
|
filter.Limit = pageSize
|
|
filter.Offset = (page - 1) * pageSize
|
|
|
|
users, total, err := s.repo.ListUsers(ctx, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &domain.UserPage{Users: users, Total: total, Page: page, PageSize: pageSize}, nil
|
|
}
|
|
|
|
func (s *Service) GetUser(ctx context.Context, id uuid.UUID) (*domain.User, error) {
|
|
return s.repo.GetUser(ctx, id)
|
|
}
|
|
|
|
func (s *Service) UpdateUser(ctx context.Context, user *domain.User, newPassword string) error {
|
|
if newPassword != "" {
|
|
hashed, err := bcrypt.GenerateFromPassword([]byte(s.pepperPassword(newPassword)), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user.PasswordHash = string(hashed)
|
|
}
|
|
|
|
return s.repo.UpdateUser(ctx, user)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// CreateReview stores a buyer rating ensuring the order is delivered and owned by the requester.
|
|
func (s *Service) CreateReview(ctx context.Context, buyerID, orderID uuid.UUID, rating int, comment string) (*domain.Review, error) {
|
|
if rating < 1 || rating > 5 {
|
|
return nil, errors.New("rating must be between 1 and 5")
|
|
}
|
|
|
|
order, err := s.repo.GetOrder(ctx, orderID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if order.Status != domain.OrderStatusDelivered {
|
|
return nil, errors.New("only delivered orders can be reviewed")
|
|
}
|
|
|
|
if order.BuyerID != buyerID {
|
|
return nil, errors.New("order does not belong to buyer")
|
|
}
|
|
|
|
review := &domain.Review{
|
|
ID: uuid.Must(uuid.NewV7()),
|
|
OrderID: orderID,
|
|
BuyerID: buyerID,
|
|
SellerID: order.SellerID,
|
|
Rating: rating,
|
|
Comment: comment,
|
|
}
|
|
|
|
if err := s.repo.CreateReview(ctx, review); err != nil {
|
|
return nil, err
|
|
}
|
|
return review, nil
|
|
}
|
|
|
|
// GetCompanyRating returns the average rating for a seller or pharmacy.
|
|
func (s *Service) GetCompanyRating(ctx context.Context, companyID uuid.UUID) (*domain.CompanyRating, error) {
|
|
return s.repo.GetCompanyRating(ctx, companyID)
|
|
}
|
|
|
|
// GetSellerDashboard aggregates commercial KPIs for the seller.
|
|
func (s *Service) GetSellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error) {
|
|
return s.repo.SellerDashboard(ctx, sellerID)
|
|
}
|
|
|
|
// GetAdminDashboard exposes marketplace-wide metrics for the last 30 days.
|
|
func (s *Service) GetAdminDashboard(ctx context.Context) (*domain.AdminDashboard, error) {
|
|
since := time.Now().AddDate(0, 0, -30)
|
|
return s.repo.AdminDashboard(ctx, since)
|
|
}
|
|
|
|
// 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 {
|
|
if company.ID == uuid.Nil {
|
|
company.ID = uuid.Must(uuid.NewV7())
|
|
company.IsVerified = false
|
|
if err := s.repo.CreateCompany(ctx, company); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if _, err := s.repo.GetCompany(ctx, company.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
user.CompanyID = company.ID
|
|
}
|
|
|
|
return s.CreateUser(ctx, user, password)
|
|
}
|
|
|
|
// Authenticate validates credentials and emits a signed JWT.
|
|
func (s *Service) Authenticate(ctx context.Context, email, password string) (string, time.Time, error) {
|
|
user, err := s.repo.GetUserByEmail(ctx, email)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(s.pepperPassword(password))); err != nil {
|
|
return "", time.Time{}, errors.New("invalid credentials")
|
|
}
|
|
|
|
expiresAt := time.Now().Add(s.tokenTTL)
|
|
claims := jwt.MapClaims{
|
|
"sub": user.ID.String(),
|
|
"role": user.Role,
|
|
"company_id": user.CompanyID.String(),
|
|
"exp": expiresAt.Unix(),
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
signed, err := token.SignedString(s.jwtSecret)
|
|
if err != nil {
|
|
return "", time.Time{}, err
|
|
}
|
|
|
|
return signed, expiresAt, nil
|
|
}
|
|
|
|
func (s *Service) pepperPassword(password string) string {
|
|
if s.passwordPepper == "" {
|
|
return password
|
|
}
|
|
return password + s.passwordPepper
|
|
}
|
|
|
|
// VerifyCompany marks a company as verified.
|
|
func (s *Service) VerifyCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error) {
|
|
company, err := s.repo.GetCompany(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
company.IsVerified = true
|
|
|
|
if err := s.repo.UpdateCompany(ctx, company); err != nil {
|
|
return nil, err
|
|
}
|
|
return company, nil
|
|
}
|