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()) }