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) { var recipes []*entities.ProductRecipe var err error 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 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) { totalIngredientQuantity := recipe.Quantity * float64(item.Quantity) currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID) if err != nil { return nil, fmt.Errorf("failed to get ingredient: %w", err) } currentIngredient.Stock -= totalIngredientQuantity movement := &entities.InventoryMovement{ OrganizationID: order.OrganizationID, OutletID: order.OutletID, ItemID: recipe.IngredientID, ItemType: "INGREDIENT", MovementType: entities.InventoryMovementTypeIngredient, Quantity: -totalIngredientQuantity, PreviousQuantity: 0, NewQuantity: 0, 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 }