feat(loyalty): EnakPoint & EnakCoin #32
@@ -42,6 +42,9 @@ const (
|
|||||||
LoyaltyExpiryUnitSuffix = "expiry_unit"
|
LoyaltyExpiryUnitSuffix = "expiry_unit"
|
||||||
LoyaltyExpiryEndOfMonthSuffix = "expiry_end_of_month"
|
LoyaltyExpiryEndOfMonthSuffix = "expiry_end_of_month"
|
||||||
LoyaltyExpiryReminderDaysSuffix = "expiry_reminder_days"
|
LoyaltyExpiryReminderDaysSuffix = "expiry_reminder_days"
|
||||||
|
LoyaltyExpiryModeSuffix = "expiry_mode"
|
||||||
|
LoyaltyExpiryFixedDatesSuffix = "expiry_fixed_dates"
|
||||||
|
LoyaltyExpiryGraceMonthsSuffix = "expiry_grace_months"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Units of loyalty.{point,coin}.expiry_unit.
|
// Units of loyalty.{point,coin}.expiry_unit.
|
||||||
@@ -50,6 +53,14 @@ const (
|
|||||||
LoyaltyExpiryUnitMonth = "MONTH"
|
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.
|
// Defaults from the PRD.
|
||||||
const (
|
const (
|
||||||
LoyaltyPointEarnPerAmountDefault = int64(100)
|
LoyaltyPointEarnPerAmountDefault = int64(100)
|
||||||
@@ -68,4 +79,8 @@ const (
|
|||||||
LoyaltyExpiryPeriodDefault = int64(12)
|
LoyaltyExpiryPeriodDefault = int64(12)
|
||||||
LoyaltyExpiryUnitDefault = LoyaltyExpiryUnitMonth
|
LoyaltyExpiryUnitDefault = LoyaltyExpiryUnitMonth
|
||||||
LoyaltyExpiryReminderDaysDefault = int64(7)
|
LoyaltyExpiryReminderDaysDefault = int64(7)
|
||||||
|
LoyaltyExpiryModeDefault = LoyaltyExpiryModeFixedDate
|
||||||
|
// Once a year on 31 December, as MM-DD.
|
||||||
|
LoyaltyExpiryFixedDatesDefault = "12-31"
|
||||||
|
LoyaltyExpiryGraceMonthsDefault = int64(3)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,15 +57,29 @@ type LoyaltyTransferSettings struct {
|
|||||||
DailyLimit *int64 `json:"daily_limit"`
|
DailyLimit *int64 `json:"daily_limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoyaltyExpirySettings is how long one currency lasts once received. The expiry
|
// LoyaltyExpirySettings is when one currency expires once received (F12). Both
|
||||||
// model is still open (note N4); these are only the stored settings.
|
// 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 {
|
type LoyaltyExpirySettings struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Period int64 `json:"period"`
|
// FIXED_DATE or ROLLING.
|
||||||
// DAY or MONTH.
|
Mode string `json:"mode"`
|
||||||
Unit string `json:"unit"`
|
// FIXED_DATE: the days of the year balances expire on, as MM-DD, sorted.
|
||||||
EndOfMonth bool `json:"end_of_month"`
|
FixedDates []string `json:"fixed_dates"`
|
||||||
ReminderDays int64 `json:"reminder_days"`
|
// 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"`
|
||||||
|
// 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoyaltySettingChange is one row of the loyalty settings history.
|
// LoyaltySettingChange is one row of the loyalty settings history.
|
||||||
@@ -109,12 +123,21 @@ type OrganizationLoyaltySettingsView struct {
|
|||||||
OrganizationLoyaltySettings
|
OrganizationLoyaltySettings
|
||||||
// What the balances in circulation are worth, before and after the change.
|
// What the balances in circulation are worth, before and after the change.
|
||||||
Impact LoyaltySettingsImpact `json:"impact"`
|
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.
|
// On PUT, the keys that changed; on a dry run, the keys that would.
|
||||||
Changes []LoyaltySettingChange `json:"changes"`
|
Changes []LoyaltySettingChange `json:"changes"`
|
||||||
// True when nothing was saved.
|
// True when nothing was saved.
|
||||||
DryRun bool `json:"dry_run"`
|
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
|
// 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
|
// the balances in circulation are worth (F2). Before and after are equal when neither
|
||||||
// changes.
|
// 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 {
|
func expiryLoyaltyFields(prefix string, s *models.LoyaltyExpirySettings) []loyaltyField {
|
||||||
return []loyaltyField{
|
return []loyaltyField{
|
||||||
boolLoyaltyField(prefix+constants.LoyaltyExpiryEnabledSuffix, &s.Enabled, false),
|
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),
|
intLoyaltyField(prefix+constants.LoyaltyExpiryPeriodSuffix, &s.Period, constants.LoyaltyExpiryPeriodDefault, 1, noLoyaltyMax),
|
||||||
enumLoyaltyField(prefix+constants.LoyaltyExpiryUnitSuffix, &s.Unit, constants.LoyaltyExpiryUnitDefault,
|
enumLoyaltyField(prefix+constants.LoyaltyExpiryUnitSuffix, &s.Unit, constants.LoyaltyExpiryUnitDefault,
|
||||||
constants.LoyaltyExpiryUnitDay, constants.LoyaltyExpiryUnitMonth),
|
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
|
// 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
|
// 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.
|
// 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{})
|
p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{})
|
||||||
s, err := p.Organization(context.Background(), uuid.New())
|
s, err := p.Organization(context.Background(), uuid.New())
|
||||||
require.NoError(t, err)
|
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{
|
assert.Equal(t, models.OrganizationLoyaltySettings{
|
||||||
PointValue: 1,
|
PointValue: 1,
|
||||||
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1},
|
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1},
|
||||||
@@ -136,6 +136,8 @@ func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
|
|||||||
constants.LoyaltyPointValueKey: "100",
|
constants.LoyaltyPointValueKey: "100",
|
||||||
constants.LoyaltyTransferDailyLimitKey: "10000",
|
constants.LoyaltyTransferDailyLimitKey: "10000",
|
||||||
"loyalty.coin.expiry_unit": "day",
|
"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{})
|
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, int64(10000), *org.Transfer.DailyLimit)
|
||||||
assert.Equal(t, "DAY", org.CoinExpiry.Unit)
|
assert.Equal(t, "DAY", org.CoinExpiry.Unit)
|
||||||
assert.Equal(t, "MONTH", org.PointExpiry.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.
|
// 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 period 0": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Period = 0 },
|
||||||
"expiry unit YEAR": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.Unit = "YEAR" },
|
"expiry unit YEAR": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.Unit = "YEAR" },
|
||||||
"negative reminder days": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.ReminderDays = -1 },
|
"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)
|
s, err := p.Organization(ctx, org)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
@@ -144,9 +145,10 @@ func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context
|
|||||||
// of the GET response, which are ignored.
|
// of the GET response, which are ignored.
|
||||||
type organizationSettingsInput struct {
|
type organizationSettingsInput struct {
|
||||||
*models.OrganizationLoyaltySettings
|
*models.OrganizationLoyaltySettings
|
||||||
Impact json.RawMessage `json:"impact"`
|
Impact json.RawMessage `json:"impact"`
|
||||||
Changes json.RawMessage `json:"changes"`
|
ExpiryPreview json.RawMessage `json:"expiry_preview"`
|
||||||
DryRun json.RawMessage `json:"dry_run"`
|
Changes json.RawMessage `json:"changes"`
|
||||||
|
DryRun json.RawMessage `json:"dry_run"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo, body []byte, dryRun bool) *contract.Response {
|
func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo, body []byte, dryRun bool) *contract.Response {
|
||||||
@@ -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) {
|
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)
|
points, coins, err := s.outstanding.OrganizationOutstanding(ctx, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -206,7 +209,11 @@ func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organ
|
|||||||
return &models.OrganizationLoyaltySettingsView{
|
return &models.OrganizationLoyaltySettingsView{
|
||||||
OrganizationLoyaltySettings: after,
|
OrganizationLoyaltySettings: after,
|
||||||
Impact: models.NewLoyaltySettingsImpact(points, coins, before, after),
|
Impact: models.NewLoyaltySettingsImpact(points, coins, before, after),
|
||||||
Changes: changes,
|
ExpiryPreview: models.LoyaltyExpiryPreview{
|
||||||
DryRun: dryRun,
|
Point: processor.ComputeExpiry(after.PointExpiry, now),
|
||||||
|
Coin: processor.ComputeExpiry(after.CoinExpiry, now),
|
||||||
|
},
|
||||||
|
Changes: changes,
|
||||||
|
DryRun: dryRun,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user