400 lines
15 KiB
Go
400 lines
15 KiB
Go
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
|
||
|
|
}
|