diff --git a/internal/constants/loyalty.go b/internal/constants/loyalty.go index 14ac2e6..6721edf 100644 --- a/internal/constants/loyalty.go +++ b/internal/constants/loyalty.go @@ -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) ) diff --git a/internal/models/loyalty.go b/internal/models/loyalty.go index 1d168ba..f7e0e1e 100644 --- a/internal/models/loyalty.go +++ b/internal/models/loyalty.go @@ -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. diff --git a/internal/processor/loyalty_expiry.go b/internal/processor/loyalty_expiry.go new file mode 100644 index 0000000..cf05322 --- /dev/null +++ b/internal/processor/loyalty_expiry.go @@ -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()) +} diff --git a/internal/processor/loyalty_expiry_test.go b/internal/processor/loyalty_expiry_test.go new file mode 100644 index 0000000..c112f30 --- /dev/null +++ b/internal/processor/loyalty_expiry_test.go @@ -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) + } +} diff --git a/internal/processor/loyalty_settings_processor.go b/internal/processor/loyalty_settings_processor.go index 4c67363..a470b42 100644 --- a/internal/processor/loyalty_settings_processor.go +++ b/internal/processor/loyalty_settings_processor.go @@ -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. diff --git a/internal/processor/loyalty_settings_processor_test.go b/internal/processor/loyalty_settings_processor_test.go index 2cb1d9d..4ee552c 100644 --- a/internal/processor/loyalty_settings_processor_test.go +++ b/internal/processor/loyalty_settings_processor_test.go @@ -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) diff --git a/internal/service/loyalty_settings_service.go b/internal/service/loyalty_settings_service.go index aa87f5d..a53708c 100644 --- a/internal/service/loyalty_settings_service.go +++ b/internal/service/loyalty_settings_service.go @@ -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 }