From ccb045818965d197488fe0b14126cb31fc0b5ead Mon Sep 17 00:00:00 2001 From: Aditya Siregar Date: Wed, 13 Aug 2025 23:14:26 +0700 Subject: [PATCH] Update payment --- internal/app/app.go | 79 +- internal/entities/inventory_movement.go | 1 + internal/models/inventory_movement.go | 1 + internal/processor/order_processor.go | 804 ++++++++++++++++-- internal/processor/split_bill_processor.go | 4 +- .../inventory_movement_repository.go | 8 + internal/repository/inventory_repository.go | 17 + internal/repository/payment_repository.go | 207 ----- internal/repository/tx_manager.go | 35 + internal/router/router.go | 1 + 10 files changed, 849 insertions(+), 308 deletions(-) create mode 100644 internal/repository/tx_manager.go diff --git a/internal/app/app.go b/internal/app/app.go index 632f667..545e5b7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -142,10 +142,11 @@ type repositories struct { fileRepo *repository.FileRepositoryImpl customerRepo *repository.CustomerRepository analyticsRepo *repository.AnalyticsRepositoryImpl - tableRepo repository.TableRepositoryInterface + tableRepo *repository.TableRepository unitRepo *repository.UnitRepository ingredientRepo *repository.IngredientRepository productRecipeRepo *repository.ProductRecipeRepository + txManager *repository.TxManager } func (a *App) initRepositories() *repositories { @@ -171,52 +172,56 @@ func (a *App) initRepositories() *repositories { unitRepo: repository.NewUnitRepository(a.db), ingredientRepo: repository.NewIngredientRepository(a.db), productRecipeRepo: repository.NewProductRecipeRepository(a.db), + txManager: repository.NewTxManager(a.db), } } type processors struct { - userProcessor *processor.UserProcessorImpl - organizationProcessor processor.OrganizationProcessor - outletProcessor processor.OutletProcessor - outletSettingProcessor *processor.OutletSettingProcessorImpl - categoryProcessor processor.CategoryProcessor - productProcessor processor.ProductProcessor - productVariantProcessor processor.ProductVariantProcessor - inventoryProcessor processor.InventoryProcessor - orderProcessor processor.OrderProcessor - paymentMethodProcessor processor.PaymentMethodProcessor - fileProcessor processor.FileProcessor - customerProcessor *processor.CustomerProcessor - analyticsProcessor *processor.AnalyticsProcessorImpl - tableProcessor *processor.TableProcessor - unitProcessor *processor.UnitProcessorImpl - ingredientProcessor *processor.IngredientProcessorImpl - productRecipeProcessor *processor.ProductRecipeProcessorImpl - fileClient processor.FileClient + userProcessor *processor.UserProcessorImpl + organizationProcessor processor.OrganizationProcessor + outletProcessor processor.OutletProcessor + outletSettingProcessor *processor.OutletSettingProcessorImpl + categoryProcessor processor.CategoryProcessor + productProcessor processor.ProductProcessor + productVariantProcessor processor.ProductVariantProcessor + inventoryProcessor processor.InventoryProcessor + orderProcessor processor.OrderProcessor + paymentMethodProcessor processor.PaymentMethodProcessor + fileProcessor processor.FileProcessor + customerProcessor *processor.CustomerProcessor + analyticsProcessor *processor.AnalyticsProcessorImpl + tableProcessor *processor.TableProcessor + unitProcessor *processor.UnitProcessorImpl + ingredientProcessor *processor.IngredientProcessorImpl + productRecipeProcessor *processor.ProductRecipeProcessorImpl + fileClient processor.FileClient + inventoryMovementService service.InventoryMovementService } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { fileClient := client.NewFileClient(cfg.S3Config) + inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo) return &processors{ - userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo), - organizationProcessor: processor.NewOrganizationProcessorImpl(repos.organizationRepo, repos.outletRepo, repos.userRepo), - outletProcessor: processor.NewOutletProcessorImpl(repos.outletRepo), - outletSettingProcessor: processor.NewOutletSettingProcessorImpl(repos.outletSettingRepo, repos.outletRepo), - categoryProcessor: processor.NewCategoryProcessorImpl(repos.categoryRepo), - productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo), - productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo), - inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo), - orderProcessor: processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo), - paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo), - fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient), - customerProcessor: processor.NewCustomerProcessor(repos.customerRepo), - analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo), - tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo), - unitProcessor: processor.NewUnitProcessor(repos.unitRepo), - ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo), - productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo), - fileClient: fileClient, + userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo), + organizationProcessor: processor.NewOrganizationProcessorImpl(repos.organizationRepo, repos.outletRepo, repos.userRepo), + outletProcessor: processor.NewOutletProcessorImpl(repos.outletRepo), + outletSettingProcessor: processor.NewOutletSettingProcessorImpl(repos.outletSettingRepo, repos.outletRepo), + categoryProcessor: processor.NewCategoryProcessorImpl(repos.categoryRepo), + productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo), + productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo), + inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo), + orderProcessor: processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService), + paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo), + fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient), + customerProcessor: processor.NewCustomerProcessor(repos.customerRepo), + analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo), + tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo), + unitProcessor: processor.NewUnitProcessor(repos.unitRepo), + ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo), + productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo), + fileClient: fileClient, + inventoryMovementService: inventoryMovementService, } } diff --git a/internal/entities/inventory_movement.go b/internal/entities/inventory_movement.go index da07c4d..cc4faa4 100644 --- a/internal/entities/inventory_movement.go +++ b/internal/entities/inventory_movement.go @@ -20,6 +20,7 @@ const ( InventoryMovementTypeTransferOut InventoryMovementType = "transfer_out" InventoryMovementTypeDamage InventoryMovementType = "damage" InventoryMovementTypeExpiry InventoryMovementType = "expiry" + InventoryMovementTypeIngredient InventoryMovementType = "ingredient" ) type InventoryMovementReferenceType string diff --git a/internal/models/inventory_movement.go b/internal/models/inventory_movement.go index 7c787f6..f27c608 100644 --- a/internal/models/inventory_movement.go +++ b/internal/models/inventory_movement.go @@ -19,6 +19,7 @@ const ( InventoryMovementTypeTransferOut InventoryMovementType = "transfer_out" InventoryMovementTypeDamage InventoryMovementType = "damage" InventoryMovementTypeExpiry InventoryMovementType = "expiry" + InventoryMovementTypeIngredient InventoryMovementType = "ingredient" ) type InventoryMovementReferenceType string diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 7e2082b..69977a5 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -1,18 +1,18 @@ package processor import ( + "apskel-pos-be/internal/constants" "context" + "errors" "fmt" - "gorm.io/gorm" - - "apskel-pos-be/internal/constants" "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 { @@ -66,8 +66,6 @@ type PaymentRepository interface { 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) - CreatePaymentWithInventoryMovement(ctx context.Context, req *models.CreatePaymentRequest, order *entities.Order, totalPaid float64) (*entities.Payment, error) - RefundPaymentWithInventoryMovement(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID, order *entities.Payment) error } type PaymentOrderItemRepository interface { @@ -87,31 +85,28 @@ type CustomerRepository interface { GetByIDAndOrganization(ctx context.Context, id, organizationID uuid.UUID) (*entities.Customer, error) } -type SimplePaymentMethodRepository struct{} - -func (r *SimplePaymentMethodRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.PaymentMethod, error) { - // TODO: Implement proper payment method repository - // For now, return a mock payment method - return &entities.PaymentMethod{ - ID: id, - Name: "Cash", - Type: entities.PaymentMethodTypeCash, - }, nil +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 + 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( @@ -126,20 +121,28 @@ func NewOrderProcessorImpl( 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), + 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, } } @@ -769,31 +772,11 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea return nil, fmt.Errorf("failed to get total paid: %w", err) } - remainingAmount := order.TotalAmount - totalPaid - if req.Amount > remainingAmount { - return nil, fmt.Errorf("payment amount exceeds remaining balance") - } - - payment, err := p.paymentRepo.CreatePaymentWithInventoryMovement(ctx, req, order, totalPaid) + payment, err := p.CreatePaymentWithInventoryMovement(ctx, req, order, totalPaid) if err != nil { return nil, err } - // Update order payment status and remaining amount in processor layer - newTotalPaid := totalPaid + req.Amount - order.RemainingAmount = order.TotalAmount - newTotalPaid - - if newTotalPaid >= order.TotalAmount { - order.PaymentStatus = entities.PaymentStatusCompleted - order.RemainingAmount = 0 - } else { - order.PaymentStatus = entities.PaymentStatusPartial - } - - if err := p.orderRepo.Update(ctx, order); err != nil { - return nil, fmt.Errorf("failed to update order payment status: %w", err) - } - paymentWithRelations, err := p.paymentRepo.GetByID(ctx, payment.ID) if err != nil { return nil, fmt.Errorf("failed to retrieve created payment: %w", err) @@ -817,7 +800,271 @@ func (p *OrderProcessorImpl) RefundPayment(ctx context.Context, paymentID uuid.U return fmt.Errorf("refund amount cannot exceed payment amount") } - return p.paymentRepo.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment) + 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.Update(ctx, orderUpdate); 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) { @@ -920,3 +1167,438 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil } 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 +} diff --git a/internal/processor/split_bill_processor.go b/internal/processor/split_bill_processor.go index d5e4f19..da7759c 100644 --- a/internal/processor/split_bill_processor.go +++ b/internal/processor/split_bill_processor.go @@ -22,9 +22,7 @@ const ( MetadataKeyLastSplitQuantities = "last_split_quantities" ) -func stringPtr(s string) *string { - return &s -} + type SplitBillValidation struct { OrderItems map[uuid.UUID]*entities.OrderItem diff --git a/internal/repository/inventory_movement_repository.go b/internal/repository/inventory_movement_repository.go index a00a8b9..f2a7c94 100644 --- a/internal/repository/inventory_movement_repository.go +++ b/internal/repository/inventory_movement_repository.go @@ -19,6 +19,7 @@ type InventoryMovementRepository interface { GetByPaymentID(ctx context.Context, paymentID uuid.UUID) ([]*entities.InventoryMovement, error) Count(ctx context.Context, filters map[string]interface{}) (int64, error) CreateWithTransaction(tx *gorm.DB, movement *entities.InventoryMovement) error + CreateInBatches(ctx context.Context, movements []*entities.InventoryMovement, batchSize int) error } type InventoryMovementRepositoryImpl struct { @@ -39,6 +40,13 @@ func (r *InventoryMovementRepositoryImpl) CreateWithTransaction(tx *gorm.DB, mov return tx.Create(movement).Error } +func (r *InventoryMovementRepositoryImpl) CreateInBatches(ctx context.Context, movements []*entities.InventoryMovement, batchSize int) error { + if len(movements) == 0 { + return nil + } + return r.db.WithContext(ctx).CreateInBatches(movements, batchSize).Error +} + func (r *InventoryMovementRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.InventoryMovement, error) { var movement entities.InventoryMovement err := r.db.WithContext(ctx).First(&movement, "id = ?", id).Error diff --git a/internal/repository/inventory_repository.go b/internal/repository/inventory_repository.go index b472ae6..943e067 100644 --- a/internal/repository/inventory_repository.go +++ b/internal/repository/inventory_repository.go @@ -28,6 +28,7 @@ type InventoryRepository interface { SetQuantity(ctx context.Context, productID, outletID uuid.UUID, quantity int) (*entities.Inventory, error) UpdateReorderLevel(ctx context.Context, id uuid.UUID, reorderLevel int) error BulkCreate(ctx context.Context, inventoryItems []*entities.Inventory) error + BulkUpdate(ctx context.Context, inventoryItems []*entities.Inventory) error BulkAdjustQuantity(ctx context.Context, adjustments map[uuid.UUID]int, outletID uuid.UUID) error GetTotalValueByOutlet(ctx context.Context, outletID uuid.UUID) (float64, error) } @@ -276,6 +277,22 @@ func (r *InventoryRepositoryImpl) BulkCreate(ctx context.Context, inventoryItems return r.db.WithContext(ctx).CreateInBatches(inventoryItems, 100).Error } +func (r *InventoryRepositoryImpl) BulkUpdate(ctx context.Context, inventoryItems []*entities.Inventory) error { + if len(inventoryItems) == 0 { + return nil + } + + // Use GORM's transaction for bulk updates + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, inventory := range inventoryItems { + if err := tx.Save(inventory).Error; err != nil { + return fmt.Errorf("failed to update inventory for product %s: %w", inventory.ProductID, err) + } + } + return nil + }) +} + func (r *InventoryRepositoryImpl) BulkAdjustQuantity(ctx context.Context, adjustments map[uuid.UUID]int, outletID uuid.UUID) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for productID, delta := range adjustments { diff --git a/internal/repository/payment_repository.go b/internal/repository/payment_repository.go index 1067780..ba2d7a4 100644 --- a/internal/repository/payment_repository.go +++ b/internal/repository/payment_repository.go @@ -2,12 +2,9 @@ package repository import ( "context" - "errors" - "fmt" "time" "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" "github.com/google/uuid" "gorm.io/gorm" @@ -95,207 +92,3 @@ func (r *PaymentRepositoryImpl) GetTotalPaidByOrderID(ctx context.Context, order Scan(&total).Error return total, err } - -func (r *PaymentRepositoryImpl) CreatePaymentWithInventoryMovement(ctx context.Context, req *models.CreatePaymentRequest, order *entities.Order, totalPaid float64) (*entities.Payment, error) { - var payment *entities.Payment - var orderJustCompleted bool - - err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) 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 := tx.Create(payment).Error; err != nil { - return fmt.Errorf("failed to create payment: %w", err) - } - - newTotalPaid := totalPaid + req.Amount - if newTotalPaid >= order.TotalAmount { - if order.PaymentStatus != entities.PaymentStatusCompleted { - orderJustCompleted = true - } - if err := tx.Model(&entities.Order{}).Where("id = ?", req.OrderID).Update("status", entities.OrderStatusCompleted).Error; err != nil { - return fmt.Errorf("failed to update order status: %w", err) - } - } - - if orderJustCompleted { - orderItems, err := r.getOrderItemsWithTransaction(tx, req.OrderID) - if err != nil { - return fmt.Errorf("failed to get order items for inventory adjustment: %w", err) - } - - for _, item := range orderItems { - updatedInventory, err := r.adjustInventoryWithTransaction(tx, item.ProductID, order.OutletID, -item.Quantity) - if err != nil { - return fmt.Errorf("failed to adjust inventory for product %s: %w", item.ProductID, err) - } - - 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 := r.createInventoryMovementWithTransaction(tx, movement); err != nil { - return fmt.Errorf("failed to create inventory movement for product %s: %w", item.ProductID, err) - } - } - } - - return nil - }) - - if err != nil { - return nil, err - } - - return payment, nil -} - -func (r *PaymentRepositoryImpl) RefundPaymentWithInventoryMovement(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID, payment *entities.Payment) error { - return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := r.RefundPayment(ctx, paymentID, refundAmount, reason, refundedBy); err != nil { - return fmt.Errorf("failed to refund payment: %w", err) - } - - // Get order for inventory management - order, err := r.getOrderWithTransaction(tx, payment.OrderID) - if err != nil { - return fmt.Errorf("failed to get order: %w", err) - } - - // Update order refund amount - order.RefundAmount += refundAmount - if err := tx.Model(&entities.Order{}).Where("id = ?", order.ID).Update("refund_amount", order.RefundAmount).Error; err != nil { - return fmt.Errorf("failed to update order refund amount: %w", err) - } - - refundRatio := refundAmount / payment.Amount - - orderItems, err := r.getOrderItemsWithTransaction(tx, order.ID) - if err != nil { - return fmt.Errorf("failed to get order items for inventory adjustment: %w", err) - } - - for _, item := range orderItems { - refundedQuantity := int(float64(item.Quantity) * refundRatio) - if refundedQuantity > 0 { - updatedInventory, err := r.adjustInventoryWithTransaction(tx, item.ProductID, order.OutletID, refundedQuantity) - if err != nil { - return fmt.Errorf("failed to restore inventory for product %s: %w", item.ProductID, err) - } - - movement := &entities.InventoryMovement{ - OrganizationID: order.OrganizationID, - OutletID: order.OutletID, - ItemID: item.ProductID, - ItemType: "PRODUCT", - MovementType: entities.InventoryMovementTypeRefund, - Quantity: float64(refundedQuantity), - PreviousQuantity: float64(updatedInventory.Quantity - refundedQuantity), // Subtract the quantity that was added - NewQuantity: float64(updatedInventory.Quantity), - UnitCost: item.UnitCost, - TotalCost: float64(refundedQuantity) * item.UnitCost, - ReferenceType: func() *entities.InventoryMovementReferenceType { - t := entities.InventoryMovementReferenceTypeRefund - return &t - }(), - ReferenceID: &paymentID, - OrderID: &order.ID, - PaymentID: &paymentID, - UserID: refundedBy, - Reason: stringPtr(fmt.Sprintf("Refund: %s", reason)), - Notes: stringPtr(fmt.Sprintf("Order: %s, Payment: %s, Refund Amount: %.2f", order.OrderNumber, paymentID, refundAmount)), - Metadata: entities.Metadata{"order_item_id": item.ID, "refund_ratio": refundRatio}, - } - - if err := r.createInventoryMovementWithTransaction(tx, movement); err != nil { - return fmt.Errorf("failed to create inventory movement for refund product %s: %w", item.ProductID, err) - } - } - } - - return nil - }) -} - -// Helper methods for transaction operations -func (r *PaymentRepositoryImpl) getOrderWithTransaction(tx *gorm.DB, orderID uuid.UUID) (*entities.Order, error) { - var order entities.Order - err := tx.First(&order, "id = ?", orderID).Error - if err != nil { - return nil, err - } - return &order, nil -} - -func (r *PaymentRepositoryImpl) getOrderItemsWithTransaction(tx *gorm.DB, orderID uuid.UUID) ([]*entities.OrderItem, error) { - var orderItems []*entities.OrderItem - err := tx.Where("order_id = ?", orderID).Find(&orderItems).Error - return orderItems, err -} - -func (r *PaymentRepositoryImpl) adjustInventoryWithTransaction(tx *gorm.DB, productID, outletID uuid.UUID, delta int) (*entities.Inventory, error) { - var inventory entities.Inventory - - // Try to find existing inventory - if err := tx.Where("product_id = ? AND outlet_id = ?", productID, outletID).First(&inventory).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - // Inventory doesn't exist, create it with initial quantity - inventory = entities.Inventory{ - ProductID: productID, - OutletID: outletID, - Quantity: 0, - ReorderLevel: 0, - } - if err := tx.Create(&inventory).Error; err != nil { - return nil, fmt.Errorf("failed to create inventory record: %w", err) - } - } else { - return nil, err - } - } - - inventory.UpdateQuantity(delta) - if err := tx.Save(&inventory).Error; err != nil { - return nil, err - } - - return &inventory, nil -} - -func (r *PaymentRepositoryImpl) createInventoryMovementWithTransaction(tx *gorm.DB, movement *entities.InventoryMovement) error { - return tx.Create(movement).Error -} - -// Helper function to create string pointer -func stringPtr(s string) *string { - return &s -} diff --git a/internal/repository/tx_manager.go b/internal/repository/tx_manager.go new file mode 100644 index 0000000..94a19e8 --- /dev/null +++ b/internal/repository/tx_manager.go @@ -0,0 +1,35 @@ +package repository + +import ( + "context" + + "gorm.io/gorm" +) + +type txKeyType struct{} + +var txKey = txKeyType{} + +// DBFromContext returns the transactional *gorm.DB from context if present; otherwise returns base. +func DBFromContext(ctx context.Context, base *gorm.DB) *gorm.DB { + if v := ctx.Value(txKey); v != nil { + if tx, ok := v.(*gorm.DB); ok && tx != nil { + return tx + } + } + return base +} + +type TxManager struct { + db *gorm.DB +} + +func NewTxManager(db *gorm.DB) *TxManager { return &TxManager{db: db} } + +// WithTransaction runs fn inside a DB transaction, injecting the *gorm.DB tx into ctx. +func (m *TxManager) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error { + return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + ctxTx := context.WithValue(ctx, txKey, tx) + return fn(ctxTx) + }) +} diff --git a/internal/router/router.go b/internal/router/router.go index 1149745..c46e966 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -105,6 +105,7 @@ func (r *Router) Init() *gin.Engine { middleware.Recover(), middleware.HTTPStatLogger(), middleware.PopulateContext(), + middleware.CORS(), ) r.addAppRoutes(engine)