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
+1
-2
@@ -398,8 +398,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
|||||||
loyaltySettingsProcessor := processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)
|
loyaltySettingsProcessor := processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)
|
||||||
// Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3)
|
// Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3)
|
||||||
earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
||||||
orderProcessor.SetOrderPaidHook(earningProcessor)
|
orderProcessor.SetLoyalty(earningProcessor)
|
||||||
orderProcessor.SetOrderRefundedHook(earningProcessor)
|
|
||||||
|
|
||||||
return &processors{
|
return &processors{
|
||||||
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo),
|
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo),
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ type OrderResponse struct {
|
|||||||
TotalPaid float64 `json:"total_paid"`
|
TotalPaid float64 `json:"total_paid"`
|
||||||
PaymentCount int `json:"payment_count"`
|
PaymentCount int `json:"payment_count"`
|
||||||
SplitType *string `json:"split_type,omitempty"`
|
SplitType *string `json:"split_type,omitempty"`
|
||||||
|
// EnakPoint and EnakCoin the order earned, for the order screen and the receipt
|
||||||
|
// (docs/prd-point-coin.md F3). Zero when it earned nothing.
|
||||||
|
PointsEarned int64 `json:"points_earned"`
|
||||||
|
CoinsEarned int64 `json:"coins_earned"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrderItemResponse struct {
|
type OrderItemResponse struct {
|
||||||
|
|||||||
@@ -182,6 +182,9 @@ type OrderResponse struct {
|
|||||||
TotalPaid float64
|
TotalPaid float64
|
||||||
PaymentCount int
|
PaymentCount int
|
||||||
SplitType *string
|
SplitType *string
|
||||||
|
// What the order earned (docs/prd-point-coin.md F3), from its EARN rows.
|
||||||
|
PointsEarned int64
|
||||||
|
CoinsEarned int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrderItemResponse struct {
|
type OrderItemResponse struct {
|
||||||
|
|||||||
@@ -182,3 +182,17 @@ func earningDescription(order *repository.EarningOrder) string {
|
|||||||
}
|
}
|
||||||
return truncateRunes(description, walletDescriptionLimit)
|
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)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed))
|
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 }{}
|
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)
|
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)
|
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 {
|
type orderPaidHookFake struct {
|
||||||
calls []uuid.UUID
|
calls []uuid.UUID
|
||||||
ctxs []context.Context
|
ctxs []context.Context
|
||||||
|
refunded []uuid.UUID
|
||||||
|
earned map[uuid.UUID]OrderEarned
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *orderPaidHookFake) OnOrderPaid(ctx context.Context, orderID uuid.UUID) {
|
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)
|
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) {
|
func newHookedOrderProcessor(split SplitBillProcessor) (*OrderProcessorImpl, *hookOrderRepo, *orderPaidHookFake) {
|
||||||
hook := &orderPaidHookFake{}
|
hook := &orderPaidHookFake{}
|
||||||
orders := &hookOrderRepo{
|
orders := &hookOrderRepo{
|
||||||
@@ -126,7 +136,7 @@ func newHookedOrderProcessor(split SplitBillProcessor) (*OrderProcessorImpl, *ho
|
|||||||
splitBillProcessor: split,
|
splitBillProcessor: split,
|
||||||
txManager: repository.NewTxManager(nil),
|
txManager: repository.NewTxManager(nil),
|
||||||
}
|
}
|
||||||
p.SetOrderPaidHook(hook)
|
p.SetLoyalty(hook)
|
||||||
return p, orders, hook
|
return p, orders, hook
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +185,7 @@ func TestOrderPaidHook_SplitBillOnlyOnTheSettlingPayment(t *testing.T) {
|
|||||||
|
|
||||||
func TestOrderPaidHook_NoHookIsFine(t *testing.T) {
|
func TestOrderPaidHook_NoHookIsFine(t *testing.T) {
|
||||||
p, orders, _ := newHookedOrderProcessor(nil)
|
p, orders, _ := newHookedOrderProcessor(nil)
|
||||||
p.SetOrderPaidHook(nil)
|
p.SetLoyalty(nil)
|
||||||
_, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{})
|
_, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
@@ -204,43 +214,59 @@ func (r *hookPaymentRepo) RefundPayment(_ context.Context, id uuid.UUID, amount
|
|||||||
return nil
|
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) {
|
func TestOrderRefundedHook(t *testing.T) {
|
||||||
t.Run("void", func(t *testing.T) {
|
t.Run("void", func(t *testing.T) {
|
||||||
p, orders, _ := newHookedOrderProcessor(nil)
|
p, orders, hook := newHookedOrderProcessor(nil)
|
||||||
hook := &orderRefundedHookFake{}
|
|
||||||
p.SetOrderRefundedHook(hook)
|
|
||||||
require.NoError(t, p.VoidOrder(context.Background(), &models.VoidOrderRequest{OrderID: orders.order.ID, Type: "ALL", Reason: "salah input"}, uuid.New()))
|
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) {
|
t.Run("refund order", func(t *testing.T) {
|
||||||
p, orders, _ := newHookedOrderProcessor(nil)
|
p, orders, hook := newHookedOrderProcessor(nil)
|
||||||
orders.order.PaymentStatus = entities.PaymentStatusCompleted
|
orders.order.PaymentStatus = entities.PaymentStatusCompleted
|
||||||
hook := &orderRefundedHookFake{}
|
|
||||||
p.SetOrderRefundedHook(hook)
|
|
||||||
amount := 25000.0
|
amount := 25000.0
|
||||||
require.NoError(t, p.RefundOrder(context.Background(), orders.order.ID, &models.RefundOrderRequest{RefundAmount: &amount}, uuid.New()))
|
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) {
|
t.Run("refund payment", func(t *testing.T) {
|
||||||
p, orders, _ := newHookedOrderProcessor(nil)
|
p, orders, hook := newHookedOrderProcessor(nil)
|
||||||
payments := p.paymentRepo.(*hookPaymentRepo)
|
payments := p.paymentRepo.(*hookPaymentRepo)
|
||||||
payment := &entities.Payment{ID: uuid.New(), OrderID: orders.order.ID, Amount: 50000, Status: entities.PaymentTransactionStatusCompleted}
|
payment := &entities.Payment{ID: uuid.New(), OrderID: orders.order.ID, Amount: 50000, Status: entities.PaymentTransactionStatusCompleted}
|
||||||
payments.created = append(payments.created, payment)
|
payments.created = append(payments.created, payment)
|
||||||
hook := &orderRefundedHookFake{}
|
|
||||||
p.SetOrderRefundedHook(hook)
|
|
||||||
require.NoError(t, p.RefundPayment(context.Background(), payment.ID, 20000, "kembali", uuid.New()))
|
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.
|
// A refund that fails does not run the hook.
|
||||||
assert.Error(t, p.RefundPayment(context.Background(), payment.ID, 60000, "terlalu besar", uuid.New()))
|
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/constants"
|
||||||
"apskel-pos-be/internal/entities"
|
"apskel-pos-be/internal/entities"
|
||||||
|
"apskel-pos-be/internal/logger"
|
||||||
"apskel-pos-be/internal/mappers"
|
"apskel-pos-be/internal/mappers"
|
||||||
"apskel-pos-be/internal/models"
|
"apskel-pos-be/internal/models"
|
||||||
"apskel-pos-be/internal/repository"
|
"apskel-pos-be/internal/repository"
|
||||||
@@ -109,40 +110,31 @@ type OrderProcessorImpl struct {
|
|||||||
ingredientRepo IngredientRepository
|
ingredientRepo IngredientRepository
|
||||||
inventoryMovementService InventoryMovementService
|
inventoryMovementService InventoryMovementService
|
||||||
productOutletPriceRepo repository.ProductOutletPriceRepository
|
productOutletPriceRepo repository.ProductOutletPriceRepository
|
||||||
orderPaidHook OrderPaidHook
|
loyalty OrderLoyalty
|
||||||
orderRefundedHook OrderRefundedHook
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderPaidHook is told when an order has just become fully paid and the payment has
|
// OrderLoyalty is what the order flow tells and asks the loyalty program
|
||||||
// committed. EarningProcessor is one (docs/prd-point-coin.md F3).
|
// (docs/prd-point-coin.md F3, F10). EarningProcessor is one.
|
||||||
type OrderPaidHook interface {
|
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)
|
OnOrderPaid(ctx context.Context, orderID uuid.UUID)
|
||||||
}
|
// OnOrderRefunded runs once an order has been voided or (partly) refunded and that
|
||||||
|
// has committed.
|
||||||
// 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(ctx context.Context, orderID uuid.UUID)
|
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.
|
// OrderEarned is what one order earned, as written in its EARN rows.
|
||||||
func (p *OrderProcessorImpl) SetOrderRefundedHook(hook OrderRefundedHook) {
|
type OrderEarned struct {
|
||||||
p.orderRefundedHook = hook
|
Points int64
|
||||||
|
Coins int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// onOrderRefunded is called at the end of VoidOrder, RefundOrder and RefundPayment,
|
// SetLoyalty connects the order flow to the loyalty program.
|
||||||
// after their writes have committed, with the same guarantees as onOrderPaid: it can
|
func (p *OrderProcessorImpl) SetLoyalty(loyalty OrderLoyalty) {
|
||||||
// never block or fail the void or refund.
|
p.loyalty = loyalty
|
||||||
func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) {
|
|
||||||
if p.orderRefundedHook == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.orderRefundedHook.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// onOrderPaid is the single place every path that completes an order's payment goes
|
// 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
|
// transaction and from the request being cancelled, and anything it does cannot fail
|
||||||
// the payment.
|
// the payment.
|
||||||
func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID) {
|
func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID) {
|
||||||
if p.orderPaidHook == nil {
|
if p.loyalty == nil {
|
||||||
return
|
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(
|
func NewOrderProcessorImpl(
|
||||||
@@ -343,6 +370,7 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
|
|||||||
}
|
}
|
||||||
|
|
||||||
response := mappers.OrderEntityToResponse(orderWithRelations)
|
response := mappers.OrderEntityToResponse(orderWithRelations)
|
||||||
|
p.attachEarnings(ctx, response)
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,6 +509,7 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
|
|||||||
}
|
}
|
||||||
|
|
||||||
updatedOrderResponse := mappers.OrderEntityToResponse(orderWithRelations)
|
updatedOrderResponse := mappers.OrderEntityToResponse(orderWithRelations)
|
||||||
|
p.attachEarnings(ctx, updatedOrderResponse)
|
||||||
|
|
||||||
return &models.AddToOrderResponse{
|
return &models.AddToOrderResponse{
|
||||||
OrderID: orderID,
|
OrderID: orderID,
|
||||||
@@ -548,6 +577,7 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req
|
|||||||
}
|
}
|
||||||
|
|
||||||
response := mappers.OrderEntityToResponse(orderWithRelations)
|
response := mappers.OrderEntityToResponse(orderWithRelations)
|
||||||
|
p.attachEarnings(ctx, response)
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,6 +588,7 @@ func (p *OrderProcessorImpl) GetOrderByID(ctx context.Context, id uuid.UUID) (*m
|
|||||||
}
|
}
|
||||||
|
|
||||||
response := mappers.OrderEntityToResponse(order)
|
response := mappers.OrderEntityToResponse(order)
|
||||||
|
p.attachEarnings(ctx, response)
|
||||||
return response, nil
|
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
|
totalPages := int(total) / req.Limit
|
||||||
if int(total)%req.Limit > 0 {
|
if int(total)%req.Limit > 0 {
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ type EarningRepository interface {
|
|||||||
ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error)
|
ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error)
|
||||||
// LotIDsCreatedBy returns the lots a credit created.
|
// LotIDsCreatedBy returns the lots a credit created.
|
||||||
LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error)
|
LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error)
|
||||||
|
// EarnedByOrders sums the EARN rows of each order per currency, as
|
||||||
|
// map[order]map[currency]amount. Orders that earned nothing are absent.
|
||||||
|
EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]map[string]int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type earningRepository struct {
|
type earningRepository struct {
|
||||||
@@ -232,3 +235,35 @@ func (r *earningRepository) LotIDsCreatedBy(ctx context.Context, transactionID u
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *earningRepository) EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]map[string]int64, error) {
|
||||||
|
earned := map[uuid.UUID]map[string]int64{}
|
||||||
|
if len(orderIDs) == 0 {
|
||||||
|
return earned, nil
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
OrderID string
|
||||||
|
Currency string
|
||||||
|
Amount int64
|
||||||
|
}
|
||||||
|
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||||
|
SELECT reference_id::text AS order_id, currency, SUM(amount) AS amount
|
||||||
|
FROM wallet_transactions
|
||||||
|
WHERE reference_type = ? AND type = ? AND reference_id IN ?
|
||||||
|
GROUP BY reference_id, currency`,
|
||||||
|
constants.WalletRefTypeOrder, constants.WalletTxTypeEarn, orderIDs).Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to sum earnings of orders: %w", err)
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
id, err := uuid.Parse(row.OrderID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if earned[id] == nil {
|
||||||
|
earned[id] = map[string]int64{}
|
||||||
|
}
|
||||||
|
earned[id][row.Currency] = row.Amount
|
||||||
|
}
|
||||||
|
return earned, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse {
|
|||||||
TotalPaid: resp.TotalPaid,
|
TotalPaid: resp.TotalPaid,
|
||||||
PaymentCount: resp.PaymentCount,
|
PaymentCount: resp.PaymentCount,
|
||||||
SplitType: resp.SplitType,
|
SplitType: resp.SplitType,
|
||||||
|
PointsEarned: resp.PointsEarned,
|
||||||
|
CoinsEarned: resp.CoinsEarned,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package transformer
|
package transformer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"apskel-pos-be/internal/contract"
|
"apskel-pos-be/internal/contract"
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,3 +49,14 @@ func TestAddToOrderContractToModelCarriesWeight(t *testing.T) {
|
|||||||
require.NotNil(t, result.OrderItems[0].Weight)
|
require.NotNil(t, result.OrderItems[0].Weight)
|
||||||
require.Equal(t, weight, *result.OrderItems[0].Weight)
|
require.Equal(t, weight, *result.OrderItems[0].Weight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOrderModelToContractCarriesEarnings(t *testing.T) {
|
||||||
|
got := OrderModelToContract(&models.OrderResponse{PointsEarned: 875, CoinsEarned: 3})
|
||||||
|
assert.Equal(t, int64(875), got.PointsEarned)
|
||||||
|
assert.Equal(t, int64(3), got.CoinsEarned)
|
||||||
|
|
||||||
|
body, err := json.Marshal(OrderModelToContract(&models.OrderResponse{}))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, string(body), `"points_earned":0`, "present as 0 when nothing was earned")
|
||||||
|
assert.Contains(t, string(body), `"coins_earned":0`)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user