feat(loyalty): EnakPoint & EnakCoin #32
@@ -42,6 +42,9 @@ const (
|
||||
LoyaltyExpiryUnitSuffix = "expiry_unit"
|
||||
LoyaltyExpiryEndOfMonthSuffix = "expiry_end_of_month"
|
||||
LoyaltyExpiryReminderDaysSuffix = "expiry_reminder_days"
|
||||
LoyaltyExpiryModeSuffix = "expiry_mode"
|
||||
LoyaltyExpiryFixedDatesSuffix = "expiry_fixed_dates"
|
||||
LoyaltyExpiryGraceMonthsSuffix = "expiry_grace_months"
|
||||
)
|
||||
|
||||
// Units of loyalty.{point,coin}.expiry_unit.
|
||||
@@ -50,6 +53,14 @@ const (
|
||||
LoyaltyExpiryUnitMonth = "MONTH"
|
||||
)
|
||||
|
||||
// Modes of loyalty.{point,coin}.expiry_mode (note N4, decided: both are supported).
|
||||
const (
|
||||
// Every balance expires on the same fixed dates of the year, such as 31 Dec.
|
||||
LoyaltyExpiryModeFixedDate = "FIXED_DATE"
|
||||
// Every balance lasts a period from when it was received.
|
||||
LoyaltyExpiryModeRolling = "ROLLING"
|
||||
)
|
||||
|
||||
// Defaults from the PRD.
|
||||
const (
|
||||
LoyaltyPointEarnPerAmountDefault = int64(100)
|
||||
@@ -68,4 +79,8 @@ const (
|
||||
LoyaltyExpiryPeriodDefault = int64(12)
|
||||
LoyaltyExpiryUnitDefault = LoyaltyExpiryUnitMonth
|
||||
LoyaltyExpiryReminderDaysDefault = int64(7)
|
||||
LoyaltyExpiryModeDefault = LoyaltyExpiryModeFixedDate
|
||||
// Once a year on 31 December, as MM-DD.
|
||||
LoyaltyExpiryFixedDatesDefault = "12-31"
|
||||
LoyaltyExpiryGraceMonthsDefault = int64(3)
|
||||
)
|
||||
|
||||
@@ -57,14 +57,28 @@ type LoyaltyTransferSettings struct {
|
||||
DailyLimit *int64 `json:"daily_limit"`
|
||||
}
|
||||
|
||||
// LoyaltyExpirySettings is how long one currency lasts once received. The expiry
|
||||
// model is still open (note N4); these are only the stored settings.
|
||||
// LoyaltyExpirySettings is when one currency expires once received (F12). Both
|
||||
// models of note N4 are supported, and the owner picks one:
|
||||
//
|
||||
// - FIXED_DATE: everything expires on the next of FixedDates falling on or after
|
||||
// the day received + GraceMonths, so a balance received just before a date moves
|
||||
// on to the one after.
|
||||
// - ROLLING: everything lasts Period Units from the day received, to the end of
|
||||
// that month when EndOfMonth is set.
|
||||
type LoyaltyExpirySettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// FIXED_DATE or ROLLING.
|
||||
Mode string `json:"mode"`
|
||||
// FIXED_DATE: the days of the year balances expire on, as MM-DD, sorted.
|
||||
FixedDates []string `json:"fixed_dates"`
|
||||
// FIXED_DATE: how many months a balance lasts at least before a fixed date takes it.
|
||||
GraceMonths int64 `json:"grace_months"`
|
||||
// ROLLING: how long a balance lasts.
|
||||
Period int64 `json:"period"`
|
||||
// DAY or MONTH.
|
||||
// ROLLING: DAY or MONTH.
|
||||
Unit string `json:"unit"`
|
||||
EndOfMonth bool `json:"end_of_month"`
|
||||
// Days before expiry the customer is reminded; 0 for no reminder.
|
||||
ReminderDays int64 `json:"reminder_days"`
|
||||
}
|
||||
|
||||
@@ -109,12 +123,21 @@ type OrganizationLoyaltySettingsView struct {
|
||||
OrganizationLoyaltySettings
|
||||
// What the balances in circulation are worth, before and after the change.
|
||||
Impact LoyaltySettingsImpact `json:"impact"`
|
||||
// When a balance received now would expire under these settings (F12).
|
||||
ExpiryPreview LoyaltyExpiryPreview `json:"expiry_preview"`
|
||||
// On PUT, the keys that changed; on a dry run, the keys that would.
|
||||
Changes []LoyaltySettingChange `json:"changes"`
|
||||
// True when nothing was saved.
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
// LoyaltyExpiryPreview is what the dashboard shows next to the expiry settings: "the
|
||||
// EnakPoint received today expire on …". Nil means they never expire.
|
||||
type LoyaltyExpiryPreview struct {
|
||||
Point *time.Time `json:"point"`
|
||||
Coin *time.Time `json:"coin"`
|
||||
}
|
||||
|
||||
// LoyaltySettingsImpact shows how a change of point value or exchange rate changes what
|
||||
// the balances in circulation are worth (F2). Before and after are equal when neither
|
||||
// changes.
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// Expiry rules of docs/prd-point-coin.md F12, with the model of note N4 decided: both
|
||||
// FIXED_DATE and ROLLING are supported and the owner picks one per currency.
|
||||
//
|
||||
// A lot expires at the last second of its expiry day in the customer's time zone, so
|
||||
// "expires on 31 Dec" means it can still be spent all of 31 Dec, and the day the apps
|
||||
// group it under is that day.
|
||||
|
||||
const (
|
||||
// A longer grace would push a balance past a whole cycle of fixed dates.
|
||||
loyaltyExpiryMaxGraceMonths = 24
|
||||
// A refund gives the EnakPoint back at least this long, even when the lot they
|
||||
// came from has expired or is about to (note N4, decided).
|
||||
refundMinimumValidity = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// ExpirySettings picks the settings of one currency.
|
||||
func ExpirySettings(s *models.OrganizationLoyaltySettings, currency string) models.LoyaltyExpirySettings {
|
||||
if currency == constants.WalletCurrencyCoin {
|
||||
return s.CoinExpiry
|
||||
}
|
||||
return s.PointExpiry
|
||||
}
|
||||
|
||||
// ComputeExpiry is when a balance received at receivedAt expires, or nil when it never
|
||||
// does because expiry is off. It is the expiry of every lot that is not carried over
|
||||
// from another one: EARN, ADJUSTMENT and, capped by its origin, EXCHANGE_IN.
|
||||
func ComputeExpiry(s models.LoyaltyExpirySettings, receivedAt time.Time) *time.Time {
|
||||
if !s.Enabled {
|
||||
return nil
|
||||
}
|
||||
day := walletDay(receivedAt)
|
||||
if s.Mode == constants.LoyaltyExpiryModeRolling {
|
||||
if s.Unit == constants.LoyaltyExpiryUnitDay {
|
||||
day = day.AddDate(0, 0, int(s.Period))
|
||||
} else {
|
||||
day = addMonthsClamped(day, int(s.Period))
|
||||
}
|
||||
if s.EndOfMonth {
|
||||
day = lastDayOfMonth(day)
|
||||
}
|
||||
return endOfWalletDay(day)
|
||||
}
|
||||
return endOfWalletDay(fixedDateOnOrAfter(s.FixedDates, addMonthsClamped(day, int(s.GraceMonths))))
|
||||
}
|
||||
|
||||
// ActivationExpiry is the expiry given to the balances that already exist, without an
|
||||
// expiry, when expiry is turned on (note N4, decided): a full period from activation
|
||||
// when ROLLING, the second fixed date after activation when FIXED_DATE. Either way no
|
||||
// customer loses a balance soon after the rule is announced.
|
||||
func ActivationExpiry(s models.LoyaltyExpirySettings, at time.Time) *time.Time {
|
||||
if s.Mode == constants.LoyaltyExpiryModeRolling {
|
||||
s.Enabled = true
|
||||
return ComputeExpiry(s, at)
|
||||
}
|
||||
first := fixedDateOnOrAfter(s.FixedDates, walletDay(at))
|
||||
return endOfWalletDay(fixedDateOnOrAfter(s.FixedDates, first.AddDate(0, 0, 1)))
|
||||
}
|
||||
|
||||
// RefundExpiry is the expiry of EnakPoint given back by a refund: that of the lot they
|
||||
// came from, but at least seven days from now (note N4, decided). A lot that never
|
||||
// expires stays so.
|
||||
func RefundExpiry(origin *time.Time, now time.Time) *time.Time {
|
||||
if origin == nil {
|
||||
return nil
|
||||
}
|
||||
minimum := endOfWalletDay(walletDay(now.Add(refundMinimumValidity)))
|
||||
if origin.Before(*minimum) {
|
||||
return minimum
|
||||
}
|
||||
return origin
|
||||
}
|
||||
|
||||
// EarlierExpiry is the sooner of two expiries, where nil means never.
|
||||
func EarlierExpiry(a, b *time.Time) *time.Time {
|
||||
switch {
|
||||
case a == nil:
|
||||
return b
|
||||
case b == nil:
|
||||
return a
|
||||
case b.Before(*a):
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// normalizeDaysOfYear checks days written as MM-DD and returns them sorted without
|
||||
// duplicates. 29 February is refused, as it does not come every year.
|
||||
func normalizeDaysOfYear(days []string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, d := range days {
|
||||
d = strings.TrimSpace(d)
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := time.Parse("01-02", d)
|
||||
if err != nil || parsed.Format("01-02") != d {
|
||||
return nil, fmt.Errorf("has %q, which is not a day of the year written as MM-DD", d)
|
||||
}
|
||||
if d == "02-29" {
|
||||
return nil, errors.New("cannot use 02-29, which is not in every year")
|
||||
}
|
||||
if !seen[d] {
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("must list at least one day of the year, as MM-DD")
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fixedDateOnOrAfter is the first of the days of the year that falls on or after day.
|
||||
func fixedDateOnOrAfter(days []string, day time.Time) time.Time {
|
||||
days, err := normalizeDaysOfYear(days)
|
||||
if err != nil {
|
||||
days = []string{constants.LoyaltyExpiryFixedDatesDefault}
|
||||
}
|
||||
for year := day.Year(); ; year++ {
|
||||
for _, d := range days {
|
||||
parsed, _ := time.Parse("01-02", d)
|
||||
candidate := time.Date(year, parsed.Month(), parsed.Day(), 0, 0, 0, 0, walletDisplayLocation)
|
||||
if !candidate.Before(day) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// walletDay is the calendar day of t in the customer's time zone, at midnight.
|
||||
func walletDay(t time.Time) time.Time {
|
||||
local := t.In(walletDisplayLocation)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, walletDisplayLocation)
|
||||
}
|
||||
|
||||
func endOfWalletDay(day time.Time) *time.Time {
|
||||
end := time.Date(day.Year(), day.Month(), day.Day(), 23, 59, 59, 0, walletDisplayLocation)
|
||||
return &end
|
||||
}
|
||||
|
||||
// addMonthsClamped adds months, keeping to the last day of a shorter month: 31 Jan
|
||||
// plus one month is 28 or 29 Feb, not 3 Mar.
|
||||
func addMonthsClamped(day time.Time, months int) time.Time {
|
||||
first := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, day.Location()).AddDate(0, months, 0)
|
||||
last := lastDayOfMonth(first).Day()
|
||||
d := day.Day()
|
||||
if d > last {
|
||||
d = last
|
||||
}
|
||||
return time.Date(first.Year(), first.Month(), d, 0, 0, 0, 0, day.Location())
|
||||
}
|
||||
|
||||
func lastDayOfMonth(day time.Time) time.Time {
|
||||
return time.Date(day.Year(), day.Month()+1, 0, 0, 0, 0, 0, day.Location())
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// wib is a moment in the customer's time zone.
|
||||
func wib(year int, month time.Month, day, hour, minute int) time.Time {
|
||||
return time.Date(year, month, day, hour, minute, 0, 0, walletDisplayLocation)
|
||||
}
|
||||
|
||||
// expiryDay is the day a lot expires on, as the apps show it.
|
||||
func expiryDay(t *testing.T, at *time.Time) string {
|
||||
t.Helper()
|
||||
require.NotNil(t, at)
|
||||
local := at.In(walletDisplayLocation)
|
||||
assert.Equal(t, "23:59:59", local.Format("15:04:05"), "a lot lasts to the end of its day")
|
||||
return local.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func fixedDate(dates ...string) models.LoyaltyExpirySettings {
|
||||
return models.LoyaltyExpirySettings{Enabled: true, Mode: "FIXED_DATE", FixedDates: dates, GraceMonths: 3}
|
||||
}
|
||||
|
||||
func rolling(period int64, unit string, endOfMonth bool) models.LoyaltyExpirySettings {
|
||||
return models.LoyaltyExpirySettings{Enabled: true, Mode: "ROLLING", Period: period, Unit: unit, EndOfMonth: endOfMonth}
|
||||
}
|
||||
|
||||
func TestComputeExpiry_OffNeverExpires(t *testing.T) {
|
||||
s := fixedDate("12-31")
|
||||
s.Enabled = false
|
||||
assert.Nil(t, ComputeExpiry(s, wib(2026, 5, 1, 10, 0)))
|
||||
}
|
||||
|
||||
func TestComputeExpiry_FixedDateWithGrace(t *testing.T) {
|
||||
yearly := fixedDate("12-31")
|
||||
for received, want := range map[time.Time]string{
|
||||
wib(2026, 1, 15, 9, 0): "2026-12-31",
|
||||
wib(2026, 9, 30, 23, 0): "2026-12-31", // 30 Dec + nothing: still this year
|
||||
wib(2026, 10, 1, 0, 30): "2027-12-31", // within 3 months of 31 Dec: next year
|
||||
wib(2026, 12, 31, 8, 0): "2027-12-31",
|
||||
} {
|
||||
assert.Equal(t, want, expiryDay(t, ComputeExpiry(yearly, received)), received.String())
|
||||
}
|
||||
|
||||
twice := fixedDate("12-31", "06-30")
|
||||
assert.Equal(t, "2026-06-30", expiryDay(t, ComputeExpiry(twice, wib(2026, 3, 1, 12, 0))))
|
||||
assert.Equal(t, "2026-12-31", expiryDay(t, ComputeExpiry(twice, wib(2026, 4, 1, 12, 0))))
|
||||
assert.Equal(t, "2027-06-30", expiryDay(t, ComputeExpiry(twice, wib(2026, 10, 1, 12, 0))))
|
||||
|
||||
noGrace := fixedDate("12-31")
|
||||
noGrace.GraceMonths = 0
|
||||
assert.Equal(t, "2026-12-31", expiryDay(t, ComputeExpiry(noGrace, wib(2026, 12, 31, 22, 0))))
|
||||
}
|
||||
|
||||
// The day received is the customer's day: 20:00 UTC on 31 Mar is already 1 Apr in WIB.
|
||||
func TestComputeExpiry_UsesTheCustomersDay(t *testing.T) {
|
||||
received := time.Date(2026, 3, 31, 20, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, "2026-04-11", expiryDay(t, ComputeExpiry(rolling(10, "DAY", false), received)))
|
||||
}
|
||||
|
||||
func TestComputeExpiry_Rolling(t *testing.T) {
|
||||
assert.Equal(t, "2027-03-14", expiryDay(t, ComputeExpiry(rolling(12, "MONTH", false), wib(2026, 3, 14, 10, 0))))
|
||||
assert.Equal(t, "2027-03-31", expiryDay(t, ComputeExpiry(rolling(12, "MONTH", true), wib(2026, 3, 14, 10, 0))))
|
||||
assert.Equal(t, "2026-03-24", expiryDay(t, ComputeExpiry(rolling(10, "DAY", false), wib(2026, 3, 14, 10, 0))))
|
||||
// A month that is shorter keeps to its last day.
|
||||
assert.Equal(t, "2026-02-28", expiryDay(t, ComputeExpiry(rolling(1, "MONTH", false), wib(2026, 1, 31, 10, 0))))
|
||||
}
|
||||
|
||||
func TestActivationExpiry(t *testing.T) {
|
||||
// FIXED_DATE: the second fixed date after activation, so nobody loses a balance
|
||||
// weeks after the rule is announced.
|
||||
assert.Equal(t, "2027-12-31", expiryDay(t, ActivationExpiry(fixedDate("12-31"), wib(2026, 11, 20, 10, 0))))
|
||||
assert.Equal(t, "2027-12-31", expiryDay(t, ActivationExpiry(fixedDate("12-31"), wib(2026, 12, 31, 10, 0))))
|
||||
assert.Equal(t, "2027-06-30", expiryDay(t, ActivationExpiry(fixedDate("06-30", "12-31"), wib(2026, 11, 20, 10, 0))))
|
||||
// ROLLING: a full period from activation.
|
||||
assert.Equal(t, "2027-11-20", expiryDay(t, ActivationExpiry(rolling(12, "MONTH", false), wib(2026, 11, 20, 10, 0))))
|
||||
}
|
||||
|
||||
func TestRefundExpiry(t *testing.T) {
|
||||
now := wib(2026, 6, 1, 10, 0)
|
||||
later := wib(2026, 12, 31, 23, 59)
|
||||
soon := wib(2026, 6, 3, 23, 59)
|
||||
past := wib(2026, 5, 1, 23, 59)
|
||||
|
||||
assert.Nil(t, RefundExpiry(nil, now))
|
||||
assert.Equal(t, &later, RefundExpiry(&later, now))
|
||||
assert.Equal(t, "2026-06-08", expiryDay(t, RefundExpiry(&soon, now)))
|
||||
assert.Equal(t, "2026-06-08", expiryDay(t, RefundExpiry(&past, now)))
|
||||
}
|
||||
|
||||
func TestEarlierExpiry(t *testing.T) {
|
||||
a, b := wib(2026, 1, 1, 0, 0), wib(2026, 2, 1, 0, 0)
|
||||
assert.Equal(t, &a, EarlierExpiry(&a, &b))
|
||||
assert.Equal(t, &a, EarlierExpiry(&b, &a))
|
||||
assert.Equal(t, &a, EarlierExpiry(nil, &a))
|
||||
assert.Equal(t, &a, EarlierExpiry(&a, nil))
|
||||
assert.Nil(t, EarlierExpiry(nil, nil))
|
||||
}
|
||||
|
||||
func TestNormalizeDaysOfYear(t *testing.T) {
|
||||
days, err := normalizeDaysOfYear([]string{" 12-31", "06-30", "12-31", ""})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"06-30", "12-31"}, days)
|
||||
for _, bad := range [][]string{{}, {"13-01"}, {"12-32"}, {"1-31"}, {"02-29"}, {"31-12"}} {
|
||||
_, err := normalizeDaysOfYear(bad)
|
||||
assert.Error(t, err, bad)
|
||||
}
|
||||
}
|
||||
@@ -236,6 +236,10 @@ func organizationLoyaltyFields(s *models.OrganizationLoyaltySettings) []loyaltyF
|
||||
func expiryLoyaltyFields(prefix string, s *models.LoyaltyExpirySettings) []loyaltyField {
|
||||
return []loyaltyField{
|
||||
boolLoyaltyField(prefix+constants.LoyaltyExpiryEnabledSuffix, &s.Enabled, false),
|
||||
enumLoyaltyField(prefix+constants.LoyaltyExpiryModeSuffix, &s.Mode, constants.LoyaltyExpiryModeDefault,
|
||||
constants.LoyaltyExpiryModeFixedDate, constants.LoyaltyExpiryModeRolling),
|
||||
dayOfYearListLoyaltyField(prefix+constants.LoyaltyExpiryFixedDatesSuffix, &s.FixedDates, constants.LoyaltyExpiryFixedDatesDefault),
|
||||
intLoyaltyField(prefix+constants.LoyaltyExpiryGraceMonthsSuffix, &s.GraceMonths, constants.LoyaltyExpiryGraceMonthsDefault, 0, loyaltyExpiryMaxGraceMonths),
|
||||
intLoyaltyField(prefix+constants.LoyaltyExpiryPeriodSuffix, &s.Period, constants.LoyaltyExpiryPeriodDefault, 1, noLoyaltyMax),
|
||||
enumLoyaltyField(prefix+constants.LoyaltyExpiryUnitSuffix, &s.Unit, constants.LoyaltyExpiryUnitDefault,
|
||||
constants.LoyaltyExpiryUnitDay, constants.LoyaltyExpiryUnitMonth),
|
||||
@@ -362,6 +366,46 @@ func enumLoyaltyField(key string, v *string, def string, allowed ...string) loya
|
||||
}
|
||||
}
|
||||
|
||||
// dayOfYearListLoyaltyField is a non-empty set of days of the year, stored as sorted
|
||||
// MM-DD values joined by commas, such as "06-30,12-31".
|
||||
func dayOfYearListLoyaltyField(key string, v *[]string, def string) loyaltyField {
|
||||
normalize := func(days []string) ([]string, error) {
|
||||
out, err := normalizeDaysOfYear(days)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s %v", ErrInvalidLoyaltySettings, key, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return loyaltyField{
|
||||
key: key,
|
||||
parse: func(raw string) bool {
|
||||
days, err := normalize(strings.Split(raw, ","))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
*v = days
|
||||
return true
|
||||
},
|
||||
reset: func() { *v, _ = normalizeDaysOfYear(strings.Split(def, ",")) },
|
||||
validate: func() error {
|
||||
days, err := normalize(*v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*v = days
|
||||
return nil
|
||||
},
|
||||
format: func() *string {
|
||||
days, err := normalizeDaysOfYear(*v)
|
||||
if err != nil {
|
||||
days = *v
|
||||
}
|
||||
s := strings.Join(days, ",")
|
||||
return &s
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// loadLoyaltyFields fills each field from its stored value, or its default when there
|
||||
// is none. A stored value that cannot be used also falls back to the default, so a bad
|
||||
// row can never reach a calculation, and it is logged so it gets fixed.
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing.
|
||||
p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{})
|
||||
s, err := p.Organization(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
expiry := models.LoyaltyExpirySettings{Enabled: false, Period: 12, Unit: "MONTH", EndOfMonth: false, ReminderDays: 7}
|
||||
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},
|
||||
@@ -136,6 +136,8 @@ func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
|
||||
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{})
|
||||
@@ -153,6 +155,9 @@ func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
|
||||
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.
|
||||
@@ -302,6 +307,11 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
|
||||
"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)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -145,6 +146,7 @@ func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context
|
||||
type organizationSettingsInput struct {
|
||||
*models.OrganizationLoyaltySettings
|
||||
Impact json.RawMessage `json:"impact"`
|
||||
ExpiryPreview json.RawMessage `json:"expiry_preview"`
|
||||
Changes json.RawMessage `json:"changes"`
|
||||
DryRun json.RawMessage `json:"dry_run"`
|
||||
}
|
||||
@@ -196,6 +198,7 @@ func (s *LoyaltySettingsServiceImpl) ListHistory(ctx context.Context, apctx *app
|
||||
}
|
||||
|
||||
func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, changes []models.LoyaltySettingChange, dryRun bool) (*models.OrganizationLoyaltySettingsView, error) {
|
||||
now := time.Now()
|
||||
points, coins, err := s.outstanding.OrganizationOutstanding(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -206,6 +209,10 @@ func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organ
|
||||
return &models.OrganizationLoyaltySettingsView{
|
||||
OrganizationLoyaltySettings: after,
|
||||
Impact: models.NewLoyaltySettingsImpact(points, coins, before, after),
|
||||
ExpiryPreview: models.LoyaltyExpiryPreview{
|
||||
Point: processor.ComputeExpiry(after.PointExpiry, now),
|
||||
Coin: processor.ComputeExpiry(after.CoinExpiry, now),
|
||||
},
|
||||
Changes: changes,
|
||||
DryRun: dryRun,
|
||||
}, nil
|
||||
|
||||
Reference in New Issue
Block a user