From 4d63673a25774cac3aa52e2ee44384b72b94014a Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 13:29:25 +0700 Subject: [PATCH] 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 --- internal/app/app.go | 2 +- internal/handler/loyalty_settings_db_test.go | 2 +- internal/handler/wallet_admin_db_test.go | 2 +- internal/models/loyalty.go | 11 ++++ internal/processor/earning_processor.go | 51 ++++++++++----- .../processor/earning_processor_db_test.go | 6 +- .../processor/loyalty_settings_processor.go | 62 ++++++++++++++++--- .../loyalty_settings_processor_test.go | 57 ++++++++++++++++- internal/processor/point_payment_refund.go | 6 +- internal/processor/point_refund_db_test.go | 2 +- internal/processor/wallet_admin_processor.go | 32 ++++++---- .../processor/wallet_exchange_processor.go | 11 ++-- .../wallet_exchange_processor_test.go | 20 ++++++ .../repository/loyalty_settings_repository.go | 43 +++++++++++++ .../loyalty_settings_repository_test.go | 2 +- internal/service/loyalty_settings_service.go | 23 ++++--- 16 files changed, 269 insertions(+), 63 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 91862c4..ee0c19b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -480,7 +480,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor walletTransferProcessor: walletTransferProcessor, walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)), customerDeviceProcessor: customerDeviceProcessor, - walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), + walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), loyaltySettingsProcessor, repos.txManager), } } diff --git a/internal/handler/loyalty_settings_db_test.go b/internal/handler/loyalty_settings_db_test.go index d776ba1..c427002 100644 --- a/internal/handler/loyalty_settings_db_test.go +++ b/internal/handler/loyalty_settings_db_test.go @@ -113,7 +113,7 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) { orgSettings, err := settings.Organization(context.Background(), org) require.NoError(t, err) orgSettings.PointValue = 5 - _, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings) + _, _, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings) require.NoError(t, err) status, body = call(http.MethodGet, "/manager"+path, "") require.Equal(t, http.StatusOK, status, body) diff --git a/internal/handler/wallet_admin_db_test.go b/internal/handler/wallet_admin_db_test.go index 5ca8677..7c4de20 100644 --- a/internal/handler/wallet_admin_db_test.go +++ b/internal/handler/wallet_admin_db_test.go @@ -74,7 +74,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) { })) h := NewWalletAdminHandler( - service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))), + service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm), txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))), validator.NewWalletValidator(), ) gin.SetMode(gin.TestMode) diff --git a/internal/models/loyalty.go b/internal/models/loyalty.go index f7e0e1e..3a0f8b1 100644 --- a/internal/models/loyalty.go +++ b/internal/models/loyalty.go @@ -125,12 +125,23 @@ type OrganizationLoyaltySettingsView struct { Impact LoyaltySettingsImpact `json:"impact"` // When a balance received now would expire under these settings (F12). ExpiryPreview LoyaltyExpiryPreview `json:"expiry_preview"` + // The currencies this change turns expiry on for, and the balances affected. + ExpiryActivations []LoyaltyExpiryActivation `json:"expiry_activations"` // On PUT, the keys that changed; on a dry run, the keys that would. Changes []LoyaltySettingChange `json:"changes"` // True when nothing was saved. DryRun bool `json:"dry_run"` } +// LoyaltyExpiryActivation is expiry being turned on for a currency: the balances that +// had no expiry and the expiry they get (F12). On a dry run nothing is dated yet. +type LoyaltyExpiryActivation struct { + Currency string `json:"currency"` + Lots int64 `json:"lots"` + Amount int64 `json:"amount"` + ExpiresAt time.Time `json:"expires_at"` +} + // LoyaltyExpiryPreview is what the dashboard shows next to the expiry settings: "the // EnakPoint received today expire on …". Nil means they never expire. type LoyaltyExpiryPreview struct { diff --git a/internal/processor/earning_processor.go b/internal/processor/earning_processor.go index 042a50a..b820ea1 100644 --- a/internal/processor/earning_processor.go +++ b/internal/processor/earning_processor.go @@ -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) } diff --git a/internal/processor/earning_processor_db_test.go b/internal/processor/earning_processor_db_test.go index 0a58841..7fad218 100644 --- a/internal/processor/earning_processor_db_test.go +++ b/internal/processor/earning_processor_db_test.go @@ -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) +} diff --git a/internal/processor/loyalty_settings_processor.go b/internal/processor/loyalty_settings_processor.go index a470b42..2b78e45 100644 --- a/internal/processor/loyalty_settings_processor.go +++ b/internal/processor/loyalty_settings_processor.go @@ -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 } diff --git a/internal/processor/loyalty_settings_processor_test.go b/internal/processor/loyalty_settings_processor_test.go index 4ee552c..ecbdb1e 100644 --- a/internal/processor/loyalty_settings_processor_test.go +++ b/internal/processor/loyalty_settings_processor_test.go @@ -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") +} diff --git a/internal/processor/point_payment_refund.go b/internal/processor/point_payment_refund.go index e869795..9b6397a 100644 --- a/internal/processor/point_payment_refund.go +++ b/internal/processor/point_payment_refund.go @@ -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 diff --git a/internal/processor/point_refund_db_test.go b/internal/processor/point_refund_db_test.go index f6a5956..55a5e59 100644 --- a/internal/processor/point_refund_db_test.go +++ b/internal/processor/point_refund_db_test.go @@ -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) diff --git a/internal/processor/wallet_admin_processor.go b/internal/processor/wallet_admin_processor.go index d48c495..3909a35 100644 --- a/internal/processor/wallet_admin_processor.go +++ b/internal/processor/wallet_admin_processor.go @@ -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 { diff --git a/internal/processor/wallet_exchange_processor.go b/internal/processor/wallet_exchange_processor.go index 79beac3..71552a1 100644 --- a/internal/processor/wallet_exchange_processor.go +++ b/internal/processor/wallet_exchange_processor.go @@ -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 } diff --git a/internal/processor/wallet_exchange_processor_test.go b/internal/processor/wallet_exchange_processor_test.go index 83672f1..a6d64c5 100644 --- a/internal/processor/wallet_exchange_processor_test.go +++ b/internal/processor/wallet_exchange_processor_test.go @@ -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") +} diff --git a/internal/repository/loyalty_settings_repository.go b/internal/repository/loyalty_settings_repository.go index 4a0f0e0..757634f 100644 --- a/internal/repository/loyalty_settings_repository.go +++ b/internal/repository/loyalty_settings_repository.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/google/uuid" "gorm.io/gorm" @@ -47,6 +48,12 @@ type LoyaltySettingsRepository interface { InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error // ListChanges returns a page of history, newest first, and the total count. ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) + + // UndatedBalance counts an organization's lots of one currency that still hold + // something and have no expiry, and what they hold. + UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (lots, amount int64, err error) + // DateUndatedLots gives those lots an expiry, when expiry is turned on (F12). + DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (lots, amount int64, err error) } type loyaltySettingsRepository struct { @@ -174,3 +181,39 @@ func (r *loyaltySettingsRepository) ListChanges(ctx context.Context, filter Loya } return changes, total, nil } + +func (r *loyaltySettingsRepository) UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (int64, int64, error) { + var row struct{ Lots, Amount int64 } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT COUNT(*) AS lots, COALESCE(SUM(remaining_amount), 0) AS amount + FROM wallet_lots + WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0`, + organizationID, currency).Scan(&row).Error + if err != nil { + return 0, 0, fmt.Errorf("failed to sum undated wallet lots: %w", err) + } + return row.Lots, row.Amount, nil +} + +func (r *loyaltySettingsRepository) DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (int64, int64, error) { + db, err := r.requireTx(ctx) + if err != nil { + return 0, 0, err + } + var rows []struct{ RemainingAmount int64 } + // Only lots that still hold something: an empty lot never expires anything, and + // leaving it undated keeps the history as it was. + err = db.Raw(` + UPDATE wallet_lots SET expires_at = ? + WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0 + RETURNING remaining_amount`, + expiresAt, organizationID, currency).Scan(&rows).Error + if err != nil { + return 0, 0, fmt.Errorf("failed to date wallet lots: %w", err) + } + var amount int64 + for _, row := range rows { + amount += row.RemainingAmount + } + return int64(len(rows)), amount, nil +} diff --git a/internal/repository/loyalty_settings_repository_test.go b/internal/repository/loyalty_settings_repository_test.go index f8fb7d6..233c66d 100644 --- a/internal/repository/loyalty_settings_repository_test.go +++ b/internal/repository/loyalty_settings_repository_test.go @@ -124,7 +124,7 @@ func TestLoyaltySettings_AgainstPostgres(t *testing.T) { orgSettings.PointValue = 200 saved := make(chan error, 1) go func() { - _, err := p.UpdateOrganization(ctx, org, admin, *orgSettings) + _, _, err := p.UpdateOrganization(ctx, org, admin, *orgSettings) saved <- err }() select { diff --git a/internal/service/loyalty_settings_service.go b/internal/service/loyalty_settings_service.go index a53708c..1d81921 100644 --- a/internal/service/loyalty_settings_service.go +++ b/internal/service/loyalty_settings_service.go @@ -134,7 +134,7 @@ func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context if err != nil { return loyaltyErrorResponse(err) } - view, err := s.organizationView(ctx, apctx.OrganizationID, *current, *current, nil, false) + view, err := s.organizationView(ctx, apctx.OrganizationID, *current, *current, nil, nil, false) if err != nil { return loyaltyErrorResponse(err) } @@ -145,10 +145,11 @@ func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context // of the GET response, which are ignored. type organizationSettingsInput struct { *models.OrganizationLoyaltySettings - Impact json.RawMessage `json:"impact"` - ExpiryPreview json.RawMessage `json:"expiry_preview"` - Changes json.RawMessage `json:"changes"` - DryRun json.RawMessage `json:"dry_run"` + Impact json.RawMessage `json:"impact"` + ExpiryPreview json.RawMessage `json:"expiry_preview"` + ExpiryActivations json.RawMessage `json:"expiry_activations"` + Changes json.RawMessage `json:"changes"` + DryRun json.RawMessage `json:"dry_run"` } func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo, body []byte, dryRun bool) *contract.Response { @@ -166,10 +167,11 @@ func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Cont } var changes []models.LoyaltySettingChange + var activations []models.LoyaltyExpiryActivation if dryRun { - changes, err = s.settings.PreviewOrganization(ctx, apctx.OrganizationID, next) + changes, activations, err = s.settings.PreviewOrganization(ctx, apctx.OrganizationID, next) } else { - changes, err = s.settings.UpdateOrganization(ctx, apctx.OrganizationID, apctx.UserID, next) + changes, activations, err = s.settings.UpdateOrganization(ctx, apctx.OrganizationID, apctx.UserID, next) } if err != nil { return loyaltyErrorResponse(err) @@ -182,7 +184,7 @@ func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Cont } after = *saved } - view, err := s.organizationView(ctx, apctx.OrganizationID, *current, after, changes, dryRun) + view, err := s.organizationView(ctx, apctx.OrganizationID, *current, after, changes, activations, dryRun) if err != nil { return loyaltyErrorResponse(err) } @@ -197,12 +199,15 @@ func (s *LoyaltySettingsServiceImpl) ListHistory(ctx context.Context, apctx *app return contract.BuildSuccessResponse(history) } -func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, changes []models.LoyaltySettingChange, dryRun bool) (*models.OrganizationLoyaltySettingsView, error) { +func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, changes []models.LoyaltySettingChange, activations []models.LoyaltyExpiryActivation, dryRun bool) (*models.OrganizationLoyaltySettingsView, error) { now := time.Now() points, coins, err := s.outstanding.OrganizationOutstanding(ctx, organizationID) if err != nil { return nil, err } + if activations == nil { + activations = []models.LoyaltyExpiryActivation{} + } if changes == nil { changes = []models.LoyaltySettingChange{} }