feat(loyalty): show what an order earned on the order and receipt

Adds points_earned and coins_earned to the order response
(docs/prd-point-coin.md F3, PC-205). The POS prints the receipt from this
response, so the receipt gets them too.

The values are the sums of the order's EARN rows, read in one query for a
list of orders. They are filled for create, add items, update, detail and
list, and are 0 for an order that earned nothing. UpdateOrder earns before
building its response, so a payment completed there already shows the
earning. A failure to read them is logged and leaves them at 0 rather than
failing the order read. The self-order session listing reads orders
directly from the repository and still shows 0.

The two order hooks are merged into one OrderLoyalty interface (paid,
refunded, earned by orders) with a single SetLoyalty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 10:50:37 +07:00
co-authored by Claude Opus 5.5
parent eb5b63677f
commit fc97c78300
10 changed files with 194 additions and 53 deletions
+64 -28
View File
@@ -7,6 +7,7 @@ import (
"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"
@@ -109,40 +110,31 @@ type OrderProcessorImpl struct {
ingredientRepo IngredientRepository
inventoryMovementService InventoryMovementService
productOutletPriceRepo repository.ProductOutletPriceRepository
orderPaidHook OrderPaidHook
orderRefundedHook OrderRefundedHook
loyalty OrderLoyalty
}
// OrderPaidHook is told when an order has just become fully paid and the payment has
// committed. EarningProcessor is one (docs/prd-point-coin.md F3).
type OrderPaidHook interface {
// 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)
}
// SetOrderPaidHook sets what runs when an order becomes fully paid.
func (p *OrderProcessorImpl) SetOrderPaidHook(hook OrderPaidHook) {
p.orderPaidHook = hook
}
// OrderRefundedHook is told when an order has been voided or (partly) refunded and
// that has committed. EarningProcessor is one (docs/prd-point-coin.md F10).
type OrderRefundedHook interface {
// 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)
}
// SetOrderRefundedHook sets what runs when an order is voided or refunded.
func (p *OrderProcessorImpl) SetOrderRefundedHook(hook OrderRefundedHook) {
p.orderRefundedHook = hook
// OrderEarned is what one order earned, as written in its EARN rows.
type OrderEarned struct {
Points int64
Coins int64
}
// 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) {
if p.orderRefundedHook == nil {
return
}
p.orderRefundedHook.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
// SetLoyalty connects the order flow to the loyalty program.
func (p *OrderProcessorImpl) SetLoyalty(loyalty OrderLoyalty) {
p.loyalty = loyalty
}
// onOrderPaid is the single place every path that completes an order's payment goes
@@ -151,10 +143,45 @@ func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.U
// 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.orderPaidHook == nil {
if p.loyalty == nil {
return
}
p.orderPaidHook.OnOrderPaid(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
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) {
if p.loyalty == nil {
return
}
p.loyalty.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(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(
@@ -343,6 +370,7 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
}
response := mappers.OrderEntityToResponse(orderWithRelations)
p.attachEarnings(ctx, response)
return response, nil
}
@@ -481,6 +509,7 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
}
updatedOrderResponse := mappers.OrderEntityToResponse(orderWithRelations)
p.attachEarnings(ctx, updatedOrderResponse)
return &models.AddToOrderResponse{
OrderID: orderID,
@@ -548,6 +577,7 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req
}
response := mappers.OrderEntityToResponse(orderWithRelations)
p.attachEarnings(ctx, response)
return response, nil
}
@@ -558,6 +588,7 @@ func (p *OrderProcessorImpl) GetOrderByID(ctx context.Context, id uuid.UUID) (*m
}
response := mappers.OrderEntityToResponse(order)
p.attachEarnings(ctx, response)
return response, nil
}
@@ -619,6 +650,11 @@ 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 {