EnakGame phases 1-8 of docs/tasks-enakgame.md (EG-101 to EG-803), built on the existing EnakPoint/EnakCoin wallet (docs/rfc-enakgame.md). Foundation (phase 1) - Migrations 000103-000106: games extended with organization, slug, status, entry cost and result rules, old games archived (not deleted); budgets, versioned reward configs, sessions and session rewards; the ledger types GAME_SPEND_REFUND, GAME_REWARD and REWARD_REDEEM_REFUND; audit_logs. - AuditLogger writes in the caller's transaction only. - enakgame.limit.user_daily and global_daily organization settings. Games and sessions (phases 2-4) - Admin /marketing/enakgame: games, reward config versions (immutable but for status, one ACTIVE per game), budgets with non-overlapping global periods and a daily job opening the next month. - Customer /customer/enakgame: start (Idempotency-Key, entry cost and config frozen on the session), complete (result validation, reward engine, max_reward cap, daily limits via game_reward_counters, one GAME_REWARD per budget), automatic refunds for system errors and deactivated games, and a session job. - Reward engine: FIXED, SCORE_BASED, OUTCOME_BASED, PROBABILITY (crypto/rand), rounded down. Vouchers and budgets (phases 5-6) - Migration 000108 and 000107: vouchers, codes, redemptions, cost attribution; Economy Guard counters. - STATIC and CODE_POOL redemption in one transaction with the REDEEM PIN action; realized cost traced through the lots to the budget that paid the reward. - Budget metrics: realized cost, forecast, exposure and status. Migrations 000109-000110 add the wallet_lots indexes they need, built CONCURRENTLY. Events (phase 7) - Migration 000111: game events, each with its own EVENT budget. Event extras stack per PRD §16 defaults, with event and per-customer limits. External vouchers (phase 8) - VoucherProvider contract, two-step PENDING redemption and a recovery job, tested with a fake provider. No provider adapter is registered yet, so EXTERNAL vouchers stay out of the catalog. Not yet decided before release: reward rounding, event stacking, budget exhaustion policy and thresholds (RFC §19.2). Migrations 000103-000111 have not been run on any shared database. Also fixes a leftover PAYMENT filter in a wallet test and a data race in a test PIN fake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
566 lines
21 KiB
Go
566 lines
21 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"apskel-pos-be/internal/constants"
|
|
"apskel-pos-be/internal/entities"
|
|
"apskel-pos-be/internal/logger"
|
|
"apskel-pos-be/internal/models"
|
|
"apskel-pos-be/internal/repository"
|
|
)
|
|
|
|
// ErrInvalidLoyaltySettings wraps every rejection of a setting that breaks the rules in
|
|
// docs/prd-point-coin.md F1, F2 and F12.
|
|
var ErrInvalidLoyaltySettings = errors.New("invalid loyalty settings")
|
|
|
|
// LoyaltySettingsProcessor reads and writes the loyalty settings of outlets (F1) and
|
|
// organizations (F2, F12). Reads always return every setting, with the PRD default for
|
|
// a key that was never set, so callers never handle a missing value. Every write
|
|
// records what changed in loyalty_setting_changes.
|
|
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, now: time.Now}
|
|
}
|
|
|
|
// Outlet returns an outlet's settings.
|
|
func (p *LoyaltySettingsProcessor) Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) {
|
|
raw, err := p.repo.GetOutletValues(ctx, outletID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var s models.OutletLoyaltySettings
|
|
loadLoyaltyFields(outletLoyaltyFields(&s), raw, "outlet "+outletID.String())
|
|
return &s, nil
|
|
}
|
|
|
|
// OutletForOrganization returns an outlet's settings for the dashboard, and
|
|
// repository.ErrLoyaltyOutletNotFound unless the outlet belongs to the organization.
|
|
func (p *LoyaltySettingsProcessor) OutletForOrganization(ctx context.Context, organizationID, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) {
|
|
owner, err := p.repo.OutletOrganizationID(ctx, outletID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if owner != organizationID {
|
|
return nil, repository.ErrLoyaltyOutletNotFound
|
|
}
|
|
return p.Outlet(ctx, outletID)
|
|
}
|
|
|
|
// Organization returns an organization's settings.
|
|
func (p *LoyaltySettingsProcessor) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
|
|
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var s models.OrganizationLoyaltySettings
|
|
loadLoyaltyFields(organizationLoyaltyFields(&s), raw, "organization "+organizationID.String())
|
|
return &s, nil
|
|
}
|
|
|
|
// PointValue is the rupiah value of one EnakPoint in an organization.
|
|
func (p *LoyaltySettingsProcessor) PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) {
|
|
s, err := p.Organization(ctx, organizationID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return s.PointValue, nil
|
|
}
|
|
|
|
// UpdateOutlet replaces an outlet's settings and returns what changed. The outlet must
|
|
// belong to the organization; otherwise repository.ErrLoyaltyOutletNotFound.
|
|
func (p *LoyaltySettingsProcessor) UpdateOutlet(ctx context.Context, organizationID, outletID, changedBy uuid.UUID, settings models.OutletLoyaltySettings) ([]models.LoyaltySettingChange, error) {
|
|
if err := validateLoyaltyFields(outletLoyaltyFields(&settings)); err != nil {
|
|
return nil, err
|
|
}
|
|
var changes []models.LoyaltySettingChange
|
|
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
|
owner, err := p.repo.OutletOrganizationID(ctx, outletID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if owner != organizationID {
|
|
return repository.ErrLoyaltyOutletNotFound
|
|
}
|
|
if err := p.repo.LockScope(ctx, outletID); err != nil {
|
|
return err
|
|
}
|
|
raw, err := p.repo.GetOutletValues(ctx, outletID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var current models.OutletLoyaltySettings
|
|
loadLoyaltyFields(outletLoyaltyFields(¤t), raw, "outlet "+outletID.String())
|
|
|
|
changes, err = p.apply(ctx, organizationID, &outletID, changedBy, raw,
|
|
outletLoyaltyFields(¤t), outletLoyaltyFields(&settings),
|
|
func(key string, value *string) error { return p.repo.SetOutletValue(ctx, outletID, key, value) })
|
|
return err
|
|
})
|
|
return changes, err
|
|
}
|
|
|
|
// UpdateOrganization replaces an organization's settings and returns what changed.
|
|
//
|
|
// 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, 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
|
|
}
|
|
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var current models.OrganizationLoyaltySettings
|
|
loadLoyaltyFields(organizationLoyaltyFields(¤t), raw, "organization "+organizationID.String())
|
|
|
|
changes, err = p.apply(ctx, organizationID, nil, changedBy, raw,
|
|
organizationLoyaltyFields(¤t), organizationLoyaltyFields(&settings),
|
|
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
|
|
})
|
|
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
|
|
// on its default and not changed is not written, so the history only shows real
|
|
// changes. current and next list the same keys in the same order.
|
|
func (p *LoyaltySettingsProcessor) apply(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, changedBy uuid.UUID, raw map[string]string, current, next []loyaltyField, store func(key string, value *string) error) ([]models.LoyaltySettingChange, error) {
|
|
rows := diffLoyaltyFields(organizationID, outletID, changedBy, raw, current, next)
|
|
for _, row := range rows {
|
|
if err := store(row.Key, row.NewValue); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := p.repo.InsertChanges(ctx, rows); err != nil {
|
|
return nil, err
|
|
}
|
|
changes := make([]models.LoyaltySettingChange, 0, len(rows))
|
|
for _, row := range rows {
|
|
changes = append(changes, loyaltySettingChangeModel(row))
|
|
}
|
|
return changes, nil
|
|
}
|
|
|
|
// ListChanges returns a page of an organization's settings history, newest first.
|
|
// A nil outlet lists the whole organization, outlets included.
|
|
func (p *LoyaltySettingsProcessor) ListChanges(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int) (*models.PaginatedResponse[models.LoyaltySettingChange], error) {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if limit < 1 || limit > 100 {
|
|
limit = 20
|
|
}
|
|
rows, total, err := p.repo.ListChanges(ctx, repository.LoyaltySettingChangeFilter{
|
|
OrganizationID: organizationID, OutletID: outletID, Offset: (page - 1) * limit, Limit: limit,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]models.LoyaltySettingChange, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, loyaltySettingChangeModel(row))
|
|
}
|
|
return &models.PaginatedResponse[models.LoyaltySettingChange]{
|
|
Data: items,
|
|
Pagination: models.Pagination{
|
|
Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit)),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func loyaltySettingChangeModel(row entities.LoyaltySettingChange) models.LoyaltySettingChange {
|
|
return models.LoyaltySettingChange{
|
|
ID: row.ID,
|
|
OrganizationID: row.OrganizationID,
|
|
OutletID: row.OutletID,
|
|
Key: row.Key,
|
|
OldValue: row.OldValue,
|
|
NewValue: row.NewValue,
|
|
ChangedBy: row.ChangedBy,
|
|
CreatedAt: row.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// outletLoyaltyFields describes every outlet key (F1), bound to s.
|
|
func outletLoyaltyFields(s *models.OutletLoyaltySettings) []loyaltyField {
|
|
return []loyaltyField{
|
|
boolLoyaltyField(constants.LoyaltyPointEnabledKey, &s.Point.Enabled, false),
|
|
enumLoyaltyField(constants.LoyaltyPointEarnModeKey, &s.Point.EarnMode, constants.LoyaltyEarnModeDefault,
|
|
constants.LoyaltyEarnModePerAmount, constants.LoyaltyEarnModePercentage),
|
|
intLoyaltyField(constants.LoyaltyPointEarnPerAmountKey, &s.Point.EarnPerAmount, constants.LoyaltyPointEarnPerAmountDefault, 1, noLoyaltyMax),
|
|
intLoyaltyField(constants.LoyaltyPointEarnValueKey, &s.Point.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
|
percentLoyaltyField(constants.LoyaltyPointEarnPercentKey, &s.Point.EarnPercent, constants.LoyaltyEarnPercentDefault),
|
|
intLoyaltyField(constants.LoyaltyPointMinOrderAmountKey, &s.Point.MinOrderAmount, 0, 0, noLoyaltyMax),
|
|
optionalIntLoyaltyField(constants.LoyaltyPointMaxPerOrderKey, &s.Point.MaxPerOrder, 0),
|
|
|
|
boolLoyaltyField(constants.LoyaltyCoinEnabledKey, &s.Coin.Enabled, false),
|
|
enumLoyaltyField(constants.LoyaltyCoinEarnModeKey, &s.Coin.EarnMode, constants.LoyaltyEarnModeDefault,
|
|
constants.LoyaltyEarnModePerAmount, constants.LoyaltyEarnModePercentage),
|
|
intLoyaltyField(constants.LoyaltyCoinEarnPerAmountKey, &s.Coin.EarnPerAmount, constants.LoyaltyCoinEarnPerAmountDefault, 1, noLoyaltyMax),
|
|
intLoyaltyField(constants.LoyaltyCoinEarnValueKey, &s.Coin.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
|
percentLoyaltyField(constants.LoyaltyCoinEarnPercentKey, &s.Coin.EarnPercent, constants.LoyaltyEarnPercentDefault),
|
|
intLoyaltyField(constants.LoyaltyCoinMinOrderAmountKey, &s.Coin.MinOrderAmount, 0, 0, noLoyaltyMax),
|
|
optionalIntLoyaltyField(constants.LoyaltyCoinMaxPerOrderKey, &s.Coin.MaxPerOrder, 0),
|
|
}
|
|
}
|
|
|
|
// organizationLoyaltyFields describes every organization key (F2, F12, and the EnakGame
|
|
// limits of docs/rfc-enakgame.md §5.10), bound to s.
|
|
func organizationLoyaltyFields(s *models.OrganizationLoyaltySettings) []loyaltyField {
|
|
fields := []loyaltyField{
|
|
intLoyaltyField(constants.LoyaltyPointValueKey, &s.PointValue, constants.LoyaltyPointValueDefault, 1, noLoyaltyMax),
|
|
intLoyaltyField(constants.LoyaltyExchangeCoinAmountKey, &s.Exchange.CoinAmount, constants.LoyaltyExchangeAmountDefault, 1, noLoyaltyMax),
|
|
intLoyaltyField(constants.LoyaltyExchangePointAmountKey, &s.Exchange.PointAmount, constants.LoyaltyExchangeAmountDefault, 1, noLoyaltyMax),
|
|
boolLoyaltyField(constants.LoyaltyTransferEnabledKey, &s.Transfer.Enabled, constants.LoyaltyTransferEnabledDefault),
|
|
intLoyaltyField(constants.LoyaltyTransferMinAmountKey, &s.Transfer.MinAmount, constants.LoyaltyTransferMinAmountDefault, 1, noLoyaltyMax),
|
|
optionalIntLoyaltyField(constants.LoyaltyTransferMaxPerTransactionKey, &s.Transfer.MaxPerTransaction, 1),
|
|
optionalIntLoyaltyField(constants.LoyaltyTransferDailyLimitKey, &s.Transfer.DailyLimit, 1),
|
|
intLoyaltyField(constants.EnakGameLimitUserDailyKey, &s.EnakGame.UserDailyLimit, 0, 0, noLoyaltyMax),
|
|
intLoyaltyField(constants.EnakGameLimitGlobalDailyKey, &s.EnakGame.GlobalDailyLimit, 0, 0, noLoyaltyMax),
|
|
}
|
|
fields = append(fields, expiryLoyaltyFields("loyalty.point.", &s.PointExpiry)...)
|
|
return append(fields, expiryLoyaltyFields("loyalty.coin.", &s.CoinExpiry)...)
|
|
}
|
|
|
|
func expiryLoyaltyFields(prefix string, s *models.LoyaltyExpirySettings) []loyaltyField {
|
|
return []loyaltyField{
|
|
boolLoyaltyField(prefix+constants.LoyaltyExpiryEnabledSuffix, &s.Enabled, false),
|
|
enumLoyaltyField(prefix+constants.LoyaltyExpiryModeSuffix, &s.Mode, constants.LoyaltyExpiryModeDefault,
|
|
constants.LoyaltyExpiryModeFixedDate, constants.LoyaltyExpiryModeRolling),
|
|
dayOfYearListLoyaltyField(prefix+constants.LoyaltyExpiryFixedDatesSuffix, &s.FixedDates, constants.LoyaltyExpiryFixedDatesDefault),
|
|
intLoyaltyField(prefix+constants.LoyaltyExpiryGraceMonthsSuffix, &s.GraceMonths, constants.LoyaltyExpiryGraceMonthsDefault, 0, loyaltyExpiryMaxGraceMonths),
|
|
intLoyaltyField(prefix+constants.LoyaltyExpiryPeriodSuffix, &s.Period, constants.LoyaltyExpiryPeriodDefault, 1, noLoyaltyMax),
|
|
enumLoyaltyField(prefix+constants.LoyaltyExpiryUnitSuffix, &s.Unit, constants.LoyaltyExpiryUnitDefault,
|
|
constants.LoyaltyExpiryUnitDay, constants.LoyaltyExpiryUnitMonth),
|
|
boolLoyaltyField(prefix+constants.LoyaltyExpiryEndOfMonthSuffix, &s.EndOfMonth, false),
|
|
intLoyaltyField(prefix+constants.LoyaltyExpiryReminderDaysSuffix, &s.ReminderDays, constants.LoyaltyExpiryReminderDaysDefault, 0, noLoyaltyMax),
|
|
}
|
|
}
|
|
|
|
// loyaltyField is one setting key bound to the struct field that holds it. The same
|
|
// description drives reading (parse or default), validating, and writing (format).
|
|
type loyaltyField struct {
|
|
key string
|
|
// parse sets the field from a stored value, or reports it unusable.
|
|
parse func(raw string) bool
|
|
// reset sets the field to its default.
|
|
reset func()
|
|
// validate checks the field's current value.
|
|
validate func() error
|
|
// format is the field's current value as stored; nil means no stored value.
|
|
format func() *string
|
|
}
|
|
|
|
const noLoyaltyMax = int64(-1)
|
|
|
|
func intLoyaltyField(key string, v *int64, def, min, max int64) loyaltyField {
|
|
check := func(n int64) error {
|
|
if n < min {
|
|
return fmt.Errorf("%w: %s must be at least %d", ErrInvalidLoyaltySettings, key, min)
|
|
}
|
|
if max != noLoyaltyMax && n > max {
|
|
return fmt.Errorf("%w: %s must be at most %d", ErrInvalidLoyaltySettings, key, max)
|
|
}
|
|
return nil
|
|
}
|
|
return loyaltyField{
|
|
key: key,
|
|
parse: func(raw string) bool {
|
|
n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
|
if err != nil || check(n) != nil {
|
|
return false
|
|
}
|
|
*v = n
|
|
return true
|
|
},
|
|
reset: func() { *v = def },
|
|
validate: func() error { return check(*v) },
|
|
format: func() *string { s := strconv.FormatInt(*v, 10); return &s },
|
|
}
|
|
}
|
|
|
|
// percentLoyaltyField is a percentage from 0 to 100 with at most two decimals, so it
|
|
// is whole in hundredths and calculations with it stay in integers.
|
|
func percentLoyaltyField(key string, v *float64, def float64) loyaltyField {
|
|
check := func(n float64) error {
|
|
// The negated form also rejects NaN.
|
|
if !(n >= 0 && n <= 100) {
|
|
return fmt.Errorf("%w: %s must be between 0 and 100", ErrInvalidLoyaltySettings, key)
|
|
}
|
|
if math.Abs(n*100-math.Round(n*100)) > 1e-6 {
|
|
return fmt.Errorf("%w: %s must have at most two decimals", ErrInvalidLoyaltySettings, key)
|
|
}
|
|
return nil
|
|
}
|
|
return loyaltyField{
|
|
key: key,
|
|
parse: func(raw string) bool {
|
|
n, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
|
if err != nil || check(n) != nil {
|
|
return false
|
|
}
|
|
*v = n
|
|
return true
|
|
},
|
|
reset: func() { *v = def },
|
|
validate: func() error { return check(*v) },
|
|
format: func() *string { s := strconv.FormatFloat(*v, 'f', -1, 64); return &s },
|
|
}
|
|
}
|
|
|
|
// optionalIntLoyaltyField is a limit that may be unset, meaning no limit.
|
|
func optionalIntLoyaltyField(key string, v **int64, min int64) loyaltyField {
|
|
return loyaltyField{
|
|
key: key,
|
|
parse: func(raw string) bool {
|
|
n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
|
if err != nil || n < min {
|
|
return false
|
|
}
|
|
*v = &n
|
|
return true
|
|
},
|
|
reset: func() { *v = nil },
|
|
validate: func() error {
|
|
if *v != nil && **v < min {
|
|
return fmt.Errorf("%w: %s must be at least %d, or empty for no limit", ErrInvalidLoyaltySettings, key, min)
|
|
}
|
|
return nil
|
|
},
|
|
format: func() *string {
|
|
if *v == nil {
|
|
return nil
|
|
}
|
|
s := strconv.FormatInt(**v, 10)
|
|
return &s
|
|
},
|
|
}
|
|
}
|
|
|
|
func boolLoyaltyField(key string, v *bool, def bool) loyaltyField {
|
|
return loyaltyField{
|
|
key: key,
|
|
parse: func(raw string) bool {
|
|
b, err := strconv.ParseBool(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
*v = b
|
|
return true
|
|
},
|
|
reset: func() { *v = def },
|
|
validate: func() error { return nil },
|
|
format: func() *string { s := strconv.FormatBool(*v); return &s },
|
|
}
|
|
}
|
|
|
|
func enumLoyaltyField(key string, v *string, def string, allowed ...string) loyaltyField {
|
|
valid := func(s string) bool {
|
|
for _, a := range allowed {
|
|
if s == a {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
return loyaltyField{
|
|
key: key,
|
|
parse: func(raw string) bool {
|
|
s := strings.ToUpper(strings.TrimSpace(raw))
|
|
if !valid(s) {
|
|
return false
|
|
}
|
|
*v = s
|
|
return true
|
|
},
|
|
reset: func() { *v = def },
|
|
validate: func() error {
|
|
if !valid(*v) {
|
|
return fmt.Errorf("%w: %s must be one of %s", ErrInvalidLoyaltySettings, key, strings.Join(allowed, ", "))
|
|
}
|
|
return nil
|
|
},
|
|
format: func() *string { s := *v; return &s },
|
|
}
|
|
}
|
|
|
|
// dayOfYearListLoyaltyField is a non-empty set of days of the year, stored as sorted
|
|
// MM-DD values joined by commas, such as "06-30,12-31".
|
|
func dayOfYearListLoyaltyField(key string, v *[]string, def string) loyaltyField {
|
|
normalize := func(days []string) ([]string, error) {
|
|
out, err := normalizeDaysOfYear(days)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %s %v", ErrInvalidLoyaltySettings, key, err)
|
|
}
|
|
return out, nil
|
|
}
|
|
return loyaltyField{
|
|
key: key,
|
|
parse: func(raw string) bool {
|
|
days, err := normalize(strings.Split(raw, ","))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
*v = days
|
|
return true
|
|
},
|
|
reset: func() { *v, _ = normalizeDaysOfYear(strings.Split(def, ",")) },
|
|
validate: func() error {
|
|
days, err := normalize(*v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*v = days
|
|
return nil
|
|
},
|
|
format: func() *string {
|
|
days, err := normalizeDaysOfYear(*v)
|
|
if err != nil {
|
|
days = *v
|
|
}
|
|
s := strings.Join(days, ",")
|
|
return &s
|
|
},
|
|
}
|
|
}
|
|
|
|
// loadLoyaltyFields fills each field from its stored value, or its default when there
|
|
// is none. A stored value that cannot be used also falls back to the default, so a bad
|
|
// row can never reach a calculation, and it is logged so it gets fixed.
|
|
func loadLoyaltyFields(fields []loyaltyField, raw map[string]string, scope string) {
|
|
for _, f := range fields {
|
|
f.reset()
|
|
value, ok := raw[f.key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !f.parse(value) {
|
|
f.reset()
|
|
logger.NonContext.Warnf("Loyalty setting %s of %s has an unusable value %q; using the default", f.key, scope, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func validateLoyaltyFields(fields []loyaltyField) error {
|
|
for _, f := range fields {
|
|
if err := f.validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func equalOptionalStrings(a, b *string) bool {
|
|
if a == nil || b == nil {
|
|
return a == nil && b == nil
|
|
}
|
|
return *a == *b
|
|
}
|
|
|
|
// diffLoyaltyFields lists the keys whose effective value differs between current and
|
|
// next, with the stored old value (nil when the key was on its default). current and
|
|
// next list the same keys in the same order.
|
|
func diffLoyaltyFields(organizationID uuid.UUID, outletID *uuid.UUID, changedBy uuid.UUID, raw map[string]string, current, next []loyaltyField) []entities.LoyaltySettingChange {
|
|
var rows []entities.LoyaltySettingChange
|
|
for i, field := range next {
|
|
newValue := field.format()
|
|
if equalOptionalStrings(current[i].format(), newValue) {
|
|
continue
|
|
}
|
|
var oldValue *string
|
|
if v, ok := raw[field.key]; ok {
|
|
oldValue = &v
|
|
}
|
|
rows = append(rows, entities.LoyaltySettingChange{
|
|
OrganizationID: organizationID,
|
|
OutletID: outletID,
|
|
Key: field.key,
|
|
OldValue: oldValue,
|
|
NewValue: newValue,
|
|
ChangedBy: changedBy,
|
|
})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// PreviewOrganization validates new organization settings and lists what saving them
|
|
// 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, nil, err
|
|
}
|
|
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var current models.OrganizationLoyaltySettings
|
|
loadLoyaltyFields(organizationLoyaltyFields(¤t), raw, "organization "+organizationID.String())
|
|
rows := diffLoyaltyFields(organizationID, nil, uuid.Nil, raw, organizationLoyaltyFields(¤t), organizationLoyaltyFields(&settings))
|
|
changes := make([]models.LoyaltySettingChange, 0, len(rows))
|
|
for _, row := range rows {
|
|
changes = append(changes, loyaltySettingChangeModel(row))
|
|
}
|
|
activations, err := p.expiryActivations(ctx, organizationID, current, settings, false)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return changes, activations, nil
|
|
}
|