Adds paying with the EnakPoint method (docs/prd-point-coin.md F9, K7,
PC-305).
POST /payments with the EnakPoint method now takes points and the
customer's payment code and goes through PointPaymentProcessor instead of
the generic path, which would record a payment without taking any balance.
After checking the order, its customer (not walk-in, active), the outlet
(accepts EnakPoint, minimum) and the method, it redeems the code, then in
one transaction locks the order row and the wallet, recomputes the F9
limits from fresh data, inserts the payment with points_used and the frozen
point_value, writes the PAYMENT ledger row (key payment:{id}, the outlet,
the cashier) and updates the order. The limits are
min(balance, floor(min(remaining, total × max_payment_percent / 100 − paid
with EnakPoint) / point_value)) in cents, so EnakPoint never pays more than
what is left and gives no change.
Unlike the generic CreatePayment, which always marks the order paid, an
EnakPoint payment leaves it partial with the right remaining amount until
it is settled, so the rest can be paid in cash. Settling it triggers
earning, whose basis leaves out the EnakPoint part. Splitting with the
EnakPoint method is refused. Refusals answer 400. The payment response
carries points_used and point_value for the receipt.
GET /orders/:id/point-payment/preview returns eligibility, balance, point
value and the maximum for the use-maximum button.
The payment and order repositories write outside transactions, so this
path uses its own repository that joins it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1790 lines
65 KiB
Go
1790 lines
65 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"apskel-pos-be/internal/appcontext"
|
|
"apskel-pos-be/internal/constants"
|
|
"apskel-pos-be/internal/entities"
|
|
"apskel-pos-be/internal/logger"
|
|
"apskel-pos-be/internal/mappers"
|
|
"apskel-pos-be/internal/models"
|
|
"apskel-pos-be/internal/repository"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type OrderProcessor interface {
|
|
CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error)
|
|
AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error)
|
|
UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error)
|
|
GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error)
|
|
ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error)
|
|
VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error
|
|
RefundOrder(ctx context.Context, id uuid.UUID, req *models.RefundOrderRequest, refundedBy uuid.UUID) error
|
|
CreatePayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error)
|
|
RefundPayment(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID) error
|
|
SetOrderCustomer(ctx context.Context, orderID uuid.UUID, req *models.SetOrderCustomerRequest, organizationID uuid.UUID) (*models.SetOrderCustomerResponse, error)
|
|
SplitBill(ctx context.Context, req *models.SplitBillRequest) (*models.SplitBillResponse, error)
|
|
}
|
|
|
|
type OrderRepository interface {
|
|
Create(ctx context.Context, order *entities.Order) error
|
|
GetByID(ctx context.Context, id uuid.UUID) (*entities.Order, error)
|
|
GetWithRelations(ctx context.Context, id uuid.UUID) (*entities.Order, error)
|
|
Update(ctx context.Context, order *entities.Order) error
|
|
UpdateStatusSuccess(ctx context.Context, id uuid.UUID, orderStatus entities.OrderStatus, paymentStatus entities.PaymentStatus) error
|
|
Delete(ctx context.Context, id uuid.UUID) error
|
|
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Order, int64, error)
|
|
GetByOrderNumber(ctx context.Context, orderNumber string) (*entities.Order, error)
|
|
ExistsByOrderNumber(ctx context.Context, orderNumber string) (bool, error)
|
|
VoidOrder(ctx context.Context, id uuid.UUID, reason string, voidedBy uuid.UUID) error
|
|
VoidOrderWithStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus, reason string, voidedBy uuid.UUID) error
|
|
RefundOrder(ctx context.Context, id uuid.UUID, reason string, refundedBy uuid.UUID) error
|
|
UpdatePaymentStatus(ctx context.Context, id uuid.UUID, status entities.PaymentStatus) error
|
|
UpdateStatus(ctx context.Context, id uuid.UUID, status entities.OrderStatus) error
|
|
GetNextOrderNumber(ctx context.Context, organizationID, outletID uuid.UUID) (string, error)
|
|
}
|
|
|
|
type OrderItemRepository interface {
|
|
Create(ctx context.Context, orderItem *entities.OrderItem) error
|
|
GetByID(ctx context.Context, id uuid.UUID) (*entities.OrderItem, error)
|
|
GetByOrderID(ctx context.Context, orderID uuid.UUID) ([]*entities.OrderItem, error)
|
|
Update(ctx context.Context, orderItem *entities.OrderItem) error
|
|
Delete(ctx context.Context, id uuid.UUID) error
|
|
RefundOrderItem(ctx context.Context, id uuid.UUID, refundQuantity int, refundAmount float64, reason string, refundedBy uuid.UUID) error
|
|
VoidOrderItem(ctx context.Context, id uuid.UUID, voidQuantity int, reason string, voidedBy uuid.UUID) error
|
|
UpdateStatus(ctx context.Context, id uuid.UUID, status entities.OrderItemStatus) error
|
|
}
|
|
|
|
type PaymentRepository interface {
|
|
Create(ctx context.Context, payment *entities.Payment) error
|
|
GetByID(ctx context.Context, id uuid.UUID) (*entities.Payment, error)
|
|
GetByOrderID(ctx context.Context, orderID uuid.UUID) ([]*entities.Payment, error)
|
|
Update(ctx context.Context, payment *entities.Payment) error
|
|
Delete(ctx context.Context, id uuid.UUID) error
|
|
RefundPayment(ctx context.Context, id uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID) error
|
|
UpdateStatus(ctx context.Context, id uuid.UUID, status entities.PaymentTransactionStatus) error
|
|
GetTotalPaidByOrderID(ctx context.Context, orderID uuid.UUID) (float64, error)
|
|
}
|
|
|
|
type PaymentOrderItemRepository interface {
|
|
Create(ctx context.Context, paymentOrderItem *entities.PaymentOrderItem) error
|
|
GetByID(ctx context.Context, id uuid.UUID) (*entities.PaymentOrderItem, error)
|
|
GetByPaymentID(ctx context.Context, paymentID uuid.UUID) ([]*entities.PaymentOrderItem, error)
|
|
GetPaidQuantitiesByOrderID(ctx context.Context, orderID uuid.UUID) (map[uuid.UUID]int, error)
|
|
Update(ctx context.Context, paymentOrderItem *entities.PaymentOrderItem) error
|
|
Delete(ctx context.Context, id uuid.UUID) error
|
|
}
|
|
|
|
type PaymentMethodRepository interface {
|
|
GetByID(ctx context.Context, id uuid.UUID) (*entities.PaymentMethod, error)
|
|
}
|
|
|
|
type CustomerRepository interface {
|
|
GetByIDAndOrganization(ctx context.Context, id, organizationID uuid.UUID) (*entities.Customer, error)
|
|
}
|
|
|
|
type InventoryMovementService interface {
|
|
CreateIngredientMovement(ctx context.Context, ingredientID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID, purchaseOrderItemID *uuid.UUID) error
|
|
CreateProductMovement(ctx context.Context, productID, organizationID, outletID, userID uuid.UUID, movementType entities.InventoryMovementType, quantity float64, unitCost float64, reason string, referenceType *entities.InventoryMovementReferenceType, referenceID *uuid.UUID) error
|
|
}
|
|
|
|
type OrderProcessorImpl struct {
|
|
orderRepo OrderRepository
|
|
orderItemRepo OrderItemRepository
|
|
paymentRepo PaymentRepository
|
|
paymentOrderItemRepo PaymentOrderItemRepository
|
|
productRepo ProductRepository
|
|
paymentMethodRepo PaymentMethodRepository
|
|
inventoryRepo repository.InventoryRepository
|
|
inventoryMovementRepo repository.InventoryMovementRepository
|
|
productVariantRepo repository.ProductVariantRepository
|
|
outletRepo OutletRepository
|
|
customerRepo CustomerRepository
|
|
splitBillProcessor SplitBillProcessor
|
|
txManager *repository.TxManager
|
|
productRecipeRepo *repository.ProductRecipeRepository
|
|
ingredientRepo IngredientRepository
|
|
inventoryMovementService InventoryMovementService
|
|
productOutletPriceRepo repository.ProductOutletPriceRepository
|
|
loyalty OrderLoyalty
|
|
pointPayments *PointPaymentProcessor
|
|
paymentCodes paymentCodeRedeemer
|
|
}
|
|
|
|
// OrderLoyalty is what the order flow tells and asks the loyalty program
|
|
// (docs/prd-point-coin.md F3, F10). EarningProcessor is one.
|
|
type OrderLoyalty interface {
|
|
// OnOrderPaid runs once an order has just become fully paid and the payment has
|
|
// committed.
|
|
OnOrderPaid(ctx context.Context, orderID uuid.UUID)
|
|
// OnOrderRefunded runs once an order has been voided or (partly) refunded and that
|
|
// has committed.
|
|
OnOrderRefunded(ctx context.Context, orderID uuid.UUID)
|
|
// EarnedByOrders returns what each order earned.
|
|
EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]OrderEarned, error)
|
|
}
|
|
|
|
// OrderEarned is what one order earned, as written in its EARN rows.
|
|
type OrderEarned struct {
|
|
Points int64
|
|
Coins int64
|
|
}
|
|
|
|
// SetLoyalty connects the order flow to the loyalty program.
|
|
func (p *OrderProcessorImpl) SetLoyalty(loyalty OrderLoyalty) {
|
|
p.loyalty = loyalty
|
|
}
|
|
|
|
type paymentCodeRedeemer interface {
|
|
Redeem(ctx context.Context, code string, customerID uuid.UUID) error
|
|
}
|
|
|
|
// SetPointPayments enables paying with the EnakPoint method: CreatePayment hands such
|
|
// payments to pointPayments, approved by the code the customer shows (F9).
|
|
func (p *OrderProcessorImpl) SetPointPayments(pointPayments *PointPaymentProcessor, codes paymentCodeRedeemer) {
|
|
p.pointPayments = pointPayments
|
|
p.paymentCodes = codes
|
|
}
|
|
|
|
// createPointPayment is CreatePayment for the EnakPoint method. It never uses the
|
|
// generic payment path, which would record the payment without taking any balance.
|
|
func (p *OrderProcessorImpl) createPointPayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error) {
|
|
if p.pointPayments == nil || p.paymentCodes == nil {
|
|
return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected)
|
|
}
|
|
if req.Points == nil || req.PaymentCode == nil || *req.PaymentCode == "" {
|
|
return nil, fmt.Errorf("%w: points and the customer's payment code are required", ErrPointPaymentRejected)
|
|
}
|
|
var cashier *uuid.UUID
|
|
if id := appcontext.FromContext(ctx).UserID; id != uuid.Nil {
|
|
cashier = &id
|
|
}
|
|
code := *req.PaymentCode
|
|
result, err := p.pointPayments.Pay(ctx, PointPaymentInput{
|
|
OrderID: req.OrderID,
|
|
PaymentMethodID: req.PaymentMethodID,
|
|
Points: *req.Points,
|
|
CashierID: cashier,
|
|
Authorize: func(ctx context.Context, customerID uuid.UUID) error {
|
|
if err := p.paymentCodes.Redeem(ctx, code, customerID); err != nil {
|
|
return fmt.Errorf("%w: %v", ErrPointPaymentRejected, err)
|
|
}
|
|
return nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if result.Completed {
|
|
p.onOrderPaid(ctx, req.OrderID)
|
|
}
|
|
payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve created payment: %w", err)
|
|
}
|
|
return mappers.PaymentEntityToResponse(payment), nil
|
|
}
|
|
|
|
// onOrderPaid is the single place every path that completes an order's payment goes
|
|
// through: UpdateOrder, CreatePayment and both kinds of split bill. It must be called
|
|
// after the payment has committed. The hook runs detached from the caller's
|
|
// transaction and from the request being cancelled, and anything it does cannot fail
|
|
// the payment.
|
|
func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID) {
|
|
if p.loyalty == nil {
|
|
return
|
|
}
|
|
p.loyalty.OnOrderPaid(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
|
|
}
|
|
|
|
// onOrderRefunded is called at the end of VoidOrder, RefundOrder and RefundPayment,
|
|
// after their writes have committed, with the same guarantees as onOrderPaid: it can
|
|
// never block or fail the void or refund.
|
|
func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) {
|
|
if p.loyalty == nil {
|
|
return
|
|
}
|
|
p.loyalty.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
|
|
}
|
|
|
|
// attachEarnings fills points_earned and coins_earned (F3). A failure is logged and
|
|
// leaves them at zero rather than failing the order read.
|
|
func (p *OrderProcessorImpl) attachEarnings(ctx context.Context, responses ...*models.OrderResponse) {
|
|
if p.loyalty == nil || len(responses) == 0 {
|
|
return
|
|
}
|
|
ids := make([]uuid.UUID, 0, len(responses))
|
|
for _, r := range responses {
|
|
if r != nil {
|
|
ids = append(ids, r.ID)
|
|
}
|
|
}
|
|
earned, err := p.loyalty.EarnedByOrders(repository.DetachTransaction(ctx), ids)
|
|
if err != nil {
|
|
logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::attachEarnings -> failed to read earnings")
|
|
return
|
|
}
|
|
for _, r := range responses {
|
|
if r != nil {
|
|
e := earned[r.ID]
|
|
r.PointsEarned, r.CoinsEarned = e.Points, e.Coins
|
|
}
|
|
}
|
|
}
|
|
|
|
func NewOrderProcessorImpl(
|
|
orderRepo OrderRepository,
|
|
orderItemRepo OrderItemRepository,
|
|
paymentRepo PaymentRepository,
|
|
paymentOrderItemRepo PaymentOrderItemRepository,
|
|
productRepo ProductRepository,
|
|
paymentMethodRepo PaymentMethodRepository,
|
|
inventoryRepo repository.InventoryRepository,
|
|
inventoryMovementRepo repository.InventoryMovementRepository,
|
|
productVariantRepo repository.ProductVariantRepository,
|
|
outletRepo OutletRepository,
|
|
customerRepo CustomerRepository,
|
|
txManager *repository.TxManager,
|
|
productRecipeRepo *repository.ProductRecipeRepository,
|
|
ingredientRepo IngredientRepository,
|
|
inventoryMovementService InventoryMovementService,
|
|
productOutletPriceRepo repository.ProductOutletPriceRepository,
|
|
) *OrderProcessorImpl {
|
|
return &OrderProcessorImpl{
|
|
orderRepo: orderRepo,
|
|
orderItemRepo: orderItemRepo,
|
|
paymentRepo: paymentRepo,
|
|
paymentOrderItemRepo: paymentOrderItemRepo,
|
|
productRepo: productRepo,
|
|
paymentMethodRepo: paymentMethodRepo,
|
|
inventoryRepo: inventoryRepo,
|
|
inventoryMovementRepo: inventoryMovementRepo,
|
|
productVariantRepo: productVariantRepo,
|
|
outletRepo: outletRepo,
|
|
customerRepo: customerRepo,
|
|
splitBillProcessor: NewSplitBillProcessorImpl(orderRepo, orderItemRepo, paymentRepo, paymentOrderItemRepo, outletRepo),
|
|
txManager: txManager,
|
|
productRecipeRepo: productRecipeRepo,
|
|
ingredientRepo: ingredientRepo,
|
|
inventoryMovementService: inventoryMovementService,
|
|
productOutletPriceRepo: productOutletPriceRepo,
|
|
}
|
|
}
|
|
|
|
// resolveLineQuantity reconciles the requested quantity and weight against how the
|
|
// product is sold, returning the values to store on the order line.
|
|
//
|
|
// For weight-based products a line is exactly one weighing, so quantity is pinned to 1
|
|
// and the weighed amount is what gets priced. This mirrors
|
|
// chk_order_items_weight_single_line in the database.
|
|
func resolveLineQuantity(product *entities.Product, quantity int, weight *float64) (int, *float64, error) {
|
|
if product.SellBy == constants.SellByWeight {
|
|
if weight == nil {
|
|
return 0, nil, fmt.Errorf("product %s is sold by weight and requires a weight", product.Name)
|
|
}
|
|
rounded := entities.RoundWeight(*weight)
|
|
if rounded <= 0 {
|
|
return 0, nil, fmt.Errorf("weight for product %s must be greater than 0", product.Name)
|
|
}
|
|
return 1, &rounded, nil
|
|
}
|
|
|
|
if weight != nil {
|
|
return 0, nil, fmt.Errorf("product %s is not sold by weight and must not carry a weight", product.Name)
|
|
}
|
|
if quantity < 1 {
|
|
return 0, nil, fmt.Errorf("quantity for product %s must be at least 1", product.Name)
|
|
}
|
|
return quantity, nil, nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) {
|
|
orderNumber, err := p.orderRepo.GetNextOrderNumber(ctx, organizationID, req.OutletID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate order number: %w", err)
|
|
}
|
|
|
|
outlet, err := p.outletRepo.GetByID(ctx, req.OutletID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("outlet not found: %w", err)
|
|
}
|
|
|
|
var subtotal, totalCost float64
|
|
var orderItems []*entities.OrderItem
|
|
|
|
for _, itemReq := range req.OrderItems {
|
|
product, err := p.productRepo.GetByID(ctx, itemReq.ProductID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("product not found: %w", err)
|
|
}
|
|
|
|
unitPrice := product.Price
|
|
unitCost := product.Cost
|
|
|
|
if p.productOutletPriceRepo != nil {
|
|
if outletPrice, err := p.productOutletPriceRepo.GetByProductAndOutlet(ctx, itemReq.ProductID, req.OutletID); err == nil {
|
|
unitPrice = outletPrice.Price
|
|
}
|
|
}
|
|
|
|
if itemReq.ProductVariantID != nil {
|
|
variant, err := p.productVariantRepo.GetByID(ctx, *itemReq.ProductVariantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("product variant not found: %w", err)
|
|
}
|
|
|
|
if variant.ProductID != itemReq.ProductID {
|
|
return nil, fmt.Errorf("product variant does not belong to the specified product")
|
|
}
|
|
|
|
unitPrice += variant.PriceModifier
|
|
if variant.Cost > 0 {
|
|
unitCost = variant.Cost
|
|
}
|
|
}
|
|
|
|
quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
orderItem := &entities.OrderItem{
|
|
ProductID: itemReq.ProductID,
|
|
ProductVariantID: itemReq.ProductVariantID,
|
|
Quantity: quantity,
|
|
Weight: weight,
|
|
UnitID: product.UnitID,
|
|
UnitPrice: unitPrice,
|
|
UnitCost: unitCost,
|
|
Modifiers: entities.Modifiers(itemReq.Modifiers),
|
|
Notes: itemReq.Notes,
|
|
Metadata: entities.Metadata(itemReq.Metadata),
|
|
Status: entities.OrderItemStatusPending,
|
|
}
|
|
orderItem.CalculateTotalPrice()
|
|
orderItem.CalculateTotalCost()
|
|
|
|
subtotal += orderItem.TotalPrice
|
|
totalCost += orderItem.TotalCost
|
|
|
|
orderItems = append(orderItems, orderItem)
|
|
}
|
|
|
|
taxAmount := subtotal * outlet.TaxRate
|
|
totalAmount := subtotal + taxAmount
|
|
|
|
metadata := entities.Metadata(req.Metadata)
|
|
if req.CustomerName != nil {
|
|
if metadata == nil {
|
|
metadata = make(entities.Metadata)
|
|
}
|
|
metadata["customer_name"] = *req.CustomerName
|
|
}
|
|
order := &entities.Order{
|
|
OrganizationID: organizationID,
|
|
OutletID: req.OutletID,
|
|
UserID: req.UserID,
|
|
CustomerID: req.CustomerID,
|
|
OrderNumber: orderNumber,
|
|
TableNumber: req.TableNumber,
|
|
OrderType: entities.OrderType(req.OrderType),
|
|
Status: entities.OrderStatusPending,
|
|
Subtotal: subtotal,
|
|
TaxAmount: taxAmount,
|
|
DiscountAmount: 0,
|
|
TotalAmount: totalAmount,
|
|
TotalCost: totalCost,
|
|
RemainingAmount: totalAmount, // Initialize remaining amount equal to total amount
|
|
PaymentStatus: entities.PaymentStatusPending,
|
|
IsVoid: false,
|
|
IsRefund: false,
|
|
Metadata: metadata,
|
|
}
|
|
|
|
if err := p.orderRepo.Create(ctx, order); err != nil {
|
|
return nil, fmt.Errorf("failed to create order: %w", err)
|
|
}
|
|
|
|
for _, orderItem := range orderItems {
|
|
orderItem.OrderID = order.ID
|
|
if err := p.orderItemRepo.Create(ctx, orderItem); err != nil {
|
|
return nil, fmt.Errorf("failed to create order item: %w", err)
|
|
}
|
|
}
|
|
|
|
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, order.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve created order: %w", err)
|
|
}
|
|
|
|
response := mappers.OrderEntityToResponse(orderWithRelations)
|
|
p.attachEarnings(ctx, response)
|
|
return response, nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error) {
|
|
order, err := p.orderRepo.GetByID(ctx, orderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("order not found: %w", err)
|
|
}
|
|
|
|
if order.IsVoid {
|
|
return nil, fmt.Errorf("cannot modify voided order")
|
|
}
|
|
|
|
if order.PaymentStatus == entities.PaymentStatusCompleted {
|
|
return nil, fmt.Errorf("cannot modify fully paid order")
|
|
}
|
|
|
|
// Get outlet information for tax rate
|
|
outlet, err := p.outletRepo.GetByID(ctx, order.OutletID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("outlet not found: %w", err)
|
|
}
|
|
|
|
var newSubtotal, newTotalCost float64
|
|
var addedOrderItems []*entities.OrderItem
|
|
|
|
for _, itemReq := range req.OrderItems {
|
|
product, err := p.productRepo.GetByID(ctx, itemReq.ProductID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("product not found: %w", err)
|
|
}
|
|
|
|
// Use product price from database
|
|
unitPrice := product.Price
|
|
unitCost := product.Cost
|
|
|
|
if p.productOutletPriceRepo != nil {
|
|
if outletPrice, err := p.productOutletPriceRepo.GetByProductAndOutlet(ctx, itemReq.ProductID, order.OutletID); err == nil {
|
|
unitPrice = outletPrice.Price
|
|
}
|
|
}
|
|
|
|
// Handle product variant if specified
|
|
if itemReq.ProductVariantID != nil {
|
|
variant, err := p.productVariantRepo.GetByID(ctx, *itemReq.ProductVariantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("product variant not found: %w", err)
|
|
}
|
|
|
|
// Verify variant belongs to the product
|
|
if variant.ProductID != itemReq.ProductID {
|
|
return nil, fmt.Errorf("product variant does not belong to the specified product")
|
|
}
|
|
|
|
// Apply price modifier
|
|
unitPrice += variant.PriceModifier
|
|
// Use variant cost if available, otherwise use product cost
|
|
if variant.Cost > 0 {
|
|
unitCost = variant.Cost
|
|
}
|
|
}
|
|
|
|
quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
orderItem := &entities.OrderItem{
|
|
OrderID: orderID,
|
|
ProductID: itemReq.ProductID,
|
|
ProductVariantID: itemReq.ProductVariantID,
|
|
Quantity: quantity,
|
|
Weight: weight,
|
|
UnitID: product.UnitID,
|
|
UnitPrice: unitPrice,
|
|
UnitCost: unitCost,
|
|
Modifiers: entities.Modifiers(itemReq.Modifiers),
|
|
Notes: itemReq.Notes,
|
|
Metadata: entities.Metadata(itemReq.Metadata),
|
|
Status: entities.OrderItemStatusPending,
|
|
}
|
|
orderItem.CalculateTotalPrice()
|
|
orderItem.CalculateTotalCost()
|
|
|
|
newSubtotal += orderItem.TotalPrice
|
|
newTotalCost += orderItem.TotalCost
|
|
|
|
addedOrderItems = append(addedOrderItems, orderItem)
|
|
}
|
|
|
|
order.Subtotal += newSubtotal
|
|
order.TotalCost += newTotalCost
|
|
// Recalculate tax amount using outlet's tax rate
|
|
order.TaxAmount = order.Subtotal * outlet.TaxRate
|
|
order.TotalAmount = order.Subtotal + order.TaxAmount - order.DiscountAmount
|
|
|
|
// Recalculate remaining amount when items are added
|
|
totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, orderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get total paid amount: %w", err)
|
|
}
|
|
|
|
order.RemainingAmount = order.TotalAmount - totalPaid
|
|
if order.RemainingAmount < 0 {
|
|
order.RemainingAmount = 0
|
|
}
|
|
|
|
if req.Metadata != nil {
|
|
if order.Metadata == nil {
|
|
order.Metadata = make(entities.Metadata)
|
|
}
|
|
for k, v := range req.Metadata {
|
|
order.Metadata[k] = v
|
|
}
|
|
}
|
|
|
|
if err := p.orderRepo.Update(ctx, order); err != nil {
|
|
return nil, fmt.Errorf("failed to update order: %w", err)
|
|
}
|
|
|
|
var addedItemResponses []models.OrderItemResponse
|
|
for _, orderItem := range addedOrderItems {
|
|
if err := p.orderItemRepo.Create(ctx, orderItem); err != nil {
|
|
return nil, fmt.Errorf("failed to create order item: %w", err)
|
|
}
|
|
|
|
itemResponse := mappers.OrderItemEntityToResponse(orderItem, order.OutletID)
|
|
if itemResponse != nil {
|
|
addedItemResponses = append(addedItemResponses, *itemResponse)
|
|
}
|
|
}
|
|
|
|
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, orderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve updated order: %w", err)
|
|
}
|
|
|
|
updatedOrderResponse := mappers.OrderEntityToResponse(orderWithRelations)
|
|
p.attachEarnings(ctx, updatedOrderResponse)
|
|
|
|
return &models.AddToOrderResponse{
|
|
OrderID: orderID,
|
|
OrderNumber: order.OrderNumber,
|
|
AddedItems: addedItemResponses,
|
|
UpdatedOrder: *updatedOrderResponse,
|
|
}, nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error) {
|
|
// Get existing order
|
|
order, err := p.orderRepo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("order not found: %w", err)
|
|
}
|
|
|
|
// Check if order can be modified
|
|
if order.IsVoid {
|
|
return nil, fmt.Errorf("cannot modify voided order")
|
|
}
|
|
|
|
// Apply updates
|
|
if req.TableNumber != nil {
|
|
order.TableNumber = req.TableNumber
|
|
}
|
|
if req.Status != nil {
|
|
order.Status = entities.OrderStatus(*req.Status)
|
|
}
|
|
if req.DiscountAmount != nil {
|
|
order.DiscountAmount = *req.DiscountAmount
|
|
// Recalculate total amount
|
|
order.TotalAmount = order.Subtotal + order.TaxAmount - order.DiscountAmount
|
|
|
|
// Recalculate remaining amount when discount is applied
|
|
totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get total paid amount: %w", err)
|
|
}
|
|
|
|
order.RemainingAmount = order.TotalAmount - totalPaid
|
|
if order.RemainingAmount < 0 {
|
|
order.RemainingAmount = 0
|
|
}
|
|
}
|
|
if req.Metadata != nil {
|
|
if order.Metadata == nil {
|
|
order.Metadata = make(entities.Metadata)
|
|
}
|
|
for k, v := range req.Metadata {
|
|
order.Metadata[k] = v
|
|
}
|
|
}
|
|
|
|
order.Status = entities.OrderStatusCompleted
|
|
order.PaymentStatus = entities.PaymentStatusCompleted
|
|
|
|
if err := p.orderRepo.UpdateStatusSuccess(ctx, order.ID, order.Status, order.PaymentStatus); err != nil {
|
|
return nil, fmt.Errorf("failed to update order: %w", err)
|
|
}
|
|
p.onOrderPaid(ctx, order.ID)
|
|
|
|
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve updated order: %w", err)
|
|
}
|
|
|
|
response := mappers.OrderEntityToResponse(orderWithRelations)
|
|
p.attachEarnings(ctx, response)
|
|
return response, nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error) {
|
|
order, err := p.orderRepo.GetWithRelations(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("order not found: %w", err)
|
|
}
|
|
|
|
response := mappers.OrderEntityToResponse(order)
|
|
p.attachEarnings(ctx, response)
|
|
return response, nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error) {
|
|
filters := make(map[string]interface{})
|
|
if req.OrganizationID != nil {
|
|
filters["organization_id"] = *req.OrganizationID
|
|
}
|
|
if req.OutletID != nil {
|
|
filters["outlet_id"] = *req.OutletID
|
|
}
|
|
if req.UserID != nil {
|
|
filters["user_id"] = *req.UserID
|
|
}
|
|
if req.CustomerID != nil {
|
|
filters["customer_id"] = *req.CustomerID
|
|
}
|
|
if req.OrderType != nil {
|
|
filters["order_type"] = string(*req.OrderType)
|
|
}
|
|
if req.Status != nil {
|
|
filters["status"] = string(*req.Status)
|
|
}
|
|
if req.PaymentStatus != nil {
|
|
filters["payment_status"] = string(*req.PaymentStatus)
|
|
}
|
|
if req.IsVoid != nil {
|
|
filters["is_void"] = *req.IsVoid
|
|
}
|
|
if req.IsRefund != nil {
|
|
filters["is_refund"] = *req.IsRefund
|
|
}
|
|
if req.DateFrom != nil {
|
|
filters["date_from"] = *req.DateFrom
|
|
}
|
|
if req.DateTo != nil {
|
|
filters["date_to"] = *req.DateTo
|
|
}
|
|
if req.Search != "" {
|
|
filters["search"] = req.Search
|
|
}
|
|
|
|
offset := (req.Page - 1) * req.Limit
|
|
orders, total, err := p.orderRepo.List(ctx, filters, req.Limit, offset)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list orders: %w", err)
|
|
}
|
|
|
|
orderResponses := make([]models.OrderResponse, len(orders))
|
|
allPayments := make([]models.PaymentResponse, 0)
|
|
|
|
for i, order := range orders {
|
|
response := mappers.OrderEntityToResponse(order)
|
|
if response != nil {
|
|
orderResponses[i] = *response
|
|
// Add payments from this order to the allPayments list
|
|
if response.Payments != nil {
|
|
allPayments = append(allPayments, response.Payments...)
|
|
}
|
|
}
|
|
}
|
|
listed := make([]*models.OrderResponse, len(orderResponses))
|
|
for i := range orderResponses {
|
|
listed[i] = &orderResponses[i]
|
|
}
|
|
p.attachEarnings(ctx, listed...)
|
|
|
|
totalPages := int(total) / req.Limit
|
|
if int(total)%req.Limit > 0 {
|
|
totalPages++
|
|
}
|
|
|
|
return &models.ListOrdersResponse{
|
|
Orders: orderResponses,
|
|
Payments: allPayments,
|
|
TotalCount: int(total),
|
|
Page: req.Page,
|
|
Limit: req.Limit,
|
|
TotalPages: totalPages,
|
|
}, nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error {
|
|
if req.OrderID != req.OrderID {
|
|
return fmt.Errorf("order ID mismatch: path parameter does not match request body")
|
|
}
|
|
|
|
order, err := p.orderRepo.GetByID(ctx, req.OrderID)
|
|
if err != nil {
|
|
return fmt.Errorf("order not found: %w", err)
|
|
}
|
|
|
|
if order.IsVoid {
|
|
return fmt.Errorf("order is already voided")
|
|
}
|
|
|
|
if order.PaymentStatus == entities.PaymentStatusCompleted {
|
|
return fmt.Errorf("cannot void fully paid order")
|
|
}
|
|
|
|
if req.Type == "ALL" {
|
|
if err := p.orderRepo.VoidOrderWithStatus(ctx, req.OrderID, entities.OrderStatusCancelled, req.Reason, voidedBy); err != nil {
|
|
return fmt.Errorf("failed to void order: %w", err)
|
|
}
|
|
} else if req.Type == "ITEM" {
|
|
if len(req.Items) == 0 {
|
|
return fmt.Errorf("items list is required when voiding specific items")
|
|
}
|
|
|
|
var totalVoidedAmount float64
|
|
var totalVoidedCost float64
|
|
|
|
for _, itemVoid := range req.Items {
|
|
orderItemID := itemVoid.OrderItemID
|
|
|
|
orderItem, err := p.orderItemRepo.GetByID(ctx, orderItemID)
|
|
if err != nil {
|
|
return fmt.Errorf("order item not found: %w", err)
|
|
}
|
|
|
|
if orderItem.OrderID != req.OrderID {
|
|
return fmt.Errorf("order item does not belong to this order")
|
|
}
|
|
|
|
if 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)
|
|
}
|
|
|
|
billable := orderItem.BillableQuantityFor(itemVoid.Quantity)
|
|
voidedAmount := entities.RoundMoney(billable * orderItem.UnitPrice)
|
|
voidedCost := entities.RoundMoney(billable * orderItem.UnitCost)
|
|
|
|
totalVoidedAmount += voidedAmount
|
|
totalVoidedCost += voidedCost
|
|
|
|
if err := p.orderItemRepo.VoidOrderItem(ctx, orderItemID, itemVoid.Quantity, req.Reason, voidedBy); err != nil {
|
|
return fmt.Errorf("failed to void order item %d: %w", itemVoid.OrderItemID, err)
|
|
}
|
|
}
|
|
|
|
outlet, err := p.outletRepo.GetByID(ctx, order.OutletID)
|
|
if err != nil {
|
|
return fmt.Errorf("outlet not found: %w", err)
|
|
}
|
|
|
|
// 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
|
|
order.TotalAmount = order.Subtotal + order.TaxAmount - order.DiscountAmount
|
|
|
|
if err := p.orderRepo.Update(ctx, order); err != nil {
|
|
return fmt.Errorf("failed to update order totals: %w", err)
|
|
}
|
|
|
|
remainingItems, err := p.orderItemRepo.GetByOrderID(ctx, req.OrderID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get remaining order items: %w", err)
|
|
}
|
|
|
|
hasActiveItems := false
|
|
for _, item := range remainingItems {
|
|
if item.Status != entities.OrderItemStatusCancelled {
|
|
hasActiveItems = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !hasActiveItems {
|
|
if err := p.orderRepo.VoidOrderWithStatus(ctx, req.OrderID, entities.OrderStatusCancelled, req.Reason, voidedBy); err != nil {
|
|
return fmt.Errorf("failed to void order after all items voided: %w", err)
|
|
}
|
|
}
|
|
} else {
|
|
return fmt.Errorf("invalid void type: must be 'ALL' or 'ITEM'")
|
|
}
|
|
|
|
// VoidOrder refuses fully paid orders today, so there is usually nothing to take
|
|
// back; this keeps a void correct if that rule changes. The order repository writes
|
|
// outside the caller's transaction, so the void is already committed here.
|
|
p.onOrderRefunded(ctx, req.OrderID)
|
|
return nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req *models.RefundOrderRequest, refundedBy uuid.UUID) error {
|
|
order, err := p.orderRepo.GetWithRelations(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("order not found: %w", err)
|
|
}
|
|
|
|
if order.IsRefund {
|
|
return fmt.Errorf("order is already refunded")
|
|
}
|
|
|
|
if order.PaymentStatus != entities.PaymentStatusCompleted {
|
|
return fmt.Errorf("order is not paid, cannot refund")
|
|
}
|
|
|
|
reason := "No reason provided"
|
|
if req.Reason != nil {
|
|
reason = *req.Reason
|
|
}
|
|
|
|
// Process refund based on request type
|
|
if req.RefundAmount != nil {
|
|
// Full or partial refund by amount
|
|
if *req.RefundAmount > order.TotalAmount {
|
|
return fmt.Errorf("refund amount cannot exceed order total")
|
|
}
|
|
|
|
// Update order refund amount
|
|
order.RefundAmount = *req.RefundAmount
|
|
if err := p.orderRepo.Update(ctx, order); err != nil {
|
|
return fmt.Errorf("failed to update order refund amount: %w", err)
|
|
}
|
|
|
|
// Mark order as refunded
|
|
if err := p.orderRepo.RefundOrder(ctx, id, reason, refundedBy); err != nil {
|
|
return fmt.Errorf("failed to mark order as refunded: %w", err)
|
|
}
|
|
|
|
} else if len(req.OrderItems) > 0 {
|
|
// Refund by specific items
|
|
totalRefundAmount := float64(0)
|
|
|
|
for _, itemRefund := range req.OrderItems {
|
|
// Get order item
|
|
orderItem, err := p.orderItemRepo.GetByID(ctx, itemRefund.OrderItemID)
|
|
if err != nil {
|
|
return fmt.Errorf("order item not found: %w", err)
|
|
}
|
|
|
|
if orderItem.OrderID != id {
|
|
return fmt.Errorf("order item does not belong to this order")
|
|
}
|
|
|
|
// Calculate refund amount for this item
|
|
refundQuantity := itemRefund.RefundQuantity
|
|
if refundQuantity == 0 {
|
|
refundQuantity = orderItem.Quantity
|
|
}
|
|
|
|
if refundQuantity > orderItem.Quantity {
|
|
return fmt.Errorf("refund quantity cannot exceed original quantity")
|
|
}
|
|
|
|
refundAmount := entities.RoundMoney(orderItem.BillableQuantityFor(refundQuantity) * orderItem.UnitPrice)
|
|
if itemRefund.RefundAmount != nil {
|
|
refundAmount = *itemRefund.RefundAmount
|
|
}
|
|
|
|
// Process item refund
|
|
itemReason := reason
|
|
if itemRefund.Reason != nil {
|
|
itemReason = *itemRefund.Reason
|
|
}
|
|
|
|
if err := p.orderItemRepo.RefundOrderItem(ctx, itemRefund.OrderItemID, refundQuantity, refundAmount, itemReason, refundedBy); err != nil {
|
|
return fmt.Errorf("failed to refund order item: %w", err)
|
|
}
|
|
|
|
totalRefundAmount += refundAmount
|
|
}
|
|
|
|
// Update order refund amount
|
|
order.RefundAmount = totalRefundAmount
|
|
if err := p.orderRepo.Update(ctx, order); err != nil {
|
|
return fmt.Errorf("failed to update order refund amount: %w", err)
|
|
}
|
|
|
|
// Mark order as refunded
|
|
if err := p.orderRepo.RefundOrder(ctx, id, reason, refundedBy); err != nil {
|
|
return fmt.Errorf("failed to mark order as refunded: %w", err)
|
|
}
|
|
}
|
|
|
|
p.onOrderRefunded(ctx, id)
|
|
return nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error) {
|
|
order, err := p.orderRepo.GetByID(ctx, req.OrderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("order not found: %w", err)
|
|
}
|
|
|
|
if order.IsVoid {
|
|
return nil, fmt.Errorf("cannot process payment for voided order")
|
|
}
|
|
|
|
if order.PaymentStatus == entities.PaymentStatusCompleted {
|
|
return nil, fmt.Errorf("order is already fully paid")
|
|
}
|
|
|
|
method, err := p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("payment method not found: %w", err)
|
|
}
|
|
if method.Type == entities.PaymentMethodTypePoint {
|
|
return p.createPointPayment(ctx, req)
|
|
}
|
|
|
|
totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, req.OrderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get total paid: %w", err)
|
|
}
|
|
|
|
payment, err := p.CreatePaymentWithInventoryMovement(ctx, req, order, totalPaid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Not from updateOrderStatus: that runs inside the payment's transaction.
|
|
p.onOrderPaid(ctx, req.OrderID)
|
|
|
|
paymentWithRelations, err := p.paymentRepo.GetByID(ctx, payment.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retrieve created payment: %w", err)
|
|
}
|
|
|
|
response := mappers.PaymentEntityToResponse(paymentWithRelations)
|
|
return response, nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) RefundPayment(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID) error {
|
|
payment, err := p.paymentRepo.GetByID(ctx, paymentID)
|
|
if err != nil {
|
|
return fmt.Errorf("payment not found: %w", err)
|
|
}
|
|
|
|
if payment.Status != entities.PaymentTransactionStatusCompleted {
|
|
return fmt.Errorf("payment is not completed, cannot refund")
|
|
}
|
|
|
|
if refundAmount > payment.Amount {
|
|
return fmt.Errorf("refund amount cannot exceed payment amount")
|
|
}
|
|
|
|
if err := p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment); err != nil {
|
|
return err
|
|
}
|
|
p.onOrderRefunded(ctx, payment.OrderID)
|
|
return nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) CreatePaymentWithInventoryMovement(ctx context.Context, req *models.CreatePaymentRequest, order *entities.Order, totalPaid float64) (*entities.Payment, error) {
|
|
var payment *entities.Payment
|
|
|
|
err := p.txManager.WithTransaction(ctx, func(ctx context.Context) error {
|
|
var err error
|
|
payment, err = p.createPayment(ctx, req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create payment: %w", err)
|
|
}
|
|
|
|
if err := p.updateOrderStatus(ctx, req.OrderID); err != nil {
|
|
return fmt.Errorf("failed to update order status: %w", err)
|
|
}
|
|
|
|
if err := p.processInventoryAdjustments(ctx, req.OrderID, order, payment); err != nil {
|
|
return fmt.Errorf("failed to process inventory adjustments: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return payment, nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) createPayment(ctx context.Context, req *models.CreatePaymentRequest) (*entities.Payment, error) {
|
|
payment := &entities.Payment{
|
|
OrderID: req.OrderID,
|
|
PaymentMethodID: req.PaymentMethodID,
|
|
Amount: req.Amount,
|
|
Status: entities.PaymentTransactionStatusCompleted,
|
|
TransactionID: req.TransactionID,
|
|
SplitNumber: req.SplitNumber,
|
|
SplitTotal: req.SplitTotal,
|
|
SplitDescription: req.SplitDescription,
|
|
Metadata: entities.Metadata(req.Metadata),
|
|
}
|
|
|
|
if err := p.paymentRepo.Create(ctx, payment); err != nil {
|
|
return nil, fmt.Errorf("failed to create payment: %w", err)
|
|
}
|
|
|
|
return payment, nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) updateOrderStatus(ctx context.Context, orderID uuid.UUID) error {
|
|
orderUpdate := &entities.Order{
|
|
ID: orderID,
|
|
Status: entities.OrderStatusCompleted,
|
|
PaymentStatus: entities.PaymentStatusCompleted,
|
|
}
|
|
|
|
if err := p.orderRepo.UpdateStatusSuccess(ctx, orderID, orderUpdate.Status, orderUpdate.PaymentStatus); err != nil {
|
|
return fmt.Errorf("failed to update order status: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) processInventoryAdjustments(ctx context.Context, orderID uuid.UUID, order *entities.Order, payment *entities.Payment) error {
|
|
orderItems, err := p.orderItemRepo.GetByOrderID(ctx, orderID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get order items for inventory adjustment: %w", err)
|
|
}
|
|
|
|
var inventoryMovements []*entities.InventoryMovement
|
|
var inventoryUpdates []*entities.Inventory
|
|
var ingredientUpdates []*entities.Ingredient
|
|
var ingredientMovements []*entities.InventoryMovement
|
|
|
|
for _, item := range orderItems {
|
|
updatedInventory, err := p.prepareProductInventoryUpdate(ctx, item, order.OutletID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to prepare product inventory update for product %s: %w", item.ProductID, err)
|
|
}
|
|
inventoryUpdates = append(inventoryUpdates, updatedInventory)
|
|
|
|
productMovement := p.prepareProductInventoryMovement(item, order, payment, updatedInventory)
|
|
inventoryMovements = append(inventoryMovements, productMovement)
|
|
|
|
ingredientData, err := p.prepareIngredientRecipeData(ctx, item, order, payment)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to prepare ingredient recipe data for product %s: %w", item.ProductID, err)
|
|
}
|
|
|
|
ingredientUpdates = append(ingredientUpdates, ingredientData.ingredientUpdates...)
|
|
ingredientMovements = append(ingredientMovements, ingredientData.movements...)
|
|
}
|
|
|
|
if len(inventoryUpdates) > 0 {
|
|
if err := p.bulkUpdateInventory(ctx, inventoryUpdates); err != nil {
|
|
return fmt.Errorf("failed to bulk update product inventory: %w", err)
|
|
}
|
|
}
|
|
|
|
allMovements := append(inventoryMovements, ingredientMovements...)
|
|
if len(allMovements) > 0 {
|
|
if err := p.bulkCreateInventoryMovements(ctx, allMovements); err != nil {
|
|
return fmt.Errorf("failed to bulk create inventory movements: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// adjustIngredientInventoryWithTransaction adjusts ingredient inventory within a transaction
|
|
func (p *OrderProcessorImpl) adjustIngredientInventoryWithTransaction(ctx context.Context, ingredientID, outletID uuid.UUID, delta float64) (*entities.Inventory, error) {
|
|
var inventory entities.Inventory
|
|
|
|
// Try to get existing ingredient inventory
|
|
existingInventory, err := p.inventoryRepo.GetByProductAndOutlet(ctx, ingredientID, outletID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
// Create new ingredient inventory record if it doesn't exist
|
|
inventory = entities.Inventory{
|
|
ProductID: ingredientID,
|
|
OutletID: outletID,
|
|
Quantity: 0,
|
|
ReorderLevel: 0,
|
|
}
|
|
if err := p.inventoryRepo.Create(ctx, &inventory); err != nil {
|
|
return nil, fmt.Errorf("failed to create ingredient inventory record: %w", err)
|
|
}
|
|
} else {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
// Use existing ingredient inventory
|
|
inventory = *existingInventory
|
|
}
|
|
|
|
// Update quantity (note: ingredients use float64 quantities, but inventory uses int)
|
|
// Convert delta to int for inventory system
|
|
deltaInt := int(delta)
|
|
inventory.Quantity += deltaInt
|
|
if inventory.Quantity < 0 {
|
|
inventory.Quantity = 0
|
|
}
|
|
|
|
if err := p.inventoryRepo.Update(ctx, &inventory); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &inventory, nil
|
|
}
|
|
|
|
// createIngredientInventoryMovement creates an inventory movement record for ingredient usage
|
|
func (p *OrderProcessorImpl) createIngredientInventoryMovement(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment, updatedInventory *entities.Inventory, totalIngredientQuantity float64) error {
|
|
ingredient, err := p.getIngredientDetails(ctx, recipe.IngredientID, order.OrganizationID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get ingredient details: %w", err)
|
|
}
|
|
|
|
movement := &entities.InventoryMovement{
|
|
OrganizationID: order.OrganizationID,
|
|
OutletID: order.OutletID,
|
|
ItemID: recipe.IngredientID,
|
|
ItemType: "INGREDIENT",
|
|
MovementType: entities.InventoryMovementTypeIngredient,
|
|
Quantity: -totalIngredientQuantity, // Negative because we're consuming ingredients
|
|
PreviousQuantity: float64(updatedInventory.Quantity + int(totalIngredientQuantity)), // Add back the quantity that was subtracted
|
|
NewQuantity: float64(updatedInventory.Quantity),
|
|
UnitCost: ingredient.Cost, // Use ingredient cost
|
|
TotalCost: totalIngredientQuantity * ingredient.Cost,
|
|
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
|
t := entities.InventoryMovementReferenceTypePayment
|
|
return &t
|
|
}(),
|
|
ReferenceID: &payment.ID,
|
|
OrderID: &order.ID,
|
|
PaymentID: &payment.ID,
|
|
UserID: order.UserID,
|
|
Reason: stringPtr("Ingredient consumption from order payment"),
|
|
Notes: stringPtr(fmt.Sprintf("Order: %s, Payment: %s, Product: %s, Ingredient: %s", order.OrderNumber, payment.ID, item.Product.Name, ingredient.Name)),
|
|
Metadata: entities.Metadata{"order_item_id": item.ID, "product_id": item.ProductID, "ingredient_id": recipe.IngredientID, "recipe_quantity": recipe.Quantity, "order_quantity": item.Quantity},
|
|
}
|
|
|
|
if err := p.inventoryMovementRepo.Create(ctx, movement); err != nil {
|
|
return fmt.Errorf("failed to create ingredient inventory movement: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// getIngredientDetails retrieves ingredient details for cost calculation
|
|
func (p *OrderProcessorImpl) getIngredientDetails(ctx context.Context, ingredientID uuid.UUID, organizationID uuid.UUID) (*entities.Ingredient, error) {
|
|
ingredient, err := p.ingredientRepo.GetByID(ctx, ingredientID, organizationID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get ingredient details: %w", err)
|
|
}
|
|
return ingredient, nil
|
|
}
|
|
|
|
// createInventoryMovement creates an inventory movement record for audit trail
|
|
func (p *OrderProcessorImpl) createInventoryMovement(ctx context.Context, item *entities.OrderItem, order *entities.Order, payment *entities.Payment, updatedInventory *entities.Inventory) error {
|
|
movement := &entities.InventoryMovement{
|
|
OrganizationID: order.OrganizationID,
|
|
OutletID: order.OutletID,
|
|
ItemID: item.ProductID,
|
|
ItemType: "PRODUCT",
|
|
MovementType: entities.InventoryMovementTypeSale,
|
|
Quantity: float64(-item.Quantity),
|
|
PreviousQuantity: float64(updatedInventory.Quantity + item.Quantity), // Add back the quantity that was subtracted
|
|
NewQuantity: float64(updatedInventory.Quantity),
|
|
UnitCost: item.UnitCost,
|
|
TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost),
|
|
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
|
t := entities.InventoryMovementReferenceTypePayment
|
|
return &t
|
|
}(),
|
|
ReferenceID: &payment.ID,
|
|
OrderID: &order.ID,
|
|
PaymentID: &payment.ID,
|
|
UserID: order.UserID,
|
|
Reason: stringPtr("Sale from order payment"),
|
|
Notes: stringPtr(fmt.Sprintf("Order: %s, Payment: %s", order.OrderNumber, payment.ID)),
|
|
Metadata: entities.Metadata{"order_item_id": item.ID},
|
|
}
|
|
|
|
if err := p.inventoryMovementRepo.Create(ctx, movement); err != nil {
|
|
return fmt.Errorf("failed to create inventory movement: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) RefundPaymentWithInventoryMovement(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID, payment *entities.Payment) error {
|
|
return p.txManager.WithTransaction(ctx, func(ctx context.Context) error {
|
|
if err := p.processRefund(ctx, paymentID, refundAmount, reason, refundedBy); err != nil {
|
|
return fmt.Errorf("failed to process refund: %w", err)
|
|
}
|
|
|
|
if err := p.updateOrderRefundAmount(ctx, payment.OrderID, refundAmount); err != nil {
|
|
return fmt.Errorf("failed to update order refund amount: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) processRefund(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID) error {
|
|
if err := p.paymentRepo.RefundPayment(ctx, paymentID, refundAmount, reason, refundedBy); err != nil {
|
|
return fmt.Errorf("failed to refund payment: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) updateOrderRefundAmount(ctx context.Context, orderID uuid.UUID, refundAmount float64) error {
|
|
order, err := p.orderRepo.GetByID(ctx, orderID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get order: %w", err)
|
|
}
|
|
|
|
order.RefundAmount += refundAmount
|
|
if err := p.orderRepo.Update(ctx, order); err != nil {
|
|
return fmt.Errorf("failed to update order refund amount: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) SetOrderCustomer(ctx context.Context, orderID uuid.UUID, req *models.SetOrderCustomerRequest, organizationID uuid.UUID) (*models.SetOrderCustomerResponse, error) {
|
|
order, err := p.orderRepo.GetByID(ctx, orderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("order not found: %w", err)
|
|
}
|
|
|
|
if order.OrganizationID != organizationID {
|
|
return nil, fmt.Errorf("order does not belong to the organization")
|
|
}
|
|
|
|
if order.Status != entities.OrderStatusPending {
|
|
return nil, fmt.Errorf("customer can only be set for pending orders")
|
|
}
|
|
|
|
// Verify customer exists and belongs to the organization
|
|
customer, err := p.customerRepo.GetByIDAndOrganization(ctx, req.CustomerID, organizationID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("customer not found or does not belong to the organization: %w", err)
|
|
}
|
|
|
|
// Update order with customer ID
|
|
order.CustomerID = &req.CustomerID
|
|
if err := p.orderRepo.Update(ctx, order); err != nil {
|
|
return nil, fmt.Errorf("failed to update order with customer: %w", err)
|
|
}
|
|
|
|
response := &models.SetOrderCustomerResponse{
|
|
OrderID: orderID,
|
|
CustomerID: req.CustomerID,
|
|
Message: fmt.Sprintf("Customer '%s' successfully set for order", customer.Name),
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBillRequest) (*models.SplitBillResponse, error) {
|
|
order, err := p.orderRepo.GetWithRelations(ctx, req.OrderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("order not found: %w", err)
|
|
}
|
|
|
|
if order.IsVoid {
|
|
return nil, fmt.Errorf("cannot split voided order")
|
|
}
|
|
|
|
if order.PaymentStatus == entities.PaymentStatusCompleted {
|
|
return nil, fmt.Errorf("cannot split fully paid order")
|
|
}
|
|
|
|
existingPayments, err := p.paymentRepo.GetByOrderID(ctx, req.OrderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get existing payments: %w", err)
|
|
}
|
|
|
|
var existingSplitType *entities.SplitType
|
|
for _, payment := range existingPayments {
|
|
if payment.SplitType != nil && payment.SplitTotal > 1 {
|
|
existingSplitType = payment.SplitType
|
|
break
|
|
}
|
|
}
|
|
|
|
if existingSplitType != nil {
|
|
requestedSplitType := entities.SplitTypeAmount
|
|
if req.IsItem() {
|
|
requestedSplitType = entities.SplitTypeItem
|
|
}
|
|
|
|
if *existingSplitType != requestedSplitType {
|
|
return nil, fmt.Errorf("order already has %s split payments. Subsequent payments must use the same split type", *existingSplitType)
|
|
}
|
|
}
|
|
|
|
payment, err := p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("payment method not found: %w", err)
|
|
}
|
|
// Splitting with EnakPoint would record a payment without taking any balance; an
|
|
// EnakPoint part goes through CreatePayment and the rest is split as usual (F9).
|
|
if payment.Type == entities.PaymentMethodTypePoint {
|
|
return nil, fmt.Errorf("%w: pay the EnakPoint part as its own payment, not as a split", ErrPointPaymentRejected)
|
|
}
|
|
|
|
customer := &entities.Customer{}
|
|
if req.CustomerID != uuid.Nil {
|
|
customer, err = p.customerRepo.GetByIDAndOrganization(ctx, req.CustomerID, order.OrganizationID)
|
|
|
|
if err != nil && err != gorm.ErrRecordNotFound {
|
|
return nil, fmt.Errorf("customer not found or does not belong to the organization: %w", err)
|
|
}
|
|
}
|
|
|
|
var response *models.SplitBillResponse
|
|
if req.IsAmount() {
|
|
response, err = p.splitBillProcessor.SplitByAmount(ctx, req, order, payment, customer)
|
|
} else if req.IsItem() {
|
|
response, err = p.splitBillProcessor.SplitByItem(ctx, req, order, payment, customer)
|
|
} else {
|
|
return nil, fmt.Errorf("invalid split type: must be AMOUNT or ITEM")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Both split paths mark the order paid on the payment that settles it.
|
|
if order.PaymentStatus == entities.PaymentStatusCompleted {
|
|
p.onOrderPaid(ctx, order.ID)
|
|
}
|
|
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: entities.RoundMoney(item.BillableQuantity() * item.UnitCost),
|
|
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
|
t := entities.InventoryMovementReferenceTypePayment
|
|
return &t
|
|
}(),
|
|
ReferenceID: &payment.ID,
|
|
OrderID: &order.ID,
|
|
PaymentID: &payment.ID,
|
|
UserID: order.UserID,
|
|
Reason: stringPtr("Sale from order payment"),
|
|
Notes: stringPtr(fmt.Sprintf("Order: %s, Payment: %s", order.OrderNumber, payment.ID)),
|
|
Metadata: entities.Metadata{"order_item_id": item.ID},
|
|
}
|
|
}
|
|
|
|
// ingredientRecipeData holds the collected data for ingredient recipes
|
|
type ingredientRecipeData struct {
|
|
ingredientUpdates []*entities.Ingredient
|
|
movements []*entities.InventoryMovement
|
|
}
|
|
|
|
// prepareIngredientRecipeData prepares ingredient recipe data without making database calls
|
|
func (p *OrderProcessorImpl) prepareIngredientRecipeData(ctx context.Context, item *entities.OrderItem, order *entities.Order, payment *entities.Payment) (*ingredientRecipeData, error) {
|
|
var recipes []*entities.ProductRecipe
|
|
var err error
|
|
if item.ProductVariantID != nil {
|
|
recipes, err = p.productRecipeRepo.GetByProductAndVariantID(ctx, item.ProductID, item.ProductVariantID, order.OrganizationID)
|
|
} else {
|
|
recipes, err = p.productRecipeRepo.GetByProductAndVariantID(ctx, item.ProductID, nil, order.OrganizationID)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get product recipes: %w", err)
|
|
}
|
|
|
|
if len(recipes) == 0 {
|
|
return &ingredientRecipeData{}, nil // No recipes found
|
|
}
|
|
|
|
var ingredientUpdates []*entities.Ingredient
|
|
var movements []*entities.InventoryMovement
|
|
|
|
for _, recipe := range recipes {
|
|
ingredientData, err := p.prepareIngredientRecipeItem(ctx, recipe, item, order, payment)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to prepare ingredient recipe item: %w", err)
|
|
}
|
|
|
|
ingredientUpdates = append(ingredientUpdates, ingredientData.ingredient)
|
|
movements = append(movements, ingredientData.movement)
|
|
}
|
|
|
|
return &ingredientRecipeData{
|
|
ingredientUpdates: ingredientUpdates,
|
|
movements: movements,
|
|
}, nil
|
|
}
|
|
|
|
// ingredientRecipeItem holds data for a single ingredient recipe item
|
|
type ingredientRecipeItem struct {
|
|
ingredient *entities.Ingredient
|
|
movement *entities.InventoryMovement
|
|
}
|
|
|
|
// prepareIngredientRecipeItem prepares data for a single ingredient recipe without making database calls
|
|
func (p *OrderProcessorImpl) prepareIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment) (*ingredientRecipeItem, error) {
|
|
totalIngredientQuantity := recipe.Quantity * item.BillableQuantity()
|
|
|
|
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get ingredient: %w", err)
|
|
}
|
|
|
|
currentIngredient.Stock -= totalIngredientQuantity
|
|
|
|
movement := &entities.InventoryMovement{
|
|
OrganizationID: order.OrganizationID,
|
|
OutletID: order.OutletID,
|
|
ItemID: recipe.IngredientID,
|
|
ItemType: "INGREDIENT",
|
|
MovementType: entities.InventoryMovementTypeIngredient,
|
|
Quantity: -totalIngredientQuantity,
|
|
PreviousQuantity: 0,
|
|
NewQuantity: 0,
|
|
UnitCost: currentIngredient.Cost,
|
|
TotalCost: totalIngredientQuantity * currentIngredient.Cost,
|
|
ReferenceType: func() *entities.InventoryMovementReferenceType {
|
|
t := entities.InventoryMovementReferenceTypePayment
|
|
return &t
|
|
}(),
|
|
ReferenceID: &payment.ID,
|
|
OrderID: &order.ID,
|
|
PaymentID: &payment.ID,
|
|
UserID: order.UserID,
|
|
Reason: stringPtr("Ingredient consumption from order payment"),
|
|
Notes: stringPtr(fmt.Sprintf("Order: %s, Payment: %s, Product: %s", order.OrderNumber, payment.ID, item.Product.Name)),
|
|
Metadata: entities.Metadata{"order_item_id": item.ID, "product_id": item.ProductID, "ingredient_id": recipe.IngredientID, "recipe_quantity": recipe.Quantity, "order_quantity": item.Quantity},
|
|
}
|
|
|
|
return &ingredientRecipeItem{
|
|
ingredient: currentIngredient, // Return unchanged ingredient
|
|
movement: movement,
|
|
}, nil
|
|
}
|
|
|
|
// bulkUpdateInventory performs bulk update of inventory records
|
|
func (p *OrderProcessorImpl) bulkUpdateInventory(ctx context.Context, inventories []*entities.Inventory) error {
|
|
if len(inventories) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Use the repository's bulk update method
|
|
return p.inventoryRepo.BulkUpdate(ctx, inventories)
|
|
}
|
|
|
|
// bulkCreateInventoryMovements performs bulk creation of inventory movement records
|
|
func (p *OrderProcessorImpl) bulkCreateInventoryMovements(ctx context.Context, movements []*entities.InventoryMovement) error {
|
|
if len(movements) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Use GORM's CreateInBatches for true bulk creation
|
|
// Convert to interface slice for GORM
|
|
movementInterfaces := make([]interface{}, len(movements))
|
|
for i, movement := range movements {
|
|
movementInterfaces[i] = movement
|
|
}
|
|
|
|
// Use the inventory movement repository's bulk create method
|
|
// Note: This assumes the repository has a bulk create method
|
|
// If not, we can implement it here using GORM's CreateInBatches
|
|
return p.inventoryMovementRepo.CreateInBatches(ctx, movements, 100)
|
|
}
|
|
|
|
// prepareRefundProductInventoryUpdate prepares product inventory restoration data without making database calls
|
|
func (p *OrderProcessorImpl) prepareRefundProductInventoryUpdate(ctx context.Context, item *entities.OrderItem, outletID uuid.UUID, refundedQuantity int) (*entities.Inventory, error) {
|
|
// Get current inventory
|
|
currentInventory, err := p.inventoryRepo.GetByProductAndOutlet(ctx, item.ProductID, outletID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get product inventory: %w", err)
|
|
}
|
|
|
|
// Calculate new quantity (restore the refunded quantity)
|
|
newQuantity := currentInventory.Quantity + refundedQuantity
|
|
|
|
// Update the inventory object (don't save to DB yet)
|
|
currentInventory.Quantity = newQuantity
|
|
return currentInventory, nil
|
|
}
|
|
|
|
// prepareRefundProductInventoryMovement prepares product inventory movement data for refunds
|
|
func (p *OrderProcessorImpl) prepareRefundProductInventoryMovement(item *entities.OrderItem, order *entities.Order, payment *entities.Payment, updatedInventory *entities.Inventory, refundedQuantity int, refundRatio float64, reason string, refundedBy uuid.UUID, refundAmount float64) *entities.InventoryMovement {
|
|
previousQuantity := updatedInventory.Quantity - refundedQuantity
|
|
|
|
return &entities.InventoryMovement{
|
|
OrganizationID: order.OrganizationID,
|
|
OutletID: order.OutletID,
|
|
ItemID: item.ProductID,
|
|
ItemType: "PRODUCT",
|
|
MovementType: entities.InventoryMovementTypeRefund,
|
|
Quantity: float64(refundedQuantity),
|
|
PreviousQuantity: float64(previousQuantity),
|
|
NewQuantity: float64(updatedInventory.Quantity),
|
|
UnitCost: item.UnitCost,
|
|
TotalCost: entities.RoundMoney(item.BillableQuantityFor(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 * item.BillableQuantity()
|
|
|
|
// 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
|
|
}
|