feat(loyalty): give new balances their expiry

Every lot now gets its expiry when it is created (docs/prd-point-coin.md
F12, PC-502), where it used to never expire until note N4 was settled:

- EARN and an ADJUSTMENT that adds: ComputeExpiry of the organization's
  settings for that currency, from the moment received.
- EXCHANGE_IN: the sooner of the EnakCoin lot's expiry and when EnakPoint
  received now expire (F4).
- PAYMENT_REFUND: the expiry of the lot the EnakPoint came from, but at
  least seven days from the refund (N4, decided). A lot that never expired
  stays so.
- TRANSFER_IN: unchanged, exactly the sender's expiry.

Turning expiry on for a currency for the first time dates every lot of the
organization that still holds something and has no expiry, MIGRATION lots
included, in the same transaction as the setting: a full period from now
when ROLLING, the second fixed date on or after today when FIXED_DATE, so
no customer loses a balance soon after the rule is announced (N4,
decided). Turning it off leaves dated lots as they are. PUT
/marketing/loyalty-settings reports these as expiry_activations (currency,
lots, amount, expires_at); a dry run counts them without dating anything.

The earning processor now also reads the organization settings, and the
wallet admin processor takes the settings reader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 13:29:25 +07:00
co-authored by Claude Opus 5.5
parent 9ce55e6002
commit 4d63673a25
16 changed files with 269 additions and 63 deletions
@@ -4,6 +4,7 @@ import (
"context"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
@@ -29,6 +30,9 @@ type loyaltyRepoFake struct {
orgValues map[string]string
changes []entities.LoyaltySettingChange
locks []uuid.UUID
// Lots without an expiry per currency: how many and what they hold.
undated map[string][2]int64
dated map[string]time.Time
}
func (f *loyaltyRepoFake) GetOutletValues(context.Context, uuid.UUID) (map[string]string, error) {
@@ -263,7 +267,7 @@ func TestLoyaltySettings_UpdateOrganization(t *testing.T) {
settings.CoinExpiry.Enabled = true
settings.CoinExpiry.Unit = constants.LoyaltyExpiryUnitDay
changes, err := p.UpdateOrganization(ctx, org, admin, *settings)
changes, _, err := p.UpdateOrganization(ctx, org, admin, *settings)
require.NoError(t, err)
keys := []string{}
for _, c := range changes {
@@ -316,7 +320,7 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
s, err := p.Organization(ctx, org)
require.NoError(t, err)
mutate(s)
_, err = p.UpdateOrganization(ctx, org, uuid.New(), *s)
_, _, err = p.UpdateOrganization(ctx, org, uuid.New(), *s)
assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name)
}
assert.Empty(t, repo.changes, "nothing is written when a value is invalid")
@@ -330,3 +334,52 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
_, err = p.UpdateOutlet(ctx, org, uuid.New(), uuid.New(), *s)
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
}
func (f *loyaltyRepoFake) UndatedBalance(_ context.Context, _ uuid.UUID, currency string) (int64, int64, error) {
u := f.undated[currency]
return u[0], u[1], nil
}
func (f *loyaltyRepoFake) DateUndatedLots(_ context.Context, _ uuid.UUID, currency string, expiresAt time.Time) (int64, int64, error) {
u := f.undated[currency]
if f.dated == nil {
f.dated = map[string]time.Time{}
}
f.dated[currency] = expiresAt
delete(f.undated, currency)
return u[0], u[1], nil
}
// Turning expiry on dates the balances that had none, a full cycle away; a dry run
// only counts them, and saving again dates nothing more.
func TestLoyaltySettings_TurningExpiryOnDatesExistingBalances(t *testing.T) {
org, admin := uuid.New(), uuid.New()
repo := &loyaltyRepoFake{undated: map[string][2]int64{"POINT": {3, 1500}, "COIN": {2, 40}}}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
p.now = func() time.Time { return time.Date(2026, 11, 20, 3, 0, 0, 0, time.UTC) }
ctx := context.Background()
settings, err := p.Organization(ctx, org)
require.NoError(t, err)
settings.PointExpiry.Enabled = true
_, preview, err := p.PreviewOrganization(ctx, org, *settings)
require.NoError(t, err)
require.Len(t, preview, 1)
assert.Equal(t, "POINT", preview[0].Currency)
assert.Equal(t, int64(1500), preview[0].Amount)
assert.Empty(t, repo.dated, "a dry run dates nothing")
_, activations, err := p.UpdateOrganization(ctx, org, admin, *settings)
require.NoError(t, err)
require.Len(t, activations, 1)
assert.Equal(t, int64(3), activations[0].Lots)
// Default FIXED_DATE on 31 Dec: the second one after 20 Nov 2026.
assert.Equal(t, "2027-12-31", activations[0].ExpiresAt.In(walletDisplayLocation).Format("2006-01-02"))
assert.Equal(t, activations[0].ExpiresAt, repo.dated["POINT"])
assert.NotContains(t, repo.dated, "COIN", "EnakCoin expiry stays off")
_, again, err := p.UpdateOrganization(ctx, org, admin, *settings)
require.NoError(t, err)
assert.Empty(t, again, "already on: nothing is activated twice")
}