Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)

This reverts commit 4e24f9bbb0.
This commit is contained in:
efrilm
2026-09-30 15:31:11 +07:00
parent 5e28b05770
commit c6062c4bb9
195 changed files with 20058 additions and 2177 deletions
+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())
}