feat(loyalty): typed loyalty settings with change history
Adds LoyaltySettingsProcessor (docs/prd-point-coin.md F1, F2, F12, PC-109). Reading returns typed settings for an outlet (earning per currency, paying with EnakPoint) and for an organization (point value, exchange rate, transfers, and the expiry settings awaiting note N4). A key that was never set takes the PRD default. A stored value that is unusable, such as an earn_per_amount of 0 that would divide by zero, also falls back to the default and is logged, so a bad row never reaches a calculation. Writing takes the whole settings struct, validates every rule in the PRD before touching the database, and stores and records in loyalty_setting_changes only the keys whose effective value changes: old value (NULL while it was on its default), new value, and who changed it. Clearing a limit deletes the stored value. Each save runs in one transaction under an advisory lock per outlet or organization, so two saves at once cannot both compute their change from the same old value. The outlet must belong to the caller's organization. Every key is described once (key, default, valid range, bound field), and reading, validating and diffing all use that description. GET /customer/wallet now reads the point value through this processor; the minimal organization settings repository from PC-106 is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
040780cd2d
commit
39e47ff0e6
@@ -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