Main #33

Merged
aefril merged 57 commits from main into staging 2026-09-30 10:17:39 +02:00
15 changed files with 1250 additions and 80 deletions
Showing only changes of commit 39e47ff0e6 - Show all commits
+5 -3
View File
@@ -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),
}
}
+71
View File
@@ -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)
)
-7
View File
@@ -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)
)
+1
View File
@@ -49,6 +49,7 @@ func GetAllEntities() []interface{} {
&WalletTransaction{},
&WalletLot{},
&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"
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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()
+81
View File
@@ -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(&current), raw, "outlet "+outletID.String())
changes, err = p.apply(ctx, organizationID, &outletID, changedBy, raw,
outletLoyaltyFields(&current), 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(&current), raw, "organization "+organizationID.String())
changes, err = p.apply(ctx, organizationID, nil, changedBy, raw,
organizationLoyaltyFields(&current), 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)
}
+9 -21
View File
@@ -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)
}
@@ -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
}
@@ -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
}