- 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
181 lines
5.7 KiB
Go
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)
|
|
}
|