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
+49 -23
View File
@@ -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)
}