2025-08-13 23:36:31 +07:00

1607 lines
58 KiB
Go

package processor
import (
"apskel-pos-be/internal/constants"
"context"
"errors"
"fmt"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
"github.com/google/uuid"
"gorm.io/gorm"
)
type OrderProcessor interface {
CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error)
AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error)
UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error)
GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error)
ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error)
VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error
RefundOrder(ctx context.Context, id uuid.UUID, req *models.RefundOrderRequest, refundedBy uuid.UUID) error
CreatePayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error)
RefundPayment(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID) error
SetOrderCustomer(ctx context.Context, orderID uuid.UUID, req *models.SetOrderCustomerRequest, organizationID uuid.UUID) (*models.SetOrderCustomerResponse, error)
SplitBill(ctx context.Context, req *models.SplitBillRequest) (*models.SplitBillResponse, error)
}
type OrderRepository interface {
Create(ctx context.Context, order *entities.Order) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.Order, error)
GetWithRelations(ctx context.Context, id uuid.UUID) (*entities.Order, error)
Update(ctx context.Context, order *entities.Order) error
UpdateStatusSuccess(ctx context.Context, id uuid.UUID, orderStatus entities.OrderStatus, paymentStatus entities.PaymentStatus) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Order, int64, error)
GetByOrderNumber(ctx context.Context, orderNumber string) (*entities.Order, error)
ExistsByOrderNumber(ctx context.Context, orderNumber string) (bool, error)
VoidOrder(ctx context.Context, id uuid.UUID, reason string, voidedBy uuid.UUID) error
VoidOrderWithStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus, reason string, voidedBy uuid.UUID) error
RefundOrder(ctx context.Context, id uuid.UUID, reason string, refundedBy uuid.UUID) error
UpdatePaymentStatus(ctx context.Context, id uuid.UUID, status entities.PaymentStatus) error
UpdateStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus) error
GetNextOrderNumber(ctx context.Context, organizationID, outletID uuid.UUID) (string, error)
}
type OrderItemRepository interface {
Create(ctx context.Context, orderItem *entities.OrderItem) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.OrderItem, error)
GetByOrderID(ctx context.Context, orderID uuid.UUID) ([]*entities.OrderItem, error)
Update(ctx context.Context, orderItem *entities.OrderItem) error
Delete(ctx context.Context, id uuid.UUID) error
RefundOrderItem(ctx context.Context, id uuid.UUID, refundQuantity int, refundAmount float64, reason string, refundedBy uuid.UUID) error
VoidOrderItem(ctx context.Context, id uuid.UUID, voidQuantity int, reason string, voidedBy uuid.UUID) error
UpdateStatus(ctx context.Context, id uuid.UUID, status entities.OrderItemStatus) error
}
type PaymentRepository interface {
Create(ctx context.Context, payment *entities.Payment) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.Payment, error)
GetByOrderID(ctx context.Context, orderID uuid.UUID) ([]*entities.Payment, error)
Update(ctx context.Context, payment *entities.Payment) error
Delete(ctx context.Context, id uuid.UUID) error
RefundPayment(ctx context.Context, id uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID) error
UpdateStatus(ctx context.Context, id uuid.UUID, status entities.PaymentTransactionStatus) error
GetTotalPaidByOrderID(ctx context.Context, orderID uuid.UUID) (float64, error)
}
type PaymentOrderItemRepository interface {
Create(ctx context.Context, paymentOrderItem *entities.PaymentOrderItem) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.PaymentOrderItem, error)
GetByPaymentID(ctx context.Context, paymentID uuid.UUID) ([]*entities.PaymentOrderItem, error)
GetPaidQuantitiesByOrderID(ctx context.Context, orderID uuid.UUID) (map[uuid.UUID]int, error)
Update(ctx context.Context, paymentOrderItem *entities.PaymentOrderItem) error
Delete(ctx context.Context, id uuid.UUID) error
}
type PaymentMethodRepository interface {
GetByID(ctx context.Context, id uuid.UUID) (*entities.PaymentMethod, error)
}
type CustomerRepository interface {
GetByIDAndOrganization(ctx context.Context, id, organizationID uuid.UUID) (*entities.Customer, error)
}
type InventoryMovementService interface {
CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
}
type OrderProcessorImpl struct {
orderRepo OrderRepository
orderItemRepo OrderItemRepository
paymentRepo PaymentRepository
paymentOrderItemRepo PaymentOrderItemRepository
productRepo ProductRepository
paymentMethodRepo PaymentMethodRepository
inventoryRepo repository.InventoryRepository
inventoryMovementRepo repository.InventoryMovementRepository
productVariantRepo repository.ProductVariantRepository
outletRepo OutletRepository
customerRepo CustomerRepository
splitBillProcessor SplitBillProcessor
txManager *repository.TxManager
productRecipeRepo *repository.ProductRecipeRepository
ingredientRepo IngredientRepository
inventoryMovementService InventoryMovementService
}
func NewOrderProcessorImpl(
orderRepo OrderRepository,
orderItemRepo OrderItemRepository,
paymentRepo PaymentRepository,
paymentOrderItemRepo PaymentOrderItemRepository,
productRepo ProductRepository,
paymentMethodRepo PaymentMethodRepository,
inventoryRepo repository.InventoryRepository,
inventoryMovementRepo repository.InventoryMovementRepository,
productVariantRepo repository.ProductVariantRepository,
outletRepo OutletRepository,
customerRepo CustomerRepository,
txManager *repository.TxManager,
productRecipeRepo *repository.ProductRecipeRepository,
ingredientRepo IngredientRepository,
inventoryMovementService InventoryMovementService,
) *OrderProcessorImpl {
return &OrderProcessorImpl{
orderRepo: orderRepo,
orderItemRepo: orderItemRepo,
paymentRepo: paymentRepo,
paymentOrderItemRepo: paymentOrderItemRepo,
productRepo: productRepo,
paymentMethodRepo: paymentMethodRepo,
inventoryRepo: inventoryRepo,
inventoryMovementRepo: inventoryMovementRepo,
productVariantRepo: productVariantRepo,
outletRepo: outletRepo,
customerRepo: customerRepo,
splitBillProcessor: NewSplitBillProcessorImpl(orderRepo, orderItemRepo, paymentRepo, paymentOrderItemRepo, outletRepo),
txManager: txManager,
productRecipeRepo: productRecipeRepo,
ingredientRepo: ingredientRepo,
inventoryMovementService: inventoryMovementService,
}
}
func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) {
orderNumber, err := p.orderRepo.GetNextOrderNumber(ctx, organizationID, req.OutletID)
if err != nil {
return nil, fmt.Errorf("failed to generate order number: %w", err)
}
outlet, err := p.outletRepo.GetByID(ctx, req.OutletID)
if err != nil {
return nil, fmt.Errorf("outlet not found: %w", err)
}
var subtotal, totalCost float64
var orderItems []*entities.OrderItem
for _, itemReq := range req.OrderItems {
product, err := p.productRepo.GetByID(ctx, itemReq.ProductID)
if err != nil {
return nil, fmt.Errorf("product not found: %w", err)
}
unitPrice := product.Price
unitCost := product.Cost
if itemReq.ProductVariantID != nil {
variant, err := p.productVariantRepo.GetByID(ctx, *itemReq.ProductVariantID)
if err != nil {
return nil, fmt.Errorf("product variant not found: %w", err)
}
if variant.ProductID != itemReq.ProductID {
return nil, fmt.Errorf("product variant does not belong to the specified product")
}
unitPrice += variant.PriceModifier
if variant.Cost > 0 {
unitCost = variant.Cost
}
}
itemTotalPrice := float64(itemReq.Quantity) * unitPrice
itemTotalCost := float64(itemReq.Quantity) * unitCost
subtotal += itemTotalPrice
totalCost += itemTotalCost
orderItem := &entities.OrderItem{
ProductID: itemReq.ProductID,
ProductVariantID: itemReq.ProductVariantID,
Quantity: itemReq.Quantity,
UnitPrice: unitPrice,
TotalPrice: itemTotalPrice,
UnitCost: unitCost,
TotalCost: itemTotalCost,
Modifiers: entities.Modifiers(itemReq.Modifiers),
Notes: itemReq.Notes,
Metadata: entities.Metadata(itemReq.Metadata),
Status: entities.OrderItemStatusPending,
}
orderItems = append(orderItems, orderItem)
}
taxAmount := subtotal * outlet.TaxRate
totalAmount := subtotal + taxAmount
metadata := entities.Metadata(req.Metadata)
if req.CustomerName != nil {
if metadata == nil {
metadata = make(entities.Metadata)
}
metadata["customer_name"] = *req.CustomerName
}
order := &entities.Order{
OrganizationID: organizationID,
OutletID: req.OutletID,
UserID: req.UserID,
CustomerID: req.CustomerID,
OrderNumber: orderNumber,
TableNumber: req.TableNumber,
OrderType: entities.OrderType(req.OrderType),
Status: entities.OrderStatusPending,
Subtotal: subtotal,
TaxAmount: taxAmount,
DiscountAmount: 0,
TotalAmount: totalAmount,
TotalCost: totalCost,
RemainingAmount: totalAmount, // Initialize remaining amount equal to total amount
PaymentStatus: entities.PaymentStatusPending,
IsVoid: false,
IsRefund: false,
Metadata: metadata,
}
if err := p.orderRepo.Create(ctx, order); err != nil {
return nil, fmt.Errorf("failed to create order: %w", err)
}
for _, orderItem := range orderItems {
orderItem.OrderID = order.ID
if err := p.orderItemRepo.Create(ctx, orderItem); err != nil {
return nil, fmt.Errorf("failed to create order item: %w", err)
}
}
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, order.ID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve created order: %w", err)
}
response := mappers.OrderEntityToResponse(orderWithRelations)
return response, nil
}
func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error) {
order, err := p.orderRepo.GetByID(ctx, orderID)
if err != nil {
return nil, fmt.Errorf("order not found: %w", err)
}
if order.IsVoid {
return nil, fmt.Errorf("cannot modify voided order")
}
if order.PaymentStatus == entities.PaymentStatusCompleted {
return nil, fmt.Errorf("cannot modify fully paid order")
}
// Get outlet information for tax rate
outlet, err := p.outletRepo.GetByID(ctx, order.OutletID)
if err != nil {
return nil, fmt.Errorf("outlet not found: %w", err)
}
var newSubtotal, newTotalCost float64
var addedOrderItems []*entities.OrderItem
for _, itemReq := range req.OrderItems {
product, err := p.productRepo.GetByID(ctx, itemReq.ProductID)
if err != nil {
return nil, fmt.Errorf("product not found: %w", err)
}
// Use product price from database
unitPrice := product.Price
unitCost := product.Cost
// Handle product variant if specified
if itemReq.ProductVariantID != nil {
variant, err := p.productVariantRepo.GetByID(ctx, *itemReq.ProductVariantID)
if err != nil {
return nil, fmt.Errorf("product variant not found: %w", err)
}
// Verify variant belongs to the product
if variant.ProductID != itemReq.ProductID {
return nil, fmt.Errorf("product variant does not belong to the specified product")
}
// Apply price modifier
unitPrice += variant.PriceModifier
// Use variant cost if available, otherwise use product cost
if variant.Cost > 0 {
unitCost = variant.Cost
}
}
itemTotalPrice := float64(itemReq.Quantity) * unitPrice
itemTotalCost := float64(itemReq.Quantity) * unitCost
newSubtotal += itemTotalPrice
newTotalCost += itemTotalCost
orderItem := &entities.OrderItem{
OrderID: orderID,
ProductID: itemReq.ProductID,
ProductVariantID: itemReq.ProductVariantID,
Quantity: itemReq.Quantity,
UnitPrice: unitPrice, // Use price from database
TotalPrice: itemTotalPrice,
UnitCost: unitCost,
TotalCost: itemTotalCost,
Modifiers: entities.Modifiers(itemReq.Modifiers),
Notes: itemReq.Notes,
Metadata: entities.Metadata(itemReq.Metadata),
Status: entities.OrderItemStatusPending,
}
addedOrderItems = append(addedOrderItems, orderItem)
}
order.Subtotal += newSubtotal
order.TotalCost += newTotalCost
// Recalculate tax amount using outlet's tax rate
order.TaxAmount = order.Subtotal * outlet.TaxRate
order.TotalAmount = order.Subtotal + order.TaxAmount - order.DiscountAmount
// Recalculate remaining amount when items are added
totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, orderID)
if err != nil {
return nil, fmt.Errorf("failed to get total paid amount: %w", err)
}
order.RemainingAmount = order.TotalAmount - totalPaid
if order.RemainingAmount < 0 {
order.RemainingAmount = 0
}
if req.Metadata != nil {
if order.Metadata == nil {
order.Metadata = make(entities.Metadata)
}
for k, v := range req.Metadata {
order.Metadata[k] = v
}
}
if err := p.orderRepo.Update(ctx, order); err != nil {
return nil, fmt.Errorf("failed to update order: %w", err)
}
var addedItemResponses []models.OrderItemResponse
for _, orderItem := range addedOrderItems {
if err := p.orderItemRepo.Create(ctx, orderItem); err != nil {
return nil, fmt.Errorf("failed to create order item: %w", err)
}
itemResponse := models.OrderItemResponse{
ID: orderItem.ID,
OrderID: orderItem.OrderID,
ProductID: orderItem.ProductID,
ProductVariantID: orderItem.ProductVariantID,
Quantity: orderItem.Quantity,
UnitPrice: orderItem.UnitPrice,
TotalPrice: orderItem.TotalPrice,
UnitCost: orderItem.UnitCost,
TotalCost: orderItem.TotalCost,
RefundAmount: orderItem.RefundAmount,
RefundQuantity: orderItem.RefundQuantity,
IsPartiallyRefunded: orderItem.IsPartiallyRefunded,
IsFullyRefunded: orderItem.IsFullyRefunded,
RefundReason: orderItem.RefundReason,
RefundedAt: orderItem.RefundedAt,
RefundedBy: orderItem.RefundedBy,
Modifiers: []map[string]interface{}(orderItem.Modifiers),
Notes: orderItem.Notes,
Metadata: map[string]interface{}(orderItem.Metadata),
Status: constants.OrderItemStatus(orderItem.Status),
CreatedAt: orderItem.CreatedAt,
UpdatedAt: orderItem.UpdatedAt,
}
addedItemResponses = append(addedItemResponses, itemResponse)
}
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, orderID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve updated order: %w", err)
}
updatedOrderResponse := mappers.OrderEntityToResponse(orderWithRelations)
return &models.AddToOrderResponse{
OrderID: orderID,
OrderNumber: order.OrderNumber,
AddedItems: addedItemResponses,
UpdatedOrder: *updatedOrderResponse,
}, nil
}
func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error) {
// Get existing order
order, err := p.orderRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("order not found: %w", err)
}
// Check if order can be modified
if order.IsVoid {
return nil, fmt.Errorf("cannot modify voided order")
}
// Apply updates
if req.TableNumber != nil {
order.TableNumber = req.TableNumber
}
if req.Status != nil {
order.Status = entities.OrderStatus(*req.Status)
}
if req.DiscountAmount != nil {
order.DiscountAmount = *req.DiscountAmount
// Recalculate total amount
order.TotalAmount = order.Subtotal + order.TaxAmount - order.DiscountAmount
// Recalculate remaining amount when discount is applied
totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get total paid amount: %w", err)
}
order.RemainingAmount = order.TotalAmount - totalPaid
if order.RemainingAmount < 0 {
order.RemainingAmount = 0
}
}
if req.Metadata != nil {
if order.Metadata == nil {
order.Metadata = make(entities.Metadata)
}
for k, v := range req.Metadata {
order.Metadata[k] = v
}
}
order.Status = entities.OrderStatusCompleted
order.PaymentStatus = entities.PaymentStatusCompleted
if err := p.orderRepo.UpdateStatusSuccess(ctx, order.ID, order.Status, order.PaymentStatus); err != nil {
return nil, fmt.Errorf("failed to update order: %w", err)
}
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to retrieve updated order: %w", err)
}
response := mappers.OrderEntityToResponse(orderWithRelations)
return response, nil
}
func (p *OrderProcessorImpl) GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error) {
order, err := p.orderRepo.GetWithRelations(ctx, id)
if err != nil {
return nil, fmt.Errorf("order not found: %w", err)
}
response := mappers.OrderEntityToResponse(order)
return response, nil
}
func (p *OrderProcessorImpl) ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error) {
filters := make(map[string]interface{})
if req.OrganizationID != nil {
filters["organization_id"] = *req.OrganizationID
}
if req.OutletID != nil {
filters["outlet_id"] = *req.OutletID
}
if req.UserID != nil {
filters["user_id"] = *req.UserID
}
if req.CustomerID != nil {
filters["customer_id"] = *req.CustomerID
}
if req.OrderType != nil {
filters["order_type"] = string(*req.OrderType)
}
if req.Status != nil {
filters["status"] = string(*req.Status)
}
if req.PaymentStatus != nil {
filters["payment_status"] = string(*req.PaymentStatus)
}
if req.IsVoid != nil {
filters["is_void"] = *req.IsVoid
}
if req.IsRefund != nil {
filters["is_refund"] = *req.IsRefund
}
if req.DateFrom != nil {
filters["date_from"] = *req.DateFrom
}
if req.DateTo != nil {
filters["date_to"] = *req.DateTo
}
if req.Search != "" {
filters["search"] = req.Search
}
offset := (req.Page - 1) * req.Limit
orders, total, err := p.orderRepo.List(ctx, filters, req.Limit, offset)
if err != nil {
return nil, fmt.Errorf("failed to list orders: %w", err)
}
orderResponses := make([]models.OrderResponse, len(orders))
allPayments := make([]models.PaymentResponse, 0)
for i, order := range orders {
response := mappers.OrderEntityToResponse(order)
if response != nil {
orderResponses[i] = *response
// Add payments from this order to the allPayments list
if response.Payments != nil {
allPayments = append(allPayments, response.Payments...)
}
}
}
totalPages := int(total) / req.Limit
if int(total)%req.Limit > 0 {
totalPages++
}
return &models.ListOrdersResponse{
Orders: orderResponses,
Payments: allPayments,
TotalCount: int(total),
Page: req.Page,
Limit: req.Limit,
TotalPages: totalPages,
}, nil
}
func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error {
if req.OrderID != req.OrderID {
return fmt.Errorf("order ID mismatch: path parameter does not match request body")
}
order, err := p.orderRepo.GetByID(ctx, req.OrderID)
if err != nil {
return fmt.Errorf("order not found: %w", err)
}
if order.IsVoid {
return fmt.Errorf("order is already voided")
}
if order.PaymentStatus == entities.PaymentStatusCompleted {
return fmt.Errorf("cannot void fully paid order")
}
if req.Type == "ALL" {
if err := p.orderRepo.VoidOrderWithStatus(ctx, req.OrderID, entities.OrderStatusCancelled, req.Reason, voidedBy); err != nil {
return fmt.Errorf("failed to void order: %w", err)
}
} else if req.Type == "ITEM" {
if len(req.Items) == 0 {
return fmt.Errorf("items list is required when voiding specific items")
}
var totalVoidedAmount float64
var totalVoidedCost float64
for _, itemVoid := range req.Items {
orderItemID := itemVoid.OrderItemID
orderItem, err := p.orderItemRepo.GetByID(ctx, orderItemID)
if err != nil {
return fmt.Errorf("order item not found: %w", err)
}
if orderItem.OrderID != req.OrderID {
return fmt.Errorf("order item does not belong to this order")
}
if itemVoid.Quantity > orderItem.Quantity {
return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID)
}
voidedAmount := float64(itemVoid.Quantity) * orderItem.UnitPrice
voidedCost := float64(itemVoid.Quantity) * orderItem.UnitCost
totalVoidedAmount += voidedAmount
totalVoidedCost += voidedCost
if err := p.orderItemRepo.VoidOrderItem(ctx, orderItemID, itemVoid.Quantity, req.Reason, voidedBy); err != nil {
return fmt.Errorf("failed to void order item %d: %w", itemVoid.OrderItemID, err)
}
}
outlet, err := p.outletRepo.GetByID(ctx, order.OutletID)
if err != nil {
return fmt.Errorf("outlet not found: %w", err)
}
order.Subtotal -= totalVoidedAmount
order.TotalCost -= totalVoidedCost
order.TaxAmount = order.Subtotal * outlet.TaxRate // Recalculate tax using outlet's tax rate
order.TotalAmount = order.Subtotal + order.TaxAmount - order.DiscountAmount
if err := p.orderRepo.Update(ctx, order); err != nil {
return fmt.Errorf("failed to update order totals: %w", err)
}
remainingItems, err := p.orderItemRepo.GetByOrderID(ctx, req.OrderID)
if err != nil {
return fmt.Errorf("failed to get remaining order items: %w", err)
}
hasActiveItems := false
for _, item := range remainingItems {
if item.Status != entities.OrderItemStatusCancelled {
hasActiveItems = true
break
}
}
if !hasActiveItems {
if err := p.orderRepo.VoidOrderWithStatus(ctx, req.OrderID, entities.OrderStatusCancelled, req.Reason, voidedBy); err != nil {
return fmt.Errorf("failed to void order after all items voided: %w", err)
}
}
} else {
return fmt.Errorf("invalid void type: must be 'ALL' or 'ITEM'")
}
return nil
}
func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req *models.RefundOrderRequest, refundedBy uuid.UUID) error {
order, err := p.orderRepo.GetWithRelations(ctx, id)
if err != nil {
return fmt.Errorf("order not found: %w", err)
}
if order.IsRefund {
return fmt.Errorf("order is already refunded")
}
if order.PaymentStatus != entities.PaymentStatusCompleted {
return fmt.Errorf("order is not paid, cannot refund")
}
reason := "No reason provided"
if req.Reason != nil {
reason = *req.Reason
}
// Process refund based on request type
if req.RefundAmount != nil {
// Full or partial refund by amount
if *req.RefundAmount > order.TotalAmount {
return fmt.Errorf("refund amount cannot exceed order total")
}
// Update order refund amount
order.RefundAmount = *req.RefundAmount
if err := p.orderRepo.Update(ctx, order); err != nil {
return fmt.Errorf("failed to update order refund amount: %w", err)
}
// Mark order as refunded
if err := p.orderRepo.RefundOrder(ctx, id, reason, refundedBy); err != nil {
return fmt.Errorf("failed to mark order as refunded: %w", err)
}
} else if len(req.OrderItems) > 0 {
// Refund by specific items
totalRefundAmount := float64(0)
for _, itemRefund := range req.OrderItems {
// Get order item
orderItem, err := p.orderItemRepo.GetByID(ctx, itemRefund.OrderItemID)
if err != nil {
return fmt.Errorf("order item not found: %w", err)
}
if orderItem.OrderID != id {
return fmt.Errorf("order item does not belong to this order")
}
// Calculate refund amount for this item
refundQuantity := itemRefund.RefundQuantity
if refundQuantity == 0 {
refundQuantity = orderItem.Quantity
}
if refundQuantity > orderItem.Quantity {
return fmt.Errorf("refund quantity cannot exceed original quantity")
}
refundAmount := float64(refundQuantity) * orderItem.UnitPrice
if itemRefund.RefundAmount != nil {
refundAmount = *itemRefund.RefundAmount
}
// Process item refund
itemReason := reason
if itemRefund.Reason != nil {
itemReason = *itemRefund.Reason
}
if err := p.orderItemRepo.RefundOrderItem(ctx, itemRefund.OrderItemID, refundQuantity, refundAmount, itemReason, refundedBy); err != nil {
return fmt.Errorf("failed to refund order item: %w", err)
}
totalRefundAmount += refundAmount
}
// Update order refund amount
order.RefundAmount = totalRefundAmount
if err := p.orderRepo.Update(ctx, order); err != nil {
return fmt.Errorf("failed to update order refund amount: %w", err)
}
// Mark order as refunded
if err := p.orderRepo.RefundOrder(ctx, id, reason, refundedBy); err != nil {
return fmt.Errorf("failed to mark order as refunded: %w", err)
}
}
return nil
}
func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error) {
order, err := p.orderRepo.GetByID(ctx, req.OrderID)
if err != nil {
return nil, fmt.Errorf("order not found: %w", err)
}
if order.IsVoid {
return nil, fmt.Errorf("cannot process payment for voided order")
}
if order.PaymentStatus == entities.PaymentStatusCompleted {
return nil, fmt.Errorf("order is already fully paid")
}
_, err = p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
if err != nil {
return nil, fmt.Errorf("payment method not found: %w", err)
}
totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, req.OrderID)
if err != nil {
return nil, fmt.Errorf("failed to get total paid: %w", err)
}
payment, err := p.CreatePaymentWithInventoryMovement(ctx, req, order, totalPaid)
if err != nil {
return nil, err
}
paymentWithRelations, err := p.paymentRepo.GetByID(ctx, payment.ID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve created payment: %w", err)
}
response := mappers.PaymentEntityToResponse(paymentWithRelations)
return response, nil
}
func (p *OrderProcessorImpl) RefundPayment(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID) error {
payment, err := p.paymentRepo.GetByID(ctx, paymentID)
if err != nil {
return fmt.Errorf("payment not found: %w", err)
}
if payment.Status != entities.PaymentTransactionStatusCompleted {
return fmt.Errorf("payment is not completed, cannot refund")
}
if refundAmount > payment.Amount {
return fmt.Errorf("refund amount cannot exceed payment amount")
}
return p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment)
}
func (p *OrderProcessorImpl) CreatePaymentWithInventoryMovement(ctx context.Context, req *models.CreatePaymentRequest, order *entities.Order, totalPaid float64) (*entities.Payment, error) {
var payment *entities.Payment
err := p.txManager.WithTransaction(ctx, func(ctx context.Context) error {
var err error
payment, err = p.createPayment(ctx, req)
if err != nil {
return fmt.Errorf("failed to create payment: %w", err)
}
if err := p.updateOrderStatus(ctx, req.OrderID); err != nil {
return fmt.Errorf("failed to update order status: %w", err)
}
if err := p.processInventoryAdjustments(ctx, req.OrderID, order, payment); err != nil {
return fmt.Errorf("failed to process inventory adjustments: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return payment, nil
}
func (p *OrderProcessorImpl) createPayment(ctx context.Context, req *models.CreatePaymentRequest) (*entities.Payment, error) {
payment := &entities.Payment{
OrderID: req.OrderID,
PaymentMethodID: req.PaymentMethodID,
Amount: req.Amount,
Status: entities.PaymentTransactionStatusCompleted,
TransactionID: req.TransactionID,
SplitNumber: req.SplitNumber,
SplitTotal: req.SplitTotal,
SplitDescription: req.SplitDescription,
Metadata: entities.Metadata(req.Metadata),
}
if err := p.paymentRepo.Create(ctx, payment); err != nil {
return nil, fmt.Errorf("failed to create payment: %w", err)
}
return payment, nil
}
func (p *OrderProcessorImpl) updateOrderStatus(ctx context.Context, orderID uuid.UUID) error {
orderUpdate := &entities.Order{
ID: orderID,
Status: entities.OrderStatusCompleted,
PaymentStatus: entities.PaymentStatusCompleted,
}
if err := p.orderRepo.UpdateStatusSuccess(ctx, orderID, orderUpdate.Status, orderUpdate.PaymentStatus); err != nil {
return fmt.Errorf("failed to update order status: %w", err)
}
return nil
}
func (p *OrderProcessorImpl) processInventoryAdjustments(ctx context.Context, orderID uuid.UUID, order *entities.Order, payment *entities.Payment) error {
orderItems, err := p.orderItemRepo.GetByOrderID(ctx, orderID)
if err != nil {
return fmt.Errorf("failed to get order items for inventory adjustment: %w", err)
}
var inventoryMovements []*entities.InventoryMovement
var inventoryUpdates []*entities.Inventory
var ingredientUpdates []*entities.Ingredient
var ingredientMovements []*entities.InventoryMovement
for _, item := range orderItems {
updatedInventory, err := p.prepareProductInventoryUpdate(ctx, item, order.OutletID)
if err != nil {
return fmt.Errorf("failed to prepare product inventory update for product %s: %w", item.ProductID, err)
}
inventoryUpdates = append(inventoryUpdates, updatedInventory)
productMovement := p.prepareProductInventoryMovement(item, order, payment, updatedInventory)
inventoryMovements = append(inventoryMovements, productMovement)
ingredientData, err := p.prepareIngredientRecipeData(ctx, item, order, payment)
if err != nil {
return fmt.Errorf("failed to prepare ingredient recipe data for product %s: %w", item.ProductID, err)
}
ingredientUpdates = append(ingredientUpdates, ingredientData.ingredientUpdates...)
ingredientMovements = append(ingredientMovements, ingredientData.movements...)
}
if len(inventoryUpdates) > 0 {
if err := p.bulkUpdateInventory(ctx, inventoryUpdates); err != nil {
return fmt.Errorf("failed to bulk update product inventory: %w", err)
}
}
allMovements := append(inventoryMovements, ingredientMovements...)
if len(allMovements) > 0 {
if err := p.bulkCreateInventoryMovements(ctx, allMovements); err != nil {
return fmt.Errorf("failed to bulk create inventory movements: %w", err)
}
}
return nil
}
// adjustIngredientInventoryWithTransaction adjusts ingredient inventory within a transaction
func (p *OrderProcessorImpl) adjustIngredientInventoryWithTransaction(ctx context.Context, ingredientID, outletID uuid.UUID, delta float64) (*entities.Inventory, error) {
var inventory entities.Inventory
// Try to get existing ingredient inventory
existingInventory, err := p.inventoryRepo.GetByProductAndOutlet(ctx, ingredientID, outletID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// Create new ingredient inventory record if it doesn't exist
inventory = entities.Inventory{
ProductID: ingredientID,
OutletID: outletID,
Quantity: 0,
ReorderLevel: 0,
}
if err := p.inventoryRepo.Create(ctx, &inventory); err != nil {
return nil, fmt.Errorf("failed to create ingredient inventory record: %w", err)
}
} else {
return nil, err
}
} else {
// Use existing ingredient inventory
inventory = *existingInventory
}
// Update quantity (note: ingredients use float64 quantities, but inventory uses int)
// Convert delta to int for inventory system
deltaInt := int(delta)
inventory.Quantity += deltaInt
if inventory.Quantity < 0 {
inventory.Quantity = 0
}
if err := p.inventoryRepo.Update(ctx, &inventory); err != nil {
return nil, err
}
return &inventory, nil
}
// createIngredientInventoryMovement creates an inventory movement record for ingredient usage
func (p *OrderProcessorImpl) createIngredientInventoryMovement(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment, updatedInventory *entities.Inventory, totalIngredientQuantity float64) error {
ingredient, err := p.getIngredientDetails(ctx, recipe.IngredientID, order.OrganizationID)
if err != nil {
return fmt.Errorf("failed to get ingredient details: %w", err)
}
movement := &entities.InventoryMovement{
OrganizationID: order.OrganizationID,
OutletID: order.OutletID,
ItemID: recipe.IngredientID,
ItemType: "INGREDIENT",
MovementType: entities.InventoryMovementTypeIngredient,
Quantity: -totalIngredientQuantity, // Negative because we're consuming ingredients
PreviousQuantity: float64(updatedInventory.Quantity + int(totalIngredientQuantity)), // Add back the quantity that was subtracted
NewQuantity: float64(updatedInventory.Quantity),
UnitCost: ingredient.Cost, // Use ingredient cost
TotalCost: totalIngredientQuantity * ingredient.Cost,
ReferenceType: func() *entities.InventoryMovementReferenceType {
t := entities.InventoryMovementReferenceTypePayment
return &t
}(),
ReferenceID: &payment.ID,
OrderID: &order.ID,
PaymentID: &payment.ID,
UserID: order.UserID,
Reason: stringPtr("Ingredient consumption from order payment"),
Notes: stringPtr(fmt.Sprintf("Order: %s, Payment: %s, Product: %s, Ingredient: %s", order.OrderNumber, payment.ID, item.Product.Name, ingredient.Name)),
Metadata: entities.Metadata{"order_item_id": item.ID, "product_id": item.ProductID, "ingredient_id": recipe.IngredientID, "recipe_quantity": recipe.Quantity, "order_quantity": item.Quantity},
}
if err := p.inventoryMovementRepo.Create(ctx, movement); err != nil {
return fmt.Errorf("failed to create ingredient inventory movement: %w", err)
}
return nil
}
// getIngredientDetails retrieves ingredient details for cost calculation
func (p *OrderProcessorImpl) getIngredientDetails(ctx context.Context, ingredientID uuid.UUID, organizationID uuid.UUID) (*entities.Ingredient, error) {
ingredient, err := p.ingredientRepo.GetByID(ctx, ingredientID, organizationID)
if err != nil {
return nil, fmt.Errorf("failed to get ingredient details: %w", err)
}
return ingredient, nil
}
// createInventoryMovement creates an inventory movement record for audit trail
func (p *OrderProcessorImpl) createInventoryMovement(ctx context.Context, item *entities.OrderItem, order *entities.Order, payment *entities.Payment, updatedInventory *entities.Inventory) error {
movement := &entities.InventoryMovement{
OrganizationID: order.OrganizationID,
OutletID: order.OutletID,
ItemID: item.ProductID,
ItemType: "PRODUCT",
MovementType: entities.InventoryMovementTypeSale,
Quantity: float64(-item.Quantity),
PreviousQuantity: float64(updatedInventory.Quantity + item.Quantity), // Add back the quantity that was subtracted
NewQuantity: float64(updatedInventory.Quantity),
UnitCost: item.UnitCost,
TotalCost: float64(item.Quantity) * item.UnitCost,
ReferenceType: func() *entities.InventoryMovementReferenceType {
t := entities.InventoryMovementReferenceTypePayment
return &t
}(),
ReferenceID: &payment.ID,
OrderID: &order.ID,
PaymentID: &payment.ID,
UserID: order.UserID,
Reason: stringPtr("Sale from order payment"),
Notes: stringPtr(fmt.Sprintf("Order: %s, Payment: %s", order.OrderNumber, payment.ID)),
Metadata: entities.Metadata{"order_item_id": item.ID},
}
if err := p.inventoryMovementRepo.Create(ctx, movement); err != nil {
return fmt.Errorf("failed to create inventory movement: %w", err)
}
return nil
}
func (p *OrderProcessorImpl) RefundPaymentWithInventoryMovement(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID, payment *entities.Payment) error {
return p.txManager.WithTransaction(ctx, func(ctx context.Context) error {
if err := p.processRefund(ctx, paymentID, refundAmount, reason, refundedBy); err != nil {
return fmt.Errorf("failed to process refund: %w", err)
}
if err := p.updateOrderRefundAmount(ctx, payment.OrderID, refundAmount); err != nil {
return fmt.Errorf("failed to update order refund amount: %w", err)
}
return nil
})
}
func (p *OrderProcessorImpl) processRefund(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID) error {
if err := p.paymentRepo.RefundPayment(ctx, paymentID, refundAmount, reason, refundedBy); err != nil {
return fmt.Errorf("failed to refund payment: %w", err)
}
return nil
}
func (p *OrderProcessorImpl) updateOrderRefundAmount(ctx context.Context, orderID uuid.UUID, refundAmount float64) error {
order, err := p.orderRepo.GetByID(ctx, orderID)
if err != nil {
return fmt.Errorf("failed to get order: %w", err)
}
order.RefundAmount += refundAmount
if err := p.orderRepo.Update(ctx, order); err != nil {
return fmt.Errorf("failed to update order refund amount: %w", err)
}
return nil
}
func (p *OrderProcessorImpl) SetOrderCustomer(ctx context.Context, orderID uuid.UUID, req *models.SetOrderCustomerRequest, organizationID uuid.UUID) (*models.SetOrderCustomerResponse, error) {
order, err := p.orderRepo.GetByID(ctx, orderID)
if err != nil {
return nil, fmt.Errorf("order not found: %w", err)
}
if order.OrganizationID != organizationID {
return nil, fmt.Errorf("order does not belong to the organization")
}
if order.Status != entities.OrderStatusPending {
return nil, fmt.Errorf("customer can only be set for pending orders")
}
// Verify customer exists and belongs to the organization
customer, err := p.customerRepo.GetByIDAndOrganization(ctx, req.CustomerID, organizationID)
if err != nil {
return nil, fmt.Errorf("customer not found or does not belong to the organization: %w", err)
}
// Update order with customer ID
order.CustomerID = &req.CustomerID
if err := p.orderRepo.Update(ctx, order); err != nil {
return nil, fmt.Errorf("failed to update order with customer: %w", err)
}
response := &models.SetOrderCustomerResponse{
OrderID: orderID,
CustomerID: req.CustomerID,
Message: fmt.Sprintf("Customer '%s' successfully set for order", customer.Name),
}
return response, nil
}
func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBillRequest) (*models.SplitBillResponse, error) {
order, err := p.orderRepo.GetWithRelations(ctx, req.OrderID)
if err != nil {
return nil, fmt.Errorf("order not found: %w", err)
}
if order.IsVoid {
return nil, fmt.Errorf("cannot split voided order")
}
if order.PaymentStatus == entities.PaymentStatusCompleted {
return nil, fmt.Errorf("cannot split fully paid order")
}
existingPayments, err := p.paymentRepo.GetByOrderID(ctx, req.OrderID)
if err != nil {
return nil, fmt.Errorf("failed to get existing payments: %w", err)
}
var existingSplitType *entities.SplitType
for _, payment := range existingPayments {
if payment.SplitType != nil && payment.SplitTotal > 1 {
existingSplitType = payment.SplitType
break
}
}
if existingSplitType != nil {
requestedSplitType := entities.SplitTypeAmount
if req.IsItem() {
requestedSplitType = entities.SplitTypeItem
}
if *existingSplitType != requestedSplitType {
return nil, fmt.Errorf("order already has %s split payments. Subsequent payments must use the same split type", *existingSplitType)
}
}
payment, err := p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
if err != nil {
return nil, fmt.Errorf("payment method not found: %w", err)
}
customer := &entities.Customer{}
if req.CustomerID != uuid.Nil {
customer, err = p.customerRepo.GetByIDAndOrganization(ctx, req.CustomerID, order.OrganizationID)
if err != nil && err != gorm.ErrRecordNotFound {
return nil, fmt.Errorf("customer not found or does not belong to the organization: %w", err)
}
}
var response *models.SplitBillResponse
if req.IsAmount() {
response, err = p.splitBillProcessor.SplitByAmount(ctx, req, order, payment, customer)
} else if req.IsItem() {
response, err = p.splitBillProcessor.SplitByItem(ctx, req, order, payment, customer)
} else {
return nil, fmt.Errorf("invalid split type: must be AMOUNT or ITEM")
}
if err != nil {
return nil, err
}
return response, nil
}
// Helper method to adjust inventory within a transaction
func (p *OrderProcessorImpl) adjustInventoryWithTransaction(ctx context.Context, productID, outletID uuid.UUID, delta int) (*entities.Inventory, error) {
var inventory entities.Inventory
// Try to get existing inventory
existingInventory, err := p.inventoryRepo.GetByProductAndOutlet(ctx, productID, outletID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// Create new inventory record if it doesn't exist
inventory = entities.Inventory{
ProductID: productID,
OutletID: outletID,
Quantity: 0,
ReorderLevel: 0,
}
if err := p.inventoryRepo.Create(ctx, &inventory); err != nil {
return nil, fmt.Errorf("failed to create inventory record: %w", err)
}
} else {
return nil, err
}
} else {
// Use existing inventory
inventory = *existingInventory
}
// Update quantity
inventory.UpdateQuantity(delta)
if err := p.inventoryRepo.Update(ctx, &inventory); err != nil {
return nil, err
}
return &inventory, nil
}
func (p *OrderProcessorImpl) createIngredientProductMovement(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment, totalIngredientQuantity float64) error {
ingredient, err := p.getIngredientDetails(ctx, recipe.IngredientID, order.OrganizationID)
if err != nil {
return fmt.Errorf("failed to get ingredient details: %w", err)
}
err = p.inventoryMovementService.CreateProductMovement(
ctx,
recipe.IngredientID, // ingredientID as productID
order.OrganizationID,
order.OutletID,
order.UserID,
entities.InventoryMovementTypeSale, // Movement Type "Sales"
-totalIngredientQuantity, // Negative quantity for consumption
ingredient.Cost, // Unit cost from ingredient
"Ingredient consumption from order payment",
func() *entities.InventoryMovementReferenceType {
t := entities.InventoryMovementReferenceTypePayment
return &t
}(),
&payment.ID,
)
if err != nil {
return fmt.Errorf("failed to create ingredient product movement: %w", err)
}
return nil
}
// createRefundIngredientProductMovement creates a product movement record for ingredient with Movement Type "Refund" and ItemType "INGREDIENT"
func (p *OrderProcessorImpl) createRefundIngredientProductMovement(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment, totalIngredientQuantity float64, refundRatio float64, reason string, refundedBy uuid.UUID, refundAmount float64) error {
// Get ingredient details for cost calculation
ingredient, err := p.getIngredientDetails(ctx, recipe.IngredientID, order.OrganizationID)
if err != nil {
return fmt.Errorf("failed to get ingredient details: %w", err)
}
// Create product movement using the inventory movement service
err = p.inventoryMovementService.CreateProductMovement(
ctx,
recipe.IngredientID, // ingredientID as productID
order.OrganizationID,
order.OutletID,
order.UserID,
entities.InventoryMovementTypeRefund, // Movement Type "Refund"
totalIngredientQuantity, // Positive quantity for restoration
ingredient.Cost, // Unit cost from ingredient
fmt.Sprintf("Ingredient restoration from order refund: %s", reason),
func() *entities.InventoryMovementReferenceType {
t := entities.InventoryMovementReferenceTypeRefund
return &t
}(),
&payment.ID,
)
if err != nil {
return fmt.Errorf("failed to create refund ingredient product movement: %w", err)
}
return nil
}
// prepareProductInventoryUpdate prepares inventory update data without making database calls
func (p *OrderProcessorImpl) prepareProductInventoryUpdate(ctx context.Context, item *entities.OrderItem, outletID uuid.UUID) (*entities.Inventory, error) {
// Get current inventory
currentInventory, err := p.inventoryRepo.GetByProductAndOutlet(ctx, item.ProductID, outletID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// Create new inventory record if it doesn't exist
currentInventory = &entities.Inventory{
ProductID: item.ProductID,
OutletID: outletID,
Quantity: 0,
ReorderLevel: 0,
}
} else {
return nil, err
}
}
// Calculate new quantity
newQuantity := currentInventory.Quantity - item.Quantity
if newQuantity < 0 {
newQuantity = 0
}
// Update the inventory object (don't save to DB yet)
currentInventory.Quantity = newQuantity
return currentInventory, nil
}
// prepareProductInventoryMovement prepares inventory movement data without making database calls
func (p *OrderProcessorImpl) prepareProductInventoryMovement(item *entities.OrderItem, order *entities.Order, payment *entities.Payment, updatedInventory *entities.Inventory) *entities.InventoryMovement {
previousQuantity := updatedInventory.Quantity + item.Quantity
return &entities.InventoryMovement{
OrganizationID: order.OrganizationID,
OutletID: order.OutletID,
ItemID: item.ProductID,
ItemType: "PRODUCT",
MovementType: entities.InventoryMovementTypeSale,
Quantity: float64(-item.Quantity),
PreviousQuantity: float64(previousQuantity),
NewQuantity: float64(updatedInventory.Quantity),
UnitCost: item.UnitCost,
TotalCost: float64(item.Quantity) * item.UnitCost,
ReferenceType: func() *entities.InventoryMovementReferenceType {
t := entities.InventoryMovementReferenceTypePayment
return &t
}(),
ReferenceID: &payment.ID,
OrderID: &order.ID,
PaymentID: &payment.ID,
UserID: order.UserID,
Reason: stringPtr("Sale from order payment"),
Notes: stringPtr(fmt.Sprintf("Order: %s, Payment: %s", order.OrderNumber, payment.ID)),
Metadata: entities.Metadata{"order_item_id": item.ID},
}
}
// ingredientRecipeData holds the collected data for ingredient recipes
type ingredientRecipeData struct {
ingredientUpdates []*entities.Ingredient
movements []*entities.InventoryMovement
}
// prepareIngredientRecipeData prepares ingredient recipe data without making database calls
func (p *OrderProcessorImpl) prepareIngredientRecipeData(ctx context.Context, item *entities.OrderItem, order *entities.Order, payment *entities.Payment) (*ingredientRecipeData, error) {
// Check if the product has ingredients
product, err := p.productRepo.GetByID(ctx, item.ProductID)
if err != nil {
return nil, fmt.Errorf("failed to get product: %w", err)
}
if !product.HasIngredients {
return &ingredientRecipeData{}, nil // Product doesn't have ingredients
}
// Get product recipes based on variant (if any)
var recipes []*entities.ProductRecipe
if item.ProductVariantID != nil {
recipes, err = p.productRecipeRepo.GetByProductAndVariantID(ctx, item.ProductID, item.ProductVariantID, order.OrganizationID)
} else {
recipes, err = p.productRecipeRepo.GetByProductAndVariantID(ctx, item.ProductID, nil, order.OrganizationID)
}
if err != nil {
return nil, fmt.Errorf("failed to get product recipes: %w", err)
}
if len(recipes) == 0 {
return &ingredientRecipeData{}, nil // No recipes found
}
var ingredientUpdates []*entities.Ingredient
var movements []*entities.InventoryMovement
// Process each ingredient in the recipe
for _, recipe := range recipes {
ingredientData, err := p.prepareIngredientRecipeItem(ctx, recipe, item, order, payment)
if err != nil {
return nil, fmt.Errorf("failed to prepare ingredient recipe item: %w", err)
}
ingredientUpdates = append(ingredientUpdates, ingredientData.ingredient)
movements = append(movements, ingredientData.movement)
}
return &ingredientRecipeData{
ingredientUpdates: ingredientUpdates,
movements: movements,
}, nil
}
// ingredientRecipeItem holds data for a single ingredient recipe item
type ingredientRecipeItem struct {
ingredient *entities.Ingredient
movement *entities.InventoryMovement
}
// prepareIngredientRecipeItem prepares data for a single ingredient recipe without making database calls
func (p *OrderProcessorImpl) prepareIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment) (*ingredientRecipeItem, error) {
// Calculate total ingredient quantity needed
totalIngredientQuantity := recipe.Quantity * float64(item.Quantity)
// Get current ingredient details
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
if err != nil {
return nil, fmt.Errorf("failed to get ingredient: %w", err)
}
// For ingredients, we typically don't track quantity in the ingredient entity itself
// Instead, we create inventory movement records to track consumption
// The ingredient entity remains unchanged, but we track the movement
// Prepare movement record
movement := &entities.InventoryMovement{
OrganizationID: order.OrganizationID,
OutletID: order.OutletID,
ItemID: recipe.IngredientID,
ItemType: "INGREDIENT",
MovementType: entities.InventoryMovementTypeIngredient,
Quantity: -totalIngredientQuantity,
PreviousQuantity: 0, // We don't track current quantity in ingredient entity
NewQuantity: 0, // We don't track current quantity in ingredient entity
UnitCost: currentIngredient.Cost,
TotalCost: totalIngredientQuantity * currentIngredient.Cost,
ReferenceType: func() *entities.InventoryMovementReferenceType {
t := entities.InventoryMovementReferenceTypePayment
return &t
}(),
ReferenceID: &payment.ID,
OrderID: &order.ID,
PaymentID: &payment.ID,
UserID: order.UserID,
Reason: stringPtr("Ingredient consumption from order payment"),
Notes: stringPtr(fmt.Sprintf("Order: %s, Payment: %s, Product: %s", order.OrderNumber, payment.ID, item.Product.Name)),
Metadata: entities.Metadata{"order_item_id": item.ID, "product_id": item.ProductID, "ingredient_id": recipe.IngredientID, "recipe_quantity": recipe.Quantity, "order_quantity": item.Quantity},
}
return &ingredientRecipeItem{
ingredient: currentIngredient, // Return unchanged ingredient
movement: movement,
}, nil
}
// bulkUpdateInventory performs bulk update of inventory records
func (p *OrderProcessorImpl) bulkUpdateInventory(ctx context.Context, inventories []*entities.Inventory) error {
if len(inventories) == 0 {
return nil
}
// Use the repository's bulk update method
return p.inventoryRepo.BulkUpdate(ctx, inventories)
}
// bulkCreateInventoryMovements performs bulk creation of inventory movement records
func (p *OrderProcessorImpl) bulkCreateInventoryMovements(ctx context.Context, movements []*entities.InventoryMovement) error {
if len(movements) == 0 {
return nil
}
// Use GORM's CreateInBatches for true bulk creation
// Convert to interface slice for GORM
movementInterfaces := make([]interface{}, len(movements))
for i, movement := range movements {
movementInterfaces[i] = movement
}
// Use the inventory movement repository's bulk create method
// Note: This assumes the repository has a bulk create method
// If not, we can implement it here using GORM's CreateInBatches
return p.inventoryMovementRepo.CreateInBatches(ctx, movements, 100)
}
// prepareRefundProductInventoryUpdate prepares product inventory restoration data without making database calls
func (p *OrderProcessorImpl) prepareRefundProductInventoryUpdate(ctx context.Context, item *entities.OrderItem, outletID uuid.UUID, refundedQuantity int) (*entities.Inventory, error) {
// Get current inventory
currentInventory, err := p.inventoryRepo.GetByProductAndOutlet(ctx, item.ProductID, outletID)
if err != nil {
return nil, fmt.Errorf("failed to get product inventory: %w", err)
}
// Calculate new quantity (restore the refunded quantity)
newQuantity := currentInventory.Quantity + refundedQuantity
// Update the inventory object (don't save to DB yet)
currentInventory.Quantity = newQuantity
return currentInventory, nil
}
// prepareRefundProductInventoryMovement prepares product inventory movement data for refunds
func (p *OrderProcessorImpl) prepareRefundProductInventoryMovement(item *entities.OrderItem, order *entities.Order, payment *entities.Payment, updatedInventory *entities.Inventory, refundedQuantity int, refundRatio float64, reason string, refundedBy uuid.UUID, refundAmount float64) *entities.InventoryMovement {
previousQuantity := updatedInventory.Quantity - refundedQuantity
return &entities.InventoryMovement{
OrganizationID: order.OrganizationID,
OutletID: order.OutletID,
ItemID: item.ProductID,
ItemType: "PRODUCT",
MovementType: entities.InventoryMovementTypeRefund,
Quantity: float64(refundedQuantity),
PreviousQuantity: float64(previousQuantity),
NewQuantity: float64(updatedInventory.Quantity),
UnitCost: item.UnitCost,
TotalCost: float64(refundedQuantity) * item.UnitCost,
ReferenceType: func() *entities.InventoryMovementReferenceType {
t := entities.InventoryMovementReferenceTypeRefund
return &t
}(),
ReferenceID: &payment.ID,
OrderID: &order.ID,
PaymentID: &payment.ID,
UserID: refundedBy,
Reason: stringPtr(fmt.Sprintf("Refund: %s", reason)),
Notes: stringPtr(fmt.Sprintf("Order: %s, Payment: %s, Refund Amount: %.2f", order.OrderNumber, payment.ID, refundAmount)),
Metadata: entities.Metadata{"order_item_id": item.ID, "refund_ratio": refundRatio},
}
}
// prepareRefundedIngredientRecipeData prepares ingredient recipe restoration data for refunds
func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeData(ctx context.Context, item *entities.OrderItem, order *entities.Order, payment *entities.Payment, refundRatio float64, reason string, refundedBy uuid.UUID, refundAmount float64) (*ingredientRecipeData, error) {
// Check if the product has ingredients
product, err := p.productRepo.GetByID(ctx, item.ProductID)
if err != nil {
return nil, fmt.Errorf("failed to get product: %w", err)
}
if !product.HasIngredients {
return &ingredientRecipeData{}, nil // Product doesn't have ingredients
}
// Get product recipes based on variant (if any)
var recipes []*entities.ProductRecipe
if item.ProductVariantID != nil {
recipes, err = p.productRecipeRepo.GetByProductAndVariantID(ctx, item.ProductID, item.ProductVariantID, order.OrganizationID)
} else {
recipes, err = p.productRecipeRepo.GetByProductAndVariantID(ctx, item.ProductID, nil, order.OrganizationID)
}
if err != nil {
return nil, fmt.Errorf("failed to get product recipes: %w", err)
}
if len(recipes) == 0 {
return &ingredientRecipeData{}, nil // No recipes found
}
var ingredientUpdates []*entities.Ingredient
var movements []*entities.InventoryMovement
// Process each ingredient in the recipe
for _, recipe := range recipes {
ingredientData, err := p.prepareRefundedIngredientRecipeItem(ctx, recipe, item, order, payment, refundRatio, reason, refundedBy, refundAmount)
if err != nil {
return nil, fmt.Errorf("failed to prepare ingredient recipe restoration: %w", err)
}
ingredientUpdates = append(ingredientUpdates, ingredientData.ingredient)
movements = append(movements, ingredientData.movement)
}
return &ingredientRecipeData{
ingredientUpdates: ingredientUpdates,
movements: movements,
}, nil
}
// prepareRefundedIngredientRecipeItem prepares data for a single ingredient recipe restoration
func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment, refundRatio float64, reason string, refundedBy uuid.UUID, refundAmount float64) (*ingredientRecipeItem, error) {
// Calculate total ingredient quantity needed based on order item quantity
totalIngredientQuantity := recipe.Quantity * float64(item.Quantity)
// Get current ingredient details
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
if err != nil {
return nil, fmt.Errorf("failed to get ingredient: %w", err)
}
// For ingredients, we typically don't track quantity in the ingredient entity itself
// Instead, we create inventory movement records to track restoration
// The ingredient entity remains unchanged, but we track the movement
// Prepare movement record
movement := &entities.InventoryMovement{
OrganizationID: order.OrganizationID,
OutletID: order.OutletID,
ItemID: recipe.IngredientID,
ItemType: "INGREDIENT",
MovementType: entities.InventoryMovementTypeRefund,
Quantity: totalIngredientQuantity,
PreviousQuantity: 0, // We don't track current quantity in ingredient entity
NewQuantity: 0, // We don't track current quantity in ingredient entity
UnitCost: currentIngredient.Cost,
TotalCost: totalIngredientQuantity * currentIngredient.Cost,
ReferenceType: func() *entities.InventoryMovementReferenceType {
t := entities.InventoryMovementReferenceTypeRefund
return &t
}(),
ReferenceID: &payment.ID,
OrderID: &order.ID,
PaymentID: &payment.ID,
UserID: refundedBy,
Reason: stringPtr(fmt.Sprintf("Refund: %s", reason)),
Notes: stringPtr(fmt.Sprintf("Order: %s, Payment: %s, Refund Amount: %.2f", order.OrderNumber, payment.ID, refundAmount)),
Metadata: entities.Metadata{"order_item_id": item.ID, "refund_ratio": refundRatio},
}
return &ingredientRecipeItem{
ingredient: currentIngredient, // Return unchanged ingredient
movement: movement,
}, nil
}
// Helper function to create string pointer
func stringPtr(s string) *string {
return &s
}