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:
efrilm
2026-09-30 10:23:08 +07:00
co-authored by Claude Opus 5.5
parent 040780cd2d
commit 39e47ff0e6
15 changed files with 1250 additions and 80 deletions
@@ -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 }