Update payment

This commit is contained in:
Aditya Siregar 2025-08-13 23:14:26 +07:00
parent 451697b783
commit ccb0458189
10 changed files with 849 additions and 308 deletions

View File

@ -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,
}
}

View File

@ -20,6 +20,7 @@ const (
InventoryMovementTypeTransferOut InventoryMovementType = "transfer_out"
InventoryMovementTypeDamage InventoryMovementType = "damage"
InventoryMovementTypeExpiry InventoryMovementType = "expiry"
InventoryMovementTypeIngredient InventoryMovementType = "ingredient"
)
type InventoryMovementReferenceType string

View File

@ -19,6 +19,7 @@ const (
InventoryMovementTypeTransferOut InventoryMovementType = "transfer_out"
InventoryMovementTypeDamage InventoryMovementType = "damage"
InventoryMovementTypeExpiry InventoryMovementType = "expiry"
InventoryMovementTypeIngredient InventoryMovementType = "ingredient"
)
type InventoryMovementReferenceType string

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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
}

View File

@ -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)
})
}

View File

@ -105,6 +105,7 @@ func (r *Router) Init() *gin.Engine {
middleware.Recover(),
middleware.HTTPStatLogger(),
middleware.PopulateContext(),
middleware.CORS(),
)
r.addAppRoutes(engine)