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>
171 lines
5.4 KiB
Go
171 lines
5.4 KiB
Go
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())
|
|
}
|