Main #33
+5
-3
@@ -265,7 +265,7 @@ type repositories struct {
|
|||||||
cashAdvanceRepo *repository.CashAdvanceRepositoryImpl
|
cashAdvanceRepo *repository.CashAdvanceRepositoryImpl
|
||||||
walletRepo repository.WalletRepository
|
walletRepo repository.WalletRepository
|
||||||
walletQueryRepo repository.WalletQueryRepository
|
walletQueryRepo repository.WalletQueryRepository
|
||||||
organizationSettingRepo repository.OrganizationSettingRepository
|
loyaltySettingsRepo repository.LoyaltySettingsRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) initRepositories() *repositories {
|
func (a *App) initRepositories() *repositories {
|
||||||
@@ -323,7 +323,7 @@ func (a *App) initRepositories() *repositories {
|
|||||||
cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db),
|
cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db),
|
||||||
walletRepo: repository.NewWalletRepository(a.db),
|
walletRepo: repository.NewWalletRepository(a.db),
|
||||||
walletQueryRepo: repository.NewWalletQueryRepository(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
|
cashAdvanceProcessor *processor.CashAdvanceProcessorImpl
|
||||||
walletProcessor *processor.WalletProcessor
|
walletProcessor *processor.WalletProcessor
|
||||||
walletAdminProcessor *processor.WalletAdminProcessor
|
walletAdminProcessor *processor.WalletAdminProcessor
|
||||||
|
loyaltySettingsProcessor *processor.LoyaltySettingsProcessor
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
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),
|
campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo),
|
||||||
campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo),
|
campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo),
|
||||||
customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()),
|
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,
|
otpProcessor: otpProcessor,
|
||||||
fileClient: fileClient,
|
fileClient: fileClient,
|
||||||
inventoryMovementService: inventoryMovementService,
|
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),
|
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo),
|
||||||
cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo),
|
cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo),
|
||||||
walletProcessor: processor.NewWalletProcessor(repos.walletRepo),
|
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),
|
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -42,10 +42,3 @@ const (
|
|||||||
WalletRefTypeLegacyTokens = "LEGACY_TOKENS"
|
WalletRefTypeLegacyTokens = "LEGACY_TOKENS"
|
||||||
WalletRefTypeRewardRedemption = "REWARD_REDEMPTION"
|
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)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func GetAllEntities() []interface{} {
|
|||||||
&WalletTransaction{},
|
&WalletTransaction{},
|
||||||
&WalletLot{},
|
&WalletLot{},
|
||||||
&WalletLotAllocation{},
|
&WalletLotAllocation{},
|
||||||
|
&LoyaltySettingChange{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -97,7 +97,7 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
h := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
|
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,
|
nil,
|
||||||
)))
|
)))
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
|
|||||||
// The customer's own history shows the adjustment too, with the reason in the
|
// The customer's own history shows the adjustment too, with the reason in the
|
||||||
// description and without the admin's identity fields.
|
// description and without the admin's identity fields.
|
||||||
customerView := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
|
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 := gin.New()
|
||||||
crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions)
|
crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
package processor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
"apskel-pos-be/internal/logger"
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
"apskel-pos-be/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidLoyaltySettings wraps every rejection of a setting that breaks the rules in
|
||||||
|
// docs/prd-point-coin.md F1, F2 and F12.
|
||||||
|
var ErrInvalidLoyaltySettings = errors.New("invalid loyalty settings")
|
||||||
|
|
||||||
|
// LoyaltySettingsProcessor reads and writes the loyalty settings of outlets (F1) and
|
||||||
|
// organizations (F2, F12). Reads always return every setting, with the PRD default for
|
||||||
|
// a key that was never set, so callers never handle a missing value. Every write
|
||||||
|
// records what changed in loyalty_setting_changes.
|
||||||
|
type LoyaltySettingsProcessor struct {
|
||||||
|
repo repository.LoyaltySettingsRepository
|
||||||
|
tx TxRunner
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLoyaltySettingsProcessor(repo repository.LoyaltySettingsRepository, tx TxRunner) *LoyaltySettingsProcessor {
|
||||||
|
return &LoyaltySettingsProcessor{repo: repo, tx: tx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outlet returns an outlet's settings.
|
||||||
|
func (p *LoyaltySettingsProcessor) Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) {
|
||||||
|
raw, err := p.repo.GetOutletValues(ctx, outletID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var s models.OutletLoyaltySettings
|
||||||
|
loadLoyaltyFields(outletLoyaltyFields(&s), raw, "outlet "+outletID.String())
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organization returns an organization's settings.
|
||||||
|
func (p *LoyaltySettingsProcessor) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
|
||||||
|
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var s models.OrganizationLoyaltySettings
|
||||||
|
loadLoyaltyFields(organizationLoyaltyFields(&s), raw, "organization "+organizationID.String())
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PointValue is the rupiah value of one EnakPoint in an organization.
|
||||||
|
func (p *LoyaltySettingsProcessor) PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) {
|
||||||
|
s, err := p.Organization(ctx, organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return s.PointValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOutlet replaces an outlet's settings and returns what changed. The outlet must
|
||||||
|
// belong to the organization; otherwise repository.ErrLoyaltyOutletNotFound.
|
||||||
|
func (p *LoyaltySettingsProcessor) UpdateOutlet(ctx context.Context, organizationID, outletID, changedBy uuid.UUID, settings models.OutletLoyaltySettings) ([]models.LoyaltySettingChange, error) {
|
||||||
|
if err := validateLoyaltyFields(outletLoyaltyFields(&settings)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var changes []models.LoyaltySettingChange
|
||||||
|
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||||
|
owner, err := p.repo.OutletOrganizationID(ctx, outletID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if owner != organizationID {
|
||||||
|
return repository.ErrLoyaltyOutletNotFound
|
||||||
|
}
|
||||||
|
if err := p.repo.LockScope(ctx, outletID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, err := p.repo.GetOutletValues(ctx, outletID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var current models.OutletLoyaltySettings
|
||||||
|
loadLoyaltyFields(outletLoyaltyFields(¤t), raw, "outlet "+outletID.String())
|
||||||
|
|
||||||
|
changes, err = p.apply(ctx, organizationID, &outletID, changedBy, raw,
|
||||||
|
outletLoyaltyFields(¤t), outletLoyaltyFields(&settings),
|
||||||
|
func(key string, value *string) error { return p.repo.SetOutletValue(ctx, outletID, key, value) })
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return changes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOrganization replaces an organization's settings and returns what changed.
|
||||||
|
func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organizationID, changedBy uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) {
|
||||||
|
if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var changes []models.LoyaltySettingChange
|
||||||
|
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||||
|
if err := p.repo.LockScope(ctx, organizationID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var current models.OrganizationLoyaltySettings
|
||||||
|
loadLoyaltyFields(organizationLoyaltyFields(¤t), raw, "organization "+organizationID.String())
|
||||||
|
|
||||||
|
changes, err = p.apply(ctx, organizationID, nil, changedBy, raw,
|
||||||
|
organizationLoyaltyFields(¤t), organizationLoyaltyFields(&settings),
|
||||||
|
func(key string, value *string) error {
|
||||||
|
return p.repo.SetOrganizationValue(ctx, organizationID, key, value)
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return changes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply stores every field whose effective value changes and records it. A key left
|
||||||
|
// on its default and not changed is not written, so the history only shows real
|
||||||
|
// changes. current and next list the same keys in the same order.
|
||||||
|
func (p *LoyaltySettingsProcessor) apply(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, changedBy uuid.UUID, raw map[string]string, current, next []loyaltyField, store func(key string, value *string) error) ([]models.LoyaltySettingChange, error) {
|
||||||
|
var rows []entities.LoyaltySettingChange
|
||||||
|
for i, field := range next {
|
||||||
|
newValue := field.format()
|
||||||
|
if equalOptionalStrings(current[i].format(), newValue) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var oldValue *string
|
||||||
|
if v, ok := raw[field.key]; ok {
|
||||||
|
oldValue = &v
|
||||||
|
}
|
||||||
|
if err := store(field.key, newValue); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows = append(rows, entities.LoyaltySettingChange{
|
||||||
|
OrganizationID: organizationID,
|
||||||
|
OutletID: outletID,
|
||||||
|
Key: field.key,
|
||||||
|
OldValue: oldValue,
|
||||||
|
NewValue: newValue,
|
||||||
|
ChangedBy: changedBy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := p.repo.InsertChanges(ctx, rows); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
changes := make([]models.LoyaltySettingChange, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
changes = append(changes, loyaltySettingChangeModel(row))
|
||||||
|
}
|
||||||
|
return changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChanges returns a page of an organization's settings history, newest first.
|
||||||
|
// A nil outlet lists the whole organization, outlets included.
|
||||||
|
func (p *LoyaltySettingsProcessor) ListChanges(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int) (*models.PaginatedResponse[models.LoyaltySettingChange], error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if limit < 1 || limit > 100 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
rows, total, err := p.repo.ListChanges(ctx, repository.LoyaltySettingChangeFilter{
|
||||||
|
OrganizationID: organizationID, OutletID: outletID, Offset: (page - 1) * limit, Limit: limit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]models.LoyaltySettingChange, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, loyaltySettingChangeModel(row))
|
||||||
|
}
|
||||||
|
return &models.PaginatedResponse[models.LoyaltySettingChange]{
|
||||||
|
Data: items,
|
||||||
|
Pagination: models.Pagination{
|
||||||
|
Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit)),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loyaltySettingChangeModel(row entities.LoyaltySettingChange) models.LoyaltySettingChange {
|
||||||
|
return models.LoyaltySettingChange{
|
||||||
|
ID: row.ID,
|
||||||
|
OrganizationID: row.OrganizationID,
|
||||||
|
OutletID: row.OutletID,
|
||||||
|
Key: row.Key,
|
||||||
|
OldValue: row.OldValue,
|
||||||
|
NewValue: row.NewValue,
|
||||||
|
ChangedBy: row.ChangedBy,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// outletLoyaltyFields describes every outlet key (F1), bound to s.
|
||||||
|
func outletLoyaltyFields(s *models.OutletLoyaltySettings) []loyaltyField {
|
||||||
|
return []loyaltyField{
|
||||||
|
boolLoyaltyField(constants.LoyaltyPointEnabledKey, &s.Point.Enabled, false),
|
||||||
|
intLoyaltyField(constants.LoyaltyPointEarnPerAmountKey, &s.Point.EarnPerAmount, constants.LoyaltyPointEarnPerAmountDefault, 1, noLoyaltyMax),
|
||||||
|
intLoyaltyField(constants.LoyaltyPointEarnValueKey, &s.Point.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
||||||
|
intLoyaltyField(constants.LoyaltyPointMinOrderAmountKey, &s.Point.MinOrderAmount, 0, 0, noLoyaltyMax),
|
||||||
|
optionalIntLoyaltyField(constants.LoyaltyPointMaxPerOrderKey, &s.Point.MaxPerOrder, 0),
|
||||||
|
|
||||||
|
boolLoyaltyField(constants.LoyaltyCoinEnabledKey, &s.Coin.Enabled, false),
|
||||||
|
intLoyaltyField(constants.LoyaltyCoinEarnPerAmountKey, &s.Coin.EarnPerAmount, constants.LoyaltyCoinEarnPerAmountDefault, 1, noLoyaltyMax),
|
||||||
|
intLoyaltyField(constants.LoyaltyCoinEarnValueKey, &s.Coin.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
|
||||||
|
intLoyaltyField(constants.LoyaltyCoinMinOrderAmountKey, &s.Coin.MinOrderAmount, 0, 0, noLoyaltyMax),
|
||||||
|
optionalIntLoyaltyField(constants.LoyaltyCoinMaxPerOrderKey, &s.Coin.MaxPerOrder, 0),
|
||||||
|
|
||||||
|
boolLoyaltyField(constants.LoyaltyPointAcceptPaymentKey, &s.PointPayment.AcceptPayment, false),
|
||||||
|
intLoyaltyField(constants.LoyaltyPointMinPaymentPointsKey, &s.PointPayment.MinPaymentPoints, constants.LoyaltyMinPaymentPointsDefault, 1, noLoyaltyMax),
|
||||||
|
intLoyaltyField(constants.LoyaltyPointMaxPaymentPercentKey, &s.PointPayment.MaxPaymentPercent, constants.LoyaltyMaxPaymentPercentDefault, 0, 100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// organizationLoyaltyFields describes every organization key (F2, F12), bound to s.
|
||||||
|
func organizationLoyaltyFields(s *models.OrganizationLoyaltySettings) []loyaltyField {
|
||||||
|
fields := []loyaltyField{
|
||||||
|
intLoyaltyField(constants.LoyaltyPointValueKey, &s.PointValue, constants.LoyaltyPointValueDefault, 1, noLoyaltyMax),
|
||||||
|
intLoyaltyField(constants.LoyaltyExchangeCoinAmountKey, &s.Exchange.CoinAmount, constants.LoyaltyExchangeAmountDefault, 1, noLoyaltyMax),
|
||||||
|
intLoyaltyField(constants.LoyaltyExchangePointAmountKey, &s.Exchange.PointAmount, constants.LoyaltyExchangeAmountDefault, 1, noLoyaltyMax),
|
||||||
|
boolLoyaltyField(constants.LoyaltyTransferEnabledKey, &s.Transfer.Enabled, constants.LoyaltyTransferEnabledDefault),
|
||||||
|
intLoyaltyField(constants.LoyaltyTransferMinAmountKey, &s.Transfer.MinAmount, constants.LoyaltyTransferMinAmountDefault, 1, noLoyaltyMax),
|
||||||
|
optionalIntLoyaltyField(constants.LoyaltyTransferMaxPerTransactionKey, &s.Transfer.MaxPerTransaction, 1),
|
||||||
|
optionalIntLoyaltyField(constants.LoyaltyTransferDailyLimitKey, &s.Transfer.DailyLimit, 1),
|
||||||
|
}
|
||||||
|
fields = append(fields, expiryLoyaltyFields("loyalty.point.", &s.PointExpiry)...)
|
||||||
|
return append(fields, expiryLoyaltyFields("loyalty.coin.", &s.CoinExpiry)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func expiryLoyaltyFields(prefix string, s *models.LoyaltyExpirySettings) []loyaltyField {
|
||||||
|
return []loyaltyField{
|
||||||
|
boolLoyaltyField(prefix+constants.LoyaltyExpiryEnabledSuffix, &s.Enabled, false),
|
||||||
|
intLoyaltyField(prefix+constants.LoyaltyExpiryPeriodSuffix, &s.Period, constants.LoyaltyExpiryPeriodDefault, 1, noLoyaltyMax),
|
||||||
|
enumLoyaltyField(prefix+constants.LoyaltyExpiryUnitSuffix, &s.Unit, constants.LoyaltyExpiryUnitDefault,
|
||||||
|
constants.LoyaltyExpiryUnitDay, constants.LoyaltyExpiryUnitMonth),
|
||||||
|
boolLoyaltyField(prefix+constants.LoyaltyExpiryEndOfMonthSuffix, &s.EndOfMonth, false),
|
||||||
|
intLoyaltyField(prefix+constants.LoyaltyExpiryReminderDaysSuffix, &s.ReminderDays, constants.LoyaltyExpiryReminderDaysDefault, 0, noLoyaltyMax),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loyaltyField is one setting key bound to the struct field that holds it. The same
|
||||||
|
// description drives reading (parse or default), validating, and writing (format).
|
||||||
|
type loyaltyField struct {
|
||||||
|
key string
|
||||||
|
// parse sets the field from a stored value, or reports it unusable.
|
||||||
|
parse func(raw string) bool
|
||||||
|
// reset sets the field to its default.
|
||||||
|
reset func()
|
||||||
|
// validate checks the field's current value.
|
||||||
|
validate func() error
|
||||||
|
// format is the field's current value as stored; nil means no stored value.
|
||||||
|
format func() *string
|
||||||
|
}
|
||||||
|
|
||||||
|
const noLoyaltyMax = int64(-1)
|
||||||
|
|
||||||
|
func intLoyaltyField(key string, v *int64, def, min, max int64) loyaltyField {
|
||||||
|
check := func(n int64) error {
|
||||||
|
if n < min {
|
||||||
|
return fmt.Errorf("%w: %s must be at least %d", ErrInvalidLoyaltySettings, key, min)
|
||||||
|
}
|
||||||
|
if max != noLoyaltyMax && n > max {
|
||||||
|
return fmt.Errorf("%w: %s must be at most %d", ErrInvalidLoyaltySettings, key, max)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return loyaltyField{
|
||||||
|
key: key,
|
||||||
|
parse: func(raw string) bool {
|
||||||
|
n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||||
|
if err != nil || check(n) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
*v = n
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
reset: func() { *v = def },
|
||||||
|
validate: func() error { return check(*v) },
|
||||||
|
format: func() *string { s := strconv.FormatInt(*v, 10); return &s },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionalIntLoyaltyField is a limit that may be unset, meaning no limit.
|
||||||
|
func optionalIntLoyaltyField(key string, v **int64, min int64) loyaltyField {
|
||||||
|
return loyaltyField{
|
||||||
|
key: key,
|
||||||
|
parse: func(raw string) bool {
|
||||||
|
n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||||
|
if err != nil || n < min {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
*v = &n
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
reset: func() { *v = nil },
|
||||||
|
validate: func() error {
|
||||||
|
if *v != nil && **v < min {
|
||||||
|
return fmt.Errorf("%w: %s must be at least %d, or empty for no limit", ErrInvalidLoyaltySettings, key, min)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
format: func() *string {
|
||||||
|
if *v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := strconv.FormatInt(**v, 10)
|
||||||
|
return &s
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolLoyaltyField(key string, v *bool, def bool) loyaltyField {
|
||||||
|
return loyaltyField{
|
||||||
|
key: key,
|
||||||
|
parse: func(raw string) bool {
|
||||||
|
b, err := strconv.ParseBool(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
*v = b
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
reset: func() { *v = def },
|
||||||
|
validate: func() error { return nil },
|
||||||
|
format: func() *string { s := strconv.FormatBool(*v); return &s },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enumLoyaltyField(key string, v *string, def string, allowed ...string) loyaltyField {
|
||||||
|
valid := func(s string) bool {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if s == a {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return loyaltyField{
|
||||||
|
key: key,
|
||||||
|
parse: func(raw string) bool {
|
||||||
|
s := strings.ToUpper(strings.TrimSpace(raw))
|
||||||
|
if !valid(s) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
*v = s
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
reset: func() { *v = def },
|
||||||
|
validate: func() error {
|
||||||
|
if !valid(*v) {
|
||||||
|
return fmt.Errorf("%w: %s must be one of %s", ErrInvalidLoyaltySettings, key, strings.Join(allowed, ", "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
format: func() *string { s := *v; return &s },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadLoyaltyFields fills each field from its stored value, or its default when there
|
||||||
|
// is none. A stored value that cannot be used also falls back to the default, so a bad
|
||||||
|
// row can never reach a calculation, and it is logged so it gets fixed.
|
||||||
|
func loadLoyaltyFields(fields []loyaltyField, raw map[string]string, scope string) {
|
||||||
|
for _, f := range fields {
|
||||||
|
f.reset()
|
||||||
|
value, ok := raw[f.key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !f.parse(value) {
|
||||||
|
f.reset()
|
||||||
|
logger.NonContext.Warnf("Loyalty setting %s of %s has an unusable value %q; using the default", f.key, scope, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLoyaltyFields(fields []loyaltyField) error {
|
||||||
|
for _, f := range fields {
|
||||||
|
if err := f.validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalOptionalStrings(a, b *string) bool {
|
||||||
|
if a == nil || b == nil {
|
||||||
|
return a == nil && b == nil
|
||||||
|
}
|
||||||
|
return *a == *b
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
package processor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"apskel-pos-be/internal/constants"
|
||||||
|
"apskel-pos-be/internal/entities"
|
||||||
|
"apskel-pos-be/internal/logger"
|
||||||
|
"apskel-pos-be/internal/models"
|
||||||
|
"apskel-pos-be/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
// Some processors log; the server sets the logger up at startup.
|
||||||
|
logger.Setup("fatal", "json")
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
// loyaltyRepoFake keeps settings in maps. It does not check for a transaction.
|
||||||
|
type loyaltyRepoFake struct {
|
||||||
|
outletOrg map[uuid.UUID]uuid.UUID
|
||||||
|
outletValues map[string]string
|
||||||
|
orgValues map[string]string
|
||||||
|
changes []entities.LoyaltySettingChange
|
||||||
|
locks []uuid.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *loyaltyRepoFake) GetOutletValues(context.Context, uuid.UUID) (map[string]string, error) {
|
||||||
|
return copyStringMap(f.outletValues), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *loyaltyRepoFake) GetOrganizationValues(context.Context, uuid.UUID) (map[string]string, error) {
|
||||||
|
return copyStringMap(f.orgValues), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *loyaltyRepoFake) OutletOrganizationID(_ context.Context, outletID uuid.UUID) (uuid.UUID, error) {
|
||||||
|
org, ok := f.outletOrg[outletID]
|
||||||
|
if !ok {
|
||||||
|
return uuid.Nil, repository.ErrLoyaltyOutletNotFound
|
||||||
|
}
|
||||||
|
return org, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *loyaltyRepoFake) LockScope(_ context.Context, id uuid.UUID) error {
|
||||||
|
f.locks = append(f.locks, id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *loyaltyRepoFake) SetOutletValue(_ context.Context, _ uuid.UUID, key string, value *string) error {
|
||||||
|
f.outletValues = setOptional(f.outletValues, key, value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *loyaltyRepoFake) SetOrganizationValue(_ context.Context, _ uuid.UUID, key string, value *string) error {
|
||||||
|
f.orgValues = setOptional(f.orgValues, key, value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *loyaltyRepoFake) InsertChanges(_ context.Context, changes []entities.LoyaltySettingChange) error {
|
||||||
|
f.changes = append(f.changes, changes...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *loyaltyRepoFake) ListChanges(context.Context, repository.LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) {
|
||||||
|
return f.changes, int64(len(f.changes)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyStringMap(m map[string]string) map[string]string {
|
||||||
|
out := make(map[string]string, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func setOptional(m map[string]string, key string, value *string) map[string]string {
|
||||||
|
if m == nil {
|
||||||
|
m = map[string]string{}
|
||||||
|
}
|
||||||
|
if value == nil {
|
||||||
|
delete(m, key)
|
||||||
|
} else {
|
||||||
|
m[key] = *value
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// txRunnerFake runs fn in place.
|
||||||
|
type txRunnerFake struct{}
|
||||||
|
|
||||||
|
func (txRunnerFake) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||||
|
return fn(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoyaltySettings_OutletWithoutSettingsGetsEveryDefault(t *testing.T) {
|
||||||
|
p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{})
|
||||||
|
s, err := p.Outlet(context.Background(), uuid.New())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, models.OutletLoyaltySettings{
|
||||||
|
Point: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 100, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||||
|
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 25000, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
|
||||||
|
PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: false, MinPaymentPoints: 1, MaxPaymentPercent: 100},
|
||||||
|
}, *s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing.T) {
|
||||||
|
p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{})
|
||||||
|
s, err := p.Organization(context.Background(), uuid.New())
|
||||||
|
require.NoError(t, err)
|
||||||
|
expiry := models.LoyaltyExpirySettings{Enabled: false, Period: 12, Unit: "MONTH", EndOfMonth: false, ReminderDays: 7}
|
||||||
|
assert.Equal(t, models.OrganizationLoyaltySettings{
|
||||||
|
PointValue: 1,
|
||||||
|
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1},
|
||||||
|
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
|
||||||
|
PointExpiry: expiry,
|
||||||
|
CoinExpiry: expiry,
|
||||||
|
}, *s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
|
||||||
|
repo := &loyaltyRepoFake{
|
||||||
|
outletValues: map[string]string{
|
||||||
|
constants.LoyaltyPointEnabledKey: "true",
|
||||||
|
constants.LoyaltyPointEarnPerAmountKey: " 1000 ",
|
||||||
|
constants.LoyaltyPointMaxPerOrderKey: "500",
|
||||||
|
constants.LoyaltyPointMaxPaymentPercentKey: "50",
|
||||||
|
"loyalty.unknown": "ignored",
|
||||||
|
},
|
||||||
|
orgValues: map[string]string{
|
||||||
|
constants.LoyaltyPointValueKey: "100",
|
||||||
|
constants.LoyaltyTransferDailyLimitKey: "10000",
|
||||||
|
"loyalty.coin.expiry_unit": "day",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||||
|
|
||||||
|
outlet, err := p.Outlet(context.Background(), uuid.New())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, outlet.Point.Enabled)
|
||||||
|
assert.Equal(t, int64(1000), outlet.Point.EarnPerAmount)
|
||||||
|
assert.Equal(t, int64(500), *outlet.Point.MaxPerOrder)
|
||||||
|
assert.Equal(t, int64(50), outlet.PointPayment.MaxPaymentPercent)
|
||||||
|
|
||||||
|
org, err := p.Organization(context.Background(), uuid.New())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(100), org.PointValue)
|
||||||
|
assert.Equal(t, int64(10000), *org.Transfer.DailyLimit)
|
||||||
|
assert.Equal(t, "DAY", org.CoinExpiry.Unit)
|
||||||
|
assert.Equal(t, "MONTH", org.PointExpiry.Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bad row must never reach a calculation: earn_per_amount 0 would divide by zero.
|
||||||
|
func TestLoyaltySettings_UnusableStoredValuesFallBackToDefault(t *testing.T) {
|
||||||
|
repo := &loyaltyRepoFake{
|
||||||
|
outletValues: map[string]string{
|
||||||
|
constants.LoyaltyPointEnabledKey: "yes please",
|
||||||
|
constants.LoyaltyPointEarnPerAmountKey: "0",
|
||||||
|
constants.LoyaltyCoinEarnValueKey: "-1",
|
||||||
|
constants.LoyaltyPointMaxPerOrderKey: "abc",
|
||||||
|
constants.LoyaltyPointMaxPaymentPercentKey: "150",
|
||||||
|
},
|
||||||
|
orgValues: map[string]string{
|
||||||
|
constants.LoyaltyPointValueKey: "0",
|
||||||
|
"loyalty.point.expiry_unit": "YEAR",
|
||||||
|
"loyalty.point.expiry_period": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||||
|
|
||||||
|
outlet, err := p.Outlet(context.Background(), uuid.New())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, outlet.Point.Enabled)
|
||||||
|
assert.Equal(t, int64(100), outlet.Point.EarnPerAmount)
|
||||||
|
assert.Equal(t, int64(1), outlet.Coin.EarnValue)
|
||||||
|
assert.Nil(t, outlet.Point.MaxPerOrder)
|
||||||
|
assert.Equal(t, int64(100), outlet.PointPayment.MaxPaymentPercent)
|
||||||
|
|
||||||
|
for name, raw := range map[string]string{"not set": "", "garbage": "abc", "zero": "0", "negative": "-5"} {
|
||||||
|
repo.orgValues = map[string]string{}
|
||||||
|
if raw != "" {
|
||||||
|
repo.orgValues[constants.LoyaltyPointValueKey] = raw
|
||||||
|
}
|
||||||
|
value, err := p.PointValue(context.Background(), uuid.New())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, constants.LoyaltyPointValueDefault, value, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoyaltySettings_UpdateRecordsOnlyRealChanges(t *testing.T) {
|
||||||
|
org, outlet, admin := uuid.New(), uuid.New(), uuid.New()
|
||||||
|
repo := &loyaltyRepoFake{
|
||||||
|
outletOrg: map[uuid.UUID]uuid.UUID{outlet: org},
|
||||||
|
outletValues: map[string]string{constants.LoyaltyPointEarnPerAmountKey: "200"},
|
||||||
|
}
|
||||||
|
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
settings, err := p.Outlet(ctx, outlet)
|
||||||
|
require.NoError(t, err)
|
||||||
|
settings.Point.Enabled = true // default false -> true
|
||||||
|
settings.Point.EarnPerAmount = 100 // stored 200 -> 100 (the default, but set explicitly)
|
||||||
|
settings.Point.MaxPerOrder = ptr(int64(0)) // unset -> 0
|
||||||
|
// Everything else stays on its default.
|
||||||
|
|
||||||
|
changes, err := p.UpdateOutlet(ctx, org, outlet, admin, *settings)
|
||||||
|
require.NoError(t, err)
|
||||||
|
byKey := map[string]models.LoyaltySettingChange{}
|
||||||
|
for _, c := range changes {
|
||||||
|
byKey[c.Key] = c
|
||||||
|
assert.Equal(t, admin, c.ChangedBy)
|
||||||
|
assert.Equal(t, org, c.OrganizationID)
|
||||||
|
assert.Equal(t, &outlet, c.OutletID)
|
||||||
|
}
|
||||||
|
require.Len(t, byKey, 3, "only the three keys that changed")
|
||||||
|
assert.Nil(t, byKey[constants.LoyaltyPointEnabledKey].OldValue, "was on its default")
|
||||||
|
assert.Equal(t, "true", *byKey[constants.LoyaltyPointEnabledKey].NewValue)
|
||||||
|
assert.Equal(t, "200", *byKey[constants.LoyaltyPointEarnPerAmountKey].OldValue)
|
||||||
|
assert.Equal(t, "100", *byKey[constants.LoyaltyPointEarnPerAmountKey].NewValue)
|
||||||
|
assert.Equal(t, "0", *byKey[constants.LoyaltyPointMaxPerOrderKey].NewValue)
|
||||||
|
assert.Len(t, repo.changes, 3)
|
||||||
|
assert.Equal(t, []uuid.UUID{outlet}, repo.locks)
|
||||||
|
|
||||||
|
after, err := p.Outlet(ctx, outlet)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, *settings, *after)
|
||||||
|
|
||||||
|
// Saving the same settings again changes nothing and records nothing.
|
||||||
|
changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, changes)
|
||||||
|
assert.Len(t, repo.changes, 3)
|
||||||
|
|
||||||
|
// Clearing a limit removes the stored value, recorded as a change to nothing.
|
||||||
|
after.Point.MaxPerOrder = nil
|
||||||
|
changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, changes, 1)
|
||||||
|
assert.Equal(t, "0", *changes[0].OldValue)
|
||||||
|
assert.Nil(t, changes[0].NewValue)
|
||||||
|
assert.NotContains(t, repo.outletValues, constants.LoyaltyPointMaxPerOrderKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoyaltySettings_UpdateOrganization(t *testing.T) {
|
||||||
|
org, admin := uuid.New(), uuid.New()
|
||||||
|
repo := &loyaltyRepoFake{}
|
||||||
|
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
settings, err := p.Organization(ctx, org)
|
||||||
|
require.NoError(t, err)
|
||||||
|
settings.PointValue = 100
|
||||||
|
settings.CoinExpiry.Enabled = true
|
||||||
|
settings.CoinExpiry.Unit = constants.LoyaltyExpiryUnitDay
|
||||||
|
|
||||||
|
changes, err := p.UpdateOrganization(ctx, org, admin, *settings)
|
||||||
|
require.NoError(t, err)
|
||||||
|
keys := []string{}
|
||||||
|
for _, c := range changes {
|
||||||
|
keys = append(keys, c.Key)
|
||||||
|
assert.Nil(t, c.OutletID, "organization settings have no outlet")
|
||||||
|
}
|
||||||
|
assert.ElementsMatch(t, []string{constants.LoyaltyPointValueKey, "loyalty.coin.expiry_enabled", "loyalty.coin.expiry_unit"}, keys)
|
||||||
|
|
||||||
|
value, err := p.PointValue(ctx, org)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(100), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
|
||||||
|
org, outlet := uuid.New(), uuid.New()
|
||||||
|
repo := &loyaltyRepoFake{outletOrg: map[uuid.UUID]uuid.UUID{outlet: org}}
|
||||||
|
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for name, mutate := range map[string]func(*models.OutletLoyaltySettings){
|
||||||
|
"earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 },
|
||||||
|
"negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 },
|
||||||
|
"negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 },
|
||||||
|
"negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) },
|
||||||
|
"payment percent over 100": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = 101 },
|
||||||
|
"negative payment percent": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = -1 },
|
||||||
|
"min payment points 0": func(s *models.OutletLoyaltySettings) { s.PointPayment.MinPaymentPoints = 0 },
|
||||||
|
} {
|
||||||
|
s, err := p.Outlet(ctx, outlet)
|
||||||
|
require.NoError(t, err)
|
||||||
|
mutate(s)
|
||||||
|
_, err = p.UpdateOutlet(ctx, org, outlet, uuid.New(), *s)
|
||||||
|
assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, mutate := range map[string]func(*models.OrganizationLoyaltySettings){
|
||||||
|
"point value 0": func(s *models.OrganizationLoyaltySettings) { s.PointValue = 0 },
|
||||||
|
"exchange coin amount 0": func(s *models.OrganizationLoyaltySettings) { s.Exchange.CoinAmount = 0 },
|
||||||
|
"transfer min 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.MinAmount = 0 },
|
||||||
|
"daily limit 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.DailyLimit = ptr(int64(0)) },
|
||||||
|
"expiry period 0": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Period = 0 },
|
||||||
|
"expiry unit YEAR": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.Unit = "YEAR" },
|
||||||
|
"negative reminder days": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.ReminderDays = -1 },
|
||||||
|
} {
|
||||||
|
s, err := p.Organization(ctx, org)
|
||||||
|
require.NoError(t, err)
|
||||||
|
mutate(s)
|
||||||
|
_, err = p.UpdateOrganization(ctx, org, uuid.New(), *s)
|
||||||
|
assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name)
|
||||||
|
}
|
||||||
|
assert.Empty(t, repo.changes, "nothing is written when a value is invalid")
|
||||||
|
assert.Empty(t, repo.locks, "invalid input is rejected before touching the database")
|
||||||
|
|
||||||
|
// An outlet of another organization.
|
||||||
|
s, err := p.Outlet(ctx, outlet)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = p.UpdateOutlet(ctx, uuid.New(), outlet, uuid.New(), *s)
|
||||||
|
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
|
||||||
|
_, err = p.UpdateOutlet(ctx, org, uuid.New(), uuid.New(), *s)
|
||||||
|
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -41,11 +40,17 @@ var walletDisplayLocation = func() *time.Location {
|
|||||||
// history and upcoming expiry (docs/prd-point-coin.md F6). It only reads.
|
// history and upcoming expiry (docs/prd-point-coin.md F6). It only reads.
|
||||||
type WalletQueryProcessor struct {
|
type WalletQueryProcessor struct {
|
||||||
repo repository.WalletQueryRepository
|
repo repository.WalletQueryRepository
|
||||||
settings repository.OrganizationSettingRepository
|
settings pointValueReader
|
||||||
now func() time.Time
|
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}
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pointValue, err := p.pointValue(ctx, organizationID)
|
pointValue, err := p.settings.PointValue(ctx, organizationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -192,23 +197,6 @@ func (p *WalletQueryProcessor) recent(ctx context.Context, customerID uuid.UUID,
|
|||||||
return p.toTransactions(ctx, rows)
|
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) {
|
func (p *WalletQueryProcessor) toTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) {
|
||||||
return buildWalletTransactions(ctx, p.repo, rows)
|
return buildWalletTransactions(ctx, p.repo, rows)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,13 +68,8 @@ func (f *walletQueryRepoFake) ListLotsBySourceTransactions(_ context.Context, id
|
|||||||
|
|
||||||
type orgSettingsFake map[string]string
|
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 {
|
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) }
|
p.now = func() time.Time { return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) }
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user