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
@@ -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.