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
+1
-1
@@ -480,7 +480,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
|||||||
walletTransferProcessor: walletTransferProcessor,
|
walletTransferProcessor: walletTransferProcessor,
|
||||||
walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)),
|
walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)),
|
||||||
customerDeviceProcessor: customerDeviceProcessor,
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
|||||||
orgSettings, err := settings.Organization(context.Background(), org)
|
orgSettings, err := settings.Organization(context.Background(), org)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
orgSettings.PointValue = 5
|
orgSettings.PointValue = 5
|
||||||
_, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings)
|
_, _, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
status, body = call(http.MethodGet, "/manager"+path, "")
|
status, body = call(http.MethodGet, "/manager"+path, "")
|
||||||
require.Equal(t, http.StatusOK, status, body)
|
require.Equal(t, http.StatusOK, status, body)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
h := NewWalletAdminHandler(
|
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(),
|
validator.NewWalletValidator(),
|
||||||
)
|
)
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|||||||
@@ -125,12 +125,23 @@ type OrganizationLoyaltySettingsView struct {
|
|||||||
Impact LoyaltySettingsImpact `json:"impact"`
|
Impact LoyaltySettingsImpact `json:"impact"`
|
||||||
// When a balance received now would expire under these settings (F12).
|
// When a balance received now would expire under these settings (F12).
|
||||||
ExpiryPreview LoyaltyExpiryPreview `json:"expiry_preview"`
|
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.
|
// On PUT, the keys that changed; on a dry run, the keys that would.
|
||||||
Changes []LoyaltySettingChange `json:"changes"`
|
Changes []LoyaltySettingChange `json:"changes"`
|
||||||
// True when nothing was saved.
|
// True when nothing was saved.
|
||||||
DryRun bool `json:"dry_run"`
|
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
|
// LoyaltyExpiryPreview is what the dashboard shows next to the expiry settings: "the
|
||||||
// EnakPoint received today expire on …". Nil means they never expire.
|
// EnakPoint received today expire on …". Nil means they never expire.
|
||||||
type LoyaltyExpiryPreview struct {
|
type LoyaltyExpiryPreview struct {
|
||||||
|
|||||||
@@ -36,17 +36,25 @@ type outletSettingsReader interface {
|
|||||||
Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error)
|
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
|
// EarningProcessor credits EnakPoint and EnakCoin for paid orders
|
||||||
// (docs/prd-point-coin.md F3).
|
// (docs/prd-point-coin.md F3).
|
||||||
type EarningProcessor struct {
|
type EarningProcessor struct {
|
||||||
orders repository.EarningRepository
|
orders repository.EarningRepository
|
||||||
settings outletSettingsReader
|
settings earningSettingsReader
|
||||||
|
now func() time.Time
|
||||||
wallet *WalletProcessor
|
wallet *WalletProcessor
|
||||||
tx TxRunner
|
tx TxRunner
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEarningProcessor(orders repository.EarningRepository, settings outletSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor {
|
func NewEarningProcessor(orders repository.EarningRepository, settings earningSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor {
|
||||||
return &EarningProcessor{orders: orders, settings: settings, wallet: wallet, tx: tx}
|
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
|
// 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
|
return &EarningOutcome{Skipped: EarningSkipNothingToEarn}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
orgSettings, err := p.settings.Organization(ctx, order.OrganizationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
receivedAt := p.now()
|
||||||
|
|
||||||
outcome := &EarningOutcome{}
|
outcome := &EarningOutcome{}
|
||||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||||
for _, c := range []struct {
|
for _, c := range []struct {
|
||||||
@@ -101,19 +115,24 @@ func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID)
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
outletID := order.OutletID
|
outletID := order.OutletID
|
||||||
res, err := p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
|
res, err := p.wallet.Credit(ctx, WalletCreditInput{
|
||||||
CustomerID: *order.CustomerID,
|
WalletEntry: WalletEntry{
|
||||||
Currency: c.currency,
|
CustomerID: *order.CustomerID,
|
||||||
Type: constants.WalletTxTypeEarn,
|
Currency: c.currency,
|
||||||
Amount: c.line.Amount,
|
Type: constants.WalletTxTypeEarn,
|
||||||
ReferenceType: constants.WalletRefTypeOrder,
|
Amount: c.line.Amount,
|
||||||
ReferenceID: order.ID,
|
ReferenceType: constants.WalletRefTypeOrder,
|
||||||
OutletID: &outletID,
|
ReferenceID: order.ID,
|
||||||
Description: earningDescription(order),
|
OutletID: &outletID,
|
||||||
Metadata: result.Metadata(c.line),
|
Description: earningDescription(order),
|
||||||
IdempotencyKey: fmt.Sprintf("earn:%s:%s", order.ID, c.currency),
|
Metadata: result.Metadata(c.line),
|
||||||
// Lots never expire until the expiry model is decided (F12, note N4).
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("crediting %s: %w", c.currency, err)
|
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
|
// failingSettings fails for the outlet settings until healed, to stand in for the
|
||||||
// database being unreachable right after a payment.
|
// database being unreachable right after a payment.
|
||||||
type failingSettings struct {
|
type failingSettings struct {
|
||||||
real outletSettingsReader
|
real earningSettingsReader
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
fail bool
|
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(875+250+600+400), balance.PointBalance)
|
||||||
assert.Equal(t, int64(3+1+2+1), balance.CoinBalance)
|
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"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
@@ -27,10 +28,11 @@ var ErrInvalidLoyaltySettings = errors.New("invalid loyalty settings")
|
|||||||
type LoyaltySettingsProcessor struct {
|
type LoyaltySettingsProcessor struct {
|
||||||
repo repository.LoyaltySettingsRepository
|
repo repository.LoyaltySettingsRepository
|
||||||
tx TxRunner
|
tx TxRunner
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLoyaltySettingsProcessor(repo repository.LoyaltySettingsRepository, tx TxRunner) *LoyaltySettingsProcessor {
|
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.
|
// 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.
|
// 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 {
|
if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
var changes []models.LoyaltySettingChange
|
var changes []models.LoyaltySettingChange
|
||||||
|
var activations []models.LoyaltyExpiryActivation
|
||||||
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||||
if err := p.repo.LockScope(ctx, organizationID); err != nil {
|
if err := p.repo.LockScope(ctx, organizationID); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -132,9 +138,42 @@ func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organ
|
|||||||
func(key string, value *string) error {
|
func(key string, value *string) error {
|
||||||
return p.repo.SetOrganizationValue(ctx, organizationID, key, value)
|
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 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
|
// 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
|
// PreviewOrganization validates new organization settings and lists what saving them
|
||||||
// would change, without writing anything.
|
// would change, and which balances turning expiry on would date, without writing
|
||||||
func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, organizationID uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) {
|
// 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 {
|
if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
|
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
var current models.OrganizationLoyaltySettings
|
var current models.OrganizationLoyaltySettings
|
||||||
loadLoyaltyFields(organizationLoyaltyFields(¤t), raw, "organization "+organizationID.String())
|
loadLoyaltyFields(organizationLoyaltyFields(¤t), raw, "organization "+organizationID.String())
|
||||||
@@ -482,5 +522,9 @@ func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, orga
|
|||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
changes = append(changes, loyaltySettingChangeModel(row))
|
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"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -29,6 +30,9 @@ type loyaltyRepoFake struct {
|
|||||||
orgValues map[string]string
|
orgValues map[string]string
|
||||||
changes []entities.LoyaltySettingChange
|
changes []entities.LoyaltySettingChange
|
||||||
locks []uuid.UUID
|
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) {
|
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.Enabled = true
|
||||||
settings.CoinExpiry.Unit = constants.LoyaltyExpiryUnitDay
|
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)
|
require.NoError(t, err)
|
||||||
keys := []string{}
|
keys := []string{}
|
||||||
for _, c := range changes {
|
for _, c := range changes {
|
||||||
@@ -316,7 +320,7 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
|
|||||||
s, err := p.Organization(ctx, org)
|
s, err := p.Organization(ctx, org)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
mutate(s)
|
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.ErrorIs(t, err, ErrInvalidLoyaltySettings, name)
|
||||||
}
|
}
|
||||||
assert.Empty(t, repo.changes, "nothing is written when a value is invalid")
|
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)
|
_, err = p.UpdateOutlet(ctx, org, uuid.New(), uuid.New(), *s)
|
||||||
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
|
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
|
// 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
|
// 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
|
// they were taken from, but at least seven days from the refund (note N4, decided). It
|
||||||
// back in total.
|
// returns how many came back in total.
|
||||||
func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid.UUID) (int64, error) {
|
func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid.UUID) (int64, error) {
|
||||||
order, err := p.repo.GetOrder(ctx, orderID, false)
|
order, err := p.repo.GetOrder(ctx, orderID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -78,7 +78,7 @@ func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid
|
|||||||
take := min(room, left)
|
take := min(room, left)
|
||||||
left -= take
|
left -= take
|
||||||
lotID := a.LotID
|
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
|
toReturn -= left
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func TestPointRefund_UsesTheFrozenValue(t *testing.T) {
|
|||||||
s, err := settings.Organization(context.Background(), e.org)
|
s, err := settings.Organization(context.Background(), e.org)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
s.PointValue = v
|
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)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
setValue(100)
|
setValue(100)
|
||||||
|
|||||||
@@ -21,15 +21,16 @@ const walletDescriptionLimit = 255
|
|||||||
// WalletAdminProcessor serves the dashboard's view of one customer's wallet and the
|
// 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).
|
// manual adjustments an admin makes to it (docs/prd-point-coin.md F7).
|
||||||
type WalletAdminProcessor struct {
|
type WalletAdminProcessor struct {
|
||||||
admin repository.WalletAdminRepository
|
admin repository.WalletAdminRepository
|
||||||
query repository.WalletQueryRepository
|
query repository.WalletQueryRepository
|
||||||
wallet *WalletProcessor
|
wallet *WalletProcessor
|
||||||
tx TxRunner
|
settings organizationSettingsReader
|
||||||
now func() time.Time
|
tx TxRunner
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, tx TxRunner) *WalletAdminProcessor {
|
func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, settings organizationSettingsReader, tx TxRunner) *WalletAdminProcessor {
|
||||||
return &WalletAdminProcessor{admin: admin, query: query, wallet: wallet, tx: tx, now: time.Now}
|
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.
|
// 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 {
|
if _, err := p.admin.GetCustomer(ctx, organizationID, customerID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var err error
|
if in.Amount < 0 {
|
||||||
if in.Amount > 0 {
|
var err error
|
||||||
// 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})
|
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
|
return err
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ func (p *WalletExchangeProcessor) Exchange(ctx context.Context, customerID uuid.
|
|||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
IdempotencyKey: inKey,
|
IdempotencyKey: inKey,
|
||||||
},
|
},
|
||||||
Lots: exchangeLots(out.Allocations, rate),
|
Lots: exchangeLots(out.Allocations, rate, ComputeExpiry(settings.PointExpiry, p.now())),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
// 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.
|
// 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
|
// Each part expires at the sooner of its EnakCoin lot's expiry and pointExpiry, when an
|
||||||
// validity until the expiry model is decided (F12, note N4), so for now the EnakCoin
|
// EnakPoint received now would expire (F4); nil means never.
|
||||||
// lot's expiry is the only bound.
|
func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSettings, pointExpiry *time.Time) []WalletLotInput {
|
||||||
func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSettings) []WalletLotInput {
|
|
||||||
var lots []WalletLotInput
|
var lots []WalletLotInput
|
||||||
var coinsSoFar int64
|
var coinsSoFar int64
|
||||||
for _, a := range allocations {
|
for _, a := range allocations {
|
||||||
@@ -239,7 +238,7 @@ func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSet
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lotID := a.LotID
|
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
|
return lots
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,3 +332,23 @@ func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) {
|
|||||||
assert.ErrorIs(t, err, ErrWalletMoveRejected)
|
assert.ErrorIs(t, err, ErrWalletMoveRejected)
|
||||||
assert.Empty(t, e.pins.actions)
|
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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -47,6 +48,12 @@ type LoyaltySettingsRepository interface {
|
|||||||
InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error
|
InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error
|
||||||
// ListChanges returns a page of history, newest first, and the total count.
|
// ListChanges returns a page of history, newest first, and the total count.
|
||||||
ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error)
|
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 {
|
type loyaltySettingsRepository struct {
|
||||||
@@ -174,3 +181,39 @@ func (r *loyaltySettingsRepository) ListChanges(ctx context.Context, filter Loya
|
|||||||
}
|
}
|
||||||
return changes, total, nil
|
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
|
orgSettings.PointValue = 200
|
||||||
saved := make(chan error, 1)
|
saved := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
_, err := p.UpdateOrganization(ctx, org, admin, *orgSettings)
|
_, _, err := p.UpdateOrganization(ctx, org, admin, *orgSettings)
|
||||||
saved <- err
|
saved <- err
|
||||||
}()
|
}()
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return loyaltyErrorResponse(err)
|
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 {
|
if err != nil {
|
||||||
return loyaltyErrorResponse(err)
|
return loyaltyErrorResponse(err)
|
||||||
}
|
}
|
||||||
@@ -145,10 +145,11 @@ func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context
|
|||||||
// of the GET response, which are ignored.
|
// of the GET response, which are ignored.
|
||||||
type organizationSettingsInput struct {
|
type organizationSettingsInput struct {
|
||||||
*models.OrganizationLoyaltySettings
|
*models.OrganizationLoyaltySettings
|
||||||
Impact json.RawMessage `json:"impact"`
|
Impact json.RawMessage `json:"impact"`
|
||||||
ExpiryPreview json.RawMessage `json:"expiry_preview"`
|
ExpiryPreview json.RawMessage `json:"expiry_preview"`
|
||||||
Changes json.RawMessage `json:"changes"`
|
ExpiryActivations json.RawMessage `json:"expiry_activations"`
|
||||||
DryRun json.RawMessage `json:"dry_run"`
|
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 {
|
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 changes []models.LoyaltySettingChange
|
||||||
|
var activations []models.LoyaltyExpiryActivation
|
||||||
if dryRun {
|
if dryRun {
|
||||||
changes, err = s.settings.PreviewOrganization(ctx, apctx.OrganizationID, next)
|
changes, activations, err = s.settings.PreviewOrganization(ctx, apctx.OrganizationID, next)
|
||||||
} else {
|
} 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 {
|
if err != nil {
|
||||||
return loyaltyErrorResponse(err)
|
return loyaltyErrorResponse(err)
|
||||||
@@ -182,7 +184,7 @@ func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Cont
|
|||||||
}
|
}
|
||||||
after = *saved
|
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 {
|
if err != nil {
|
||||||
return loyaltyErrorResponse(err)
|
return loyaltyErrorResponse(err)
|
||||||
}
|
}
|
||||||
@@ -197,12 +199,15 @@ func (s *LoyaltySettingsServiceImpl) ListHistory(ctx context.Context, apctx *app
|
|||||||
return contract.BuildSuccessResponse(history)
|
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()
|
now := time.Now()
|
||||||
points, coins, err := s.outstanding.OrganizationOutstanding(ctx, organizationID)
|
points, coins, err := s.outstanding.OrganizationOutstanding(ctx, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if activations == nil {
|
||||||
|
activations = []models.LoyaltyExpiryActivation{}
|
||||||
|
}
|
||||||
if changes == nil {
|
if changes == nil {
|
||||||
changes = []models.LoyaltySettingChange{}
|
changes = []models.LoyaltySettingChange{}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user