diff --git a/internal/app/app.go b/internal/app/app.go index f79dd26..7f9b42e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -398,8 +398,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor loyaltySettingsProcessor := processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager) // 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) - orderProcessor.SetOrderPaidHook(earningProcessor) - orderProcessor.SetOrderRefundedHook(earningProcessor) + orderProcessor.SetLoyalty(earningProcessor) return &processors{ userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo), diff --git a/internal/contract/order_contract.go b/internal/contract/order_contract.go index 80ea44f..5926775 100644 --- a/internal/contract/order_contract.go +++ b/internal/contract/order_contract.go @@ -92,6 +92,10 @@ type OrderResponse struct { TotalPaid float64 `json:"total_paid"` PaymentCount int `json:"payment_count"` 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 { diff --git a/internal/models/order.go b/internal/models/order.go index d18e2a9..d15278f 100644 --- a/internal/models/order.go +++ b/internal/models/order.go @@ -182,6 +182,9 @@ type OrderResponse struct { TotalPaid float64 PaymentCount int SplitType *string + // What the order earned (docs/prd-point-coin.md F3), from its EARN rows. + PointsEarned int64 + CoinsEarned int64 } type OrderItemResponse struct { diff --git a/internal/processor/earning_processor.go b/internal/processor/earning_processor.go index 02eb649..042a50a 100644 --- a/internal/processor/earning_processor.go +++ b/internal/processor/earning_processor.go @@ -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 +} diff --git a/internal/processor/earning_processor_db_test.go b/internal/processor/earning_processor_db_test.go index ae6565f..0a58841 100644 --- a/internal/processor/earning_processor_db_test.go +++ b/internal/processor/earning_processor_db_test.go @@ -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) diff --git a/internal/processor/order_paid_hook_test.go b/internal/processor/order_paid_hook_test.go index 964ca49..bed9ef9 100644 --- a/internal/processor/order_paid_hook_test.go +++ b/internal/processor/order_paid_hook_test.go @@ -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) +} diff --git a/internal/processor/order_processor.go b/internal/processor/order_processor.go index 2c6426c..0bd9bc5 100644 --- a/internal/processor/order_processor.go +++ b/internal/processor/order_processor.go @@ -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 { diff --git a/internal/repository/earning_repository.go b/internal/repository/earning_repository.go index 517f3dc..7b31af3 100644 --- a/internal/repository/earning_repository.go +++ b/internal/repository/earning_repository.go @@ -59,6 +59,9 @@ type EarningRepository interface { ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error) // LotIDsCreatedBy returns the lots a credit created. 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 { @@ -232,3 +235,35 @@ func (r *earningRepository) LotIDsCreatedBy(ctx context.Context, transactionID u } 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 +} diff --git a/internal/transformer/order_transformer.go b/internal/transformer/order_transformer.go index b297cef..1c34626 100644 --- a/internal/transformer/order_transformer.go +++ b/internal/transformer/order_transformer.go @@ -161,6 +161,8 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse { TotalPaid: resp.TotalPaid, PaymentCount: resp.PaymentCount, SplitType: resp.SplitType, + PointsEarned: resp.PointsEarned, + CoinsEarned: resp.CoinsEarned, } } diff --git a/internal/transformer/order_transformer_test.go b/internal/transformer/order_transformer_test.go index c3067a0..5c55afd 100644 --- a/internal/transformer/order_transformer_test.go +++ b/internal/transformer/order_transformer_test.go @@ -1,11 +1,14 @@ package transformer import ( + "encoding/json" "testing" "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -46,3 +49,14 @@ func TestAddToOrderContractToModelCarriesWeight(t *testing.T) { require.NotNil(t, 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`) +}