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
+1 -1
View File
@@ -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),
}
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+11
View File
@@ -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 {
+25 -6
View File
@@ -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,7 +115,8 @@ func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID)
continue
}
outletID := order.OutletID
res, err := p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
res, err := p.wallet.Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{
CustomerID: *order.CustomerID,
Currency: c.currency,
Type: constants.WalletTxTypeEarn,
@@ -112,8 +127,12 @@ func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID)
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).
}})
},
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(&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
}
@@ -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")
}
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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)
+14 -6
View File
@@ -24,12 +24,13 @@ type WalletAdminProcessor struct {
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
}
if in.Amount < 0 {
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 {
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")
}
@@ -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
}
@@ -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 {
+10 -5
View File
@@ -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)
}
@@ -147,6 +147,7 @@ type organizationSettingsInput struct {
*models.OrganizationLoyaltySettings
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"`
}
@@ -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{}
}