From ea9dceb333d5f6b2db5767923c291d2fc6af54a0 Mon Sep 17 00:00:00 2001 From: Efril Date: Wed, 3 Jun 2026 23:59:15 +0700 Subject: [PATCH] fix: prevent race condition on order subtotal calculation --- internal/processor/order_processor.go | 14 ++++++++++++-- internal/repository/tx_manager.go | 13 +++++++++++++ internal/service/order_service.go | 22 +++++++++++++++++++--- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 02e3d48..3d22958 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -338,7 +338,7 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, ProductID: itemReq.ProductID, ProductVariantID: itemReq.ProductVariantID, Quantity: itemReq.Quantity, - UnitPrice: unitPrice, // Use price from database + UnitPrice: unitPrice, TotalPrice: itemTotalPrice, UnitCost: unitCost, TotalCost: itemTotalCost, @@ -594,6 +594,10 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde return fmt.Errorf("order item does not belong to this order") } + if orderItem.Status == entities.OrderItemStatusCancelled { + return fmt.Errorf("order item %s is already cancelled", orderItemID) + } + if itemVoid.Quantity > orderItem.Quantity { return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID) } @@ -614,9 +618,15 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde return fmt.Errorf("outlet not found: %w", err) } + // Reload order to get latest state + order, err = p.orderRepo.GetByID(ctx, req.OrderID) + if err != nil { + return fmt.Errorf("failed to reload order: %w", err) + } + order.Subtotal -= totalVoidedAmount order.TotalCost -= totalVoidedCost - order.TaxAmount = order.Subtotal * outlet.TaxRate // Recalculate tax using outlet's tax rate + order.TaxAmount = order.Subtotal * outlet.TaxRate order.TotalAmount = order.Subtotal + order.TaxAmount - order.DiscountAmount if err := p.orderRepo.Update(ctx, order); err != nil { diff --git a/internal/repository/tx_manager.go b/internal/repository/tx_manager.go index 243a43a..ee9a3f8 100644 --- a/internal/repository/tx_manager.go +++ b/internal/repository/tx_manager.go @@ -2,6 +2,7 @@ package repository import ( "context" + "database/sql" "gorm.io/gorm" ) @@ -37,3 +38,15 @@ func (m *TxManager) WithTransaction(ctx context.Context, fn func(ctx context.Con return fn(ctxTx) }) } + +// WithTransactionOptions runs fn inside a DB transaction with custom TxOptions (e.g. isolation level). +func (m *TxManager) WithTransactionOptions(ctx context.Context, opts *sql.TxOptions, fn func(ctx context.Context) error) error { + if m == nil || m.db == nil { + return fn(ctx) + } + + return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + ctxTx := context.WithValue(ctx, txKey, tx) + return fn(ctxTx) + }, opts) +} diff --git a/internal/service/order_service.go b/internal/service/order_service.go index 84906c3..d7e450c 100644 --- a/internal/service/order_service.go +++ b/internal/service/order_service.go @@ -3,6 +3,7 @@ package service import ( "apskel-pos-be/internal/appcontext" "context" + "database/sql" "fmt" "time" @@ -228,7 +229,9 @@ func (s *OrderServiceImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, re var response *models.AddToOrderResponse var ingredientTransactions []*contract.CreateOrderIngredientTransactionRequest - err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { + err := s.txManager.WithTransactionOptions(ctx, &sql.TxOptions{ + Isolation: sql.LevelSerializable, + }, func(txCtx context.Context) error { addResp, err := s.orderProcessor.AddToOrder(txCtx, orderID, req) if err != nil { return fmt.Errorf("failed to add items to order: %w", err) @@ -305,8 +308,16 @@ func (s *OrderServiceImpl) VoidOrder(ctx context.Context, req *models.VoidOrderR return fmt.Errorf("invalid user ID") } - if err := s.orderProcessor.VoidOrder(ctx, req, voidedBy); err != nil { - return fmt.Errorf("failed to void order: %w", err) + err := s.txManager.WithTransactionOptions(ctx, &sql.TxOptions{ + Isolation: sql.LevelSerializable, + }, func(txCtx context.Context) error { + if err := s.orderProcessor.VoidOrder(txCtx, req, voidedBy); err != nil { + return fmt.Errorf("failed to void order: %w", err) + } + return nil + }) + if err != nil { + return err } if err := s.handleTableReleaseOnVoid(ctx, req.OrderID); err != nil { @@ -561,9 +572,14 @@ func (s *OrderServiceImpl) validateCreatePaymentRequest(req *models.CreatePaymen return fmt.Errorf("payment item amount must be greater than zero for item %d", i+1) } + fmt.Printf("[DEBUG] CreatePayment order_id=%s item[%d] order_item_id=%s amount=%.10f\n", + req.OrderID, i, item.OrderItemID, item.Amount) totalItemAmount += item.Amount } + fmt.Printf("[DEBUG] CreatePayment order_id=%s total_amount=%.10f sum_items=%.10f diff=%.10f\n", + req.OrderID, req.Amount, totalItemAmount, req.Amount-totalItemAmount) + if totalItemAmount != req.Amount { return fmt.Errorf("sum of payment item amounts must equal total payment amount") }