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>
This commit is contained in:
efrilm
2026-09-30 13:23:40 +07:00
co-authored by Claude Opus 5.5
parent 4432f0a10d
commit 9ce55e6002
7 changed files with 398 additions and 14 deletions
+15
View File
@@ -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)
)
+31 -8
View File
@@ -57,15 +57,29 @@ 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"`
Period int64 `json:"period"`
// DAY or MONTH.
Unit string `json:"unit"`
EndOfMonth bool `json:"end_of_month"`
ReminderDays int64 `json:"reminder_days"`
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"`
// 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.
@@ -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.
+170
View File
@@ -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())
}
+115
View File
@@ -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)
+12 -5
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
@@ -144,9 +145,10 @@ func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context
// of the GET response, which are ignored.
type organizationSettingsInput struct {
*models.OrganizationLoyaltySettings
Impact json.RawMessage `json:"impact"`
Changes json.RawMessage `json:"changes"`
DryRun json.RawMessage `json:"dry_run"`
Impact json.RawMessage `json:"impact"`
ExpiryPreview json.RawMessage `json:"expiry_preview"`
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 {
@@ -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,7 +209,11 @@ func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organ
return &models.OrganizationLoyaltySettingsView{
OrganizationLoyaltySettings: after,
Impact: models.NewLoyaltySettingsImpact(points, coins, before, after),
Changes: changes,
DryRun: dryRun,
ExpiryPreview: models.LoyaltyExpiryPreview{
Point: processor.ComputeExpiry(after.PointExpiry, now),
Coin: processor.ComputeExpiry(after.CoinExpiry, now),
},
Changes: changes,
DryRun: dryRun,
}, nil
}