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>
116 lines
4.7 KiB
Go
116 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|