Adds GET and PUT /marketing/loyalty-settings and GET /marketing/loyalty-settings/history (docs/prd-point-coin.md F2, PC-302). The settings are the point value, the exchange rate, transfer limits and the stored expiry settings. PUT merges the body like the outlet settings and is limited to loyalty managers. Every response carries the impact of the change on the balances in circulation: outstanding EnakPoint and EnakCoin, their rupiah value, and the coins exchanged into points, before and after. With ?dry_run=true nothing is saved and the response lists the keys that would change, for the warning shown before saving. Saving records each change in loyalty_setting_changes with who made it; history can be filtered to one outlet. Changing the value leaves what was already written alone. The diff behind saving and previewing is shared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
246 lines
10 KiB
Go
246 lines
10 KiB
Go
package processor
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/models"
|
|
"apskel-pos-be/internal/repository"
|
|
)
|
|
|
|
type walletQueryRepoFake struct {
|
|
org uuid.UUID
|
|
spendable map[string]int64
|
|
expiring []repository.WalletExpiringAmount
|
|
transactions []entities.WalletTransaction
|
|
total int64
|
|
lots []entities.WalletLot
|
|
filters []repository.WalletTransactionFilter
|
|
}
|
|
|
|
func (f *walletQueryRepoFake) GetCustomerOrganizationID(context.Context, uuid.UUID) (uuid.UUID, error) {
|
|
if f.org == uuid.Nil {
|
|
return uuid.Nil, repository.ErrWalletNotFound
|
|
}
|
|
return f.org, nil
|
|
}
|
|
|
|
func (f *walletQueryRepoFake) SpendableBalances(context.Context, uuid.UUID, time.Time) (map[string]int64, error) {
|
|
return f.spendable, nil
|
|
}
|
|
|
|
func (f *walletQueryRepoFake) NearestExpiring(context.Context, uuid.UUID, time.Time) ([]repository.WalletExpiringAmount, error) {
|
|
return f.expiring, nil
|
|
}
|
|
|
|
func (f *walletQueryRepoFake) ListTransactions(_ context.Context, filter repository.WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) {
|
|
f.filters = append(f.filters, filter)
|
|
var out []entities.WalletTransaction
|
|
for _, tx := range f.transactions {
|
|
if filter.Currency == "" || tx.Currency == filter.Currency {
|
|
out = append(out, tx)
|
|
}
|
|
}
|
|
if len(out) > filter.Limit {
|
|
out = out[:filter.Limit]
|
|
}
|
|
return out, f.total, nil
|
|
}
|
|
|
|
func (f *walletQueryRepoFake) ListLotsBySourceTransactions(_ context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
|
|
var out []entities.WalletLot
|
|
for _, lot := range f.lots {
|
|
for _, id := range ids {
|
|
if lot.SourceTransactionID == id {
|
|
out = append(out, lot)
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type orgSettingsFake map[string]string
|
|
|
|
func newWalletQueryTest(repo *walletQueryRepoFake, settings orgSettingsFake) *WalletQueryProcessor {
|
|
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
|
|
}
|
|
|
|
func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.T) {
|
|
order, payment := uuid.New(), uuid.New()
|
|
earnID, payID := uuid.New(), uuid.New()
|
|
soon := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
|
later := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
|
created := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
|
repo := &walletQueryRepoFake{
|
|
org: uuid.New(),
|
|
spendable: map[string]int64{constants.WalletCurrencyPoint: 250, constants.WalletCurrencyCoin: 8},
|
|
expiring: []repository.WalletExpiringAmount{
|
|
{Currency: constants.WalletCurrencyPoint, Date: "2026-07-01", Amount: 100},
|
|
},
|
|
transactions: []entities.WalletTransaction{
|
|
{ID: payID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, Amount: -50, BalanceAfter: 250,
|
|
ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, Description: "Bayar #ORD-1", CreatedAt: created.Add(time.Hour),
|
|
CounterpartyCustomerID: ptr(uuid.New()), Metadata: entities.Metadata{"point_value": 100}},
|
|
{ID: earnID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn, Amount: 300, BalanceAfter: 300,
|
|
ReferenceType: constants.WalletRefTypeOrder, ReferenceID: order, Description: "Belanja #ORD-1", CreatedAt: created},
|
|
},
|
|
lots: []entities.WalletLot{
|
|
{SourceTransactionID: earnID, OriginalAmount: 200, RemainingAmount: 150, ExpiresAt: &later},
|
|
{SourceTransactionID: earnID, OriginalAmount: 100, RemainingAmount: 100, ExpiresAt: &soon},
|
|
},
|
|
}
|
|
p := newWalletQueryTest(repo, orgSettingsFake{constants.LoyaltyPointValueKey: "100"})
|
|
|
|
data, err := p.Summary(context.Background(), uuid.New())
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, int64(250), data.PointBalance)
|
|
assert.Equal(t, int64(8), data.CoinBalance)
|
|
assert.Equal(t, int64(100), data.PointValue)
|
|
assert.Equal(t, int64(25000), data.PointDiscountValue)
|
|
assert.Equal(t, &models.CustomerWalletExpiring{Amount: 100, Date: "2026-07-01"}, data.NearestExpiring.Point)
|
|
assert.Nil(t, data.NearestExpiring.Coin)
|
|
|
|
require.Len(t, data.RecentTransactions, 2)
|
|
pay, earn := data.RecentTransactions[0], data.RecentTransactions[1]
|
|
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypePayment, ID: payment}, pay.Destination)
|
|
assert.Nil(t, pay.Source)
|
|
assert.Empty(t, pay.Lots)
|
|
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypeOrder, ID: order}, earn.Source)
|
|
assert.Nil(t, earn.Destination)
|
|
assert.Equal(t, &soon, earn.ExpiresAt, "earliest expiry among the lots the credit created")
|
|
assert.Len(t, earn.Lots, 2)
|
|
|
|
// The pre-wallet fields are still filled, from the wallet.
|
|
assert.Equal(t, int64(250), data.TotalPoints)
|
|
assert.Equal(t, int64(8), data.TotalTokens)
|
|
require.Len(t, data.PointsHistory, 2)
|
|
assert.Equal(t, int64(-50), data.PointsHistory[0].Points)
|
|
assert.Equal(t, constants.WalletTxTypePayment, data.PointsHistory[0].Type)
|
|
assert.Equal(t, created.Add(time.Hour), data.LastUpdated)
|
|
}
|
|
|
|
func TestWalletQueryProcessor_PointValueFallsBackToDefault(t *testing.T) {
|
|
for name, settings := range map[string]orgSettingsFake{
|
|
"not set": {},
|
|
"garbage": {constants.LoyaltyPointValueKey: "abc"},
|
|
"zero": {constants.LoyaltyPointValueKey: "0"},
|
|
"negative": {constants.LoyaltyPointValueKey: "-5"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
repo := &walletQueryRepoFake{org: uuid.New(), spendable: map[string]int64{constants.WalletCurrencyPoint: 40}}
|
|
data, err := newWalletQueryTest(repo, settings).Summary(context.Background(), uuid.New())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, constants.LoyaltyPointValueDefault, data.PointValue)
|
|
assert.Equal(t, int64(40), data.PointDiscountValue)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWalletQueryProcessor_SummaryForCustomerWithoutWallet(t *testing.T) {
|
|
repo := &walletQueryRepoFake{org: uuid.New(), spendable: map[string]int64{}}
|
|
data, err := newWalletQueryTest(repo, orgSettingsFake{}).Summary(context.Background(), uuid.New())
|
|
require.NoError(t, err)
|
|
assert.Zero(t, data.PointBalance)
|
|
assert.Zero(t, data.CoinBalance)
|
|
assert.NotNil(t, data.RecentTransactions, "an empty list, not null, for the app")
|
|
|
|
_, err = newWalletQueryTest(&walletQueryRepoFake{}, orgSettingsFake{}).Summary(context.Background(), uuid.New())
|
|
assert.ErrorIs(t, err, repository.ErrWalletNotFound)
|
|
}
|
|
|
|
func TestWalletQueryProcessor_LegacyEndpointsSplitByCurrency(t *testing.T) {
|
|
repo := &walletQueryRepoFake{
|
|
org: uuid.New(),
|
|
spendable: map[string]int64{constants.WalletCurrencyPoint: 100, constants.WalletCurrencyCoin: 8},
|
|
transactions: []entities.WalletTransaction{
|
|
{ID: uuid.New(), Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration, Amount: 8},
|
|
{ID: uuid.New(), Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: 100},
|
|
},
|
|
}
|
|
p := newWalletQueryTest(repo, orgSettingsFake{})
|
|
|
|
points, err := p.LegacyPoints(context.Background(), uuid.New())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(100), points.TotalPoints)
|
|
require.Len(t, points.PointsHistory, 1)
|
|
assert.Equal(t, int64(100), points.PointsHistory[0].Points)
|
|
|
|
tokens, err := p.LegacyTokens(context.Background(), uuid.New())
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(8), tokens.TotalTokens)
|
|
require.Len(t, tokens.TokensHistory, 1)
|
|
assert.Equal(t, int64(8), tokens.TokensHistory[0].Tokens)
|
|
}
|
|
|
|
func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) {
|
|
customer := uuid.New()
|
|
jakarta := walletDisplayLocation
|
|
|
|
repo := &walletQueryRepoFake{org: uuid.New(), total: 45}
|
|
page, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{
|
|
Page: 3, Limit: 10, Currency: "point", Type: "earn, PAYMENT", From: "2026-05-01", To: "2026-05-31",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, models.Pagination{Page: 3, Limit: 10, Total: 45, TotalPages: 5}, page.Pagination)
|
|
assert.NotNil(t, page.Data)
|
|
|
|
f := repo.filters[0]
|
|
assert.Equal(t, customer, f.CustomerID)
|
|
assert.Equal(t, 20, f.Offset)
|
|
assert.Equal(t, 10, f.Limit)
|
|
assert.Equal(t, constants.WalletCurrencyPoint, f.Currency)
|
|
assert.Equal(t, []string{constants.WalletTxTypeEarn, constants.WalletTxTypePayment}, f.Types)
|
|
assert.True(t, f.From.Equal(time.Date(2026, 5, 1, 0, 0, 0, 0, jakarta)))
|
|
assert.True(t, f.To.Equal(time.Date(2026, 6, 1, 0, 0, 0, 0, jakarta)), "to covers the whole last day")
|
|
|
|
// Defaults.
|
|
repo = &walletQueryRepoFake{org: uuid.New()}
|
|
page, err = newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, page.Pagination.Page)
|
|
assert.Equal(t, walletTransactionsPageLimit, repo.filters[0].Limit)
|
|
assert.Zero(t, repo.filters[0].Offset)
|
|
assert.Nil(t, repo.filters[0].From)
|
|
assert.Empty(t, repo.filters[0].Types)
|
|
}
|
|
|
|
func TestWalletQueryProcessor_RejectsBadQueries(t *testing.T) {
|
|
for name, q := range map[string]models.ListCustomerWalletTransactionsQuery{
|
|
"negative page": {Page: -1},
|
|
"limit too big": {Limit: walletTransactionsMaxLimit + 1},
|
|
"negative limit": {Limit: -1},
|
|
"unknown currency": {Currency: "GOLD"},
|
|
"unknown type": {Type: "EARN,BONUS"},
|
|
"bad from": {From: "01-05-2026"},
|
|
"bad to": {To: "2026-13-01"},
|
|
"from after to": {From: "2026-06-02", To: "2026-06-01"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
repo := &walletQueryRepoFake{org: uuid.New()}
|
|
_, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), uuid.New(), q)
|
|
assert.ErrorIs(t, err, ErrInvalidWalletQuery)
|
|
assert.Empty(t, repo.filters, "nothing is queried")
|
|
})
|
|
}
|
|
|
|
// A single day is a valid range.
|
|
repo := &walletQueryRepoFake{org: uuid.New()}
|
|
_, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), uuid.New(),
|
|
models.ListCustomerWalletTransactionsQuery{From: "2026-06-01", To: "2026-06-01"})
|
|
assert.NoError(t, err)
|
|
}
|
|
|
|
func (f *walletQueryRepoFake) OrganizationOutstanding(context.Context, uuid.UUID) (int64, int64, error) {
|
|
return 0, 0, nil
|
|
}
|