From 39e47ff0e6cf23e1f7b148eda78fd3dcab2918ba Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 30 Sep 2026 10:23:08 +0700 Subject: [PATCH] feat(loyalty): typed loyalty settings with change history Adds LoyaltySettingsProcessor (docs/prd-point-coin.md F1, F2, F12, PC-109). Reading returns typed settings for an outlet (earning per currency, paying with EnakPoint) and for an organization (point value, exchange rate, transfers, and the expiry settings awaiting note N4). A key that was never set takes the PRD default. A stored value that is unusable, such as an earn_per_amount of 0 that would divide by zero, also falls back to the default and is logged, so a bad row never reaches a calculation. Writing takes the whole settings struct, validates every rule in the PRD before touching the database, and stores and records in loyalty_setting_changes only the keys whose effective value changes: old value (NULL while it was on its default), new value, and who changed it. Clearing a limit deletes the stored value. Each save runs in one transaction under an advisory lock per outlet or organization, so two saves at once cannot both compute their change from the same old value. The outlet must belong to the caller's organization. Every key is described once (key, default, valid range, bound field), and reading, validating and diffing all use that description. GET /customer/wallet now reads the point value through this processor; the minimal organization settings repository from PC-106 is removed. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 8 +- internal/constants/loyalty.go | 71 ++++ internal/constants/wallet.go | 7 - internal/entities/entities.go | 1 + internal/entities/loyalty_setting_change.go | 34 ++ internal/handler/customer_wallet_db_test.go | 2 +- internal/handler/wallet_admin_db_test.go | 2 +- internal/models/loyalty.go | 81 ++++ .../processor/loyalty_settings_processor.go | 399 ++++++++++++++++++ .../loyalty_settings_processor_test.go | 322 ++++++++++++++ internal/processor/wallet_query_processor.go | 30 +- .../processor/wallet_query_processor_test.go | 7 +- .../repository/loyalty_settings_repository.go | 176 ++++++++ .../loyalty_settings_repository_test.go | 149 +++++++ .../organization_setting_repository.go | 41 -- 15 files changed, 1250 insertions(+), 80 deletions(-) create mode 100644 internal/constants/loyalty.go create mode 100644 internal/entities/loyalty_setting_change.go create mode 100644 internal/models/loyalty.go create mode 100644 internal/processor/loyalty_settings_processor.go create mode 100644 internal/processor/loyalty_settings_processor_test.go create mode 100644 internal/repository/loyalty_settings_repository.go create mode 100644 internal/repository/loyalty_settings_repository_test.go delete mode 100644 internal/repository/organization_setting_repository.go 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 -}