package usecase import ( "context" "errors" "fmt" "math" "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" "github.com/saveinmed/backend-go/internal/infrastructure/mapbox" "github.com/saveinmed/backend-go/internal/notifications" ) // 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 BatchCreateProducts(ctx context.Context, products []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) ListRecords(ctx context.Context, filter domain.RecordSearchFilter) ([]domain.Product, 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) CreateInventoryItem(ctx context.Context, item *domain.InventoryItem) error GetInventoryItem(ctx context.Context, id uuid.UUID) (*domain.InventoryItem, error) UpdateInventoryItem(ctx context.Context, item *domain.InventoryItem) 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) GetUserByUsername(ctx context.Context, username string) (*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 DeleteCartItemByProduct(ctx context.Context, buyerID, productID uuid.UUID) error ClearCart(ctx context.Context, 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) GetShippingSettings(ctx context.Context, vendorID uuid.UUID) (*domain.ShippingSettings, error) UpsertShippingSettings(ctx context.Context, settings *domain.ShippingSettings) error ListReviews(ctx context.Context, filter domain.ReviewFilter) ([]domain.Review, int64, error) ListShipments(ctx context.Context, filter domain.ShipmentFilter) ([]domain.Shipment, int64, error) // Financials CreateDocument(ctx context.Context, doc *domain.CompanyDocument) error ListDocuments(ctx context.Context, companyID uuid.UUID) ([]domain.CompanyDocument, error) RecordLedgerEntry(ctx context.Context, entry *domain.LedgerEntry) error GetLedger(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]domain.LedgerEntry, int64, error) GetBalance(ctx context.Context, companyID uuid.UUID) (int64, error) CreateWithdrawal(ctx context.Context, withdrawal *domain.Withdrawal) error ListWithdrawals(ctx context.Context, companyID uuid.UUID) ([]domain.Withdrawal, error) // Payment Configuration GetPaymentGatewayConfig(ctx context.Context, provider string) (*domain.PaymentGatewayConfig, error) UpsertPaymentGatewayConfig(ctx context.Context, config *domain.PaymentGatewayConfig) error GetSellerPaymentAccount(ctx context.Context, sellerID uuid.UUID) (*domain.SellerPaymentAccount, error) UpsertSellerPaymentAccount(ctx context.Context, account *domain.SellerPaymentAccount) error CreateAddress(ctx context.Context, address *domain.Address) error ListAddresses(ctx context.Context, entityID uuid.UUID) ([]domain.Address, error) GetAddress(ctx context.Context, id uuid.UUID) (*domain.Address, error) UpdateAddress(ctx context.Context, address *domain.Address) error DeleteAddress(ctx context.Context, id uuid.UUID) error ListManufacturers(ctx context.Context) ([]string, error) ListCategories(ctx context.Context) ([]string, error) GetProductByEAN(ctx context.Context, ean string) (*domain.Product, error) ReplaceCart(ctx context.Context, buyerID uuid.UUID, items []domain.CartItem) error UpdateOrderItems(ctx context.Context, orderID uuid.UUID, items []domain.OrderItem, totalCents int64) 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 mapbox *mapbox.Client notify notifications.NotificationService marketplaceCommission float64 buyerFeeRate float64 jwtSecret []byte tokenTTL time.Duration passwordPepper string } const ( passwordResetTTL = 30 * time.Minute ) // NewService wires use cases together. func NewService(repo Repository, pg PaymentGateway, mapbox *mapbox.Client, notify notifications.NotificationService, commission, buyerFeeRate float64, jwtSecret string, tokenTTL time.Duration, pepper string) *Service { return &Service{ repo: repo, pay: pg, mapbox: mapbox, notify: notify, marketplaceCommission: commission, buyerFeeRate: buyerFeeRate, jwtSecret: []byte(jwtSecret), tokenTTL: tokenTTL, passwordPepper: pepper, } } // GetNotificationService returns the notification service for push handlers func (s *Service) GetNotificationService() notifications.NotificationService { return s.notify } 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) 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") } settings, err := s.repo.GetShippingSettings(ctx, vendorID) if err != nil { // Just return empty options if settings not found return []domain.ShippingOption{}, nil } if settings == nil { return []domain.ShippingOption{}, nil } distance := domain.HaversineDistance(company.Latitude, company.Longitude, buyerLat, buyerLng) var options []domain.ShippingOption // 1. Delivery if settings.Active { if settings.MaxRadiusKm > 0 && distance <= settings.MaxRadiusKm { variableCost := int64(math.Round(distance * float64(settings.PricePerKmCents))) price := settings.MinFeeCents if variableCost > price { price = variableCost } if settings.FreeShippingThresholdCents != nil && *settings.FreeShippingThresholdCents > 0 && cartTotalCents >= *settings.FreeShippingThresholdCents { price = 0 } // Estimate: 30 mins base + 5 mins/km default estMins := 30 + int(math.Round(distance*5)) options = append(options, domain.ShippingOption{ Type: domain.ShippingOptionTypeDelivery, ValueCents: price, EstimatedMinutes: estMins, Description: "Entrega PrĂ³pria", DistanceKm: distance, }) } } // 2. Pickup if settings.PickupActive { desc := "Retirada na loja" if settings.PickupAddress != "" { desc = "Retirada em: " + settings.PickupAddress } if settings.PickupHours != "" { desc += " (" + settings.PickupHours + ")" } options = append(options, domain.ShippingOption{ Type: domain.ShippingOptionTypePickup, ValueCents: 0, EstimatedMinutes: 60, // Default 1 hour readily available Description: desc, DistanceKm: distance, }) } return options, nil } 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 } // ListRecords provides an advanced search for marketplace listings. func (s *Service) ListRecords(ctx context.Context, req domain.SearchRequest) (*domain.PaginationResponse[domain.Product], error) { page := req.Page pageSize := req.PageSize if pageSize <= 0 { pageSize = 20 } if page <= 0 { page = 1 } sortBy := strings.TrimSpace(req.SortBy) if sortBy == "" { sortBy = "updated_at" } sortOrder := strings.ToLower(strings.TrimSpace(req.SortOrder)) if sortOrder == "" { sortOrder = "desc" } filter := domain.RecordSearchFilter{ Query: strings.TrimSpace(req.Query), SortBy: sortBy, SortOrder: sortOrder, CreatedAfter: req.CreatedAfter, CreatedBefore: req.CreatedBefore, Limit: pageSize, Offset: (page - 1) * pageSize, } items, total, err := s.repo.ListRecords(ctx, filter) if err != nil { return nil, err } totalPages := int(math.Ceil(float64(total) / float64(pageSize))) return &domain.PaginationResponse[domain.Product]{ Items: items, TotalCount: total, CurrentPage: page, TotalPages: totalPages, }, 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 { // 1. Auto-clean: Check if buyer has ANY pending order and delete it to release stock. // This prevents "zombie" orders from holding stock if frontend state is lost. // We only do this for "Pending" orders. // If ListOrders doesn't filter by status, I have to filter manually. // Or I can rely on a broader clean-up. orders, _, err := s.repo.ListOrders(ctx, domain.OrderFilter{BuyerID: &order.BuyerID, Limit: 100}) if err == nil { for _, o := range orders { if o.Status == domain.OrderStatusPending { // Delete pending order (Restores stock) _ = s.DeleteOrder(ctx, o.ID) } } } order.ID = uuid.Must(uuid.NewV7()) order.Status = domain.OrderStatusPending // Calculate and Apply Shipping buyerAddr := &domain.Address{ Latitude: order.Shipping.Latitude, Longitude: order.Shipping.Longitude, } fee, dist, err := s.CalculateShipping(ctx, buyerAddr, order.SellerID, order.TotalCents) if err == nil { order.ShippingFeeCents = fee order.DistanceKm = dist order.TotalCents += fee } else { // Log warning but proceed? Or fail? // Ensure fee is 0 if error or out of range. // If error is "out of range", we might want to block order. if err.Error() == "address out of delivery range" { return err } // logic for other errors: // fmt.Printf("Shipping calculation warning: %v\n", err) } if err := s.repo.CreateOrder(ctx, order); err != nil { return err } // Async notification (ignore errors to not block request) go func() { // Fetch buyer details buyer, err := s.repo.GetUser(context.Background(), order.BuyerID) if err != nil { return } // Fetch seller details (hack: get list and pick first for MVP) users, _, _ := s.repo.ListUsers(context.Background(), domain.UserFilter{CompanyID: &order.SellerID, Limit: 1}) var seller *domain.User if len(users) > 0 { seller = &users[0] } else { // Fallback mock seller = &domain.User{Name: "Seller", Email: "seller@platform.com"} } s.notify.NotifyOrderCreated(context.Background(), order, buyer, seller) }() return nil } func (s *Service) CalculateShipping(ctx context.Context, buyerAddress *domain.Address, sellerID uuid.UUID, cartTotalCents int64) (int64, float64, error) { // 1. Get Shipping Settings for Seller settings, err := s.repo.GetShippingSettings(ctx, sellerID) if err != nil { // If settings not found, assume free or default? Or error? // For MVP, if no settings, Free Shipping (0). return 0, 0, nil } if settings == nil { return 0, 0, nil } if !settings.Active { return 0, 0, nil } // 2. Validate coordinates if buyerAddress.Latitude == 0 || buyerAddress.Longitude == 0 { return settings.MinFeeCents, 0, nil } // 3. Calculate Distance (Mapbox) distKm, err := s.mapbox.GetDrivingDistance(settings.Latitude, settings.Longitude, buyerAddress.Latitude, buyerAddress.Longitude) if err != nil { // Fallback to Haversine if mapbox fails? distKm = haversine(buyerAddress.Latitude, buyerAddress.Longitude, settings.Latitude, settings.Longitude) } // 4. Check Radius if distKm > settings.MaxRadiusKm { // Logic from python: error out return 0, distKm, errors.New("address out of delivery range") } // 5. Calculate Fee // Logic: // If FreeShippingThreshold set and Total >= Threshold -> Free (0) // Else If PricePerKm > 0 -> dist * PricePerKm // Else If MinFee > 0 -> MinFee (Fixed) // Else -> Free (0) if settings.FreeShippingThresholdCents != nil && cartTotalCents >= *settings.FreeShippingThresholdCents { return 0, distKm, nil } var fee int64 if settings.PricePerKmCents > 0 { fee = int64(distKm * float64(settings.PricePerKmCents)) // Optional: Ensure min fee? Python logic says if per_km use per_km. // Usually there's a base fee + per km, but here model is separate. // Let's stick to simple logic. } else if settings.MinFeeCents > 0 { fee = settings.MinFeeCents } else { fee = 0 } return fee, distKm, nil } // haversine calculates distance between two points in km func haversine(lat1, lon1, lat2, lon2 float64) float64 { const R = 6371 // Earth radius in km dLat := (lat2 - lat1) * (math.Pi / 180.0) dLon := (lon2 - lon1) * (math.Pi / 180.0) lat1Rad := lat1 * (math.Pi / 180.0) lat2Rad := lat2 * (math.Pi / 180.0) a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Sin(dLon/2)*math.Sin(dLon/2)*math.Cos(lat1Rad)*math.Cos(lat2Rad) c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) return R * c } 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 { order, err := s.repo.GetOrder(ctx, id) if err != nil { return err } // State Machine Logic switch order.Status { case domain.OrderStatusPending: if status != domain.OrderStatusPaid && status != domain.OrderStatusCancelled { return errors.New("invalid transition from Pending") } case domain.OrderStatusPaid: if status != domain.OrderStatusInvoiced && status != domain.OrderStatusShipped && status != domain.OrderStatusCancelled { return errors.New("invalid transition from Paid") } case domain.OrderStatusInvoiced: // Can go to Shipped if status != domain.OrderStatusShipped && status != domain.OrderStatusCancelled { return errors.New("invalid transition from Invoiced") } case domain.OrderStatusShipped: if status != domain.OrderStatusDelivered { return errors.New("invalid transition from Shipped") } case domain.OrderStatusDelivered: if status != domain.OrderStatusCompleted { return errors.New("invalid transition from Delivered") } case domain.OrderStatusCompleted, domain.OrderStatusCancelled: return errors.New("order is in terminal state") } if err := s.repo.UpdateOrderStatus(ctx, id, status); err != nil { return err } // Restore stock if order is cancelled if status == domain.OrderStatusCancelled && (order.Status == domain.OrderStatusPending || order.Status == domain.OrderStatusPaid || order.Status == domain.OrderStatusInvoiced) { for _, item := range order.Items { // Restore stock if _, err := s.repo.AdjustInventory(ctx, item.ProductID, int64(item.Quantity), "Order Cancelled"); err != nil { // Log error but don't fail the request (or maybe we should?) // ideally this whole operation should be atomic. // For now, logging. // In a real system, we'd want a saga or transaction. // fmt.Printf("Failed to restore stock for item %s: %v\n", item.ProductID, err) } } } // Async notification go func() { // Re-fetch order to get all details if necessary, but we have fields. // Need buyer to send email updatedOrder, _ := s.repo.GetOrder(context.Background(), id) if updatedOrder == nil { return } buyer, err := s.repo.GetUser(context.Background(), updatedOrder.BuyerID) if err != nil { return } s.notify.NotifyOrderStatusChanged(context.Background(), updatedOrder, buyer) }() return nil } func (s *Service) DeleteOrder(ctx context.Context, id uuid.UUID) error { order, err := s.repo.GetOrder(ctx, id) if err != nil { return err } // Only restore stock if order reserved it (Pending, Paid, Invoiced) if order.Status == domain.OrderStatusPending || order.Status == domain.OrderStatusPaid || order.Status == domain.OrderStatusInvoiced { for _, item := range order.Items { // Restore stock if _, err := s.repo.AdjustInventory(ctx, item.ProductID, int64(item.Quantity), "Order Deleted"); err != nil { // Log error but proceed? Or fail? // For now proceed to ensure deletion, but log would be good. } } } 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 } // Financial Ledger: Credit Sale _ = s.repo.RecordLedgerEntry(ctx, &domain.LedgerEntry{ ID: uuid.Must(uuid.NewV7()), CompanyID: order.SellerID, AmountCents: order.TotalCents, // Credit full amount Type: "SALE", Description: "Order #" + order.ID.String(), ReferenceID: &order.ID, }) // Financial Ledger: Debit Fee _ = s.repo.RecordLedgerEntry(ctx, &domain.LedgerEntry{ ID: uuid.Must(uuid.NewV7()), CompanyID: order.SellerID, AmountCents: -marketplaceFee, // Debit fee Type: "FEE", Description: "Marketplace Fee #" + order.ID.String(), ReferenceID: &order.ID, }) } 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) } func (s *Service) ReplaceCart(ctx context.Context, buyerID uuid.UUID, reqItems []domain.CartItem) (*domain.CartSummary, error) { var validItems []domain.CartItem for _, item := range reqItems { // Fetch product to get price product, err := s.repo.GetProduct(ctx, item.ProductID) if err != nil { continue } unitPrice := product.PriceCents if s.buyerFeeRate > 0 { unitPrice = int64(float64(unitPrice) * (1 + s.buyerFeeRate)) } validItems = append(validItems, domain.CartItem{ ID: uuid.Must(uuid.NewV7()), BuyerID: buyerID, ProductID: item.ProductID, Quantity: item.Quantity, UnitCents: unitPrice, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), }) } if err := s.repo.ReplaceCart(ctx, buyerID, validItems); err != nil { return nil, err } return s.cartSummary(ctx, buyerID) } func (s *Service) UpdateOrderItems(ctx context.Context, orderID uuid.UUID, items []domain.OrderItem, totalCents int64) error { // Ensure order exists if _, err := s.repo.GetOrder(ctx, orderID); err != nil { return err } return s.repo.UpdateOrderItems(ctx, orderID, items, totalCents) } // 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 } } // Stock check disabled for Dictionary mode. // In the future, check inventory_items availability via AdjustInventory logic or similar. // Apply Buyer Fee (12% or configured) unitPrice := product.PriceCents if s.buyerFeeRate > 0 { unitPrice = int64(float64(unitPrice) * (1 + s.buyerFeeRate)) } _, err = s.repo.AddCartItem(ctx, &domain.CartItem{ ID: uuid.Must(uuid.NewV7()), BuyerID: buyerID, ProductID: productID, Quantity: quantity, UnitCents: unitPrice, // Batch and ExpiresAt handled at fulfillment or selection time }) if err != nil { return nil, err } return s.cartSummary(ctx, buyerID) } // Reorder duplicates items from a previous order into the cart. // It skips items that are out of stock or discontinued. func (s *Service) Reorder(ctx context.Context, buyerID, orderID uuid.UUID) (*domain.CartSummary, []string, error) { order, err := s.repo.GetOrder(ctx, orderID) if err != nil { return nil, nil, err } if order.BuyerID != buyerID { return nil, nil, errors.New("order does not belong to buyer") } var warnings []string for _, item := range order.Items { // Try to add to cart // We use the original quantity. // Ideally we should check if product still exists. AddItemToCart does that. if _, err := s.AddItemToCart(ctx, buyerID, item.ProductID, int64(item.Quantity)); err != nil { warnings = append(warnings, fmt.Sprintf("Item %s not added: %v", item.ProductID, err)) } } // Return final cart state summary, err := s.cartSummary(ctx, buyerID) return summary, warnings, err } // 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) RemoveCartItemByProduct(ctx context.Context, buyerID, productID uuid.UUID) (*domain.CartSummary, error) { // We ignore "not found" error to be idempotent, or handle it? // Logic says if it returns error, we return it. // But if we want to "ensure removed", we might ignore not found. // For now, standard behavior. if err := s.repo.DeleteCartItemByProduct(ctx, buyerID, productID); err != nil { // return nil, err // Actually, if item is not found, we still want to return the cart summary, // but maybe we should return error to let frontend know? // Let's return error for now to be consistent with DeleteCartItem. return nil, err } return s.cartSummary(ctx, buyerID) } func (s *Service) ClearCart(ctx context.Context, buyerID uuid.UUID) (*domain.CartSummary, error) { if err := s.repo.ClearCart(ctx, 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{ ID: buyerID, 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, identifier, password string) (string, time.Time, error) { // Try fetching by username first user, err := s.repo.GetUserByUsername(ctx, identifier) if err != nil { // Try fetching by email user, err = s.repo.GetUserByEmail(ctx, identifier) if err != nil { // Return generic error to avoid leaking DB details or user existence return "", time.Time{}, errors.New("invalid credentials") } } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(s.pepperPassword(password))); err != nil { return "", time.Time{}, errors.New("invalid credentials") } // Check if company is verified if user.CompanyID != uuid.Nil { _, err := s.repo.GetCompany(ctx, user.CompanyID) if err != nil { // If company not found, something is wrong with data integrity, prevent login return "", time.Time{}, errors.New("associated company not found") } // if !company.IsVerified { // return "", time.Time{}, errors.New("account pending approval") // } } return s.issueAccessToken(user) } func (s *Service) pepperPassword(password string) string { if s.passwordPepper == "" { return password } return password + s.passwordPepper } // RefreshToken validates the provided JWT and issues a new access token. func (s *Service) RefreshToken(ctx context.Context, tokenStr string) (string, time.Time, error) { claims, err := s.parseToken(tokenStr) if err != nil { return "", time.Time{}, err } if scope, ok := claims["scope"].(string); ok && scope != "" { return "", time.Time{}, errors.New("invalid token scope") } sub, ok := claims["sub"].(string) if !ok || sub == "" { return "", time.Time{}, errors.New("invalid token subject") } userID, err := uuid.FromString(sub) if err != nil { return "", time.Time{}, errors.New("invalid token subject") } user, err := s.repo.GetUser(ctx, userID) if err != nil { return "", time.Time{}, err } return s.issueAccessToken(user) } // CreatePasswordResetToken generates a short-lived token for password reset. func (s *Service) CreatePasswordResetToken(ctx context.Context, email string) (string, time.Time, error) { user, err := s.repo.GetUserByEmail(ctx, email) if err != nil { return "", time.Time{}, err } expiresAt := time.Now().Add(passwordResetTTL) claims := jwt.MapClaims{ "sub": user.ID.String(), "scope": "password_reset", } signed, err := s.signToken(claims, expiresAt) if err != nil { return "", time.Time{}, err } return signed, expiresAt, nil } // ResetPassword validates the reset token and updates the user password. func (s *Service) ResetPassword(ctx context.Context, tokenStr, newPassword string) error { claims, err := s.parseToken(tokenStr) if err != nil { return err } scope, _ := claims["scope"].(string) if scope != "password_reset" { return errors.New("invalid token scope") } sub, ok := claims["sub"].(string) if !ok || sub == "" { return errors.New("invalid token subject") } userID, err := uuid.FromString(sub) if err != nil { return errors.New("invalid token subject") } user, err := s.repo.GetUser(ctx, userID) if err != nil { return err } return s.UpdateUser(ctx, user, newPassword) } // VerifyEmail marks the user email as verified based on a JWT token. func (s *Service) VerifyEmail(ctx context.Context, tokenStr string) (*domain.User, error) { claims, err := s.parseToken(tokenStr) if err != nil { return nil, err } if scope, ok := claims["scope"].(string); ok && scope != "" && scope != "email_verify" { return nil, errors.New("invalid token scope") } sub, ok := claims["sub"].(string) if !ok || sub == "" { return nil, errors.New("invalid token subject") } userID, err := uuid.FromString(sub) if err != nil { return nil, errors.New("invalid token subject") } user, err := s.repo.GetUser(ctx, userID) if err != nil { return nil, err } if !user.EmailVerified { user.EmailVerified = true if err := s.repo.UpdateUser(ctx, user); err != nil { return nil, err } } return user, nil } func (s *Service) issueAccessToken(user *domain.User) (string, time.Time, error) { expiresAt := time.Now().Add(s.tokenTTL) claims := jwt.MapClaims{ "sub": user.ID.String(), "role": user.Role, "company_id": user.CompanyID.String(), } signed, err := s.signToken(claims, expiresAt) if err != nil { return "", time.Time{}, err } return signed, expiresAt, nil } func (s *Service) signToken(claims jwt.MapClaims, expiresAt time.Time) (string, error) { claims["exp"] = expiresAt.Unix() token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(s.jwtSecret) } func (s *Service) parseToken(tokenStr string) (jwt.MapClaims, error) { if strings.TrimSpace(tokenStr) == "" { return nil, errors.New("token is required") } claims := jwt.MapClaims{} parser := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) token, err := parser.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (any, error) { return s.jwtSecret, nil }) if err != nil || !token.Valid { return nil, errors.New("invalid token") } return claims, nil } // 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 } func (s *Service) ListReviews(ctx context.Context, filter domain.ReviewFilter, page, pageSize int) (*domain.ReviewPage, error) { if pageSize <= 0 { pageSize = 20 } if page <= 0 { page = 1 } filter.Limit = pageSize filter.Offset = (page - 1) * pageSize reviews, total, err := s.repo.ListReviews(ctx, filter) if err != nil { return nil, err } return &domain.ReviewPage{Reviews: reviews, Total: total, Page: page, PageSize: pageSize}, nil } func (s *Service) ListShipments(ctx context.Context, filter domain.ShipmentFilter, page, pageSize int) (*domain.ShipmentPage, error) { if pageSize <= 0 { pageSize = 20 } if page <= 0 { page = 1 } filter.Limit = pageSize filter.Offset = (page - 1) * pageSize shipments, total, err := s.repo.ListShipments(ctx, filter) if err != nil { return nil, err } return &domain.ShipmentPage{Shipments: shipments, Total: total, Page: page, PageSize: pageSize}, nil } func (s *Service) GetShippingSettings(ctx context.Context, vendorID uuid.UUID) (*domain.ShippingSettings, error) { return s.repo.GetShippingSettings(ctx, vendorID) } func (s *Service) UpsertShippingSettings(ctx context.Context, settings *domain.ShippingSettings) error { return s.repo.UpsertShippingSettings(ctx, settings) } func (s *Service) CreateAddress(ctx context.Context, address *domain.Address) error { address.ID = uuid.Must(uuid.NewV7()) return s.repo.CreateAddress(ctx, address) } func (s *Service) ListAddresses(ctx context.Context, entityID uuid.UUID) ([]domain.Address, error) { return s.repo.ListAddresses(ctx, entityID) } // UpdateAddress updates an existing address. func (s *Service) UpdateAddress(ctx context.Context, addr *domain.Address, requester *domain.User) error { // Verify ownership or Admin existing, err := s.repo.GetAddress(ctx, addr.ID) if err != nil { return err } if requester.Role != "Admin" && existing.EntityID != requester.ID && (requester.CompanyID == uuid.Nil || existing.EntityID != requester.CompanyID) { return errors.New("unauthorized to update this address") } // Update fields existing.Title = addr.Title existing.ZipCode = addr.ZipCode existing.Street = addr.Street existing.Number = addr.Number existing.Complement = addr.Complement existing.District = addr.District existing.City = addr.City existing.State = addr.State return s.repo.UpdateAddress(ctx, existing) } // DeleteAddress deletes an address. func (s *Service) DeleteAddress(ctx context.Context, id uuid.UUID, requester *domain.User) error { // Verify ownership or Admin existing, err := s.repo.GetAddress(ctx, id) if err != nil { return err } if requester.Role != "Admin" && existing.EntityID != requester.ID && (requester.CompanyID == uuid.Nil || existing.EntityID != requester.CompanyID) { return errors.New("unauthorized to delete this address") } return s.repo.DeleteAddress(ctx, id) } func (s *Service) ListManufacturers(ctx context.Context) ([]string, error) { return s.repo.ListManufacturers(ctx) }