saveinmed/backend/internal/usecase/order_usecase.go
caio-machado-dev bf85072bff chore: remove legacy services and restructure monorepo
- remove backend-old (Medusa), saveinmed-frontend (Next.js/Appwrite) and marketplace dirs
- split Go usecases by domain and move notifications/payments to infrastructure
- reorganize frontend pages into auth, dashboard and marketplace modules
- add Makefile, docker-compose.yml and architecture docs
2026-02-25 16:51:34 -03:00

181 lines
5.7 KiB
Go

package usecase
import (
"context"
"errors"
"fmt"
"github.com/gofrs/uuid/v5"
"github.com/saveinmed/backend-go/internal/domain"
)
// CreateOrder validates and persists a new order, calculates shipping, reserves
// stock and fires async notifications. Any pre-existing pending order for the
// same buyer is deleted first to prevent zombie drafts.
func (s *Service) CreateOrder(ctx context.Context, order *domain.Order) error {
// Delete any stale pending orders from this buyer before creating a new one.
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 {
_ = s.DeleteOrder(ctx, o.ID)
}
}
}
order.ID = uuid.Must(uuid.NewV7())
order.Status = domain.OrderStatusPending
// Calculate and apply shipping cost.
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 {
if err.Error() == "address out of delivery range" {
return err
}
}
if err := s.repo.CreateOrder(ctx, order); err != nil {
return err
}
// Fire async notifications without blocking the HTTP response.
go func() {
buyer, err := s.repo.GetUser(context.Background(), order.BuyerID)
if err != nil {
return
}
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 {
seller = &domain.User{Name: "Seller", Email: "seller@platform.com"}
}
s.notify.NotifyOrderCreated(context.Background(), order, buyer, seller)
}()
return nil
}
// ListOrders returns a paginated list of orders.
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
}
// GetOrder retrieves an order by ID.
func (s *Service) GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) {
return s.repo.GetOrder(ctx, id)
}
// UpdateOrderStatus enforces the order state machine, restores stock
// on cancellation, and fires async notifications.
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 transitions.
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:
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 when cancelling a non-shipped order.
if status == domain.OrderStatusCancelled && (order.Status == domain.OrderStatusPending || order.Status == domain.OrderStatusPaid || order.Status == domain.OrderStatusInvoiced) {
for _, item := range order.Items {
if _, err := s.repo.AdjustInventory(ctx, item.ProductID, int64(item.Quantity), "Order Cancelled"); err != nil {
// TODO: enqueue a reconciliation job instead of silently ignoring.
fmt.Printf("WARN: failed to restore stock for item %s: %v\n", item.ProductID, err)
}
}
}
// Async buyer notification.
go func() {
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
}
// DeleteOrder removes an order and restores reserved stock for non-shipped orders.
func (s *Service) DeleteOrder(ctx context.Context, id uuid.UUID) error {
order, err := s.repo.GetOrder(ctx, id)
if err != nil {
return err
}
if order.Status == domain.OrderStatusPending || order.Status == domain.OrderStatusPaid || order.Status == domain.OrderStatusInvoiced {
for _, item := range order.Items {
if _, err := s.repo.AdjustInventory(ctx, item.ProductID, int64(item.Quantity), "Order Deleted"); err != nil {
fmt.Printf("WARN: failed to restore stock for item %s: %v\n", item.ProductID, err)
}
}
}
return s.repo.DeleteOrder(ctx, id)
}
// UpdateOrderItems replaces the line-items and total on an existing order.
func (s *Service) UpdateOrderItems(ctx context.Context, orderID uuid.UUID, items []domain.OrderItem, totalCents int64) error {
if _, err := s.repo.GetOrder(ctx, orderID); err != nil {
return err
}
return s.repo.UpdateOrderItems(ctx, orderID, items, totalCents)
}