feat(loyalty): EnakPoint & EnakCoin #32

Merged
aefril merged 36 commits from feature/point-coint into main 2026-09-30 10:03:08 +02:00
10 changed files with 194 additions and 53 deletions
Showing only changes of commit fc97c78300 - Show all commits
+1 -2
View File
@@ -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),
+4
View File
@@ -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 {
+3
View File
@@ -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 {
+14
View File
@@ -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)
+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)
}
+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 {
+35
View File
@@ -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
}
@@ -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,
}
}
@@ -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`)
}