Files
apskel-pos-backend/internal/processor/loyalty_settings_processor_test.go
T
efrilmandClaude Opus 5.5 9ce55e6002 feat(loyalty): expiry settings for both expiry models
Settles note N4 of docs/prd-point-coin.md: both expiry models are
supported, chosen per currency by the owner, defaulting to one fixed date a
year (PC-501, F12).

New organization keys, per currency (loyalty.point.* / loyalty.coin.*):
- expiry_mode: FIXED_DATE (default) or ROLLING.
- expiry_fixed_dates: the days of the year balances expire on, as sorted
  MM-DD values ("12-31" by default, "06-30,12-31" for twice a year). 29 Feb
  is refused.
- expiry_grace_months: 0 to 24, default 3. A balance lasts at least this
  long before a fixed date takes it.
The existing period, unit and end_of_month keys now belong to ROLLING, and
reminder_days to both.

ComputeExpiry gives the expiry of a balance received at a time: the first
fixed date on or after the day received plus the grace months, or the day
received plus the period (to the end of that month when asked). Days are
the customer's (WIB), a shorter month keeps to its last day, and a lot
lasts to 23:59:59 of its day so the apps group it under that day. Nil when
expiry is off. ActivationExpiry, RefundExpiry and EarlierExpiry hold the
other decided rules and are used by PC-502.

GET and PUT /marketing/loyalty-settings return expiry_preview: when a
balance received now would expire, for the dashboard's "received today
expires on ..." hint, also on a dry run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:23:40 +07:00

333 lines
13 KiB
Go

