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:
co-authored by
Claude Opus 5.5
parent
eb5b63677f
commit
fc97c78300
@@ -182,3 +182,17 @@ func earningDescription(order *repository.EarningOrder) string {
|
||||
}
|
||||
return truncateRunes(description, walletDescriptionLimit)
|
||||
}
|
||||
|
||||
// EarnedByOrders returns what each order earned, as written in its EARN rows, for the
|
||||
// order screen and the receipt (F3). Orders that earned nothing are absent.
|
||||
func (p *EarningProcessor) EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]OrderEarned, error) {
|
||||
rows, err := p.orders.EarnedByOrders(ctx, orderIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[uuid.UUID]OrderEarned, len(rows))
|
||||
for id, byCurrency := range rows {
|
||||
out[id] = OrderEarned{Points: byCurrency[constants.WalletCurrencyPoint], Coins: byCurrency[constants.WalletCurrencyCoin]}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -214,6 +214,14 @@ func TestEarningProcessor_AgainstPostgres(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed))
|
||||
|
||||
// What the order screen and receipt show is what the ledger has.
|
||||
shown, err := earning.EarnedByOrders(ctx, []uuid.UUID{paid, split, missed, uuid.New()})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, OrderEarned{Points: 875, Coins: 3}, shown[paid])
|
||||
assert.Equal(t, OrderEarned{Points: 600, Coins: 2}, shown[split])
|
||||
assert.Equal(t, OrderEarned{Points: 400, Coins: 1}, shown[missed])
|
||||
assert.Len(t, shown, 3, "an order without earning is absent, so it shows 0")
|
||||
|
||||
balance := struct{ PointBalance, CoinBalance int64 }{}
|
||||
require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, regular).Scan(&balance).Error)
|
||||
assert.Equal(t, int64(875+250+600+400), balance.PointBalance)
|
||||
|
||||
@@ -103,8 +103,10 @@ func (f splitFake) SplitByItem(_ context.Context, _ *models.SplitBillRequest, or
|
||||
}
|
||||
|
||||
type orderPaidHookFake struct {
|
||||
calls []uuid.UUID
|
||||
ctxs []context.Context
|
||||
calls []uuid.UUID
|
||||
ctxs []context.Context
|
||||
refunded []uuid.UUID
|
||||
earned map[uuid.UUID]OrderEarned
|
||||
}
|
||||
|
||||
func (h *orderPaidHookFake) OnOrderPaid(ctx context.Context, orderID uuid.UUID) {
|
||||
@@ -112,6 +114,14 @@ func (h *orderPaidHookFake) OnOrderPaid(ctx context.Context, orderID uuid.UUID)
|
||||
h.ctxs = append(h.ctxs, ctx)
|
||||
}
|
||||
|
||||
func (h *orderPaidHookFake) OnOrderRefunded(_ context.Context, orderID uuid.UUID) {
|
||||
h.refunded = append(h.refunded, orderID)
|
||||
}
|
||||
|
||||
func (h *orderPaidHookFake) EarnedByOrders(context.Context, []uuid.UUID) (map[uuid.UUID]OrderEarned, error) {
|
||||
return h.earned, nil
|
||||
}
|
||||
|
||||
func newHookedOrderProcessor(split SplitBillProcessor) (*OrderProcessorImpl, *hookOrderRepo, *orderPaidHookFake) {
|
||||
hook := &orderPaidHookFake{}
|
||||
orders := &hookOrderRepo{
|
||||
@@ -126,7 +136,7 @@ func newHookedOrderProcessor(split SplitBillProcessor) (*OrderProcessorImpl, *ho
|
||||
splitBillProcessor: split,
|
||||
txManager: repository.NewTxManager(nil),
|
||||
}
|
||||
p.SetOrderPaidHook(hook)
|
||||
p.SetLoyalty(hook)
|
||||
return p, orders, hook
|
||||
}
|
||||
|
||||
@@ -175,7 +185,7 @@ func TestOrderPaidHook_SplitBillOnlyOnTheSettlingPayment(t *testing.T) {
|
||||
|
||||
func TestOrderPaidHook_NoHookIsFine(t *testing.T) {
|
||||
p, orders, _ := newHookedOrderProcessor(nil)
|
||||
p.SetOrderPaidHook(nil)
|
||||
p.SetLoyalty(nil)
|
||||
_, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -204,43 +214,59 @@ func (r *hookPaymentRepo) RefundPayment(_ context.Context, id uuid.UUID, amount
|
||||
return nil
|
||||
}
|
||||
|
||||
type orderRefundedHookFake struct{ calls []uuid.UUID }
|
||||
|
||||
func (h *orderRefundedHookFake) OnOrderRefunded(_ context.Context, orderID uuid.UUID) {
|
||||
h.calls = append(h.calls, orderID)
|
||||
}
|
||||
|
||||
func TestOrderRefundedHook(t *testing.T) {
|
||||
t.Run("void", func(t *testing.T) {
|
||||
p, orders, _ := newHookedOrderProcessor(nil)
|
||||
hook := &orderRefundedHookFake{}
|
||||
p.SetOrderRefundedHook(hook)
|
||||
p, orders, hook := newHookedOrderProcessor(nil)
|
||||
require.NoError(t, p.VoidOrder(context.Background(), &models.VoidOrderRequest{OrderID: orders.order.ID, Type: "ALL", Reason: "salah input"}, uuid.New()))
|
||||
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
|
||||
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded)
|
||||
})
|
||||
|
||||
t.Run("refund order", func(t *testing.T) {
|
||||
p, orders, _ := newHookedOrderProcessor(nil)
|
||||
p, orders, hook := newHookedOrderProcessor(nil)
|
||||
orders.order.PaymentStatus = entities.PaymentStatusCompleted
|
||||
hook := &orderRefundedHookFake{}
|
||||
p.SetOrderRefundedHook(hook)
|
||||
amount := 25000.0
|
||||
require.NoError(t, p.RefundOrder(context.Background(), orders.order.ID, &models.RefundOrderRequest{RefundAmount: &amount}, uuid.New()))
|
||||
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
|
||||
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded)
|
||||
})
|
||||
|
||||
t.Run("refund payment", func(t *testing.T) {
|
||||
p, orders, _ := newHookedOrderProcessor(nil)
|
||||
p, orders, hook := newHookedOrderProcessor(nil)
|
||||
payments := p.paymentRepo.(*hookPaymentRepo)
|
||||
payment := &entities.Payment{ID: uuid.New(), OrderID: orders.order.ID, Amount: 50000, Status: entities.PaymentTransactionStatusCompleted}
|
||||
payments.created = append(payments.created, payment)
|
||||
hook := &orderRefundedHookFake{}
|
||||
p.SetOrderRefundedHook(hook)
|
||||
require.NoError(t, p.RefundPayment(context.Background(), payment.ID, 20000, "kembali", uuid.New()))
|
||||
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
|
||||
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded)
|
||||
|
||||
// A refund that fails does not run the hook.
|
||||
assert.Error(t, p.RefundPayment(context.Background(), payment.ID, 60000, "terlalu besar", uuid.New()))
|
||||
assert.Len(t, hook.calls, 1)
|
||||
assert.Len(t, hook.refunded, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *hookOrderRepo) List(context.Context, map[string]interface{}, int, int) ([]*entities.Order, int64, error) {
|
||||
o := *r.order
|
||||
other := entities.Order{ID: uuid.New()}
|
||||
return []*entities.Order{&o, &other}, 2, nil
|
||||
}
|
||||
|
||||
func TestOrderResponsesCarryEarnings(t *testing.T) {
|
||||
p, orders, hook := newHookedOrderProcessor(nil)
|
||||
hook.earned = map[uuid.UUID]OrderEarned{orders.order.ID: {Points: 875, Coins: 3}}
|
||||
|
||||
got, err := p.GetOrderByID(context.Background(), orders.order.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(875), got.PointsEarned)
|
||||
assert.Equal(t, int64(3), got.CoinsEarned)
|
||||
|
||||
list, err := p.ListOrders(context.Background(), &models.ListOrdersRequest{Page: 1, Limit: 10})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list.Orders, 2)
|
||||
assert.Equal(t, int64(875), list.Orders[0].PointsEarned)
|
||||
assert.Equal(t, int64(0), list.Orders[1].PointsEarned, "an order that earned nothing shows 0")
|
||||
|
||||
// Without the loyalty program the fields stay at 0.
|
||||
p.SetLoyalty(nil)
|
||||
got, err = p.GetOrderByID(context.Background(), orders.order.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), got.PointsEarned)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user