feat(wallet): serve customer balances and history from the wallet

GET /customer/wallet now reads the EnakPoint & EnakCoin wallet
(docs/prd-point-coin.md F6, PC-106): spendable point and coin balances, the
rupiah value of one EnakPoint and of the balance, the nearest day each
currency loses balance (grouped by Asia/Jakarta day), and recent ledger
rows. The fields of the pre-wallet response stay, filled from the wallet, so
app versions that read them keep working.

Adds GET /customer/wallet/transactions with pagination and filters for
currency, one or more types, and an inclusive date range. Each row shows
where the value came from (additions) or went to (deductions) as in §8.1,
and additions list their lots and earliest expiry. The counterparty id, the
admin and the metadata are left out; the description already carries the
masked name. A malformed query answers 400, a missing customer 404.

/customer/points and /customer/tokens keep their shape and now read the
wallet too, so customer_points_repository is no longer used for balances.

Balances are what the customer can spend: lots that have expired but that
the expiry job has not processed are not counted. The point value is read
from organization_settings (loyalty.point.value, default 1) through a small
repository that the typed settings reader in PC-109 will build on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-30 09:15:26 +07:00
co-authored by Claude Opus 5.5
parent 41b75810fd
commit e5db0325cc
13 changed files with 1199 additions and 130 deletions
@@ -0,0 +1,246 @@
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 (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.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)
}