Files
apskel-pos-backend/internal/processor/split_bill_processor.go
T
efrilmandClaude Opus 5 992bb04816 feat(order): support weight-based products
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>
2026-09-06 17:14:42 +07:00

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
}