feat: add percentage loyalti setting mode

This commit is contained in:
efrilm
2026-10-02 14:19:12 +07:00
parent d3e3ea6b08
commit b372821b23
13 changed files with 193 additions and 36 deletions
+14
View File
@@ -7,12 +7,16 @@ package constants
// Per outlet (F1): what an order earns, and whether EnakPoint can pay.
const (
LoyaltyPointEnabledKey = "loyalty.point.enabled"
LoyaltyPointEarnModeKey = "loyalty.point.earn_mode"
LoyaltyPointEarnPercentKey = "loyalty.point.earn_percent"
LoyaltyPointEarnPerAmountKey = "loyalty.point.earn_per_amount"
LoyaltyPointEarnValueKey = "loyalty.point.earn_value"
LoyaltyPointMinOrderAmountKey = "loyalty.point.min_order_amount"
LoyaltyPointMaxPerOrderKey = "loyalty.point.max_per_order"
LoyaltyCoinEnabledKey = "loyalty.coin.enabled"
LoyaltyCoinEarnModeKey = "loyalty.coin.earn_mode"
LoyaltyCoinEarnPercentKey = "loyalty.coin.earn_percent"
LoyaltyCoinEarnPerAmountKey = "loyalty.coin.earn_per_amount"
LoyaltyCoinEarnValueKey = "loyalty.coin.earn_value"
LoyaltyCoinMinOrderAmountKey = "loyalty.coin.min_order_amount"
@@ -47,6 +51,14 @@ const (
LoyaltyExpiryGraceMonthsSuffix = "expiry_grace_months"
)
// Modes of loyalty.{point,coin}.earn_mode.
const (
// earn_value for every earn_per_amount rupiah of the basis.
LoyaltyEarnModePerAmount = "PER_AMOUNT"
// earn_percent percent of the basis.
LoyaltyEarnModePercentage = "PERCENTAGE"
)
// Units of loyalty.{point,coin}.expiry_unit.
const (
LoyaltyExpiryUnitDay = "DAY"
@@ -66,6 +78,8 @@ const (
LoyaltyPointEarnPerAmountDefault = int64(100)
LoyaltyCoinEarnPerAmountDefault = int64(25000)
LoyaltyEarnValueDefault = int64(1)
LoyaltyEarnModeDefault = LoyaltyEarnModePerAmount
LoyaltyEarnPercentDefault = float64(1)
LoyaltyMinPaymentPointsDefault = int64(1)
LoyaltyMaxPaymentPercentDefault = int64(100)
+4 -2
View File
@@ -91,8 +91,8 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
status, body := call(http.MethodGet, "/manager"+path, "")
require.Equal(t, http.StatusOK, status, body)
got := data(body)
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(100), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(25000), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
assert.Equal(t, map[string]any{"enabled": false, "earn_mode": "PER_AMOUNT", "earn_per_amount": float64(100), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
assert.Equal(t, map[string]any{"enabled": false, "earn_mode": "PER_AMOUNT", "earn_per_amount": float64(25000), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
assert.Equal(t, map[string]any{"accept_payment": false, "min_payment_points": float64(1), "max_payment_percent": float64(100)}, got["point_payment"])
assert.EqualValues(t, 1, got["point_value"])
assert.EqualValues(t, 1, got["point_cashback_percent"])
@@ -135,6 +135,8 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
for name, bad := range map[string]string{
"earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`,
"negative earn_value": `{"coin": {"earn_value": -1}}`,
"earn mode unknown": `{"point": {"earn_mode": "PERCENT"}}`,
"earn_percent over 100": `{"coin": {"earn_percent": 101}}`,
"negative min_order": `{"point": {"min_order_amount": -5}}`,
"negative max_per_order": `{"point": {"max_per_order": -1}}`,
"payment percent over 100": `{"point_payment": {"max_payment_percent": 101}}`,
+29 -10
View File
@@ -5,6 +5,8 @@ import (
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
)
// OutletLoyaltySettings are an outlet's loyalty settings (docs/prd-point-coin.md F1).
@@ -15,15 +17,23 @@ type OutletLoyaltySettings struct {
PointPayment LoyaltyPointPaymentSettings `json:"point_payment"`
}
// LoyaltyEarnSettings is how much of one currency an order earns:
// floor(basis / EarnPerAmount) × EarnValue, nothing below MinOrderAmount, and at most
// MaxPerOrder when set.
// LoyaltyEarnSettings is how much of one currency an order earns, nothing below
// MinOrderAmount, and at most MaxPerOrder when set:
//
// - PER_AMOUNT: floor(basis / EarnPerAmount) × EarnValue;
// - PERCENTAGE: floor(basis × EarnPercent / 100).
//
// The settings of the mode not in use are kept, so switching back restores them.
type LoyaltyEarnSettings struct {
Enabled bool `json:"enabled"`
EarnPerAmount int64 `json:"earn_per_amount"`
EarnValue int64 `json:"earn_value"`
MinOrderAmount int64 `json:"min_order_amount"`
MaxPerOrder *int64 `json:"max_per_order"`
Enabled bool `json:"enabled"`
// PER_AMOUNT or PERCENTAGE.
EarnMode string `json:"earn_mode"`
EarnPerAmount int64 `json:"earn_per_amount"`
EarnValue int64 `json:"earn_value"`
// PERCENTAGE: percent of the basis earned, 0–100 with at most two decimals.
EarnPercent float64 `json:"earn_percent"`
MinOrderAmount int64 `json:"min_order_amount"`
MaxPerOrder *int64 `json:"max_per_order"`
}
type LoyaltyPointPaymentSettings struct {
@@ -102,8 +112,8 @@ type OutletLoyaltySettingsView struct {
// The organization's rupiah value of one EnakPoint, which the cashback depends on.
PointValue int64 `json:"point_value"`
// Effective EnakPoint cashback in percent: earn_value × point_value /
// earn_per_amount × 100. Shown next to the setting so an owner cannot misread the
// scale (F1).
// earn_per_amount × 100, or earn_percent × point_value in PERCENTAGE mode. Shown
// next to the setting so an owner cannot misread the scale (F1).
PointCashbackPercent float64 `json:"point_cashback_percent"`
// Set on PUT: the keys that changed.
Changes []LoyaltySettingChange `json:"changes,omitempty"`
@@ -168,6 +178,15 @@ type LoyaltySettingsImpact struct {
CoinRupiahAfter int64 `json:"coin_rupiah_after"`
}
// CashbackPercent is the rupiah value of what an order earns as a percentage of its
// basis, in the mode the settings are in, rounded to two decimals.
func (s LoyaltyEarnSettings) CashbackPercent(pointValue int64) float64 {
if s.EarnMode == constants.LoyaltyEarnModePercentage {
return math.Round(s.EarnPercent*float64(pointValue)*100) / 100
}
return LoyaltyCashbackPercent(s.EarnValue, pointValue, s.EarnPerAmount)
}
// NewLoyaltySettingsImpact computes the impact of moving from one organization setting
// to another on the balances in circulation.
func NewLoyaltySettingsImpact(points, coins int64, before, after OrganizationLoyaltySettings) LoyaltySettingsImpact {
+8
View File
@@ -14,6 +14,14 @@ func TestLoyaltyCashbackPercent(t *testing.T) {
assert.Equal(t, 0.0, LoyaltyCashbackPercent(1, 1, 0))
}
func TestLoyaltyEarnSettings_CashbackPercent(t *testing.T) {
s := LoyaltyEarnSettings{EarnMode: "PER_AMOUNT", EarnPerAmount: 100, EarnValue: 1, EarnPercent: 2.5}
assert.Equal(t, 1.0, s.CashbackPercent(1))
s.EarnMode = "PERCENTAGE"
assert.Equal(t, 2.5, s.CashbackPercent(1))
assert.Equal(t, 250.0, s.CashbackPercent(100), "each point earned is worth point_value rupiah")
}
func TestNewLoyaltySettingsImpact(t *testing.T) {
before := OrganizationLoyaltySettings{PointValue: 1, Exchange: LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}}
after := OrganizationLoyaltySettings{PointValue: 100, Exchange: LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}}
+21 -5
View File
@@ -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 },
+1 -1
View File
@@ -111,7 +111,7 @@ func (s *LoyaltySettingsServiceImpl) outletView(ctx context.Context, organizatio
OutletID: outletID,
OutletLoyaltySettings: settings,
PointValue: pointValue,
PointCashbackPercent: models.LoyaltyCashbackPercent(settings.Point.EarnValue, pointValue, settings.Point.EarnPerAmount),
PointCashbackPercent: settings.Point.CashbackPercent(pointValue),
Changes: changes,
}, nil
}