saveinmed/backend-old/internal/usecase/usecase.go
2026-01-16 10:51:52 -03:00

999 lines
31 KiB
Go

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/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)
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
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
}
// 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
notify notifications.NotificationService
jwtSecret []byte
tokenTTL time.Duration
marketplaceCommission float64
passwordPepper string
}
const (
passwordResetTTL = 30 * time.Minute
)
// NewService wires use cases together.
func NewService(repo Repository, pay PaymentGateway, notify notifications.NotificationService, commissionPct float64, jwtSecret string, tokenTTL time.Duration, passwordPepper string) *Service {
return &Service{
repo: repo,
pay: pay,
notify: notify,
jwtSecret: []byte(jwtSecret),
tokenTTL: tokenTTL,
marketplaceCommission: commissionPct,
passwordPepper: passwordPepper,
}
}
// 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 {
order.ID = uuid.Must(uuid.NewV7())
order.Status = domain.OrderStatusPending
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) 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 {
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)
}
// 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)
}
// 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) 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, 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")
}
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)
}