diff --git a/internal/app/app.go b/internal/app/app.go index 60af15d..5058603 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -135,6 +135,7 @@ type repositories struct { orderRepo *repository.OrderRepositoryImpl orderItemRepo *repository.OrderItemRepositoryImpl paymentRepo *repository.PaymentRepositoryImpl + paymentOrderItemRepo *repository.PaymentOrderItemRepositoryImpl paymentMethodRepo *repository.PaymentMethodRepositoryImpl fileRepo *repository.FileRepositoryImpl customerRepo *repository.CustomerRepository @@ -158,6 +159,7 @@ func (a *App) initRepositories() *repositories { orderRepo: repository.NewOrderRepositoryImpl(a.db), orderItemRepo: repository.NewOrderItemRepositoryImpl(a.db), paymentRepo: repository.NewPaymentRepositoryImpl(a.db), + paymentOrderItemRepo: repository.NewPaymentOrderItemRepositoryImpl(a.db), paymentMethodRepo: repository.NewPaymentMethodRepositoryImpl(a.db), fileRepo: repository.NewFileRepositoryImpl(a.db), customerRepo: repository.NewCustomerRepository(a.db), @@ -199,7 +201,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor 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.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo), + 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), diff --git a/internal/contract/order_contract.go b/internal/contract/order_contract.go index 4279e9f..cc4e922 100644 --- a/internal/contract/order_contract.go +++ b/internal/contract/order_contract.go @@ -56,23 +56,38 @@ type UpdateOrderItemRequest struct { } type OrderResponse struct { - ID uuid.UUID `json:"id"` - OrderNumber string `json:"order_number"` - OutletID uuid.UUID `json:"outlet_id"` - UserID uuid.UUID `json:"user_id"` - TableNumber *string `json:"table_number"` - OrderType string `json:"order_type"` - Status string `json:"status"` - Subtotal float64 `json:"subtotal"` - TaxAmount float64 `json:"tax_amount"` - DiscountAmount float64 `json:"discount_amount"` - TotalAmount float64 `json:"total_amount"` - Notes *string `json:"notes"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - OrderItems []OrderItemResponse `json:"order_items,omitempty"` - IsRefund bool `json:"is_refund"` + ID uuid.UUID `json:"id"` + OrderNumber string `json:"order_number"` + OutletID uuid.UUID `json:"outlet_id"` + UserID uuid.UUID `json:"user_id"` + TableNumber *string `json:"table_number"` + OrderType string `json:"order_type"` + Status string `json:"status"` + Subtotal float64 `json:"subtotal"` + TaxAmount float64 `json:"tax_amount"` + DiscountAmount float64 `json:"discount_amount"` + TotalAmount float64 `json:"total_amount"` + TotalCost float64 `json:"total_cost"` + RemainingAmount float64 `json:"remaining_amount"` + PaymentStatus string `json:"payment_status"` + RefundAmount float64 `json:"refund_amount"` + IsVoid bool `json:"is_void"` + IsRefund bool `json:"is_refund"` + VoidReason *string `json:"void_reason,omitempty"` + VoidedAt *time.Time `json:"voided_at,omitempty"` + VoidedBy *uuid.UUID `json:"voided_by,omitempty"` + RefundReason *string `json:"refund_reason,omitempty"` + RefundedAt *time.Time `json:"refunded_at,omitempty"` + RefundedBy *uuid.UUID `json:"refunded_by,omitempty"` + Notes *string `json:"notes"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + OrderItems []OrderItemResponse `json:"order_items,omitempty"` + Payments []PaymentResponse `json:"payments,omitempty"` + TotalPaid float64 `json:"total_paid"` + PaymentCount int `json:"payment_count"` + SplitType *string `json:"split_type,omitempty"` } type OrderItemResponse struct { @@ -123,11 +138,12 @@ type ListOrdersRequest struct { } type ListOrdersResponse struct { - Orders []OrderResponse `json:"orders"` - TotalCount int `json:"total_count"` - Page int `json:"page"` - Limit int `json:"limit"` - TotalPages int `json:"total_pages"` + Orders []OrderResponse `json:"orders"` + Payments []PaymentResponse `json:"payments"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` } type VoidOrderRequest struct { @@ -152,7 +168,6 @@ type SetOrderCustomerResponse struct { Message string `json:"message"` } -// Payment-related contracts type CreatePaymentRequest struct { OrderID uuid.UUID `json:"order_id" validate:"required"` PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"` @@ -160,6 +175,7 @@ type CreatePaymentRequest struct { TransactionID *string `json:"transaction_id,omitempty" validate:"omitempty"` SplitNumber int `json:"split_number,omitempty" validate:"omitempty,min=1"` SplitTotal int `json:"split_total,omitempty" validate:"omitempty,min=1"` + SplitType *string `json:"split_type,omitempty" validate:"omitempty,oneof=AMOUNT ITEM"` SplitDescription *string `json:"split_description,omitempty" validate:"omitempty,max=255"` PaymentOrderItems []CreatePaymentOrderItemRequest `json:"payment_order_items,omitempty" validate:"omitempty,dive"` Metadata map[string]interface{} `json:"metadata,omitempty"` @@ -174,11 +190,14 @@ type PaymentResponse struct { ID uuid.UUID `json:"id"` OrderID uuid.UUID `json:"order_id"` PaymentMethodID uuid.UUID `json:"payment_method_id"` + PaymentMethodName string `json:"payment_method_name"` + PaymentMethodType string `json:"payment_method_type"` Amount float64 `json:"amount"` Status string `json:"status"` TransactionID *string `json:"transaction_id,omitempty"` SplitNumber int `json:"split_number"` SplitTotal int `json:"split_total"` + SplitType *string `json:"split_type,omitempty"` SplitDescription *string `json:"split_description,omitempty"` RefundAmount float64 `json:"refund_amount"` RefundReason *string `json:"refund_reason,omitempty"` @@ -216,3 +235,33 @@ type RefundPaymentRequest struct { RefundAmount float64 `json:"refund_amount" validate:"required,min=0"` Reason string `json:"reason" validate:"omitempty,max=255"` } + +type SplitBillRequest struct { + OrderID uuid.UUID `json:"order_id" validate:"required"` + OrganizationID uuid.UUID `json:"organization_id"` + PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"` + CustomerID uuid.UUID `json:"customer_id"` + Type string `json:"type" validate:"required,oneof=ITEM AMOUNT"` + Items []SplitBillItemRequest `json:"items,omitempty" validate:"required_if=Type ITEM,dive"` + Amount float64 `json:"amount,omitempty" validate:"required_if=Type AMOUNT,min=0"` +} + +type SplitBillItemRequest struct { + OrderItemID uuid.UUID `json:"order_item_id" validate:"required"` + Amount float64 `json:"amount" validate:"required,min=0"` +} + +type SplitBillResponse struct { + PaymentID uuid.UUID `json:"payment_id"` + OrderID uuid.UUID `json:"order_id"` + CustomerID uuid.UUID `json:"customer_id"` + Type string `json:"type"` + Amount float64 `json:"amount"` + Items []SplitBillItemResponse `json:"items,omitempty"` + Message string `json:"message"` +} + +type SplitBillItemResponse struct { + OrderItemID uuid.UUID `json:"order_item_id"` + Amount float64 `json:"amount"` +} diff --git a/internal/entities/order.go b/internal/entities/order.go index b8575d7..137f70f 100644 --- a/internal/entities/order.go +++ b/internal/entities/order.go @@ -28,6 +28,7 @@ const ( const ( PaymentStatusPending PaymentStatus = "pending" + PaymentStatusPartial PaymentStatus = "partial" PaymentStatusCompleted PaymentStatus = "completed" PaymentStatusFailed PaymentStatus = "failed" PaymentStatusRefunded PaymentStatus = "refunded" @@ -35,33 +36,34 @@ const ( ) type Order struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` - OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id" validate:"required"` - UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"` - CustomerID *uuid.UUID `gorm:"type:uuid;index" json:"customer_id"` - OrderNumber string `gorm:"uniqueIndex;not null;size:50" json:"order_number" validate:"required"` - TableNumber *string `gorm:"size:20" json:"table_number"` - OrderType OrderType `gorm:"not null;size:50" json:"order_type" validate:"required,oneof=dine_in takeout delivery"` - Status OrderStatus `gorm:"default:'pending';size:50" json:"status"` - Subtotal float64 `gorm:"type:decimal(10,2);not null" json:"subtotal" validate:"required,min=0"` - TaxAmount float64 `gorm:"type:decimal(10,2);not null" json:"tax_amount" validate:"required,min=0"` - DiscountAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"discount_amount" validate:"min=0"` - TotalAmount float64 `gorm:"type:decimal(10,2);not null" json:"total_amount" validate:"required,min=0"` - TotalCost float64 `gorm:"type:decimal(10,2);default:0.00" json:"total_cost"` - PaymentStatus PaymentStatus `gorm:"default:'pending';size:50" json:"payment_status"` - RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"` - IsVoid bool `gorm:"default:false" json:"is_void"` - IsRefund bool `gorm:"default:false" json:"is_refund"` - VoidReason *string `gorm:"size:255" json:"void_reason,omitempty"` - VoidedAt *time.Time `gorm:"" json:"voided_at,omitempty"` - VoidedBy *uuid.UUID `gorm:"type:uuid" json:"voided_by,omitempty"` - RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"` - RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"` - RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"` - Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"` + OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id" validate:"required"` + UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"` + CustomerID *uuid.UUID `gorm:"type:uuid;index" json:"customer_id"` + OrderNumber string `gorm:"uniqueIndex;not null;size:50" json:"order_number" validate:"required"` + TableNumber *string `gorm:"size:20" json:"table_number"` + OrderType OrderType `gorm:"not null;size:50" json:"order_type" validate:"required,oneof=dine_in takeout delivery"` + Status OrderStatus `gorm:"default:'pending';size:50" json:"status"` + Subtotal float64 `gorm:"type:decimal(10,2);not null" json:"subtotal" validate:"required,min=0"` + TaxAmount float64 `gorm:"type:decimal(10,2);not null" json:"tax_amount" validate:"required,min=0"` + DiscountAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"discount_amount" validate:"min=0"` + TotalAmount float64 `gorm:"type:decimal(10,2);not null" json:"total_amount" validate:"required,min=0"` + TotalCost float64 `gorm:"type:decimal(10,2);default:0.00" json:"total_cost"` + RemainingAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"remaining_amount"` + PaymentStatus PaymentStatus `gorm:"default:'pending';size:50" json:"payment_status"` + RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"` + IsVoid bool `gorm:"default:false" json:"is_void"` + IsRefund bool `gorm:"default:false" json:"is_refund"` + VoidReason *string `gorm:"size:255" json:"void_reason,omitempty"` + VoidedAt *time.Time `gorm:"" json:"voided_at,omitempty"` + VoidedBy *uuid.UUID `gorm:"type:uuid" json:"voided_by,omitempty"` + RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"` + RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"` + RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"` + Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"` Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"` diff --git a/internal/entities/payment.go b/internal/entities/payment.go index b03f914..1e7ea29 100644 --- a/internal/entities/payment.go +++ b/internal/entities/payment.go @@ -50,6 +50,13 @@ const ( PaymentTransactionStatusRefunded PaymentTransactionStatus = "refunded" ) +type SplitType string + +const ( + SplitTypeAmount SplitType = "AMOUNT" + SplitTypeItem SplitType = "ITEM" +) + type Payment struct { ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` OrderID uuid.UUID `gorm:"type:uuid;not null;index" json:"order_id" validate:"required"` @@ -59,6 +66,7 @@ type Payment struct { TransactionID *string `gorm:"size:255" json:"transaction_id"` SplitNumber int `gorm:"default:1" json:"split_number"` SplitTotal int `gorm:"default:1" json:"split_total"` + SplitType *SplitType `gorm:"size:20" json:"split_type,omitempty"` SplitDescription *string `gorm:"size:255" json:"split_description,omitempty"` RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"` RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"` diff --git a/internal/handler/order_handler.go b/internal/handler/order_handler.go index 8ff8555..457e958 100644 --- a/internal/handler/order_handler.go +++ b/internal/handler/order_handler.go @@ -265,7 +265,6 @@ func (h *OrderHandler) SetOrderCustomer(c *gin.Context) { ctx := c.Request.Context() contextInfo := appcontext.FromGinContext(ctx) - // Parse order ID from URL parameter orderIDStr := c.Param("id") orderID, err := uuid.Parse(orderIDStr) if err != nil { @@ -273,24 +272,47 @@ func (h *OrderHandler) SetOrderCustomer(c *gin.Context) { return } - // Parse request body var req contract.SetOrderCustomerRequest if err := c.ShouldBindJSON(&req); err != nil { util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "OrderHandler::SetOrderCustomer", err.Error())}), "OrderHandler::SetOrderCustomer") return } - // Transform contract to model modelReq := transformer.SetOrderCustomerContractToModel(&req) - // Call service response, err := h.orderService.SetOrderCustomer(ctx, orderID, modelReq, contextInfo.OrganizationID) if err != nil { util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::SetOrderCustomer", err.Error())}), "OrderHandler::SetOrderCustomer") return } - // Transform model to contract contractResp := transformer.SetOrderCustomerModelToContract(response) util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "OrderHandler::SetOrderCustomer") } + +func (h *OrderHandler) SplitBill(c *gin.Context) { + ctx := c.Request.Context() + contextInfo := appcontext.FromGinContext(ctx) + + var req contract.SplitBillRequest + if err := c.ShouldBindJSON(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "OrderHandler::SplitBill", err.Error())}), "OrderHandler::SplitBill") + return + } + + if err := h.validator.Validate(&req); err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("validation_failed", "OrderHandler::SplitBill", err.Error())}), "OrderHandler::SplitBill") + return + } + + req.OrganizationID = contextInfo.OrganizationID + modelReq := transformer.SplitBillContractToModel(&req) + response, err := h.orderService.SplitBill(c.Request.Context(), modelReq) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::SplitBill", err.Error())}), "OrderHandler::SplitBill") + return + } + + contractResp := transformer.SplitBillModelToContract(response) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "OrderHandler::SplitBill") +} diff --git a/internal/handler/outlet_handler.go b/internal/handler/outlet_handler.go index 2c995d6..5029e7d 100644 --- a/internal/handler/outlet_handler.go +++ b/internal/handler/outlet_handler.go @@ -36,7 +36,6 @@ func (h *OutletHandler) ListOutlets(c *gin.Context) { OrganizationID: contextInfo.OrganizationID, } - // Parse query parameters if pageStr := c.Query("page"); pageStr != "" { if page, err := strconv.Atoi(pageStr); err == nil { req.Page = page diff --git a/internal/mappers/order_mapper.go b/internal/mappers/order_mapper.go index 601e8bb..059adaa 100644 --- a/internal/mappers/order_mapper.go +++ b/internal/mappers/order_mapper.go @@ -8,42 +8,65 @@ import ( "github.com/google/uuid" ) -// Entity to Response mappers func OrderEntityToResponse(order *entities.Order) *models.OrderResponse { if order == nil { return nil } response := &models.OrderResponse{ - ID: order.ID, - OrganizationID: order.OrganizationID, - OutletID: order.OutletID, - UserID: order.UserID, - CustomerID: order.CustomerID, - OrderNumber: order.OrderNumber, - TableNumber: order.TableNumber, - OrderType: constants.OrderType(order.OrderType), - Status: constants.OrderStatus(order.Status), - Subtotal: order.Subtotal, - TaxAmount: order.TaxAmount, - DiscountAmount: order.DiscountAmount, - TotalAmount: order.TotalAmount, - TotalCost: order.TotalCost, - PaymentStatus: constants.PaymentStatus(order.PaymentStatus), - RefundAmount: order.RefundAmount, - IsVoid: order.IsVoid, - IsRefund: order.IsRefund, - VoidReason: order.VoidReason, - VoidedAt: order.VoidedAt, - VoidedBy: order.VoidedBy, - RefundReason: order.RefundReason, - RefundedAt: order.RefundedAt, - RefundedBy: order.RefundedBy, - Metadata: map[string]interface{}(order.Metadata), - CreatedAt: order.CreatedAt, - UpdatedAt: order.UpdatedAt, + ID: order.ID, + OrganizationID: order.OrganizationID, + OutletID: order.OutletID, + UserID: order.UserID, + CustomerID: order.CustomerID, + OrderNumber: order.OrderNumber, + TableNumber: order.TableNumber, + OrderType: constants.OrderType(order.OrderType), + Status: constants.OrderStatus(order.Status), + Subtotal: order.Subtotal, + TaxAmount: order.TaxAmount, + DiscountAmount: order.DiscountAmount, + TotalAmount: order.TotalAmount, + TotalCost: order.TotalCost, + RemainingAmount: order.RemainingAmount, + PaymentStatus: constants.PaymentStatus(order.PaymentStatus), + RefundAmount: order.RefundAmount, + IsVoid: order.IsVoid, + IsRefund: order.IsRefund, + VoidReason: order.VoidReason, + VoidedAt: order.VoidedAt, + VoidedBy: order.VoidedBy, + RefundReason: order.RefundReason, + RefundedAt: order.RefundedAt, + RefundedBy: order.RefundedBy, + Metadata: map[string]interface{}(order.Metadata), + CreatedAt: order.CreatedAt, + UpdatedAt: order.UpdatedAt, } + // Calculate payment summary and determine split type + var totalPaid float64 + var paymentCount int + var splitType *string + + if order.Payments != nil { + paymentCount = len(order.Payments) + for _, payment := range order.Payments { + if payment.Status == entities.PaymentTransactionStatusCompleted { + totalPaid += payment.Amount + } + // Determine split type from the first split payment + if splitType == nil && payment.SplitType != nil && payment.SplitTotal > 1 { + st := string(*payment.SplitType) + splitType = &st + } + } + } + + response.TotalPaid = totalPaid + response.PaymentCount = paymentCount + response.SplitType = splitType + // Map order items if order.OrderItems != nil { response.OrderItems = make([]models.OrderItemResponse, len(order.OrderItems)) @@ -119,6 +142,7 @@ func PaymentEntityToResponse(payment *entities.Payment) *models.PaymentResponse TransactionID: payment.TransactionID, SplitNumber: payment.SplitNumber, SplitTotal: payment.SplitTotal, + SplitType: (*string)(payment.SplitType), SplitDescription: payment.SplitDescription, RefundAmount: payment.RefundAmount, RefundReason: payment.RefundReason, @@ -129,6 +153,12 @@ func PaymentEntityToResponse(payment *entities.Payment) *models.PaymentResponse UpdatedAt: payment.UpdatedAt, } + // Add payment method information if available + if payment.PaymentMethod.ID != uuid.Nil { + response.PaymentMethodName = payment.PaymentMethod.Name + response.PaymentMethodType = constants.PaymentMethodType(payment.PaymentMethod.Type) + } + // Map payment order items if payment.PaymentOrderItems != nil { response.PaymentOrderItems = make([]models.PaymentOrderItemResponse, len(payment.PaymentOrderItems)) @@ -201,6 +231,12 @@ func CreatePaymentRequestToEntity(req *models.CreatePaymentRequest) *entities.Pa return nil } + var splitType *entities.SplitType + if req.SplitType != nil { + st := entities.SplitType(*req.SplitType) + splitType = &st + } + payment := &entities.Payment{ OrderID: req.OrderID, PaymentMethodID: req.PaymentMethodID, @@ -209,6 +245,7 @@ func CreatePaymentRequestToEntity(req *models.CreatePaymentRequest) *entities.Pa TransactionID: req.TransactionID, SplitNumber: req.SplitNumber, SplitTotal: req.SplitTotal, + SplitType: splitType, SplitDescription: req.SplitDescription, Metadata: entities.Metadata(req.Metadata), } diff --git a/internal/models/order.go b/internal/models/order.go index a221810..e646392 100644 --- a/internal/models/order.go +++ b/internal/models/order.go @@ -8,33 +8,34 @@ import ( ) type Order struct { - ID uuid.UUID - OrganizationID uuid.UUID - OutletID uuid.UUID - UserID uuid.UUID - CustomerID *uuid.UUID - OrderNumber string - TableNumber *string - OrderType constants.OrderType - Status constants.OrderStatus - Subtotal float64 - TaxAmount float64 - DiscountAmount float64 - TotalAmount float64 - TotalCost float64 - PaymentStatus constants.PaymentStatus - RefundAmount float64 - IsVoid bool - IsRefund bool - VoidReason *string - VoidedAt *time.Time - VoidedBy *uuid.UUID - RefundReason *string - RefundedAt *time.Time - RefundedBy *uuid.UUID - Metadata map[string]interface{} - CreatedAt time.Time - UpdatedAt time.Time + ID uuid.UUID + OrganizationID uuid.UUID + OutletID uuid.UUID + UserID uuid.UUID + CustomerID *uuid.UUID + OrderNumber string + TableNumber *string + OrderType constants.OrderType + Status constants.OrderStatus + Subtotal float64 + TaxAmount float64 + DiscountAmount float64 + TotalAmount float64 + TotalCost float64 + RemainingAmount float64 + PaymentStatus constants.PaymentStatus + RefundAmount float64 + IsVoid bool + IsRefund bool + VoidReason *string + VoidedAt *time.Time + VoidedBy *uuid.UUID + RefundReason *string + RefundedAt *time.Time + RefundedBy *uuid.UUID + Metadata map[string]interface{} + CreatedAt time.Time + UpdatedAt time.Time } type OrderItem struct { @@ -142,36 +143,40 @@ type RefundOrderItemRequest struct { // Response DTOs type OrderResponse struct { - ID uuid.UUID - OrganizationID uuid.UUID - OutletID uuid.UUID - UserID uuid.UUID - CustomerID *uuid.UUID - OrderNumber string - TableNumber *string - OrderType constants.OrderType - Status constants.OrderStatus - Subtotal float64 - TaxAmount float64 - DiscountAmount float64 - TotalAmount float64 - TotalCost float64 - PaymentStatus constants.PaymentStatus - RefundAmount float64 - IsVoid bool - IsRefund bool - VoidReason *string - VoidedAt *time.Time - VoidedBy *uuid.UUID - RefundReason *string - RefundedAt *time.Time - RefundedBy *uuid.UUID - Notes *string - Metadata map[string]interface{} - CreatedAt time.Time - UpdatedAt time.Time - OrderItems []OrderItemResponse - Payments []PaymentResponse + ID uuid.UUID + OrganizationID uuid.UUID + OutletID uuid.UUID + UserID uuid.UUID + CustomerID *uuid.UUID + OrderNumber string + TableNumber *string + OrderType constants.OrderType + Status constants.OrderStatus + Subtotal float64 + TaxAmount float64 + DiscountAmount float64 + TotalAmount float64 + TotalCost float64 + RemainingAmount float64 + PaymentStatus constants.PaymentStatus + RefundAmount float64 + IsVoid bool + IsRefund bool + VoidReason *string + VoidedAt *time.Time + VoidedBy *uuid.UUID + RefundReason *string + RefundedAt *time.Time + RefundedBy *uuid.UUID + Notes *string + Metadata map[string]interface{} + CreatedAt time.Time + UpdatedAt time.Time + OrderItems []OrderItemResponse + Payments []PaymentResponse + TotalPaid float64 + PaymentCount int + SplitType *string } type OrderItemResponse struct { @@ -230,6 +235,7 @@ type ListOrdersRequest struct { type ListOrdersResponse struct { Orders []OrderResponse + Payments []PaymentResponse TotalCount int Page int Limit int diff --git a/internal/models/payment.go b/internal/models/payment.go index 0e1b003..bf35894 100644 --- a/internal/models/payment.go +++ b/internal/models/payment.go @@ -16,6 +16,7 @@ type Payment struct { TransactionID *string SplitNumber int SplitTotal int + SplitType *string SplitDescription *string RefundAmount float64 RefundReason *string @@ -33,6 +34,7 @@ type CreatePaymentRequest struct { TransactionID *string `validate:"omitempty"` SplitNumber int `validate:"omitempty,min=1"` SplitTotal int `validate:"omitempty,min=1"` + SplitType *string `validate:"omitempty,oneof=AMOUNT ITEM"` SplitDescription *string `validate:"omitempty,max=255"` PaymentOrderItems []CreatePaymentOrderItemRequest `validate:"omitempty,dive"` Metadata map[string]interface{} @@ -48,11 +50,14 @@ type PaymentResponse struct { ID uuid.UUID OrderID uuid.UUID PaymentMethodID uuid.UUID + PaymentMethodName string + PaymentMethodType constants.PaymentMethodType Amount float64 Status constants.PaymentTransactionStatus TransactionID *string SplitNumber int SplitTotal int + SplitType *string SplitDescription *string RefundAmount float64 RefundReason *string diff --git a/internal/models/split_bill.go b/internal/models/split_bill.go new file mode 100644 index 0000000..7982e23 --- /dev/null +++ b/internal/models/split_bill.go @@ -0,0 +1,46 @@ +package models + +import "github.com/google/uuid" + +const ( + Amount = "AMOUNT" + Item = "ITEM" +) + +type SplitBillRequest struct { + OrderID uuid.UUID `validate:"required"` + PaymentMethodID uuid.UUID `validate:"required"` + CustomerID uuid.UUID `validate:"required"` + Type string `validate:"required,oneof=ITEM AMOUNT"` + Items []SplitBillItemRequest `validate:"required_if=Type ITEM,dive"` + Amount float64 `validate:"required_if=Type AMOUNT,min=0"` + OrganizationID uuid.UUID +} + +func (s *SplitBillRequest) IsItem() bool { + return s.Type == Item +} + +func (s *SplitBillRequest) IsAmount() bool { + return s.Type == Amount +} + +type SplitBillItemRequest struct { + OrderItemID uuid.UUID `validate:"required"` + Amount float64 `validate:"required,min=0"` +} + +type SplitBillResponse struct { + PaymentID uuid.UUID `json:"payment_id"` + OrderID uuid.UUID `json:"order_id"` + CustomerID uuid.UUID `json:"customer_id"` + Type string `json:"type"` + Amount float64 `json:"amount"` + Items []SplitBillItemResponse `json:"items,omitempty"` + Message string `json:"message"` +} + +type SplitBillItemResponse struct { + OrderItemID uuid.UUID `json:"order_item_id"` + Amount float64 `json:"amount"` +} diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index a9d750a..2b74f51 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -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 +} diff --git a/internal/processor/outlet_processor.go b/internal/processor/outlet_processor.go index a33882d..5ca6317 100644 --- a/internal/processor/outlet_processor.go +++ b/internal/processor/outlet_processor.go @@ -31,10 +31,8 @@ func NewOutletProcessorImpl(outletRepo *repository.OutletRepositoryImpl) *Outlet } func (p *OutletProcessorImpl) ListOutletsByOrganization(ctx context.Context, organizationID uuid.UUID, page, limit int) ([]*models.OutletResponse, int64, error) { - offset := (page - 1) * limit - // Get outlets with pagination outlets, total, err := p.outletRepo.GetByOrganizationIDWithPagination(ctx, organizationID, limit, offset) if err != nil { return nil, 0, fmt.Errorf("failed to get outlets: %w", err) diff --git a/internal/repository/order_repository.go b/internal/repository/order_repository.go index ed5e731..0bd7eee 100644 --- a/internal/repository/order_repository.go +++ b/internal/repository/order_repository.go @@ -90,7 +90,8 @@ func (r *OrderRepositoryImpl) List(ctx context.Context, filters map[string]inter Preload("OrderItems.Product"). Preload("OrderItems.ProductVariant"). Preload("Payments"). - Preload("Payments.PaymentMethod") + Preload("Payments.PaymentMethod"). + Preload("Payments.PaymentOrderItems") for key, value := range filters { switch key { diff --git a/internal/repository/payment_order_item_repository.go b/internal/repository/payment_order_item_repository.go new file mode 100644 index 0000000..354db87 --- /dev/null +++ b/internal/repository/payment_order_item_repository.go @@ -0,0 +1,62 @@ +package repository + +import ( + "context" + + "apskel-pos-be/internal/entities" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +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 PaymentOrderItemRepositoryImpl struct { + db *gorm.DB +} + +func NewPaymentOrderItemRepositoryImpl(db *gorm.DB) *PaymentOrderItemRepositoryImpl { + return &PaymentOrderItemRepositoryImpl{ + db: db, + } +} + +func (r *PaymentOrderItemRepositoryImpl) Create(ctx context.Context, paymentOrderItem *entities.PaymentOrderItem) error { + return r.db.WithContext(ctx).Create(paymentOrderItem).Error +} + +func (r *PaymentOrderItemRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.PaymentOrderItem, error) { + var paymentOrderItem entities.PaymentOrderItem + err := r.db.WithContext(ctx). + Preload("Payment"). + Preload("OrderItem"). + First(&paymentOrderItem, "id = ?", id).Error + if err != nil { + return nil, err + } + return &paymentOrderItem, nil +} + +func (r *PaymentOrderItemRepositoryImpl) GetByPaymentID(ctx context.Context, paymentID uuid.UUID) ([]*entities.PaymentOrderItem, error) { + var paymentOrderItems []*entities.PaymentOrderItem + err := r.db.WithContext(ctx). + Preload("Payment"). + Preload("OrderItem"). + Where("payment_id = ?", paymentID). + Find(&paymentOrderItems).Error + return paymentOrderItems, err +} + +func (r *PaymentOrderItemRepositoryImpl) Update(ctx context.Context, paymentOrderItem *entities.PaymentOrderItem) error { + return r.db.WithContext(ctx).Save(paymentOrderItem).Error +} + +func (r *PaymentOrderItemRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&entities.PaymentOrderItem{}, "id = ?", id).Error +} diff --git a/internal/repository/payment_repository.go b/internal/repository/payment_repository.go index 197a88f..1067780 100644 --- a/internal/repository/payment_repository.go +++ b/internal/repository/payment_repository.go @@ -122,17 +122,9 @@ func (r *PaymentRepositoryImpl) CreatePaymentWithInventoryMovement(ctx context.C if order.PaymentStatus != entities.PaymentStatusCompleted { orderJustCompleted = true } - if err := tx.Model(&entities.Order{}).Where("id = ?", req.OrderID).Update("payment_status", entities.PaymentStatusCompleted).Error; err != nil { - return fmt.Errorf("failed to update order payment status: %w", err) - } - 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) } - } else { - if err := tx.Model(&entities.Order{}).Where("id = ?", req.OrderID).Update("payment_status", entities.PaymentStatusPartiallyRefunded).Error; err != nil { - return fmt.Errorf("failed to update order payment status: %w", err) - } } if orderJustCompleted { diff --git a/internal/router/router.go b/internal/router/router.go index bdb87f8..14e7c38 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -211,6 +211,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { orders.PUT("/:id/customer", r.orderHandler.SetOrderCustomer) orders.POST("/void", r.orderHandler.VoidOrder) orders.POST("/:id/refund", r.orderHandler.RefundOrder) + orders.POST("/split-bill", r.orderHandler.SplitBill) } payments := protected.Group("/payments") diff --git a/internal/service/order_service.go b/internal/service/order_service.go index c1f0fca..a09254b 100644 --- a/internal/service/order_service.go +++ b/internal/service/order_service.go @@ -21,6 +21,7 @@ type OrderService 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 OrderServiceImpl struct { @@ -95,12 +96,10 @@ func (s *OrderServiceImpl) GetOrderByID(ctx context.Context, id uuid.UUID) (*mod } func (s *OrderServiceImpl) ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error) { - // Validate request if err := s.validateListOrdersRequest(req); err != nil { return nil, fmt.Errorf("validation error: %w", err) } - // Process order listing response, err := s.orderProcessor.ListOrders(ctx, req) if err != nil { return nil, fmt.Errorf("failed to list orders: %w", err) @@ -384,3 +383,65 @@ func (s *OrderServiceImpl) validateSetOrderCustomerRequest(req *models.SetOrderC return nil } + +func (s *OrderServiceImpl) SplitBill(ctx context.Context, req *models.SplitBillRequest) (*models.SplitBillResponse, error) { + if err := s.validateSplitBillRequest(req); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + + response, err := s.orderProcessor.SplitBill(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to split bill: %w", err) + } + + return response, nil +} + +func (s *OrderServiceImpl) validateSplitBillRequest(req *models.SplitBillRequest) error { + if req == nil { + return fmt.Errorf("request cannot be nil") + } + + if req.OrderID == uuid.Nil { + return fmt.Errorf("order ID is required") + } + + if req.PaymentMethodID == uuid.Nil { + return fmt.Errorf("payment ID is required") + } + + if req.Type != "ITEM" && req.Type != "AMOUNT" { + return fmt.Errorf("split type must be either ITEM or AMOUNT") + } + + if req.Type == "ITEM" { + if len(req.Items) == 0 { + return fmt.Errorf("items are required when splitting by ITEM") + } + + totalItemAmount := float64(0) + for i, item := range req.Items { + if item.OrderItemID == uuid.Nil { + return fmt.Errorf("order item ID is required for item %d", i+1) + } + + if item.Amount <= 0 { + return fmt.Errorf("amount must be greater than zero for item %d", i+1) + } + + totalItemAmount += item.Amount + } + + if totalItemAmount <= 0 { + return fmt.Errorf("total item amount must be greater than zero") + } + } + + if req.Type == "AMOUNT" { + if req.Amount <= 0 { + return fmt.Errorf("amount must be greater than zero when splitting by AMOUNT") + } + } + + return nil +} diff --git a/internal/service/outlet_service.go b/internal/service/outlet_service.go index cdd95aa..789eb07 100644 --- a/internal/service/outlet_service.go +++ b/internal/service/outlet_service.go @@ -29,7 +29,6 @@ func NewOutletService(outletProcessor processor.OutletProcessor) *OutletServiceI } func (s *OutletServiceImpl) ListOutlets(ctx context.Context, req *contract.ListOutletsRequest) *contract.Response { - // Validate request if req.Page < 1 { req.Page = 1 } @@ -51,7 +50,6 @@ func (s *OutletServiceImpl) ListOutlets(ctx context.Context, req *contract.ListO contractOutlets[i] = transformer.OutletModelResponseToResponse(outlet) } - // Create paginated response response := transformer.CreateListOutletsResponse(contractOutlets, int(total), req.Page, req.Limit) return contract.BuildSuccessResponse(response) } diff --git a/internal/transformer/order_transformer.go b/internal/transformer/order_transformer.go index d886408..73cbe4f 100644 --- a/internal/transformer/order_transformer.go +++ b/internal/transformer/order_transformer.go @@ -111,24 +111,45 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse { PrinterType: item.PrinterType, } } + // Map payments + payments := make([]contract.PaymentResponse, len(resp.Payments)) + for i, payment := range resp.Payments { + payments[i] = *PaymentModelToContract(&payment) + } + return &contract.OrderResponse{ - ID: resp.ID, - OrderNumber: resp.OrderNumber, - OutletID: resp.OutletID, - UserID: resp.UserID, - TableNumber: resp.TableNumber, - OrderType: string(resp.OrderType), - Status: string(resp.Status), - Subtotal: resp.Subtotal, - TaxAmount: resp.TaxAmount, - DiscountAmount: resp.DiscountAmount, - TotalAmount: resp.TotalAmount, - Notes: resp.Notes, - Metadata: resp.Metadata, - CreatedAt: resp.CreatedAt, - UpdatedAt: resp.UpdatedAt, - OrderItems: items, - IsRefund: resp.IsRefund, + ID: resp.ID, + OrderNumber: resp.OrderNumber, + OutletID: resp.OutletID, + UserID: resp.UserID, + TableNumber: resp.TableNumber, + OrderType: string(resp.OrderType), + Status: string(resp.Status), + Subtotal: resp.Subtotal, + TaxAmount: resp.TaxAmount, + DiscountAmount: resp.DiscountAmount, + TotalAmount: resp.TotalAmount, + TotalCost: resp.TotalCost, + RemainingAmount: resp.RemainingAmount, + PaymentStatus: string(resp.PaymentStatus), + RefundAmount: resp.RefundAmount, + IsVoid: resp.IsVoid, + IsRefund: resp.IsRefund, + VoidReason: resp.VoidReason, + VoidedAt: resp.VoidedAt, + VoidedBy: resp.VoidedBy, + RefundReason: resp.RefundReason, + RefundedAt: resp.RefundedAt, + RefundedBy: resp.RefundedBy, + Notes: resp.Notes, + Metadata: resp.Metadata, + CreatedAt: resp.CreatedAt, + UpdatedAt: resp.UpdatedAt, + OrderItems: items, + Payments: payments, + TotalPaid: resp.TotalPaid, + PaymentCount: resp.PaymentCount, + SplitType: resp.SplitType, } } @@ -257,8 +278,14 @@ func ListOrdersModelToContract(resp *models.ListOrdersResponse) *contract.ListOr orders[i] = *OrderModelToContract(&order) } + payments := make([]contract.PaymentResponse, len(resp.Payments)) + for i, payment := range resp.Payments { + payments[i] = *PaymentModelToContract(&payment) + } + return &contract.ListOrdersResponse{ Orders: orders, + Payments: payments, TotalCount: resp.TotalCount, Page: resp.Page, Limit: resp.Limit, @@ -309,11 +336,14 @@ func PaymentModelToContract(resp *models.PaymentResponse) *contract.PaymentRespo ID: resp.ID, OrderID: resp.OrderID, PaymentMethodID: resp.PaymentMethodID, + PaymentMethodName: resp.PaymentMethodName, + PaymentMethodType: string(resp.PaymentMethodType), Amount: resp.Amount, Status: string(resp.Status), TransactionID: resp.TransactionID, SplitNumber: resp.SplitNumber, SplitTotal: resp.SplitTotal, + SplitType: resp.SplitType, SplitDescription: resp.SplitDescription, RefundAmount: resp.RefundAmount, RefundReason: resp.RefundReason, @@ -342,3 +372,46 @@ func RefundOrderContractToModel(req *contract.RefundOrderRequest) *models.Refund OrderItems: orderItems, } } + +func SplitBillContractToModel(req *contract.SplitBillRequest) *models.SplitBillRequest { + items := make([]models.SplitBillItemRequest, len(req.Items)) + for i, item := range req.Items { + items[i] = models.SplitBillItemRequest{ + OrderItemID: item.OrderItemID, + Amount: item.Amount, + } + } + return &models.SplitBillRequest{ + OrderID: req.OrderID, + PaymentMethodID: req.PaymentMethodID, + CustomerID: req.CustomerID, + Type: req.Type, + Items: items, + Amount: req.Amount, + OrganizationID: req.OrganizationID, + } +} + +func SplitBillModelToContract(resp *models.SplitBillResponse) *contract.SplitBillResponse { + if resp == nil { + return nil + } + + items := make([]contract.SplitBillItemResponse, len(resp.Items)) + for i, item := range resp.Items { + items[i] = contract.SplitBillItemResponse{ + OrderItemID: item.OrderItemID, + Amount: item.Amount, + } + } + + return &contract.SplitBillResponse{ + PaymentID: resp.PaymentID, + OrderID: resp.OrderID, + CustomerID: resp.CustomerID, + Type: resp.Type, + Amount: resp.Amount, + Items: items, + Message: resp.Message, + } +} diff --git a/internal/transformer/outlet_transformer.go b/internal/transformer/outlet_transformer.go index 9af7b67..a72efe5 100644 --- a/internal/transformer/outlet_transformer.go +++ b/internal/transformer/outlet_transformer.go @@ -35,7 +35,7 @@ func OutletModelResponseToResponse(model *models.OutletResponse) contract.Outlet OrganizationID: model.OrganizationID, Name: model.Name, Address: *model.Address, - BusinessType: string(constants.BusinessTypeRestaurant), // Default business type + BusinessType: string(constants.BusinessTypeRestaurant), Currency: model.Currency, TaxRate: model.TaxRate, IsActive: model.IsActive, diff --git a/migrations/000033_add_remaining_amount_to_orders.down.sql b/migrations/000033_add_remaining_amount_to_orders.down.sql new file mode 100644 index 0000000..e117273 --- /dev/null +++ b/migrations/000033_add_remaining_amount_to_orders.down.sql @@ -0,0 +1,5 @@ +-- Remove constraint +ALTER TABLE orders DROP CONSTRAINT IF EXISTS check_remaining_amount_non_negative; + +-- Remove remaining_amount column from orders table +ALTER TABLE orders DROP COLUMN IF EXISTS remaining_amount; \ No newline at end of file diff --git a/migrations/000033_add_remaining_amount_to_orders.up.sql b/migrations/000033_add_remaining_amount_to_orders.up.sql new file mode 100644 index 0000000..b245d17 --- /dev/null +++ b/migrations/000033_add_remaining_amount_to_orders.up.sql @@ -0,0 +1,8 @@ +-- Add remaining_amount column to orders table +ALTER TABLE orders ADD COLUMN remaining_amount DECIMAL(10,2) DEFAULT 0.00; + +-- Update existing orders to set remaining_amount equal to total_amount +UPDATE orders SET remaining_amount = total_amount WHERE remaining_amount = 0.00; + +-- Add constraint to ensure remaining_amount is not negative +ALTER TABLE orders ADD CONSTRAINT check_remaining_amount_non_negative CHECK (remaining_amount >= 0.00); \ No newline at end of file diff --git a/migrations/000034_add_split_type_to_payments.down.sql b/migrations/000034_add_split_type_to_payments.down.sql new file mode 100644 index 0000000..76f11fa --- /dev/null +++ b/migrations/000034_add_split_type_to_payments.down.sql @@ -0,0 +1,8 @@ +-- Remove constraint +ALTER TABLE payments DROP CONSTRAINT IF EXISTS check_split_type_valid; + +-- Remove index +DROP INDEX IF EXISTS idx_payments_split_type; + +-- Remove split_type column from payments table +ALTER TABLE payments DROP COLUMN IF EXISTS split_type; \ No newline at end of file diff --git a/migrations/000034_add_split_type_to_payments.up.sql b/migrations/000034_add_split_type_to_payments.up.sql new file mode 100644 index 0000000..99765c6 --- /dev/null +++ b/migrations/000034_add_split_type_to_payments.up.sql @@ -0,0 +1,8 @@ +-- Add split_type column to payments table +ALTER TABLE payments ADD COLUMN split_type VARCHAR(20); + +-- Add index for better query performance +CREATE INDEX idx_payments_split_type ON payments(split_type); + +-- Add constraint to ensure split_type is valid +ALTER TABLE payments ADD CONSTRAINT check_split_type_valid CHECK (split_type IS NULL OR split_type IN ('AMOUNT', 'ITEM')); \ No newline at end of file