feat(loyalty): typed loyalty settings with change history
Adds LoyaltySettingsProcessor (docs/prd-point-coin.md F1, F2, F12, PC-109). Reading returns typed settings for an outlet (earning per currency, paying with EnakPoint) and for an organization (point value, exchange rate, transfers, and the expiry settings awaiting note N4). A key that was never set takes the PRD default. A stored value that is unusable, such as an earn_per_amount of 0 that would divide by zero, also falls back to the default and is logged, so a bad row never reaches a calculation. Writing takes the whole settings struct, validates every rule in the PRD before touching the database, and stores and records in loyalty_setting_changes only the keys whose effective value changes: old value (NULL while it was on its default), new value, and who changed it. Clearing a limit deletes the stored value. Each save runs in one transaction under an advisory lock per outlet or organization, so two saves at once cannot both compute their change from the same old value. The outlet must belong to the caller's organization. Every key is described once (key, default, valid range, bound field), and reading, validating and diffing all use that description. GET /customer/wallet now reads the point value through this processor; the minimal organization settings repository from PC-106 is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
040780cd2d
commit
39e47ff0e6
@@ -0,0 +1,399 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
func NewLoyaltySettingsProcessor(repo repository.LoyaltySettingsRepository, tx TxRunner) *LoyaltySettingsProcessor {
|
||||
return &LoyaltySettingsProcessor{repo: repo, tx: tx}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organizationID, changedBy uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) {
|
||||
if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var changes []models.LoyaltySettingChange
|
||||
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)
|
||||
})
|
||||
return err
|
||||
})
|
||||
return changes, err
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
if err := store(field.key, newValue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows = append(rows, entities.LoyaltySettingChange{
|
||||
OrganizationID: organizationID,
|
||||
OutletID: outletID,
|
||||
Key: field.key,
|
||||
OldValue: oldValue,
|
||||
NewValue: newValue,
|
||||
ChangedBy: changedBy,
|
||||
})
|
||||
}
|
||||
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),
|
||||
intLoyaltyField(constants.LoyaltyPointEarnPerAmountKey, &s.Point.EarnPerAmount, constants.LoyaltyPointEarnPerAmountDefault, 1, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyPointEarnValueKey, &s.Point.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyPointMinOrderAmountKey, &s.Point.MinOrderAmount, 0, 0, noLoyaltyMax),
|
||||
optionalIntLoyaltyField(constants.LoyaltyPointMaxPerOrderKey, &s.Point.MaxPerOrder, 0),
|
||||
|
||||
boolLoyaltyField(constants.LoyaltyCoinEnabledKey, &s.Coin.Enabled, false),
|
||||
intLoyaltyField(constants.LoyaltyCoinEarnPerAmountKey, &s.Coin.EarnPerAmount, constants.LoyaltyCoinEarnPerAmountDefault, 1, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyCoinEarnValueKey, &s.Coin.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyCoinMinOrderAmountKey, &s.Coin.MinOrderAmount, 0, 0, noLoyaltyMax),
|
||||
optionalIntLoyaltyField(constants.LoyaltyCoinMaxPerOrderKey, &s.Coin.MaxPerOrder, 0),
|
||||
|
||||
boolLoyaltyField(constants.LoyaltyPointAcceptPaymentKey, &s.PointPayment.AcceptPayment, false),
|
||||
intLoyaltyField(constants.LoyaltyPointMinPaymentPointsKey, &s.PointPayment.MinPaymentPoints, constants.LoyaltyMinPaymentPointsDefault, 1, noLoyaltyMax),
|
||||
intLoyaltyField(constants.LoyaltyPointMaxPaymentPercentKey, &s.PointPayment.MaxPaymentPercent, constants.LoyaltyMaxPaymentPercentDefault, 0, 100),
|
||||
}
|
||||
}
|
||||
|
||||
// organizationLoyaltyFields describes every organization key (F2, F12), 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),
|
||||
}
|
||||
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),
|
||||
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 },
|
||||
}
|
||||
}
|
||||
|
||||
// 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 },
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Some processors log; the server sets the logger up at startup.
|
||||
logger.Setup("fatal", "json")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// loyaltyRepoFake keeps settings in maps. It does not check for a transaction.
|
||||
type loyaltyRepoFake struct {
|
||||
outletOrg map[uuid.UUID]uuid.UUID
|
||||
outletValues map[string]string
|
||||
orgValues map[string]string
|
||||
changes []entities.LoyaltySettingChange
|
||||
locks []uuid.UUID
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) GetOutletValues(context.Context, uuid.UUID) (map[string]string, error) {
|
||||
return copyStringMap(f.outletValues), nil
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) GetOrganizationValues(context.Context, uuid.UUID) (map[string]string, error) {
|
||||
return copyStringMap(f.orgValues), nil
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) OutletOrganizationID(_ context.Context, outletID uuid.UUID) (uuid.UUID, error) {
|
||||
org, ok := f.outletOrg[outletID]
|
||||
if !ok {
|
||||
return uuid.Nil, repository.ErrLoyaltyOutletNotFound
|
||||
}
|
||||
return org, nil
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) LockScope(_ context.Context, id uuid.UUID) error {
|
||||
f.locks = append(f.locks, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) SetOutletValue(_ context.Context, _ uuid.UUID, key string, value *string) error {
|
||||
f.outletValues = setOptional(f.outletValues, key, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) SetOrganizationValue(_ context.Context, _ uuid.UUID, key string, value *string) error {
|
||||
f.orgValues = setOptional(f.orgValues, key, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) InsertChanges(_ context.Context, changes []entities.LoyaltySettingChange) error {
|
||||
f.changes = append(f.changes, changes...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) ListChanges(context.Context, repository.LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) {
|
||||
return f.changes, int64(len(f.changes)), nil
|
||||
}
|
||||
|
||||
func copyStringMap(m map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func setOptional(m map[string]string, key string, value *string) map[string]string {
|
||||
if m == nil {
|
||||
m = map[string]string{}
|
||||
}
|
||||
if value == nil {
|
||||
delete(m, key)
|
||||
} else {
|
||||
m[key] = *value
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// txRunnerFake runs fn in place.
|
||||
type txRunnerFake struct{}
|
||||
|
||||
func (txRunnerFake) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
func TestLoyaltySettings_OutletWithoutSettingsGetsEveryDefault(t *testing.T) {
|
||||
p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{})
|
||||
s, err := p.Outlet(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, models.OutletLoyaltySettings{
|
||||
Point: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 100, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 25000, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||
PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: false, MinPaymentPoints: 1, MaxPaymentPercent: 100},
|
||||
}, *s)
|
||||
}
|
||||
|
||||
func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing.T) {
|
||||
p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{})
|
||||
s, err := p.Organization(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
expiry := models.LoyaltyExpirySettings{Enabled: false, Period: 12, Unit: "MONTH", EndOfMonth: false, ReminderDays: 7}
|
||||
assert.Equal(t, models.OrganizationLoyaltySettings{
|
||||
PointValue: 1,
|
||||
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1},
|
||||
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
|
||||
PointExpiry: expiry,
|
||||
CoinExpiry: expiry,
|
||||
}, *s)
|
||||
}
|
||||
|
||||
func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
|
||||
repo := &loyaltyRepoFake{
|
||||
outletValues: map[string]string{
|
||||
constants.LoyaltyPointEnabledKey: "true",
|
||||
constants.LoyaltyPointEarnPerAmountKey: " 1000 ",
|
||||
constants.LoyaltyPointMaxPerOrderKey: "500",
|
||||
constants.LoyaltyPointMaxPaymentPercentKey: "50",
|
||||
"loyalty.unknown": "ignored",
|
||||
},
|
||||
orgValues: map[string]string{
|
||||
constants.LoyaltyPointValueKey: "100",
|
||||
constants.LoyaltyTransferDailyLimitKey: "10000",
|
||||
"loyalty.coin.expiry_unit": "day",
|
||||
},
|
||||
}
|
||||
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||
|
||||
outlet, err := p.Outlet(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.True(t, outlet.Point.Enabled)
|
||||
assert.Equal(t, int64(1000), outlet.Point.EarnPerAmount)
|
||||
assert.Equal(t, int64(500), *outlet.Point.MaxPerOrder)
|
||||
assert.Equal(t, int64(50), outlet.PointPayment.MaxPaymentPercent)
|
||||
|
||||
org, err := p.Organization(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(100), org.PointValue)
|
||||
assert.Equal(t, int64(10000), *org.Transfer.DailyLimit)
|
||||
assert.Equal(t, "DAY", org.CoinExpiry.Unit)
|
||||
assert.Equal(t, "MONTH", org.PointExpiry.Unit)
|
||||
}
|
||||
|
||||
// A bad row must never reach a calculation: earn_per_amount 0 would divide by zero.
|
||||
func TestLoyaltySettings_UnusableStoredValuesFallBackToDefault(t *testing.T) {
|
||||
repo := &loyaltyRepoFake{
|
||||
outletValues: map[string]string{
|
||||
constants.LoyaltyPointEnabledKey: "yes please",
|
||||
constants.LoyaltyPointEarnPerAmountKey: "0",
|
||||
constants.LoyaltyCoinEarnValueKey: "-1",
|
||||
constants.LoyaltyPointMaxPerOrderKey: "abc",
|
||||
constants.LoyaltyPointMaxPaymentPercentKey: "150",
|
||||
},
|
||||
orgValues: map[string]string{
|
||||
constants.LoyaltyPointValueKey: "0",
|
||||
"loyalty.point.expiry_unit": "YEAR",
|
||||
"loyalty.point.expiry_period": "",
|
||||
},
|
||||
}
|
||||
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||
|
||||
outlet, err := p.Outlet(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.False(t, outlet.Point.Enabled)
|
||||
assert.Equal(t, int64(100), outlet.Point.EarnPerAmount)
|
||||
assert.Equal(t, int64(1), outlet.Coin.EarnValue)
|
||||
assert.Nil(t, outlet.Point.MaxPerOrder)
|
||||
assert.Equal(t, int64(100), outlet.PointPayment.MaxPaymentPercent)
|
||||
|
||||
for name, raw := range map[string]string{"not set": "", "garbage": "abc", "zero": "0", "negative": "-5"} {
|
||||
repo.orgValues = map[string]string{}
|
||||
if raw != "" {
|
||||
repo.orgValues[constants.LoyaltyPointValueKey] = raw
|
||||
}
|
||||
value, err := p.PointValue(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, constants.LoyaltyPointValueDefault, value, name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoyaltySettings_UpdateRecordsOnlyRealChanges(t *testing.T) {
|
||||
org, outlet, admin := uuid.New(), uuid.New(), uuid.New()
|
||||
repo := &loyaltyRepoFake{
|
||||
outletOrg: map[uuid.UUID]uuid.UUID{outlet: org},
|
||||
outletValues: map[string]string{constants.LoyaltyPointEarnPerAmountKey: "200"},
|
||||
}
|
||||
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||
ctx := context.Background()
|
||||
|
||||
settings, err := p.Outlet(ctx, outlet)
|
||||
require.NoError(t, err)
|
||||
settings.Point.Enabled = true // default false -> true
|
||||
settings.Point.EarnPerAmount = 100 // stored 200 -> 100 (the default, but set explicitly)
|
||||
settings.Point.MaxPerOrder = ptr(int64(0)) // unset -> 0
|
||||
// Everything else stays on its default.
|
||||
|
||||
changes, err := p.UpdateOutlet(ctx, org, outlet, admin, *settings)
|
||||
require.NoError(t, err)
|
||||
byKey := map[string]models.LoyaltySettingChange{}
|
||||
for _, c := range changes {
|
||||
byKey[c.Key] = c
|
||||
assert.Equal(t, admin, c.ChangedBy)
|
||||
assert.Equal(t, org, c.OrganizationID)
|
||||
assert.Equal(t, &outlet, c.OutletID)
|
||||
}
|
||||
require.Len(t, byKey, 3, "only the three keys that changed")
|
||||
assert.Nil(t, byKey[constants.LoyaltyPointEnabledKey].OldValue, "was on its default")
|
||||
assert.Equal(t, "true", *byKey[constants.LoyaltyPointEnabledKey].NewValue)
|
||||
assert.Equal(t, "200", *byKey[constants.LoyaltyPointEarnPerAmountKey].OldValue)
|
||||
assert.Equal(t, "100", *byKey[constants.LoyaltyPointEarnPerAmountKey].NewValue)
|
||||
assert.Equal(t, "0", *byKey[constants.LoyaltyPointMaxPerOrderKey].NewValue)
|
||||
assert.Len(t, repo.changes, 3)
|
||||
assert.Equal(t, []uuid.UUID{outlet}, repo.locks)
|
||||
|
||||
after, err := p.Outlet(ctx, outlet)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, *settings, *after)
|
||||
|
||||
// Saving the same settings again changes nothing and records nothing.
|
||||
changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, changes)
|
||||
assert.Len(t, repo.changes, 3)
|
||||
|
||||
// Clearing a limit removes the stored value, recorded as a change to nothing.
|
||||
after.Point.MaxPerOrder = nil
|
||||
changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, changes, 1)
|
||||
assert.Equal(t, "0", *changes[0].OldValue)
|
||||
assert.Nil(t, changes[0].NewValue)
|
||||
assert.NotContains(t, repo.outletValues, constants.LoyaltyPointMaxPerOrderKey)
|
||||
}
|
||||
|
||||
func TestLoyaltySettings_UpdateOrganization(t *testing.T) {
|
||||
org, admin := uuid.New(), uuid.New()
|
||||
repo := &loyaltyRepoFake{}
|
||||
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||
ctx := context.Background()
|
||||
|
||||
settings, err := p.Organization(ctx, org)
|
||||
require.NoError(t, err)
|
||||
settings.PointValue = 100
|
||||
settings.CoinExpiry.Enabled = true
|
||||
settings.CoinExpiry.Unit = constants.LoyaltyExpiryUnitDay
|
||||
|
||||
changes, err := p.UpdateOrganization(ctx, org, admin, *settings)
|
||||
require.NoError(t, err)
|
||||
keys := []string{}
|
||||
for _, c := range changes {
|
||||
keys = append(keys, c.Key)
|
||||
assert.Nil(t, c.OutletID, "organization settings have no outlet")
|
||||
}
|
||||
assert.ElementsMatch(t, []string{constants.LoyaltyPointValueKey, "loyalty.coin.expiry_enabled", "loyalty.coin.expiry_unit"}, keys)
|
||||
|
||||
value, err := p.PointValue(ctx, org)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(100), value)
|
||||
}
|
||||
|
||||
func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
|
||||
org, outlet := uuid.New(), uuid.New()
|
||||
repo := &loyaltyRepoFake{outletOrg: map[uuid.UUID]uuid.UUID{outlet: org}}
|
||||
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||
ctx := context.Background()
|
||||
|
||||
for name, mutate := range map[string]func(*models.OutletLoyaltySettings){
|
||||
"earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 },
|
||||
"negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 },
|
||||
"negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 },
|
||||
"negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) },
|
||||
"payment percent over 100": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = 101 },
|
||||
"negative payment percent": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = -1 },
|
||||
"min payment points 0": func(s *models.OutletLoyaltySettings) { s.PointPayment.MinPaymentPoints = 0 },
|
||||
} {
|
||||
s, err := p.Outlet(ctx, outlet)
|
||||
require.NoError(t, err)
|
||||
mutate(s)
|
||||
_, err = p.UpdateOutlet(ctx, org, outlet, uuid.New(), *s)
|
||||
assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name)
|
||||
}
|
||||
|
||||
for name, mutate := range map[string]func(*models.OrganizationLoyaltySettings){
|
||||
"point value 0": func(s *models.OrganizationLoyaltySettings) { s.PointValue = 0 },
|
||||
"exchange coin amount 0": func(s *models.OrganizationLoyaltySettings) { s.Exchange.CoinAmount = 0 },
|
||||
"transfer min 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.MinAmount = 0 },
|
||||
"daily limit 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.DailyLimit = ptr(int64(0)) },
|
||||
"expiry period 0": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Period = 0 },
|
||||
"expiry unit YEAR": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.Unit = "YEAR" },
|
||||
"negative reminder days": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.ReminderDays = -1 },
|
||||
} {
|
||||
s, err := p.Organization(ctx, org)
|
||||
require.NoError(t, err)
|
||||
mutate(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")
|
||||
assert.Empty(t, repo.locks, "invalid input is rejected before touching the database")
|
||||
|
||||
// An outlet of another organization.
|
||||
s, err := p.Outlet(ctx, outlet)
|
||||
require.NoError(t, err)
|
||||
_, err = p.UpdateOutlet(ctx, uuid.New(), outlet, uuid.New(), *s)
|
||||
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
|
||||
_, err = p.UpdateOutlet(ctx, org, uuid.New(), uuid.New(), *s)
|
||||
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -41,11 +40,17 @@ var walletDisplayLocation = func() *time.Location {
|
||||
// history and upcoming expiry (docs/prd-point-coin.md F6). It only reads.
|
||||
type WalletQueryProcessor struct {
|
||||
repo repository.WalletQueryRepository
|
||||
settings repository.OrganizationSettingRepository
|
||||
settings pointValueReader
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewWalletQueryProcessor(repo repository.WalletQueryRepository, settings repository.OrganizationSettingRepository) *WalletQueryProcessor {
|
||||
// pointValueReader gives the rupiah value of one EnakPoint in an organization.
|
||||
// LoyaltySettingsProcessor is one.
|
||||
type pointValueReader interface {
|
||||
PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error)
|
||||
}
|
||||
|
||||
func NewWalletQueryProcessor(repo repository.WalletQueryRepository, settings pointValueReader) *WalletQueryProcessor {
|
||||
return &WalletQueryProcessor{repo: repo, settings: settings, now: time.Now}
|
||||
}
|
||||
|
||||
@@ -61,7 +66,7 @@ func (p *WalletQueryProcessor) Summary(ctx context.Context, customerID uuid.UUID
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pointValue, err := p.pointValue(ctx, organizationID)
|
||||
pointValue, err := p.settings.PointValue(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -192,23 +197,6 @@ func (p *WalletQueryProcessor) recent(ctx context.Context, customerID uuid.UUID,
|
||||
return p.toTransactions(ctx, rows)
|
||||
}
|
||||
|
||||
// pointValue reads the organization's rupiah value of one EnakPoint. A missing or
|
||||
// unusable value falls back to the default rather than failing the whole screen.
|
||||
func (p *WalletQueryProcessor) pointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) {
|
||||
raw, ok, err := p.settings.Get(ctx, organizationID, constants.LoyaltyPointValueKey)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !ok {
|
||||
return constants.LoyaltyPointValueDefault, nil
|
||||
}
|
||||
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil || value < 1 {
|
||||
return constants.LoyaltyPointValueDefault, nil
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (p *WalletQueryProcessor) toTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) {
|
||||
return buildWalletTransactions(ctx, p.repo, rows)
|
||||
}
|
||||
|
||||
@@ -68,13 +68,8 @@ func (f *walletQueryRepoFake) ListLotsBySourceTransactions(_ context.Context, id
|
||||
|
||||
type orgSettingsFake map[string]string
|
||||
|
||||
func (f orgSettingsFake) Get(_ context.Context, _ uuid.UUID, key string) (string, bool, error) {
|
||||
v, ok := f[key]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func newWalletQueryTest(repo *walletQueryRepoFake, settings orgSettingsFake) *WalletQueryProcessor {
|
||||
p := NewWalletQueryProcessor(repo, settings)
|
||||
p := NewWalletQueryProcessor(repo, NewLoyaltySettingsProcessor(&loyaltyRepoFake{orgValues: settings}, txRunnerFake{}))
|
||||
p.now = func() time.Time { return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) }
|
||||
return p
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user