package processor
import (
"context"
"os"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
func TestMain(m *testing.M) {
// Some processors log; the server sets the logger up at startup.
logger.Setup("fatal", "json")
os.Exit(m.Run())
}
// loyaltyRepoFake keeps settings in maps. It does not check for a transaction.
type loyaltyRepoFake struct {
outletOrg map[uuid.UUID]uuid.UUID
outletValues map[string]string
orgValues map[string]string
changes []entities.LoyaltySettingChange
locks []uuid.UUID
}
func (f *loyaltyRepoFake) GetOutletValues(context.Context, uuid.UUID) (map[string]string, error) {
return copyStringMap(f.outletValues), nil
}
func (f *loyaltyRepoFake) GetOrganizationValues(context.Context, uuid.UUID) (map[string]string, error) {
return copyStringMap(f.orgValues), nil
}
func (f *loyaltyRepoFake) OutletOrganizationID(_ context.Context, outletID uuid.UUID) (uuid.UUID, error) {
org, ok := f.outletOrg[outletID]
if !ok {
return uuid.Nil, repository.ErrLoyaltyOutletNotFound
}
return org, nil
}
func (f *loyaltyRepoFake) LockScope(_ context.Context, id uuid.UUID) error {
f.locks = append(f.locks, id)
return nil
}
func (f *loyaltyRepoFake) SetOutletValue(_ context.Context, _ uuid.UUID, key string, value *string) error {
f.outletValues = setOptional(f.outletValues, key, value)
return nil
}
func (f *loyaltyRepoFake) SetOrganizationValue(_ context.Context, _ uuid.UUID, key string, value *string) error {
f.orgValues = setOptional(f.orgValues, key, value)
return nil
}
func (f *loyaltyRepoFake) InsertChanges(_ context.Context, changes []entities.LoyaltySettingChange) error {
f.changes = append(f.changes, changes...)
return nil
}
func (f *loyaltyRepoFake) ListChanges(context.Context, repository.LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) {
return f.changes, int64(len(f.changes)), nil
}
func copyStringMap(m map[string]string) map[string]string {
out := make(map[string]string, len(m))
for k, v := range m {
out[k] = v
}
return out
}
func setOptional(m map[string]string, key string, value *string) map[string]string {
if m == nil {
m = map[string]string{}
}
if value == nil {
delete(m, key)
} else {
m[key] = *value
}
return m
}
// txRunnerFake runs fn in place.
type txRunnerFake struct{}
func (txRunnerFake) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
return fn(ctx)
}
func TestLoyaltySettings_OutletWithoutSettingsGetsEveryDefault(t *testing.T) {
p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{})
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},
PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: false, MinPaymentPoints: 1, MaxPaymentPercent: 100},
}, *s)
}
func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing.T) {
p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{})
s, err := p.Organization(context.Background(), uuid.New())
require.NoError(t, err)
expiry := models.LoyaltyExpirySettings{Enabled: false, Mode: "FIXED_DATE", FixedDates: []string{"12-31"}, GraceMonths: 3, Period: 12, Unit: "MONTH", EndOfMonth: false, ReminderDays: 7}
assert.Equal(t, models.OrganizationLoyaltySettings{
PointValue: 1,
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1},
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
PointExpiry: expiry,
CoinExpiry: expiry,
}, *s)
}
func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
repo := &loyaltyRepoFake{
outletValues: map[string]string{
constants.LoyaltyPointEnabledKey: "true",
constants.LoyaltyPointEarnPerAmountKey: " 1000 ",
constants.LoyaltyPointMaxPerOrderKey: "500",
constants.LoyaltyPointMaxPaymentPercentKey: "50",
"loyalty.unknown": "ignored",
},
orgValues: map[string]string{
constants.LoyaltyPointValueKey: "100",
constants.LoyaltyTransferDailyLimitKey: "10000",
"loyalty.coin.expiry_unit": "day",
"loyalty.point.expiry_fixed_dates": "12-31, 06-30,12-31",
"loyalty.coin.expiry_mode": "rolling",
},
}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
outlet, err := p.Outlet(context.Background(), uuid.New())
require.NoError(t, err)
assert.True(t, outlet.Point.Enabled)
assert.Equal(t, int64(1000), outlet.Point.EarnPerAmount)
assert.Equal(t, int64(500), *outlet.Point.MaxPerOrder)
assert.Equal(t, int64(50), outlet.PointPayment.MaxPaymentPercent)
org, err := p.Organization(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, int64(100), org.PointValue)
assert.Equal(t, int64(10000), *org.Transfer.DailyLimit)
assert.Equal(t, "DAY", org.CoinExpiry.Unit)
assert.Equal(t, "MONTH", org.PointExpiry.Unit)
assert.Equal(t, []string{"06-30", "12-31"}, org.PointExpiry.FixedDates)
assert.Equal(t, "ROLLING", org.CoinExpiry.Mode)
assert.Equal(t, "FIXED_DATE", org.PointExpiry.Mode)
}
// A bad row must never reach a calculation: earn_per_amount 0 would divide by zero.
func TestLoyaltySettings_UnusableStoredValuesFallBackToDefault(t *testing.T) {
repo := &loyaltyRepoFake{
outletValues: map[string]string{
constants.LoyaltyPointEnabledKey: "yes please",
constants.LoyaltyPointEarnPerAmountKey: "0",
constants.LoyaltyCoinEarnValueKey: "-1",
constants.LoyaltyPointMaxPerOrderKey: "abc",
constants.LoyaltyPointMaxPaymentPercentKey: "150",
},
orgValues: map[string]string{
constants.LoyaltyPointValueKey: "0",
"loyalty.point.expiry_unit": "YEAR",
"loyalty.point.expiry_period": "",
},
}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
outlet, err := p.Outlet(context.Background(), uuid.New())
require.NoError(t, err)
assert.False(t, outlet.Point.Enabled)
assert.Equal(t, int64(100), outlet.Point.EarnPerAmount)
assert.Equal(t, int64(1), outlet.Coin.EarnValue)
assert.Nil(t, outlet.Point.MaxPerOrder)
assert.Equal(t, int64(100), outlet.PointPayment.MaxPaymentPercent)
for name, raw := range map[string]string{"not set": "", "garbage": "abc", "zero": "0", "negative": "-5"} {
repo.orgValues = map[string]string{}
if raw != "" {
repo.orgValues[constants.LoyaltyPointValueKey] = raw
}
value, err := p.PointValue(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, constants.LoyaltyPointValueDefault, value, name)
}
}
func TestLoyaltySettings_UpdateRecordsOnlyRealChanges(t *testing.T) {
org, outlet, admin := uuid.New(), uuid.New(), uuid.New()
repo := &loyaltyRepoFake{
outletOrg: map[uuid.UUID]uuid.UUID{outlet: org},
outletValues: map[string]string{constants.LoyaltyPointEarnPerAmountKey: "200"},
}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
ctx := context.Background()
settings, err := p.Outlet(ctx, outlet)
require.NoError(t, err)
settings.Point.Enabled = true // default false -> true
settings.Point.EarnPerAmount = 100 // stored 200 -> 100 (the default, but set explicitly)
settings.Point.MaxPerOrder = ptr(int64(0)) // unset -> 0
// Everything else stays on its default.
changes, err := p.UpdateOutlet(ctx, org, outlet, admin, *settings)
require.NoError(t, err)
byKey := map[string]models.LoyaltySettingChange{}
for _, c := range changes {
byKey[c.Key] = c
assert.Equal(t, admin, c.ChangedBy)
assert.Equal(t, org, c.OrganizationID)
assert.Equal(t, &outlet, c.OutletID)
}
require.Len(t, byKey, 3, "only the three keys that changed")
assert.Nil(t, byKey[constants.LoyaltyPointEnabledKey].OldValue, "was on its default")
assert.Equal(t, "true", *byKey[constants.LoyaltyPointEnabledKey].NewValue)
assert.Equal(t, "200", *byKey[constants.LoyaltyPointEarnPerAmountKey].OldValue)
assert.Equal(t, "100", *byKey[constants.LoyaltyPointEarnPerAmountKey].NewValue)
assert.Equal(t, "0", *byKey[constants.LoyaltyPointMaxPerOrderKey].NewValue)
assert.Len(t, repo.changes, 3)
assert.Equal(t, []uuid.UUID{outlet}, repo.locks)
after, err := p.Outlet(ctx, outlet)
require.NoError(t, err)
assert.Equal(t, *settings, *after)
// Saving the same settings again changes nothing and records nothing.
changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after)
require.NoError(t, err)
assert.Empty(t, changes)
assert.Len(t, repo.changes, 3)
// Clearing a limit removes the stored value, recorded as a change to nothing.
after.Point.MaxPerOrder = nil
changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after)
require.NoError(t, err)
require.Len(t, changes, 1)
assert.Equal(t, "0", *changes[0].OldValue)
assert.Nil(t, changes[0].NewValue)
assert.NotContains(t, repo.outletValues, constants.LoyaltyPointMaxPerOrderKey)
}
func TestLoyaltySettings_UpdateOrganization(t *testing.T) {
org, admin := uuid.New(), uuid.New()
repo := &loyaltyRepoFake{}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
ctx := context.Background()
settings, err := p.Organization(ctx, org)
require.NoError(t, err)
settings.PointValue = 100
settings.CoinExpiry.Enabled = true
settings.CoinExpiry.Unit = constants.LoyaltyExpiryUnitDay
changes, err := p.UpdateOrganization(ctx, org, admin, *settings)
require.NoError(t, err)
keys := []string{}
for _, c := range changes {
keys = append(keys, c.Key)
assert.Nil(t, c.OutletID, "organization settings have no outlet")
}
assert.ElementsMatch(t, []string{constants.LoyaltyPointValueKey, "loyalty.coin.expiry_enabled", "loyalty.coin.expiry_unit"}, keys)
value, err := p.PointValue(ctx, org)
require.NoError(t, err)
assert.Equal(t, int64(100), value)
}
func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
org, outlet := uuid.New(), uuid.New()
repo := &loyaltyRepoFake{outletOrg: map[uuid.UUID]uuid.UUID{outlet: org}}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
ctx := context.Background()
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 },
"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 },
"negative payment percent": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = -1 },
"min payment points 0": func(s *models.OutletLoyaltySettings) { s.PointPayment.MinPaymentPoints = 0 },
} {
s, err := p.Outlet(ctx, outlet)
require.NoError(t, err)
mutate(s)
_, err = p.UpdateOutlet(ctx, org, outlet, uuid.New(), *s)
assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name)
}
for name, mutate := range map[string]func(*models.OrganizationLoyaltySettings){
"point value 0": func(s *models.OrganizationLoyaltySettings) { s.PointValue = 0 },
"exchange coin amount 0": func(s *models.OrganizationLoyaltySettings) { s.Exchange.CoinAmount = 0 },
"transfer min 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.MinAmount = 0 },
"daily limit 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.DailyLimit = ptr(int64(0)) },
"expiry period 0": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Period = 0 },
"expiry unit YEAR": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.Unit = "YEAR" },
"negative reminder days": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.ReminderDays = -1 },
"expiry mode YEARLY": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Mode = "YEARLY" },
"no fixed dates": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.FixedDates = nil },
"fixed date 13-01": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.FixedDates = []string{"13-01"} },
"fixed date 02-29": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.FixedDates = []string{"02-29"} },
"negative grace": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.GraceMonths = -1 },
} {
s, err := p.Organization(ctx, org)
require.NoError(t, err)
mutate(s)
_, err = p.UpdateOrganization(ctx, org, uuid.New(), *s)
assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name)
}
assert.Empty(t, repo.changes, "nothing is written when a value is invalid")
assert.Empty(t, repo.locks, "invalid input is rejected before touching the database")
// An outlet of another organization.
s, err := p.Outlet(ctx, outlet)
require.NoError(t, err)
_, err = p.UpdateOutlet(ctx, uuid.New(), outlet, uuid.New(), *s)
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
_, err = p.UpdateOutlet(ctx, org, uuid.New(), uuid.New(), *s)
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
}