package processor import ( "context" "errors" "fmt" "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), 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), 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 }, } } // 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 }