feat: add percentage loyalti setting mode
This commit is contained in:
@@ -3,6 +3,7 @@ package processor
|
||||
import (
|
||||
"math"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
@@ -36,6 +37,10 @@ func (r EarningResult) Metadata(line EarningLine) entities.Metadata {
|
||||
"min_order_amount": line.Settings.MinOrderAmount,
|
||||
"capped": line.Capped,
|
||||
}
|
||||
if line.Settings.EarnMode == constants.LoyaltyEarnModePercentage {
|
||||
m["earn_mode"] = line.Settings.EarnMode
|
||||
m["earn_percent"] = line.Settings.EarnPercent
|
||||
}
|
||||
if line.Settings.MaxPerOrder != nil {
|
||||
m["max_per_order"] = *line.Settings.MaxPerOrder
|
||||
}
|
||||
@@ -46,7 +51,8 @@ func (r EarningResult) Metadata(line EarningLine) entities.Metadata {
|
||||
//
|
||||
// basis = subtotal − discount_amount − paid with EnakPoint
|
||||
// amount = 0 if basis < min_order_amount
|
||||
// amount = floor(basis / earn_per_amount) × earn_value
|
||||
// amount = floor(basis / earn_per_amount) × earn_value in PER_AMOUNT mode
|
||||
// amount = floor(basis × earn_percent / 100) in PERCENTAGE mode
|
||||
// amount = min(amount, max_per_order) if max_per_order is set
|
||||
//
|
||||
// The part paid with EnakPoint earns nothing (Q10). Money is handled in whole cents so
|
||||
@@ -66,13 +72,23 @@ func CalculateEarning(order *entities.Order, pointPaidAmount float64, settings m
|
||||
|
||||
func earnLine(basisCents int64, s models.LoyaltyEarnSettings) EarningLine {
|
||||
line := EarningLine{Settings: s}
|
||||
if !s.Enabled || s.EarnPerAmount <= 0 || s.EarnValue <= 0 {
|
||||
if !s.Enabled || basisCents < s.MinOrderAmount*100 {
|
||||
return line
|
||||
}
|
||||
if basisCents < s.MinOrderAmount*100 {
|
||||
return line
|
||||
if s.EarnMode == constants.LoyaltyEarnModePercentage {
|
||||
// The percent has at most two decimals, so in hundredths it is whole and the
|
||||
// product stays in integers: cents × hundredths / (100 × 100 × 100).
|
||||
hundredths := int64(math.Round(s.EarnPercent * 100))
|
||||
if hundredths <= 0 {
|
||||
return line
|
||||
}
|
||||
line.Amount = basisCents * hundredths / 1_000_000
|
||||
} else {
|
||||
if s.EarnPerAmount <= 0 || s.EarnValue <= 0 {
|
||||
return line
|
||||
}
|
||||
line.Amount = basisCents / (s.EarnPerAmount * 100) * s.EarnValue
|
||||
}
|
||||
line.Amount = basisCents / (s.EarnPerAmount * 100) * s.EarnValue
|
||||
if s.MaxPerOrder != nil && line.Amount > *s.MaxPerOrder {
|
||||
line.Amount = *s.MaxPerOrder
|
||||
line.Capped = true
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
@@ -113,6 +114,44 @@ func TestCalculateEarning_EdgeCases(t *testing.T) {
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_Percentage(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
s.Point.EarnMode = constants.LoyaltyEarnModePercentage
|
||||
s.Point.EarnPercent = 1
|
||||
|
||||
// 1% of the basis; the per-amount settings are ignored, and coin keeps its own mode.
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, 0, s)
|
||||
assert.Equal(t, int64(875), got.Point.Amount)
|
||||
assert.Equal(t, int64(3), got.Coin.Amount)
|
||||
// The part paid with EnakPoint earns nothing in this mode either.
|
||||
assert.Equal(t, int64(675), CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, 20000, s).Point.Amount)
|
||||
|
||||
// Decimals, and floor, not round: 2.5% of 87.500 is 2187.5.
|
||||
s.Point.EarnPercent = 2.5
|
||||
assert.Equal(t, int64(2187), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
s.Point.EarnPercent = 0.01
|
||||
assert.Equal(t, int64(8), CalculateEarning(&entities.Order{Subtotal: 87599.99}, 0, s).Point.Amount)
|
||||
|
||||
// The minimum and the cap apply as in the other mode.
|
||||
s.Point.EarnPercent = 10
|
||||
s.Point.MinOrderAmount = 50000
|
||||
max := int64(6000)
|
||||
s.Point.MaxPerOrder = &max
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Point.Amount)
|
||||
assert.Equal(t, int64(5000), CalculateEarning(&entities.Order{Subtotal: 50000}, 0, s).Point.Amount)
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
|
||||
assert.Equal(t, int64(6000), got.Point.Amount)
|
||||
assert.True(t, got.Point.Capped)
|
||||
|
||||
// The mode and percent are frozen on the ledger row.
|
||||
assert.Equal(t, constants.LoyaltyEarnModePercentage, got.Metadata(got.Point)["earn_mode"])
|
||||
assert.Equal(t, 10.0, got.Metadata(got.Point)["earn_percent"])
|
||||
|
||||
// A zero percent earns nothing even when enabled.
|
||||
s.Point.EarnPercent = 0
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_MetadataSnapshot(t *testing.T) {
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, prdEarningSettings())
|
||||
assert.Equal(t, entities.Metadata{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -240,14 +241,20 @@ func loyaltySettingChangeModel(row entities.LoyaltySettingChange) models.Loyalty
|
||||
func outletLoyaltyFields(s *models.OutletLoyaltySettings) []loyaltyField {
|
||||
return []loyaltyField{
|
||||
boolLoyaltyField(constants.LoyaltyPointEnabledKey, &s.Point.Enabled, false),
|
||||
enumLoyaltyField(constants.LoyaltyPointEarnModeKey, &s.Point.EarnMode, constants.LoyaltyEarnModeDefault,
|
||||
constants.LoyaltyEarnModePerAmount, constants.LoyaltyEarnModePercentage),
|
||||
intLoyaltyField(constants.LoyaltyPointEarnPerAmountKey, &s.Point.EarnPerAmount, constants.LoyaltyPointEarnPerAmountDefault, 1, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyPointEarnValueKey, &s.Point.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
||||
percentLoyaltyField(constants.LoyaltyPointEarnPercentKey, &s.Point.EarnPercent, constants.LoyaltyEarnPercentDefault),
|
||||
intLoyaltyField(constants.LoyaltyPointMinOrderAmountKey, &s.Point.MinOrderAmount, 0, 0, noLoyaltyMax),
|
||||
optionalIntLoyaltyField(constants.LoyaltyPointMaxPerOrderKey, &s.Point.MaxPerOrder, 0),
|
||||
|
||||
boolLoyaltyField(constants.LoyaltyCoinEnabledKey, &s.Coin.Enabled, false),
|
||||
enumLoyaltyField(constants.LoyaltyCoinEarnModeKey, &s.Coin.EarnMode, constants.LoyaltyEarnModeDefault,
|
||||
constants.LoyaltyEarnModePerAmount, constants.LoyaltyEarnModePercentage),
|
||||
intLoyaltyField(constants.LoyaltyCoinEarnPerAmountKey, &s.Coin.EarnPerAmount, constants.LoyaltyCoinEarnPerAmountDefault, 1, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyCoinEarnValueKey, &s.Coin.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
||||
percentLoyaltyField(constants.LoyaltyCoinEarnPercentKey, &s.Coin.EarnPercent, constants.LoyaltyEarnPercentDefault),
|
||||
intLoyaltyField(constants.LoyaltyCoinMinOrderAmountKey, &s.Coin.MinOrderAmount, 0, 0, noLoyaltyMax),
|
||||
optionalIntLoyaltyField(constants.LoyaltyCoinMaxPerOrderKey, &s.Coin.MaxPerOrder, 0),
|
||||
|
||||
@@ -329,6 +336,35 @@ func intLoyaltyField(key string, v *int64, def, min, max int64) loyaltyField {
|
||||
}
|
||||
}
|
||||
|
||||
// percentLoyaltyField is a percentage from 0 to 100 with at most two decimals, so it
|
||||
// is whole in hundredths and calculations with it stay in integers.
|
||||
func percentLoyaltyField(key string, v *float64, def float64) loyaltyField {
|
||||
check := func(n float64) error {
|
||||
// The negated form also rejects NaN.
|
||||
if !(n >= 0 && n <= 100) {
|
||||
return fmt.Errorf("%w: %s must be between 0 and 100", ErrInvalidLoyaltySettings, key)
|
||||
}
|
||||
if math.Abs(n*100-math.Round(n*100)) > 1e-6 {
|
||||
return fmt.Errorf("%w: %s must have at most two decimals", ErrInvalidLoyaltySettings, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return loyaltyField{
|
||||
key: key,
|
||||
parse: func(raw string) bool {
|
||||
n, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||
if err != nil || check(n) != nil {
|
||||
return false
|
||||
}
|
||||
*v = n
|
||||
return true
|
||||
},
|
||||
reset: func() { *v = def },
|
||||
validate: func() error { return check(*v) },
|
||||
format: func() *string { s := strconv.FormatFloat(*v, 'f', -1, 64); return &s },
|
||||
}
|
||||
}
|
||||
|
||||
// optionalIntLoyaltyField is a limit that may be unset, meaning no limit.
|
||||
func optionalIntLoyaltyField(key string, v **int64, min int64) loyaltyField {
|
||||
return loyaltyField{
|
||||
|
||||
@@ -107,8 +107,8 @@ func TestLoyaltySettings_OutletWithoutSettingsGetsEveryDefault(t *testing.T) {
|
||||
s, err := p.Outlet(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, models.OutletLoyaltySettings{
|
||||
Point: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 100, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 25000, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
Point: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 100, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 25000, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: false, MinPaymentPoints: 1, MaxPaymentPercent: 100},
|
||||
}, *s)
|
||||
}
|
||||
@@ -290,6 +290,10 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
|
||||
for name, mutate := range map[string]func(*models.OutletLoyaltySettings){
|
||||
"earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 },
|
||||
"negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 },
|
||||
"earn mode unknown": func(s *models.OutletLoyaltySettings) { s.Point.EarnMode = "PERCENT" },
|
||||
"negative earn_percent": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = -1 },
|
||||
"earn_percent over 100": func(s *models.OutletLoyaltySettings) { s.Coin.EarnPercent = 100.5 },
|
||||
"earn_percent 3 decimals": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = 1.125 },
|
||||
"negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 },
|
||||
"negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) },
|
||||
"payment percent over 100": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = 101 },
|
||||
|
||||
Reference in New Issue
Block a user