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
@@ -6,6 +6,7 @@ import (
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
@@ -27,10 +28,11 @@ var ErrInvalidLoyaltySettings = errors.New("invalid loyalty settings")
type LoyaltySettingsProcessor struct {
repo repository.LoyaltySettingsRepository
tx TxRunner
now func() time.Time
}
func NewLoyaltySettingsProcessor(repo repository.LoyaltySettingsRepository, tx TxRunner) *LoyaltySettingsProcessor {
return &LoyaltySettingsProcessor{repo: repo, tx: tx}
return &LoyaltySettingsProcessor{repo: repo, tx: tx, now: time.Now}
}
// Outlet returns an outlet's settings.
@@ -111,11 +113,15 @@ func (p *LoyaltySettingsProcessor) UpdateOutlet(ctx context.Context, organizatio
}
// UpdateOrganization replaces an organization's settings and returns what changed.
func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organizationID, changedBy uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) {
//
// Turning expiry on for a currency for the first time also gives every existing lot
// without an expiry the activation expiry (F12, note N4), and the result lists them.
func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organizationID, changedBy uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, []models.LoyaltyExpiryActivation, error) {
if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil {
return nil, err
return nil, nil, err
}
var changes []models.LoyaltySettingChange
var activations []models.LoyaltyExpiryActivation
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
if err := p.repo.LockScope(ctx, organizationID); err != nil {
return err
@@ -132,9 +138,42 @@ func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organ
func(key string, value *string) error {
return p.repo.SetOrganizationValue(ctx, organizationID, key, value)
})
if err != nil {
return err
}
activations, err = p.expiryActivations(ctx, organizationID, current, settings, true)
return err
})
return changes, err
if err != nil {
return nil, nil, err
}
return changes, activations, nil
}
// expiryActivations lists the currencies whose expiry these settings turn on, with the
// balances that have no expiry yet and the expiry they get: a full period from now
// (note N4, decided). With apply, it dates those lots.
func (p *LoyaltySettingsProcessor) expiryActivations(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, apply bool) ([]models.LoyaltyExpiryActivation, error) {
activations := []models.LoyaltyExpiryActivation{}
for _, currency := range []string{constants.WalletCurrencyPoint, constants.WalletCurrencyCoin} {
was, will := ExpirySettings(&before, currency), ExpirySettings(&after, currency)
if was.Enabled || !will.Enabled {
continue
}
expiresAt := *ActivationExpiry(will, p.now())
var lots, amount int64
var err error
if apply {
lots, amount, err = p.repo.DateUndatedLots(ctx, organizationID, currency, expiresAt)
} else {
lots, amount, err = p.repo.UndatedBalance(ctx, organizationID, currency)
}
if err != nil {
return nil, err
}
activations = append(activations, models.LoyaltyExpiryActivation{Currency: currency, Lots: lots, Amount: amount, ExpiresAt: expiresAt})
}
return activations, nil
}
// apply stores every field whose effective value changes and records it. A key left
@@ -466,14 +505,15 @@ func diffLoyaltyFields(organizationID uuid.UUID, outletID *uuid.UUID, changedBy
}
// PreviewOrganization validates new organization settings and lists what saving them
// would change, without writing anything.
func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, organizationID uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) {
// would change, and which balances turning expiry on would date, without writing
// anything.
func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, organizationID uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, []models.LoyaltyExpiryActivation, error) {
if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil {
return nil, err
return nil, nil, err
}
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
if err != nil {
return nil, err
return nil, nil, err
}
var current models.OrganizationLoyaltySettings
loadLoyaltyFields(organizationLoyaltyFields(&current), raw, "organization "+organizationID.String())
@@ -482,5 +522,9 @@ func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, orga
for _, row := range rows {
changes = append(changes, loyaltySettingChangeModel(row))
}
return changes, nil
activations, err := p.expiryActivations(ctx, organizationID, current, settings, false)
if err != nil {
return nil, nil, err
}
return changes, activations, nil
}