Revert "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts merge commit645da30, returning main tof0ff59f. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
645da3048e
commit
4e24f9bbb0
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user