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:
co-authored by
Claude Opus 5.5
parent
9ce55e6002
commit
4d63673a25
@@ -36,17 +36,25 @@ type outletSettingsReader interface {
|
||||
Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error)
|
||||
}
|
||||
|
||||
// earningSettingsReader gives the outlet's earning settings and the organization's
|
||||
// expiry. LoyaltySettingsProcessor is one.
|
||||
type earningSettingsReader interface {
|
||||
outletSettingsReader
|
||||
organizationSettingsReader
|
||||
}
|
||||
|
||||
// EarningProcessor credits EnakPoint and EnakCoin for paid orders
|
||||
// (docs/prd-point-coin.md F3).
|
||||
type EarningProcessor struct {
|
||||
orders repository.EarningRepository
|
||||
settings outletSettingsReader
|
||||
settings earningSettingsReader
|
||||
now func() time.Time
|
||||
wallet *WalletProcessor
|
||||
tx TxRunner
|
||||
}
|
||||
|
||||
func NewEarningProcessor(orders repository.EarningRepository, settings outletSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor {
|
||||
return &EarningProcessor{orders: orders, settings: settings, wallet: wallet, tx: tx}
|
||||
func NewEarningProcessor(orders repository.EarningRepository, settings earningSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor {
|
||||
return &EarningProcessor{orders: orders, settings: settings, wallet: wallet, tx: tx, now: time.Now}
|
||||
}
|
||||
|
||||
// OnOrderPaid is called once an order has become fully paid and the payment has
|
||||
@@ -87,6 +95,12 @@ func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID)
|
||||
return &EarningOutcome{Skipped: EarningSkipNothingToEarn}, nil
|
||||
}
|
||||
|
||||
orgSettings, err := p.settings.Organization(ctx, order.OrganizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receivedAt := p.now()
|
||||
|
||||
outcome := &EarningOutcome{}
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
for _, c := range []struct {
|
||||
@@ -101,19 +115,24 @@ func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID)
|
||||
continue
|
||||
}
|
||||
outletID := order.OutletID
|
||||
res, err := p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
|
||||
CustomerID: *order.CustomerID,
|
||||
Currency: c.currency,
|
||||
Type: constants.WalletTxTypeEarn,
|
||||
Amount: c.line.Amount,
|
||||
ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: order.ID,
|
||||
OutletID: &outletID,
|
||||
Description: earningDescription(order),
|
||||
Metadata: result.Metadata(c.line),
|
||||
IdempotencyKey: fmt.Sprintf("earn:%s:%s", order.ID, c.currency),
|
||||
// Lots never expire until the expiry model is decided (F12, note N4).
|
||||
}})
|
||||
res, err := p.wallet.Credit(ctx, WalletCreditInput{
|
||||
WalletEntry: WalletEntry{
|
||||
CustomerID: *order.CustomerID,
|
||||
Currency: c.currency,
|
||||
Type: constants.WalletTxTypeEarn,
|
||||
Amount: c.line.Amount,
|
||||
ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: order.ID,
|
||||
OutletID: &outletID,
|
||||
Description: earningDescription(order),
|
||||
Metadata: result.Metadata(c.line),
|
||||
IdempotencyKey: fmt.Sprintf("earn:%s:%s", order.ID, c.currency),
|
||||
},
|
||||
Lots: []WalletLotInput{{
|
||||
Amount: c.line.Amount,
|
||||
ExpiresAt: ComputeExpiry(ExpirySettings(orgSettings, c.currency), receivedAt),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("crediting %s: %w", c.currency, err)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
// failingSettings fails for the outlet settings until healed, to stand in for the
|
||||
// database being unreachable right after a payment.
|
||||
type failingSettings struct {
|
||||
real outletSettingsReader
|
||||
real earningSettingsReader
|
||||
mu sync.Mutex
|
||||
fail bool
|
||||
}
|
||||
@@ -227,3 +227,7 @@ func TestEarningProcessor_AgainstPostgres(t *testing.T) {
|
||||
assert.Equal(t, int64(875+250+600+400), balance.PointBalance)
|
||||
assert.Equal(t, int64(3+1+2+1), balance.CoinBalance)
|
||||
}
|
||||
|
||||
func (f *failingSettings) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
|
||||
return f.real.Organization(ctx, organizationID)
|
||||
}
|
||||
|
||||
@@ -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(¤t), 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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
//
|
||||
// Never more than the payment used, and only what has not come back yet, so it can be
|
||||
// called again safely. Returned EnakPoint go back into lots with the expiry of the lots
|
||||
// they were taken from (the 7-day extension waits on note N4). It returns how many came
|
||||
// back in total.
|
||||
// they were taken from, but at least seven days from the refund (note N4, decided). It
|
||||
// returns how many came back in total.
|
||||
func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid.UUID) (int64, error) {
|
||||
order, err := p.repo.GetOrder(ctx, orderID, false)
|
||||
if err != nil {
|
||||
@@ -78,7 +78,7 @@ func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid
|
||||
take := min(room, left)
|
||||
left -= take
|
||||
lotID := a.LotID
|
||||
lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID})
|
||||
lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: RefundExpiry(a.ExpiresAt, p.now()), OriginLotID: &lotID})
|
||||
}
|
||||
toReturn -= left
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestPointRefund_UsesTheFrozenValue(t *testing.T) {
|
||||
s, err := settings.Organization(context.Background(), e.org)
|
||||
require.NoError(t, err)
|
||||
s.PointValue = v
|
||||
_, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s)
|
||||
_, _, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
setValue(100)
|
||||
|
||||
@@ -21,15 +21,16 @@ const walletDescriptionLimit = 255
|
||||
// WalletAdminProcessor serves the dashboard's view of one customer's wallet and the
|
||||
// manual adjustments an admin makes to it (docs/prd-point-coin.md F7).
|
||||
type WalletAdminProcessor struct {
|
||||
admin repository.WalletAdminRepository
|
||||
query repository.WalletQueryRepository
|
||||
wallet *WalletProcessor
|
||||
tx TxRunner
|
||||
now func() time.Time
|
||||
admin repository.WalletAdminRepository
|
||||
query repository.WalletQueryRepository
|
||||
wallet *WalletProcessor
|
||||
settings organizationSettingsReader
|
||||
tx TxRunner
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, tx TxRunner) *WalletAdminProcessor {
|
||||
return &WalletAdminProcessor{admin: admin, query: query, wallet: wallet, tx: tx, now: time.Now}
|
||||
func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, settings organizationSettingsReader, tx TxRunner) *WalletAdminProcessor {
|
||||
return &WalletAdminProcessor{admin: admin, query: query, wallet: wallet, settings: settings, tx: tx, now: time.Now}
|
||||
}
|
||||
|
||||
// GetCustomerWallet returns the customer's balances, lots and a page of history.
|
||||
@@ -126,13 +127,20 @@ func (p *WalletAdminProcessor) Adjust(ctx context.Context, organizationID, admin
|
||||
if _, err := p.admin.GetCustomer(ctx, organizationID, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
if in.Amount > 0 {
|
||||
// Adjustments never expire until expiry is decided (F12, note N4).
|
||||
res, err = p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: entry})
|
||||
} else {
|
||||
if in.Amount < 0 {
|
||||
var err error
|
||||
res, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: entry})
|
||||
return err
|
||||
}
|
||||
// An adjustment that adds is a new balance, and expires like one (F7, F12).
|
||||
settings, err := p.settings.Organization(ctx, organizationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err = p.wallet.Credit(ctx, WalletCreditInput{
|
||||
WalletEntry: entry,
|
||||
Lots: []WalletLotInput{{Amount: entry.Amount, ExpiresAt: ComputeExpiry(ExpirySettings(settings, in.Currency), p.now())}},
|
||||
})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -169,7 +169,7 @@ func (p *WalletExchangeProcessor) Exchange(ctx context.Context, customerID uuid.
|
||||
Metadata: metadata,
|
||||
IdempotencyKey: inKey,
|
||||
},
|
||||
Lots: exchangeLots(out.Allocations, rate),
|
||||
Lots: exchangeLots(out.Allocations, rate, ComputeExpiry(settings.PointExpiry, p.now())),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -225,10 +225,9 @@ func exchangePoints(coins int64, rate models.LoyaltyExchangeSettings) int64 {
|
||||
// and after it, which adds up exactly because the total is a multiple of
|
||||
// coin_amount. A lot too small to earn a whole EnakPoint on its own gives none.
|
||||
//
|
||||
// F4 caps the expiry at now + the EnakPoint validity as well. EnakPoint have no
|
||||
// validity until the expiry model is decided (F12, note N4), so for now the EnakCoin
|
||||
// lot's expiry is the only bound.
|
||||
func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSettings) []WalletLotInput {
|
||||
// Each part expires at the sooner of its EnakCoin lot's expiry and pointExpiry, when an
|
||||
// EnakPoint received now would expire (F4); nil means never.
|
||||
func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSettings, pointExpiry *time.Time) []WalletLotInput {
|
||||
var lots []WalletLotInput
|
||||
var coinsSoFar int64
|
||||
for _, a := range allocations {
|
||||
@@ -239,7 +238,7 @@ func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSet
|
||||
continue
|
||||
}
|
||||
lotID := a.LotID
|
||||
lots = append(lots, WalletLotInput{Amount: points, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID})
|
||||
lots = append(lots, WalletLotInput{Amount: points, ExpiresAt: EarlierExpiry(a.ExpiresAt, pointExpiry), OriginLotID: &lotID})
|
||||
}
|
||||
return lots
|
||||
}
|
||||
|
||||
@@ -332,3 +332,23 @@ func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) {
|
||||
assert.ErrorIs(t, err, ErrWalletMoveRejected)
|
||||
assert.Empty(t, e.pins.actions)
|
||||
}
|
||||
|
||||
// F4: EnakPoint from an exchange expire at the sooner of the EnakCoin lot's expiry and
|
||||
// when EnakPoint received now would expire.
|
||||
func TestWalletExchange_CappedByThePointExpiry(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
e.now = wib(2026, 6, 1, 10, 0)
|
||||
e.settings.PointExpiry = rolling(30, "DAY", false)
|
||||
c := e.member("Budi", "081234561234")
|
||||
soon, later := e.at(24*time.Hour), e.at(90*24*time.Hour)
|
||||
e.earnCoins(t, c, 10, soon)
|
||||
e.earnCoins(t, c, 10, later)
|
||||
e.earnCoins(t, c, 10, nil)
|
||||
|
||||
res, err := e.exchanges().Exchange(e.ctx, c, 30, "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Lots, 3)
|
||||
assert.Equal(t, soon, res.Lots[0].ExpiresAt, "the EnakCoin lot expires first")
|
||||
assert.Equal(t, "2026-07-01", expiryDay(t, res.Lots[1].ExpiresAt), "30 days of EnakPoint come first")
|
||||
assert.Equal(t, "2026-07-01", expiryDay(t, res.Lots[2].ExpiresAt), "a lot that never expired gets the EnakPoint expiry")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user