Products like fish are sold per weighing (4.2 ons, 5.6 ons), which the order line could not represent: quantity is INTEGER and prices are always computed as quantity * unit_price. Model one weighing as one order line. quantity stays INTEGER and keeps meaning "how many items"; the measured amount goes into a new nullable order_items.weight, and the line is priced weight * unit_price. Two weighings of the same product are two lines, never merged into one. Keeping quantity integral avoids float comparisons in void, refund and split bill, where accumulated rounding error would silently misbehave — "1.4 + 1.4 + 1.4" is not 4.2 in float64, which would leave a fully paid split-bill item marked unpaid. BillableQuantity() is now the single place that decides between weight and count; every price and cost calculation goes through it. Missing one would bill a 4.2 ons fish as a single ons — wrong money, no error. Two database constraints back the design: a weighed line always carries a positive weight, and its quantity is pinned to 1. The latter also makes void all-or-nothing for weighed lines, so the row-splitting branch can never produce a zero-weight remainder row. Also wires product.unit_id through the API, which was previously not settable at all, and corrects the misleading comment on the request's unit_price field — that value has never been used; price always comes from the database. Design notes and the audit of every price multiplication site are in docs/rfc-weight-based-products.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
346 lines
13 KiB
Go
346 lines
13 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"apskel-pos-be/internal/entities"
|
|
"apskel-pos-be/internal/models"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
SplitTypeItem = "ITEM"
|
|
SplitTypeAmount = "AMOUNT"
|
|
|
|
MetadataKeyLastSplitPaymentID = "last_split_payment_id"
|
|
MetadataKeyLastSplitCustomerID = "last_split_customer_id"
|
|
MetadataKeyLastSplitCustomerName = "last_split_customer_name"
|
|
MetadataKeyLastSplitAmount = "last_split_amount"
|
|
MetadataKeyLastSplitType = "last_split_type"
|
|
MetadataKeyLastSplitQuantities = "last_split_quantities"
|
|
)
|
|
|
|
type SplitBillValidation struct {
|
|
OrderItems map[uuid.UUID]*entities.OrderItem
|
|
PaidQuantities map[uuid.UUID]int
|
|
Outlet *entities.Outlet
|
|
TotalPaid float64
|
|
RemainingBalance float64
|
|
}
|
|
|
|
type SplitBillProcessor interface {
|
|
SplitByAmount(ctx context.Context, req *models.SplitBillRequest, order *entities.Order, payment *entities.PaymentMethod, customer *entities.Customer) (*models.SplitBillResponse, error)
|
|
SplitByItem(ctx context.Context, req *models.SplitBillRequest, order *entities.Order, payment *entities.PaymentMethod, customer *entities.Customer) (*models.SplitBillResponse, error)
|
|
}
|
|
|
|
type SplitBillProcessorImpl struct {
|
|
orderRepo OrderRepository
|
|
orderItemRepo OrderItemRepository
|
|
paymentRepo PaymentRepository
|
|
paymentOrderItemRepo PaymentOrderItemRepository
|
|
outletRepo OutletRepository
|
|
}
|
|
|
|
func NewSplitBillProcessorImpl(
|
|
orderRepo OrderRepository,
|
|
orderItemRepo OrderItemRepository,
|
|
paymentRepo PaymentRepository,
|
|
paymentOrderItemRepo PaymentOrderItemRepository,
|
|
outletRepo OutletRepository,
|
|
) *SplitBillProcessorImpl {
|
|
return &SplitBillProcessorImpl{
|
|
orderRepo: orderRepo,
|
|
orderItemRepo: orderItemRepo,
|
|
paymentRepo: paymentRepo,
|
|
paymentOrderItemRepo: paymentOrderItemRepo,
|
|
outletRepo: outletRepo,
|
|
}
|
|
}
|
|
|
|
// validateSplitBillRequest validates the split bill request and returns validation data
|
|
func (p *SplitBillProcessorImpl) validateSplitBillRequest(ctx context.Context, req *models.SplitBillRequest, order *entities.Order) (*SplitBillValidation, error) {
|
|
if req == nil {
|
|
return nil, fmt.Errorf("split bill request cannot be nil")
|
|
}
|
|
if order == nil {
|
|
return nil, fmt.Errorf("order cannot be nil")
|
|
}
|
|
if len(req.Items) == 0 && req.IsItem() {
|
|
return nil, fmt.Errorf("split bill request must contain at least one item")
|
|
}
|
|
|
|
outlet, err := p.outletRepo.GetByID(ctx, order.OutletID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("outlet not found for order %s: %w", req.OrderID, err)
|
|
}
|
|
|
|
totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, req.OrderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get total paid amount for order %s: %w", req.OrderID, err)
|
|
}
|
|
|
|
remainingBalance := order.TotalAmount - totalPaid
|
|
|
|
paidQuantities, err := p.getPaidQuantitiesForOrder(ctx, req.OrderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get paid quantities for order %s: %w", req.OrderID, err)
|
|
}
|
|
|
|
orderItems := make(map[uuid.UUID]*entities.OrderItem)
|
|
for _, item := range req.Items {
|
|
orderItem, err := p.orderItemRepo.GetByID(ctx, item.OrderItemID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("order item %s not found: %w", item.OrderItemID, err)
|
|
}
|
|
if orderItem.OrderID != req.OrderID {
|
|
return nil, fmt.Errorf("order item %s does not belong to order %s", item.OrderItemID, req.OrderID)
|
|
}
|
|
|
|
if err := validateSplitItemQuantity(item, orderItem, paidQuantities); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
orderItems[item.OrderItemID] = orderItem
|
|
}
|
|
|
|
return &SplitBillValidation{
|
|
OrderItems: orderItems,
|
|
PaidQuantities: paidQuantities,
|
|
Outlet: outlet,
|
|
TotalPaid: totalPaid,
|
|
RemainingBalance: remainingBalance,
|
|
}, nil
|
|
}
|
|
|
|
func (p *SplitBillProcessorImpl) getPaidQuantitiesForOrder(ctx context.Context, orderID uuid.UUID) (map[uuid.UUID]int, error) {
|
|
paidQuantities, err := p.paymentOrderItemRepo.GetPaidQuantitiesByOrderID(ctx, orderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get paid quantities: %w", err)
|
|
}
|
|
return paidQuantities, nil
|
|
}
|
|
|
|
func validateSplitItemQuantity(item models.SplitBillItemRequest, orderItem *entities.OrderItem, paidQuantities map[uuid.UUID]int) error {
|
|
if item.Quantity <= 0 {
|
|
return fmt.Errorf("requested quantity must be greater than 0 for order item %s", item.OrderItemID)
|
|
}
|
|
alreadyPaid := paidQuantities[item.OrderItemID]
|
|
availableQuantity := orderItem.Quantity - alreadyPaid
|
|
if item.Quantity > availableQuantity {
|
|
return fmt.Errorf("requested quantity %d for order item %s exceeds available quantity %d (total: %d, already paid: %d)", item.Quantity, item.OrderItemID, availableQuantity, orderItem.Quantity, alreadyPaid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func calculateSplitAmounts(req *models.SplitBillRequest, validation *SplitBillValidation) (float64, float64, []models.SplitBillItemResponse) {
|
|
var totalSplitAmount float64
|
|
var responseItems []models.SplitBillItemResponse
|
|
|
|
for _, item := range req.Items {
|
|
orderItem := validation.OrderItems[item.OrderItemID]
|
|
itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice)
|
|
itemTaxAmount := itemAmount * validation.Outlet.TaxRate
|
|
totalItemAmount := itemAmount + itemTaxAmount
|
|
|
|
totalSplitAmount += itemAmount
|
|
|
|
responseItems = append(responseItems, models.SplitBillItemResponse{
|
|
OrderItemID: item.OrderItemID,
|
|
Amount: totalItemAmount,
|
|
})
|
|
}
|
|
|
|
splitTaxAmount := totalSplitAmount * validation.Outlet.TaxRate
|
|
totalSplitAmountWithTax := totalSplitAmount + splitTaxAmount
|
|
return totalSplitAmount, totalSplitAmountWithTax, responseItems
|
|
}
|
|
|
|
func (p *SplitBillProcessorImpl) createSplitPayment(ctx context.Context, req *models.SplitBillRequest, order *entities.Order, payment *entities.PaymentMethod, customer *entities.Customer, totalSplitAmountWithTax float64, existingPayments []*entities.Payment) (*entities.Payment, error) {
|
|
splitNumber := len(existingPayments) + 1
|
|
splitTotal := splitNumber + 1
|
|
splitType := entities.SplitTypeItem
|
|
|
|
splitPayment := &entities.Payment{
|
|
OrderID: req.OrderID,
|
|
PaymentMethodID: payment.ID,
|
|
Amount: totalSplitAmountWithTax,
|
|
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": SplitTypeItem,
|
|
"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)
|
|
}
|
|
return splitPayment, nil
|
|
}
|
|
|
|
func (p *SplitBillProcessorImpl) createPaymentOrderItems(ctx context.Context, splitPayment *entities.Payment, req *models.SplitBillRequest, validation *SplitBillValidation) error {
|
|
for _, item := range req.Items {
|
|
orderItem := validation.OrderItems[item.OrderItemID]
|
|
itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice)
|
|
itemTaxAmount := itemAmount * validation.Outlet.TaxRate
|
|
totalItemAmount := itemAmount + itemTaxAmount
|
|
|
|
paymentOrderItem := &entities.PaymentOrderItem{
|
|
PaymentID: splitPayment.ID,
|
|
OrderItemID: item.OrderItemID,
|
|
Quantity: item.Quantity,
|
|
Amount: totalItemAmount,
|
|
}
|
|
if err := p.paymentOrderItemRepo.Create(ctx, paymentOrderItem); err != nil {
|
|
return fmt.Errorf("failed to create payment order item: %w", err)
|
|
}
|
|
|
|
// Update order item status to paid if fully covered
|
|
alreadyPaid := validation.PaidQuantities[item.OrderItemID]
|
|
newPaid := alreadyPaid + item.Quantity
|
|
if newPaid >= orderItem.Quantity {
|
|
if err := p.orderItemRepo.UpdateStatus(ctx, item.OrderItemID, entities.OrderItemStatusPaid); err != nil {
|
|
return fmt.Errorf("failed to update order item status to paid: %w", err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *SplitBillProcessorImpl) updateOrderAfterSplit(ctx context.Context, order *entities.Order, req *models.SplitBillRequest, customer *entities.Customer, totalSplitAmountWithTax float64, validation *SplitBillValidation) error {
|
|
if order.Metadata == nil {
|
|
order.Metadata = make(entities.Metadata)
|
|
}
|
|
order.Metadata[MetadataKeyLastSplitPaymentID] = req.OrderID.String()
|
|
order.Metadata[MetadataKeyLastSplitCustomerID] = req.CustomerID.String()
|
|
order.Metadata[MetadataKeyLastSplitCustomerName] = customer.Name
|
|
order.Metadata[MetadataKeyLastSplitAmount] = totalSplitAmountWithTax
|
|
order.Metadata[MetadataKeyLastSplitType] = SplitTypeItem
|
|
|
|
quantityInfo := make(map[string]interface{})
|
|
for _, item := range req.Items {
|
|
orderItem := validation.OrderItems[item.OrderItemID]
|
|
quantityInfo[item.OrderItemID.String()] = map[string]interface{}{
|
|
"quantity": item.Quantity,
|
|
"unit_price": orderItem.UnitPrice,
|
|
"total_amount": entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice),
|
|
}
|
|
}
|
|
order.Metadata[MetadataKeyLastSplitQuantities] = quantityInfo
|
|
|
|
newTotalPaid := validation.TotalPaid + totalSplitAmountWithTax
|
|
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 fmt.Errorf("failed to update order: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *SplitBillProcessorImpl) SplitByItem(ctx context.Context, req *models.SplitBillRequest, order *entities.Order, payment *entities.PaymentMethod, customer *entities.Customer) (*models.SplitBillResponse, error) {
|
|
validation, err := p.validateSplitBillRequest(ctx, req, order)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, totalSplitAmountWithTax, responseItems := calculateSplitAmounts(req, validation)
|
|
if totalSplitAmountWithTax > validation.RemainingBalance {
|
|
return nil, fmt.Errorf("split amount %.2f cannot exceed remaining balance %.2f", totalSplitAmountWithTax, validation.RemainingBalance)
|
|
}
|
|
existingPayments, err := p.paymentRepo.GetByOrderID(ctx, req.OrderID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get existing payments: %w", err)
|
|
}
|
|
splitPayment, err := p.createSplitPayment(ctx, req, order, payment, customer, totalSplitAmountWithTax, existingPayments)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := p.createPaymentOrderItems(ctx, splitPayment, req, validation); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := p.updateOrderAfterSplit(ctx, order, req, customer, totalSplitAmountWithTax, validation); err != nil {
|
|
return nil, err
|
|
}
|
|
return &models.SplitBillResponse{
|
|
PaymentID: splitPayment.ID,
|
|
OrderID: req.OrderID,
|
|
CustomerID: req.CustomerID,
|
|
Type: SplitTypeItem,
|
|
Amount: totalSplitAmountWithTax,
|
|
Items: responseItems,
|
|
Message: fmt.Sprintf("Successfully split payment by items (%.2f) for customer %s. Remaining balance: %.2f", totalSplitAmountWithTax, customer.Name, order.RemainingAmount),
|
|
}, nil
|
|
}
|
|
|
|
func (p *SplitBillProcessorImpl) SplitByAmount(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": SplitTypeAmount,
|
|
},
|
|
}
|
|
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[MetadataKeyLastSplitPaymentID] = splitPayment.ID.String()
|
|
order.Metadata[MetadataKeyLastSplitCustomerID] = req.CustomerID.String()
|
|
order.Metadata[MetadataKeyLastSplitAmount] = req.Amount
|
|
order.Metadata[MetadataKeyLastSplitType] = SplitTypeAmount
|
|
|
|
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: SplitTypeAmount,
|
|
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
|
|
}
|