saveinmed/backend/internal/usecase/usecase.go

359 lines
11 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) ([]domain.Company, error)
GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error)
UpdateCompany(ctx context.Context, company *domain.Company) error
CreateProduct(ctx context.Context, product *domain.Product) error
ListProducts(ctx context.Context) ([]domain.Product, error)
GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error)
AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error)
ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, error)
CreateOrder(ctx context.Context, order *domain.Order) error
GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error)
UpdateOrderStatus(ctx context.Context, id uuid.UUID, status domain.OrderStatus) 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
}
// 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
}
// NewService wires use cases together.
func NewService(repo Repository, pay PaymentGateway, commissionPct float64, jwtSecret string, tokenTTL time.Duration) *Service {
return &Service{repo: repo, pay: pay, jwtSecret: []byte(jwtSecret), tokenTTL: tokenTTL, marketplaceCommission: commissionPct}
}
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) ([]domain.Company, error) {
return s.repo.ListCompanies(ctx)
}
func (s *Service) GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error) {
return s.repo.GetCompany(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) ([]domain.Product, error) {
return s.repo.ListProducts(ctx)
}
func (s *Service) ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, error) {
return s.repo.ListInventory(ctx, filter)
}
func (s *Service) AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error) {
return s.repo.AdjustInventory(ctx, productID, delta, reason)
}
func (s *Service) CreateOrder(ctx context.Context, order *domain.Order) error {
order.ID = uuid.Must(uuid.NewV7())
order.Status = domain.OrderStatusPending
return s.repo.CreateOrder(ctx, order)
}
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)
}
// 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(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(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
}
// 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(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
}
// 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
}