Revert "feat(loyalty): EnakPoint & EnakCoin" (#32)

This reverts merge commit 645da30, returning main to f0ff59f.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 15:16:15 +07:00
co-authored by Claude Opus 5.5
parent 645da3048e
commit 4e24f9bbb0
195 changed files with 2179 additions and 20060 deletions
+2 -253
View File
@@ -5,10 +5,8 @@ import (
"errors"
"fmt"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
@@ -21,9 +19,6 @@ type OrderProcessor interface {
CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error)
AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error)
UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error)
// PayWithPointsInApp pays the customer's own order with EnakPoint from the app or a
// self-order, approved by their PIN (docs/prd-point-coin.md F9).
PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error)
GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error)
ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error)
VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error
@@ -114,139 +109,6 @@ type OrderProcessorImpl struct {
ingredientRepo IngredientRepository
inventoryMovementService InventoryMovementService
productOutletPriceRepo repository.ProductOutletPriceRepository
loyalty OrderLoyalty
pointPayments *PointPaymentProcessor
paymentCodes paymentCodeRedeemer
pins pinVerifier
}
// OrderLoyalty is what the order flow tells and asks the loyalty program
// (docs/prd-point-coin.md F3, F10). EarningProcessor is one.
type OrderLoyalty interface {
// OnOrderPaid runs once an order has just become fully paid and the payment has
// committed.
OnOrderPaid(ctx context.Context, orderID uuid.UUID)
// OnOrderRefunded runs once an order has been voided or (partly) refunded and that
// has committed.
OnOrderRefunded(ctx context.Context, orderID uuid.UUID)
// EarnedByOrders returns what each order earned.
EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]OrderEarned, error)
}
// OrderEarned is what one order earned, as written in its EARN rows.
type OrderEarned struct {
Points int64
Coins int64
}
// SetLoyalty connects the order flow to the loyalty program.
func (p *OrderProcessorImpl) SetLoyalty(loyalty OrderLoyalty) {
p.loyalty = loyalty
}
type paymentCodeRedeemer interface {
Redeem(ctx context.Context, code string, customerID uuid.UUID) error
}
// SetPointPayments enables paying with the EnakPoint method. CreatePayment hands such
// payments to pointPayments approved by the code the customer shows at the cashier,
// and PayWithPointsInApp approved by the customer's PIN (F9).
func (p *OrderProcessorImpl) SetPointPayments(pointPayments *PointPaymentProcessor, codes paymentCodeRedeemer, pins pinVerifier) {
p.pointPayments = pointPayments
p.paymentCodes = codes
p.pins = pins
}
// createPointPayment is CreatePayment for the EnakPoint method. It never uses the
// generic payment path, which would record the payment without taking any balance.
func (p *OrderProcessorImpl) createPointPayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error) {
if p.pointPayments == nil || p.paymentCodes == nil {
return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected)
}
if req.Points == nil || req.PaymentCode == nil || *req.PaymentCode == "" {
return nil, fmt.Errorf("%w: points and the customer's payment code are required", ErrPointPaymentRejected)
}
var cashier *uuid.UUID
if id := appcontext.FromContext(ctx).UserID; id != uuid.Nil {
cashier = &id
}
code := *req.PaymentCode
result, err := p.pointPayments.Pay(ctx, PointPaymentInput{
OrderID: req.OrderID,
PaymentMethodID: req.PaymentMethodID,
Points: *req.Points,
CashierID: cashier,
Authorize: func(ctx context.Context, customerID uuid.UUID) error {
if err := p.paymentCodes.Redeem(ctx, code, customerID); err != nil {
return fmt.Errorf("%w: %v", ErrPointPaymentRejected, err)
}
return nil
},
})
if err != nil {
return nil, err
}
if result.Completed {
p.onOrderPaid(ctx, req.OrderID)
}
payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve created payment: %w", err)
}
return mappers.PaymentEntityToResponse(payment), nil
}
// onOrderPaid is the single place every path that completes an order's payment goes
// through: UpdateOrder, CreatePayment and both kinds of split bill. It must be called
// after the payment has committed. The hook runs detached from the caller's
// transaction and from the request being cancelled, and anything it does cannot fail
// the payment.
func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID) {
if p.loyalty == nil {
return
}
p.loyalty.OnOrderPaid(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
}
// onOrderRefunded is called at the end of VoidOrder, RefundOrder and RefundPayment,
// after their writes have committed, with the same guarantees as onOrderPaid: it can
// never block or fail the void or refund.
func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) {
ctx = repository.DetachTransaction(context.WithoutCancel(ctx))
// EnakPoint paid on the order comes back first: the customer is owed it (F9).
if p.pointPayments != nil {
if _, err := p.pointPayments.RefundForOrder(ctx, orderID); err != nil {
logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::onOrderRefunded -> failed to return EnakPoint; calling it again is safe")
}
}
if p.loyalty != nil {
p.loyalty.OnOrderRefunded(ctx, orderID)
}
}
// attachEarnings fills points_earned and coins_earned (F3). A failure is logged and
// leaves them at zero rather than failing the order read.
func (p *OrderProcessorImpl) attachEarnings(ctx context.Context, responses ...*models.OrderResponse) {
if p.loyalty == nil || len(responses) == 0 {
return
}
ids := make([]uuid.UUID, 0, len(responses))
for _, r := range responses {
if r != nil {
ids = append(ids, r.ID)
}
}
earned, err := p.loyalty.EarnedByOrders(repository.DetachTransaction(ctx), ids)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::attachEarnings -> failed to read earnings")
return
}
for _, r := range responses {
if r != nil {
e := earned[r.ID]
r.PointsEarned, r.CoinsEarned = e.Points, e.Coins
}
}
}
func NewOrderProcessorImpl(
@@ -435,7 +297,6 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
}
response := mappers.OrderEntityToResponse(orderWithRelations)
p.attachEarnings(ctx, response)
return response, nil
}
@@ -574,7 +435,6 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
}
updatedOrderResponse := mappers.OrderEntityToResponse(orderWithRelations)
p.attachEarnings(ctx, updatedOrderResponse)
return &models.AddToOrderResponse{
OrderID: orderID,
@@ -634,7 +494,6 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req
if err := p.orderRepo.UpdateStatusSuccess(ctx, order.ID, order.Status, order.PaymentStatus); err != nil {
return nil, fmt.Errorf("failed to update order: %w", err)
}
p.onOrderPaid(ctx, order.ID)
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, id)
if err != nil {
@@ -642,7 +501,6 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req
}
response := mappers.OrderEntityToResponse(orderWithRelations)
p.attachEarnings(ctx, response)
return response, nil
}
@@ -653,7 +511,6 @@ func (p *OrderProcessorImpl) GetOrderByID(ctx context.Context, id uuid.UUID) (*m
}
response := mappers.OrderEntityToResponse(order)
p.attachEarnings(ctx, response)
return response, nil
}
@@ -715,11 +572,6 @@ func (p *OrderProcessorImpl) ListOrders(ctx context.Context, req *models.ListOrd
}
}
}
listed := make([]*models.OrderResponse, len(orderResponses))
for i := range orderResponses {
listed[i] = &orderResponses[i]
}
p.attachEarnings(ctx, listed...)
totalPages := int(total) / req.Limit
if int(total)%req.Limit > 0 {
@@ -840,10 +692,6 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
return fmt.Errorf("invalid void type: must be 'ALL' or 'ITEM'")
}
// VoidOrder refuses fully paid orders today, so there is usually nothing to take
// back; this keeps a void correct if that rule changes. The order repository writes
// outside the caller's transaction, so the void is already committed here.
p.onOrderRefunded(ctx, req.OrderID)
return nil
}
@@ -866,18 +714,6 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
reason = *req.Reason
}
// An order refund is handed back in cash or another method, so it cannot cover what
// was paid with EnakPoint (K7). Checked before anything is written.
if p.pointPayments != nil {
planned, err := p.plannedOrderRefund(ctx, id, req)
if err != nil {
return err
}
if err := p.pointPayments.EnsureOrderRefundAllowed(ctx, id, planned); err != nil {
return err
}
}
// Process refund based on request type
if req.RefundAmount != nil {
// Full or partial refund by amount
@@ -951,7 +787,6 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
}
}
p.onOrderRefunded(ctx, id)
return nil
}
@@ -969,13 +804,10 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea
return nil, fmt.Errorf("order is already fully paid")
}
method, err := p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
_, err = p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
if err != nil {
return nil, fmt.Errorf("payment method not found: %w", err)
}
if method.Type == entities.PaymentMethodTypePoint {
return p.createPointPayment(ctx, req)
}
totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, req.OrderID)
if err != nil {
@@ -986,8 +818,6 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea
if err != nil {
return nil, err
}
// Not from updateOrderStatus: that runs inside the payment's transaction.
p.onOrderPaid(ctx, req.OrderID)
paymentWithRelations, err := p.paymentRepo.GetByID(ctx, payment.ID)
if err != nil {
@@ -1012,11 +842,7 @@ func (p *OrderProcessorImpl) RefundPayment(ctx context.Context, paymentID uuid.U
return fmt.Errorf("refund amount cannot exceed payment amount")
}
if err := p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment); err != nil {
return err
}
p.onOrderRefunded(ctx, payment.OrderID)
return nil
return p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment)
}
func (p *OrderProcessorImpl) CreatePaymentWithInventoryMovement(ctx context.Context, req *models.CreatePaymentRequest, order *entities.Order, totalPaid float64) (*entities.Payment, error) {
@@ -1360,11 +1186,6 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil
if err != nil {
return nil, fmt.Errorf("payment method not found: %w", err)
}
// Splitting with EnakPoint would record a payment without taking any balance; an
// EnakPoint part goes through CreatePayment and the rest is split as usual (F9).
if payment.Type == entities.PaymentMethodTypePoint {
return nil, fmt.Errorf("%w: pay the EnakPoint part as its own payment, not as a split", ErrPointPaymentRejected)
}
customer := &entities.Customer{}
if req.CustomerID != uuid.Nil {
@@ -1386,10 +1207,6 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil
if err != nil {
return nil, err
}
// Both split paths mark the order paid on the payment that settles it.
if order.PaymentStatus == entities.PaymentStatusCompleted {
p.onOrderPaid(ctx, order.ID)
}
return response, nil
}
@@ -1811,71 +1628,3 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Con
func stringPtr(s string) *string {
return &s
}
// PayWithPointsInApp pays an order with EnakPoint on the customer's own request, in the
// app or a self-order. The session alone is not enough: the customer's PIN approves
// it (K8). An order that is not the customer's own is reported as not found, so the
// endpoint does not reveal other customers' orders.
func (p *OrderProcessorImpl) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) {
if p.pointPayments == nil || p.pins == nil {
return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected)
}
organizationID, owner, err := p.pointPayments.OrderOwner(ctx, orderID)
if err != nil {
return nil, err
}
if owner == nil || *owner != customerID {
return nil, repository.ErrPointPaymentOrderNotFound
}
methodID, err := p.pointPayments.PointMethodID(ctx, organizationID)
if err != nil {
return nil, err
}
result, err := p.pointPayments.Pay(ctx, PointPaymentInput{
OrderID: orderID,
PaymentMethodID: methodID,
Points: points,
Authorize: func(ctx context.Context, customerID uuid.UUID) error {
return p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info)
},
})
if err != nil {
return nil, err
}
if result.Completed {
p.onOrderPaid(ctx, orderID)
}
payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve created payment: %w", err)
}
return mappers.PaymentEntityToResponse(payment), nil
}
// plannedOrderRefund is the total RefundOrder is about to hand back, computed the same
// way it will be, without writing anything.
func (p *OrderProcessorImpl) plannedOrderRefund(ctx context.Context, orderID uuid.UUID, req *models.RefundOrderRequest) (float64, error) {
if req.RefundAmount != nil {
return *req.RefundAmount, nil
}
var total float64
for _, itemRefund := range req.OrderItems {
if itemRefund.RefundAmount != nil {
total += *itemRefund.RefundAmount
continue
}
orderItem, err := p.orderItemRepo.GetByID(ctx, itemRefund.OrderItemID)
if err != nil {
return 0, fmt.Errorf("order item not found: %w", err)
}
if orderItem.OrderID != orderID {
return 0, fmt.Errorf("order item does not belong to this order")
}
quantity := itemRefund.RefundQuantity
if quantity == 0 {
quantity = orderItem.Quantity
}
total += entities.RoundMoney(orderItem.BillableQuantityFor(quantity) * orderItem.UnitPrice)
}
return total, nil
}