diff --git a/internal/app/app.go b/internal/app/app.go index 5fba67d..389a947 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -265,7 +265,7 @@ type repositories struct { cashAdvanceRepo *repository.CashAdvanceRepositoryImpl walletRepo repository.WalletRepository walletQueryRepo repository.WalletQueryRepository - organizationSettingRepo repository.OrganizationSettingRepository + loyaltySettingsRepo repository.LoyaltySettingsRepository } func (a *App) initRepositories() *repositories { @@ -323,7 +323,7 @@ func (a *App) initRepositories() *repositories { cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db), walletRepo: repository.NewWalletRepository(a.db), walletQueryRepo: repository.NewWalletQueryRepository(a.db), - organizationSettingRepo: repository.NewOrganizationSettingRepository(a.db), + loyaltySettingsRepo: repository.NewLoyaltySettingsRepository(a.db), } } @@ -374,6 +374,7 @@ type processors struct { cashAdvanceProcessor *processor.CashAdvanceProcessorImpl walletProcessor *processor.WalletProcessor walletAdminProcessor *processor.WalletAdminProcessor + loyaltySettingsProcessor *processor.LoyaltySettingsProcessor } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -418,7 +419,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo), campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo), customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()), - customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, repos.organizationSettingRepo), repos.gameRepo), + customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)), repos.gameRepo), otpProcessor: otpProcessor, fileClient: fileClient, inventoryMovementService: inventoryMovementService, @@ -428,6 +429,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo), cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo), walletProcessor: processor.NewWalletProcessor(repos.walletRepo), + loyaltySettingsProcessor: processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager), walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager), } } diff --git a/internal/constants/loyalty.go b/internal/constants/loyalty.go new file mode 100644 index 0000000..14ac2e6 --- /dev/null +++ b/internal/constants/loyalty.go @@ -0,0 +1,71 @@ +package constants + +// Loyalty setting keys (docs/prd-point-coin.md F1, F2, F12). Outlet keys live in +// outlet_settings and organization keys in organization_settings. A key that was +// never set takes the default in the PRD. + +// Per outlet (F1): what an order earns, and whether EnakPoint can pay. +const ( + LoyaltyPointEnabledKey = "loyalty.point.enabled" + LoyaltyPointEarnPerAmountKey = "loyalty.point.earn_per_amount" + LoyaltyPointEarnValueKey = "loyalty.point.earn_value" + LoyaltyPointMinOrderAmountKey = "loyalty.point.min_order_amount" + LoyaltyPointMaxPerOrderKey = "loyalty.point.max_per_order" + + LoyaltyCoinEnabledKey = "loyalty.coin.enabled" + LoyaltyCoinEarnPerAmountKey = "loyalty.coin.earn_per_amount" + LoyaltyCoinEarnValueKey = "loyalty.coin.earn_value" + LoyaltyCoinMinOrderAmountKey = "loyalty.coin.min_order_amount" + LoyaltyCoinMaxPerOrderKey = "loyalty.coin.max_per_order" + + LoyaltyPointAcceptPaymentKey = "loyalty.point.accept_payment" + LoyaltyPointMinPaymentPointsKey = "loyalty.point.min_payment_points" + LoyaltyPointMaxPaymentPercentKey = "loyalty.point.max_payment_percent" +) + +// Per organization (F2, F12): the value of EnakPoint, the exchange rate, transfers and +// expiry, which must be the same in every outlet. +const ( + LoyaltyPointValueKey = "loyalty.point.value" + + LoyaltyExchangeCoinAmountKey = "loyalty.exchange.coin_amount" + LoyaltyExchangePointAmountKey = "loyalty.exchange.point_amount" + + LoyaltyTransferEnabledKey = "loyalty.transfer.enabled" + LoyaltyTransferMinAmountKey = "loyalty.transfer.min_amount" + LoyaltyTransferMaxPerTransactionKey = "loyalty.transfer.max_per_transaction" + LoyaltyTransferDailyLimitKey = "loyalty.transfer.daily_limit" + + // Suffixes after "loyalty.point." or "loyalty.coin.". + LoyaltyExpiryEnabledSuffix = "expiry_enabled" + LoyaltyExpiryPeriodSuffix = "expiry_period" + LoyaltyExpiryUnitSuffix = "expiry_unit" + LoyaltyExpiryEndOfMonthSuffix = "expiry_end_of_month" + LoyaltyExpiryReminderDaysSuffix = "expiry_reminder_days" +) + +// Units of loyalty.{point,coin}.expiry_unit. +const ( + LoyaltyExpiryUnitDay = "DAY" + LoyaltyExpiryUnitMonth = "MONTH" +) + +// Defaults from the PRD. +const ( + LoyaltyPointEarnPerAmountDefault = int64(100) + LoyaltyCoinEarnPerAmountDefault = int64(25000) + LoyaltyEarnValueDefault = int64(1) + + LoyaltyMinPaymentPointsDefault = int64(1) + LoyaltyMaxPaymentPercentDefault = int64(100) + + LoyaltyPointValueDefault = int64(1) + LoyaltyExchangeAmountDefault = int64(1) + + LoyaltyTransferEnabledDefault = true + LoyaltyTransferMinAmountDefault = int64(1) + + LoyaltyExpiryPeriodDefault = int64(12) + LoyaltyExpiryUnitDefault = LoyaltyExpiryUnitMonth + LoyaltyExpiryReminderDaysDefault = int64(7) +) diff --git a/internal/constants/wallet.go b/internal/constants/wallet.go index 6182154..2892fdd 100644 --- a/internal/constants/wallet.go +++ b/internal/constants/wallet.go @@ -42,10 +42,3 @@ const ( WalletRefTypeLegacyTokens = "LEGACY_TOKENS" WalletRefTypeRewardRedemption = "REWARD_REDEMPTION" ) - -// Organization loyalty settings (docs/prd-point-coin.md F2), stored in -// organization_settings. A key that was never set takes its default. -const ( - LoyaltyPointValueKey = "loyalty.point.value" - LoyaltyPointValueDefault = int64(1) -) diff --git a/internal/entities/entities.go b/internal/entities/entities.go index e26763b..8ee2e10 100644 --- a/internal/entities/entities.go +++ b/internal/entities/entities.go @@ -49,6 +49,7 @@ func GetAllEntities() []interface{} { &WalletTransaction{}, &WalletLot{}, &WalletLotAllocation{}, + &LoyaltySettingChange{}, } } diff --git a/internal/entities/loyalty_setting_change.go b/internal/entities/loyalty_setting_change.go new file mode 100644 index 0000000..db5e8f2 --- /dev/null +++ b/internal/entities/loyalty_setting_change.go @@ -0,0 +1,34 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// LoyaltySettingChange records one change to a loyalty setting: which key, from what, +// to what, by whom (docs/prd-point-coin.md F2). Append-only. +type LoyaltySettingChange struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"` + // Nil for an organization setting. + OutletID *uuid.UUID `gorm:"type:uuid" json:"outlet_id"` + Key string `gorm:"not null;size:100" json:"key"` + // Nil when the key had no stored value, or has none any more. + OldValue *string `gorm:"type:text" json:"old_value"` + NewValue *string `gorm:"type:text" json:"new_value"` + ChangedBy uuid.UUID `gorm:"type:uuid;not null" json:"changed_by"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +func (c *LoyaltySettingChange) BeforeCreate(tx *gorm.DB) error { + if c.ID == uuid.Nil { + c.ID = uuid.New() + } + return nil +} + +func (LoyaltySettingChange) TableName() string { + return "loyalty_setting_changes" +} diff --git a/internal/handler/customer_wallet_db_test.go b/internal/handler/customer_wallet_db_test.go index e3713b2..d9e2467 100644 --- a/internal/handler/customer_wallet_db_test.go +++ b/internal/handler/customer_wallet_db_test.go @@ -97,7 +97,7 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) { })) h := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor( - processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), repository.NewOrganizationSettingRepository(db)), + processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), nil, ))) gin.SetMode(gin.TestMode) diff --git a/internal/handler/wallet_admin_db_test.go b/internal/handler/wallet_admin_db_test.go index 85d0adc..1e1a690 100644 --- a/internal/handler/wallet_admin_db_test.go +++ b/internal/handler/wallet_admin_db_test.go @@ -186,7 +186,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) { // The customer's own history shows the adjustment too, with the reason in the // description and without the admin's identity fields. customerView := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor( - processor.NewWalletQueryProcessor(queryRepo, repository.NewOrganizationSettingRepository(db)), nil))) + processor.NewWalletQueryProcessor(queryRepo, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), nil))) crouter := gin.New() crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions) rec := httptest.NewRecorder() diff --git a/internal/models/loyalty.go b/internal/models/loyalty.go new file mode 100644 index 0000000..69d0970 --- /dev/null +++ b/internal/models/loyalty.go @@ -0,0 +1,81 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// OutletLoyaltySettings are an outlet's loyalty settings (docs/prd-point-coin.md F1). +type OutletLoyaltySettings struct { + Point LoyaltyEarnSettings `json:"point"` + Coin LoyaltyEarnSettings `json:"coin"` + // Paying with EnakPoint. EnakCoin cannot pay, so it has no counterpart. + PointPayment LoyaltyPointPaymentSettings `json:"point_payment"` +} + +// LoyaltyEarnSettings is how much of one currency an order earns: +// floor(basis / EarnPerAmount) × EarnValue, nothing below MinOrderAmount, and at most +// MaxPerOrder when set. +type LoyaltyEarnSettings struct { + Enabled bool `json:"enabled"` + EarnPerAmount int64 `json:"earn_per_amount"` + EarnValue int64 `json:"earn_value"` + MinOrderAmount int64 `json:"min_order_amount"` + MaxPerOrder *int64 `json:"max_per_order"` +} + +type LoyaltyPointPaymentSettings struct { + AcceptPayment bool `json:"accept_payment"` + MinPaymentPoints int64 `json:"min_payment_points"` + // Largest share of the order total, 0–100, that EnakPoint may pay. + MaxPaymentPercent int64 `json:"max_payment_percent"` +} + +// OrganizationLoyaltySettings are the loyalty settings shared by every outlet of an +// organization (docs/prd-point-coin.md F2, F12). +type OrganizationLoyaltySettings struct { + // Rupiah value of one EnakPoint when paying. + PointValue int64 `json:"point_value"` + // CoinAmount EnakCoin exchange into PointAmount EnakPoint. + Exchange LoyaltyExchangeSettings `json:"exchange"` + Transfer LoyaltyTransferSettings `json:"transfer"` + PointExpiry LoyaltyExpirySettings `json:"point_expiry"` + CoinExpiry LoyaltyExpirySettings `json:"coin_expiry"` +} + +type LoyaltyExchangeSettings struct { + CoinAmount int64 `json:"coin_amount"` + PointAmount int64 `json:"point_amount"` +} + +type LoyaltyTransferSettings struct { + Enabled bool `json:"enabled"` + MinAmount int64 `json:"min_amount"` + MaxPerTransaction *int64 `json:"max_per_transaction"` + DailyLimit *int64 `json:"daily_limit"` +} + +// LoyaltyExpirySettings is how long one currency lasts once received. The expiry +// model is still open (note N4); these are only the stored settings. +type LoyaltyExpirySettings struct { + Enabled bool `json:"enabled"` + Period int64 `json:"period"` + // DAY or MONTH. + Unit string `json:"unit"` + EndOfMonth bool `json:"end_of_month"` + ReminderDays int64 `json:"reminder_days"` +} + +// LoyaltySettingChange is one row of the loyalty settings history. +type LoyaltySettingChange struct { + ID uuid.UUID `json:"id"` + OrganizationID uuid.UUID `json:"organization_id"` + OutletID *uuid.UUID `json:"outlet_id"` + Key string `json:"key"` + // Nil when the key had no stored value, that is it was on its default. + OldValue *string `json:"old_value"` + NewValue *string `json:"new_value"` + ChangedBy uuid.UUID `json:"changed_by"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/processor/loyalty_settings_processor.go b/internal/processor/loyalty_settings_processor.go new file mode 100644 index 0000000..330456f --- /dev/null +++ b/internal/processor/loyalty_settings_processor.go @@ -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 +} diff --git a/internal/processor/loyalty_settings_processor_test.go b/internal/processor/loyalty_settings_processor_test.go new file mode 100644 index 0000000..2cb1d9d --- /dev/null +++ b/internal/processor/loyalty_settings_processor_test.go @@ -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) +} diff --git a/internal/processor/wallet_query_processor.go b/internal/processor/wallet_query_processor.go index e1051dc..02ae40d 100644 --- a/internal/processor/wallet_query_processor.go +++ b/internal/processor/wallet_query_processor.go @@ -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) } diff --git a/internal/processor/wallet_query_processor_test.go b/internal/processor/wallet_query_processor_test.go index 573f3df..1a4a9aa 100644 --- a/internal/processor/wallet_query_processor_test.go +++ b/internal/processor/wallet_query_processor_test.go @@ -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 } diff --git a/internal/repository/loyalty_settings_repository.go b/internal/repository/loyalty_settings_repository.go new file mode 100644 index 0000000..4a0f0e0 --- /dev/null +++ b/internal/repository/loyalty_settings_repository.go @@ -0,0 +1,176 @@ +package repository + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" + + "apskel-pos-be/internal/entities" +) + +// ErrLoyaltyOutletNotFound means the outlet does not exist or belongs to another +// organization. +var ErrLoyaltyOutletNotFound = errors.New("loyalty settings: outlet not found") + +// LoyaltySettingChangeFilter narrows the loyalty settings history. +type LoyaltySettingChangeFilter struct { + OrganizationID uuid.UUID + // Nil for the whole organization, outlets included. + OutletID *uuid.UUID + Offset int + Limit int +} + +// LoyaltySettingsRepository stores the raw loyalty settings (the loyalty.* keys of +// outlet_settings and organization_settings) and their history +// (docs/prd-point-coin.md F1, F2). The loyalty settings processor turns them into +// typed values and is the only writer. +type LoyaltySettingsRepository interface { + // GetOutletValues returns the stored loyalty keys of an outlet. A key with no row, + // or a NULL value, is absent. + GetOutletValues(ctx context.Context, outletID uuid.UUID) (map[string]string, error) + GetOrganizationValues(ctx context.Context, organizationID uuid.UUID) (map[string]string, error) + // OutletOrganizationID returns ErrLoyaltyOutletNotFound for an unknown outlet. + OutletOrganizationID(ctx context.Context, outletID uuid.UUID) (uuid.UUID, error) + + // LockScope serializes writers of one outlet's or one organization's settings + // until the transaction ends, so two saves at once cannot both compute their + // changes from the same old values. + LockScope(ctx context.Context, scopeID uuid.UUID) error + // SetOutletValue stores a value, or removes the key when value is nil so it falls + // back to its default. + SetOutletValue(ctx context.Context, outletID uuid.UUID, key string, value *string) error + SetOrganizationValue(ctx context.Context, organizationID uuid.UUID, key string, value *string) error + InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error + // ListChanges returns a page of history, newest first, and the total count. + ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) +} + +type loyaltySettingsRepository struct { + db *gorm.DB +} + +func NewLoyaltySettingsRepository(db *gorm.DB) LoyaltySettingsRepository { + return &loyaltySettingsRepository{db: db} +} + +// requireTx returns the caller's transaction. Writes must run in one, together with +// the history rows they produce. +func (r *loyaltySettingsRepository) requireTx(ctx context.Context) (*gorm.DB, error) { + if tx, ok := ctx.Value(txKey).(*gorm.DB); ok && tx != nil { + return tx.WithContext(ctx), nil + } + return nil, errors.New("loyalty settings: write must run inside a transaction") +} + +func (r *loyaltySettingsRepository) values(ctx context.Context, table, scopeColumn string, scopeID uuid.UUID) (map[string]string, error) { + var rows []struct { + Key string + Value string + } + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table(table). + Select("key, value"). + Where(scopeColumn+" = ? AND key LIKE 'loyalty.%' AND value IS NOT NULL", scopeID). + Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", table, err) + } + values := make(map[string]string, len(rows)) + for _, row := range rows { + values[row.Key] = row.Value + } + return values, nil +} + +func (r *loyaltySettingsRepository) GetOutletValues(ctx context.Context, outletID uuid.UUID) (map[string]string, error) { + return r.values(ctx, "outlet_settings", "outlet_id", outletID) +} + +func (r *loyaltySettingsRepository) GetOrganizationValues(ctx context.Context, organizationID uuid.UUID) (map[string]string, error) { + return r.values(ctx, "organization_settings", "organization_id", organizationID) +} + +func (r *loyaltySettingsRepository) OutletOrganizationID(ctx context.Context, outletID uuid.UUID) (uuid.UUID, error) { + var ids []string + err := DBFromContext(ctx, r.db).WithContext(ctx). + Table("outlets"). + Where("id = ?", outletID). + Limit(1). + Pluck("organization_id::text", &ids).Error + if err != nil { + return uuid.Nil, fmt.Errorf("failed to get outlet organization: %w", err) + } + if len(ids) == 0 { + return uuid.Nil, ErrLoyaltyOutletNotFound + } + return uuid.Parse(ids[0]) +} + +func (r *loyaltySettingsRepository) LockScope(ctx context.Context, scopeID uuid.UUID) error { + db, err := r.requireTx(ctx) + if err != nil { + return err + } + return db.Exec(`SELECT pg_advisory_xact_lock(hashtextextended(?, 0))`, "loyalty-settings:"+scopeID.String()).Error +} + +func (r *loyaltySettingsRepository) SetOutletValue(ctx context.Context, outletID uuid.UUID, key string, value *string) error { + return r.setValue(ctx, "outlet_settings", "outlet_id", outletID, key, value) +} + +func (r *loyaltySettingsRepository) SetOrganizationValue(ctx context.Context, organizationID uuid.UUID, key string, value *string) error { + return r.setValue(ctx, "organization_settings", "organization_id", organizationID, key, value) +} + +// setValue upserts or deletes one key. table and scopeColumn are never user input. +func (r *loyaltySettingsRepository) setValue(ctx context.Context, table, scopeColumn string, scopeID uuid.UUID, key string, value *string) error { + db, err := r.requireTx(ctx) + if err != nil { + return err + } + if value == nil { + err = db.Exec(`DELETE FROM `+table+` WHERE `+scopeColumn+` = ? AND key = ?`, scopeID, key).Error + } else { + err = db.Exec(`INSERT INTO `+table+` (`+scopeColumn+`, key, value) VALUES (?, ?, ?) + ON CONFLICT (`+scopeColumn+`, key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, + scopeID, key, *value).Error + } + if err != nil { + return fmt.Errorf("failed to store %s in %s: %w", key, table, err) + } + return nil +} + +func (r *loyaltySettingsRepository) InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error { + if len(changes) == 0 { + return nil + } + db, err := r.requireTx(ctx) + if err != nil { + return err + } + return db.Create(&changes).Error +} + +func (r *loyaltySettingsRepository) ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) { + query := DBFromContext(ctx, r.db).WithContext(ctx). + Model(&entities.LoyaltySettingChange{}). + Where("organization_id = ?", filter.OrganizationID) + if filter.OutletID != nil { + query = query.Where("outlet_id = ?", *filter.OutletID) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count loyalty setting changes: %w", err) + } + var changes []entities.LoyaltySettingChange + err := query.Order("created_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&changes).Error + if err != nil { + return nil, 0, fmt.Errorf("failed to list loyalty setting changes: %w", err) + } + return changes, total, nil +} diff --git a/internal/repository/loyalty_settings_repository_test.go b/internal/repository/loyalty_settings_repository_test.go new file mode 100644 index 0000000..f8fb7d6 --- /dev/null +++ b/internal/repository/loyalty_settings_repository_test.go @@ -0,0 +1,149 @@ +package repository_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "apskel-pos-be/internal/constants" + applogger "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/repository" +) + +// Reads and writes loyalty settings through the processor against Postgres. Needs +// TEST_DATABASE_URL pointing at a migrated database; see wallet_repository_test.go. +func TestLoyaltySettings_AgainstPostgres(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + applogger.Setup("fatal", "json") + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + ctx := context.Background() + + org, otherOrg, outlet, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New() + exec := func(q string, args ...any) { + t.Helper() + require.NoError(t, db.Exec(q, args...).Error) + } + exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'loyalty test', 'basic'), (?, 'other', 'basic')`, org, otherOrg) + exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org) + // A setting that is not a loyalty setting must be left alone. + exec(`INSERT INTO outlet_settings (outlet_id, key, value) VALUES (?, 'receipt.footer', 'Terima kasih')`, outlet) + t.Cleanup(func() { + db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg}) + db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet) + db.Exec(`DELETE FROM organization_settings WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg}) + db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet) + db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg}) + }) + + repo := repository.NewLoyaltySettingsRepository(db) + p := processor.NewLoyaltySettingsProcessor(repo, repository.NewTxManager(db)) + + // No settings: every PRD default. + settings, err := p.Outlet(ctx, outlet) + require.NoError(t, err) + assert.False(t, settings.Point.Enabled) + assert.Equal(t, int64(100), settings.Point.EarnPerAmount) + assert.Equal(t, int64(25000), settings.Coin.EarnPerAmount) + assert.Nil(t, settings.Point.MaxPerOrder) + assert.Equal(t, int64(100), settings.PointPayment.MaxPaymentPercent) + + // Change three keys. + settings.Point.Enabled = true + settings.Point.EarnPerAmount = 1000 + settings.Coin.MaxPerOrder = ptrInt64(5) + changes, err := p.UpdateOutlet(ctx, org, outlet, admin, *settings) + require.NoError(t, err) + assert.Len(t, changes, 3) + + var stored []struct{ Key, Value string } + require.NoError(t, db.Raw(`SELECT key, value FROM outlet_settings WHERE outlet_id = ? ORDER BY key`, outlet).Scan(&stored).Error) + assert.Equal(t, []struct{ Key, Value string }{ + {constants.LoyaltyCoinMaxPerOrderKey, "5"}, + {constants.LoyaltyPointEarnPerAmountKey, "1000"}, + {constants.LoyaltyPointEnabledKey, "true"}, + {"receipt.footer", "Terima kasih"}, + }, stored, "only the changed keys are stored, next to the untouched non-loyalty key") + + history, err := p.ListChanges(ctx, org, &outlet, 1, 20) + require.NoError(t, err) + require.Len(t, history.Data, 3) + for _, c := range history.Data { + assert.Equal(t, admin, c.ChangedBy) + assert.Nil(t, c.OldValue, "%s was on its default", c.Key) + assert.NotNil(t, c.NewValue) + } + + // A second change records the old value. + settings.Point.EarnPerAmount = 500 + changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *settings) + require.NoError(t, err) + require.Len(t, changes, 1) + assert.Equal(t, "1000", *changes[0].OldValue) + assert.Equal(t, "500", *changes[0].NewValue) + + // Another organization cannot change this outlet. + _, err = p.UpdateOutlet(ctx, otherOrg, outlet, admin, *settings) + assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound) + + // Writing outside a transaction is refused. + assert.Error(t, repo.SetOutletValue(ctx, outlet, constants.LoyaltyPointEnabledKey, nil)) + + // A save must wait while another save of the same organization holds the lock, so + // two admins saving at once cannot both compute their change from the same old + // value. Hold the lock in another transaction and check the save blocks until it + // is released. + txm := repository.NewTxManager(db) + holding, release, holderDone := make(chan struct{}), make(chan struct{}), make(chan error, 1) + go func() { + holderDone <- txm.WithTransaction(ctx, func(ctx context.Context) error { + if err := repo.LockScope(ctx, org); err != nil { + return err + } + close(holding) + <-release + return nil + }) + }() + <-holding + + orgSettings, err := p.Organization(ctx, org) + require.NoError(t, err) + orgSettings.PointValue = 200 + saved := make(chan error, 1) + go func() { + _, err := p.UpdateOrganization(ctx, org, admin, *orgSettings) + saved <- err + }() + select { + case err := <-saved: + t.Fatalf("the save finished while another transaction held the lock (err: %v)", err) + case <-time.After(300 * time.Millisecond): + } + close(release) + require.NoError(t, <-holderDone) + require.NoError(t, <-saved) + + value, err := p.PointValue(ctx, org) + require.NoError(t, err) + assert.Equal(t, int64(200), value) + + // The whole organization's history includes the outlet's changes. + all, err := p.ListChanges(ctx, org, nil, 1, 100) + require.NoError(t, err) + assert.EqualValues(t, 5, all.Pagination.Total, "four outlet changes and one organization change") +} + +func ptrInt64(v int64) *int64 { return &v } diff --git a/internal/repository/organization_setting_repository.go b/internal/repository/organization_setting_repository.go deleted file mode 100644 index 7c47697..0000000 --- a/internal/repository/organization_setting_repository.go +++ /dev/null @@ -1,41 +0,0 @@ -package repository - -import ( - "context" - "fmt" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -// OrganizationSettingRepository reads organization_settings, the key-value settings -// that apply to every outlet of an organization (docs/prd-point-coin.md F2). -type OrganizationSettingRepository interface { - // Get returns the stored value of a key and whether one is stored. A key that has - // never been set is not an error: the caller falls back to the key's default. - Get(ctx context.Context, organizationID uuid.UUID, key string) (string, bool, error) -} - -type organizationSettingRepository struct { - db *gorm.DB -} - -func NewOrganizationSettingRepository(db *gorm.DB) OrganizationSettingRepository { - return &organizationSettingRepository{db: db} -} - -func (r *organizationSettingRepository) Get(ctx context.Context, organizationID uuid.UUID, key string) (string, bool, error) { - var values []*string - err := DBFromContext(ctx, r.db).WithContext(ctx). - Table("organization_settings"). - Where("organization_id = ? AND key = ?", organizationID, key). - Limit(1). - Pluck("value", &values).Error - if err != nil { - return "", false, fmt.Errorf("failed to get organization setting %s: %w", key, err) - } - if len(values) == 0 || values[0] == nil { - return "", false, nil - } - return *values[0], true, nil -}