Fix Split Bill

This commit is contained in:
Aditya Siregar
2025-08-07 22:45:02 +07:00
parent 3696451dc6
commit 93a3b29ae9
24 changed files with 894 additions and 197 deletions
+328 -22
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"gorm.io/gorm"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
@@ -24,6 +26,7 @@ type OrderProcessor interface {
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 {
@@ -67,6 +70,14 @@ type PaymentRepository interface {
RefundPaymentWithInventoryMovement(ctx context.Context, paymentID uuid.UUID, refundAmount float64, reason string, refundedBy uuid.UUID, order *entities.Payment) 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)
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)
}
@@ -95,6 +106,7 @@ type OrderProcessorImpl struct {
orderRepo OrderRepository
orderItemRepo OrderItemRepository
paymentRepo PaymentRepository
paymentOrderItemRepo PaymentOrderItemRepository
productRepo ProductRepository
paymentMethodRepo PaymentMethodRepository
inventoryRepo repository.InventoryRepository
@@ -108,6 +120,7 @@ func NewOrderProcessorImpl(
orderRepo OrderRepository,
orderItemRepo OrderItemRepository,
paymentRepo PaymentRepository,
paymentOrderItemRepo PaymentOrderItemRepository,
productRepo ProductRepository,
paymentMethodRepo PaymentMethodRepository,
inventoryRepo repository.InventoryRepository,
@@ -120,6 +133,7 @@ func NewOrderProcessorImpl(
orderRepo: orderRepo,
orderItemRepo: orderItemRepo,
paymentRepo: paymentRepo,
paymentOrderItemRepo: paymentOrderItemRepo,
productRepo: productRepo,
paymentMethodRepo: paymentMethodRepo,
inventoryRepo: inventoryRepo,
@@ -203,23 +217,24 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
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,
PaymentStatus: entities.PaymentStatusPending,
IsVoid: false,
IsRefund: false,
Metadata: metadata,
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 {
@@ -325,6 +340,17 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
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)
@@ -409,6 +435,17 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req
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 {
@@ -489,16 +526,20 @@ func (p *OrderProcessorImpl) ListOrders(ctx context.Context, req *models.ListOrd
return nil, fmt.Errorf("failed to list orders: %w", err)
}
// Convert to responses
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...)
}
}
}
// Calculate total pages
totalPages := int(total) / req.Limit
if int(total)%req.Limit > 0 {
totalPages++
@@ -506,6 +547,7 @@ func (p *OrderProcessorImpl) ListOrders(ctx context.Context, req *models.ListOrd
return &models.ListOrdersResponse{
Orders: orderResponses,
Payments: allPayments,
TotalCount: int(total),
Page: req.Page,
Limit: req.Limit,
@@ -738,6 +780,21 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea
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)
@@ -769,18 +826,15 @@ func (p *OrderProcessorImpl) RefundPayment(ctx context.Context, paymentID uuid.U
}
func (p *OrderProcessorImpl) SetOrderCustomer(ctx context.Context, orderID uuid.UUID, req *models.SetOrderCustomerRequest, organizationID uuid.UUID) (*models.SetOrderCustomerResponse, error) {
// Get the order
order, err := p.orderRepo.GetByID(ctx, orderID)
if err != nil {
return nil, fmt.Errorf("order not found: %w", err)
}
// Verify order belongs to the organization
if order.OrganizationID != organizationID {
return nil, fmt.Errorf("order does not belong to the organization")
}
// Check if order status is pending (only pending orders can have customer set)
if order.Status != entities.OrderStatusPending {
return nil, fmt.Errorf("customer can only be set for pending orders")
}
@@ -805,3 +859,255 @@ func (p *OrderProcessorImpl) SetOrderCustomer(ctx context.Context, orderID uuid.
return response, nil
}
func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBillRequest) (*models.SplitBillResponse, error) {
order, err := p.orderRepo.GetWithRelations(ctx, req.OrderID)
if err != nil {
return nil, fmt.Errorf("order not found: %w", err)
}
if order.IsVoid {
return nil, fmt.Errorf("cannot split voided order")
}
if order.PaymentStatus == entities.PaymentStatusCompleted {
return nil, fmt.Errorf("cannot split fully paid order")
}
existingPayments, err := p.paymentRepo.GetByOrderID(ctx, req.OrderID)
if err != nil {
return nil, fmt.Errorf("failed to get existing payments: %w", err)
}
var existingSplitType *entities.SplitType
for _, payment := range existingPayments {
if payment.SplitType != nil && payment.SplitTotal > 1 {
existingSplitType = payment.SplitType
break
}
}
if existingSplitType != nil {
requestedSplitType := entities.SplitTypeAmount
if req.IsItem() {
requestedSplitType = entities.SplitTypeItem
}
if *existingSplitType != requestedSplitType {
return nil, fmt.Errorf("order already has %s split payments. Subsequent payments must use the same split type", *existingSplitType)
}
}
payment, err := p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
if err != nil {
return nil, fmt.Errorf("payment method not found: %w", err)
}
customer := &entities.Customer{}
if req.CustomerID != uuid.Nil {
customer, err = p.customerRepo.GetByIDAndOrganization(ctx, req.CustomerID, order.OrganizationID)
if err != nil && err != gorm.ErrRecordNotFound {
return nil, fmt.Errorf("customer not found or does not belong to the organization: %w", err)
}
}
var response *models.SplitBillResponse
if req.IsAmount() {
response, err = p.splitBillByAmount(ctx, req, order, payment, customer)
} else if req.IsItem() {
response, err = p.splitBillByItem(ctx, req, order, payment, customer)
} else {
return nil, fmt.Errorf("invalid split type: must be AMOUNT or ITEM")
}
if err != nil {
return nil, err
}
return response, nil
}
func (p *OrderProcessorImpl) splitBillByAmount(ctx context.Context, req *models.SplitBillRequest, order *entities.Order, payment *entities.PaymentMethod, customer *entities.Customer) (*models.SplitBillResponse, error) {
totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, req.OrderID)
if err != nil {
return nil, fmt.Errorf("failed to get total paid amount: %w", err)
}
remainingBalance := order.TotalAmount - totalPaid
if req.Amount > remainingBalance {
return nil, fmt.Errorf("split amount %.2f cannot exceed remaining balance %.2f", req.Amount, remainingBalance)
}
existingPayments, err := p.paymentRepo.GetByOrderID(ctx, req.OrderID)
if err != nil {
return nil, fmt.Errorf("failed to get existing payments: %w", err)
}
splitNumber := len(existingPayments) + 1
splitTotal := splitNumber + 1
splitType := entities.SplitTypeAmount
splitPayment := &entities.Payment{
OrderID: req.OrderID,
PaymentMethodID: payment.ID,
Amount: req.Amount,
Status: entities.PaymentTransactionStatusCompleted,
SplitNumber: splitNumber,
SplitTotal: splitTotal,
SplitType: &splitType,
SplitDescription: stringPtr(fmt.Sprint("Split payment for customer")),
Metadata: entities.Metadata{
"split_type": "AMOUNT",
},
}
if err := p.paymentRepo.Create(ctx, splitPayment); err != nil {
return nil, fmt.Errorf("failed to create split payment: %w", err)
}
if order.Metadata == nil {
order.Metadata = make(entities.Metadata)
}
order.Metadata["last_split_payment_id"] = splitPayment.ID.String()
order.Metadata["last_split_customer_id"] = req.CustomerID.String()
order.Metadata["last_split_amount"] = req.Amount
order.Metadata["last_split_type"] = "AMOUNT"
newTotalPaid := totalPaid + req.Amount
order.RemainingAmount = order.TotalAmount - newTotalPaid
if newTotalPaid >= order.TotalAmount {
order.PaymentStatus = entities.PaymentStatusCompleted
order.Status = entities.OrderStatusCompleted
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: %w", err)
}
return &models.SplitBillResponse{
PaymentID: splitPayment.ID,
OrderID: req.OrderID,
CustomerID: req.CustomerID,
Type: "AMOUNT",
Amount: req.Amount,
Message: fmt.Sprintf("Successfully split payment by amount %.2f for customer %s. Remaining balance: %.2f", req.Amount, customer.Name, order.RemainingAmount),
}, nil
}
func (p *OrderProcessorImpl) splitBillByItem(ctx context.Context, req *models.SplitBillRequest, order *entities.Order, payment *entities.PaymentMethod, customer *entities.Customer) (*models.SplitBillResponse, error) {
totalSplitAmount := float64(0)
for _, item := range req.Items {
totalSplitAmount += item.Amount
}
totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, req.OrderID)
if err != nil {
return nil, fmt.Errorf("failed to get total paid amount: %w", err)
}
remainingBalance := order.TotalAmount - totalPaid
if totalSplitAmount > remainingBalance {
return nil, fmt.Errorf("split amount %.2f cannot exceed remaining balance %.2f", totalSplitAmount, remainingBalance)
}
for _, item := range req.Items {
orderItem, err := p.orderItemRepo.GetByID(ctx, item.OrderItemID)
if err != nil {
return nil, fmt.Errorf("order item not found: %w", err)
}
if orderItem.OrderID != req.OrderID {
return nil, fmt.Errorf("order item does not belong to this order")
}
}
existingPayments, err := p.paymentRepo.GetByOrderID(ctx, req.OrderID)
if err != nil {
return nil, fmt.Errorf("failed to get existing payments: %w", err)
}
splitNumber := len(existingPayments) + 1
splitTotal := splitNumber + 1
splitType := entities.SplitTypeItem
splitPayment := &entities.Payment{
OrderID: req.OrderID,
PaymentMethodID: payment.ID,
Amount: totalSplitAmount,
Status: entities.PaymentTransactionStatusCompleted,
SplitNumber: splitNumber,
SplitTotal: splitTotal,
SplitType: &splitType,
SplitDescription: stringPtr(fmt.Sprintf("Split payment by items for customer: %s", customer.Name)),
Metadata: entities.Metadata{
"split_type": "ITEM",
"customer_id": req.CustomerID.String(),
"customer_name": customer.Name,
},
}
if err := p.paymentRepo.Create(ctx, splitPayment); err != nil {
return nil, fmt.Errorf("failed to create split payment: %w", err)
}
for _, item := range req.Items {
paymentOrderItem := &entities.PaymentOrderItem{
PaymentID: splitPayment.ID,
OrderItemID: item.OrderItemID,
Amount: item.Amount,
}
if err := p.paymentOrderItemRepo.Create(ctx, paymentOrderItem); err != nil {
return nil, fmt.Errorf("failed to create payment order item: %w", err)
}
}
if order.Metadata == nil {
order.Metadata = make(entities.Metadata)
}
order.Metadata["last_split_payment_id"] = splitPayment.ID.String()
order.Metadata["last_split_customer_id"] = req.CustomerID.String()
order.Metadata["last_split_customer_name"] = customer.Name
order.Metadata["last_split_amount"] = totalSplitAmount
order.Metadata["last_split_type"] = "ITEM"
newTotalPaid := totalPaid + totalSplitAmount
order.RemainingAmount = order.TotalAmount - newTotalPaid
if newTotalPaid >= order.TotalAmount {
order.PaymentStatus = entities.PaymentStatusCompleted
order.Status = entities.OrderStatusCompleted
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: %w", err)
}
responseItems := make([]models.SplitBillItemResponse, len(req.Items))
for i, item := range req.Items {
responseItems[i] = models.SplitBillItemResponse{
OrderItemID: item.OrderItemID,
Amount: item.Amount,
}
}
return &models.SplitBillResponse{
PaymentID: splitPayment.ID,
OrderID: req.OrderID,
CustomerID: req.CustomerID,
Type: "ITEM",
Amount: totalSplitAmount,
Items: responseItems,
Message: fmt.Sprintf("Successfully split payment by items (%.2f) for customer %s. Remaining balance: %.2f", totalSplitAmount, customer.Name, order.RemainingAmount),
}, nil
}