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:
co-authored by
Claude Opus 5.5
parent
41b75810fd
commit
e5db0325cc
@@ -3,7 +3,6 @@ package processor
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
@@ -12,14 +11,14 @@ import (
|
||||
)
|
||||
|
||||
type CustomerPointsProcessor struct {
|
||||
customerPointsRepo repository.CustomerPointsRepository
|
||||
gameRepo *repository.GameRepository
|
||||
walletQuery *WalletQueryProcessor
|
||||
gameRepo *repository.GameRepository
|
||||
}
|
||||
|
||||
func NewCustomerPointsProcessor(customerPointsRepo repository.CustomerPointsRepository, gameRepo *repository.GameRepository) *CustomerPointsProcessor {
|
||||
func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *repository.GameRepository) *CustomerPointsProcessor {
|
||||
return &CustomerPointsProcessor{
|
||||
customerPointsRepo: customerPointsRepo,
|
||||
gameRepo: gameRepo,
|
||||
walletQuery: walletQuery,
|
||||
gameRepo: gameRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,159 +69,74 @@ func (p *CustomerPointsProcessor) DeductPoints(ctx context.Context, customerID u
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
// The three endpoints below are the customer app's balance screens. They read the
|
||||
// EnakPoint & EnakCoin wallet; customer_points and customer_tokens are no longer used
|
||||
// for balances (docs/prd-point-coin.md §9).
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerTotalPointsAPI(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) {
|
||||
// Get total points
|
||||
totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID)
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer total points: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get points history (last 10 records)
|
||||
pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 10)
|
||||
data, err := p.walletQuery.LegacyPoints(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer points history: %w", err)
|
||||
return nil, fmt.Errorf("failed to get customer points: %w", err)
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
var historyItems []models.PointsHistoryItem
|
||||
|
||||
for _, point := range pointsHistory {
|
||||
historyItems = append(historyItems, models.PointsHistoryItem{
|
||||
ID: point.ID.String(),
|
||||
Points: point.Balance,
|
||||
Type: "BALANCE",
|
||||
Description: "Points balance",
|
||||
CreatedAt: point.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
var lastUpdated time.Time
|
||||
if len(pointsHistory) > 0 {
|
||||
lastUpdated = pointsHistory[0].CreatedAt
|
||||
}
|
||||
|
||||
return &models.GetCustomerPointsResponse{
|
||||
Status: "SUCCESS",
|
||||
Message: "Customer points retrieved successfully.",
|
||||
Data: &models.GetCustomerPointsResponseData{
|
||||
TotalPoints: totalPoints,
|
||||
PointsHistory: historyItems,
|
||||
LastUpdated: lastUpdated,
|
||||
},
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerTotalTokensAPI(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) {
|
||||
// Get total tokens
|
||||
totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID)
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer total tokens: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get tokens history (last 10 records)
|
||||
tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 10)
|
||||
data, err := p.walletQuery.LegacyTokens(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer tokens history: %w", err)
|
||||
return nil, fmt.Errorf("failed to get customer tokens: %w", err)
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
var historyItems []models.TokensHistoryItem
|
||||
|
||||
for _, token := range tokensHistory {
|
||||
historyItems = append(historyItems, models.TokensHistoryItem{
|
||||
ID: token.ID.String(),
|
||||
Tokens: token.Balance,
|
||||
Type: string(token.TokenType),
|
||||
Description: "Tokens balance",
|
||||
CreatedAt: token.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
var lastUpdated time.Time
|
||||
if len(tokensHistory) > 0 {
|
||||
lastUpdated = tokensHistory[0].CreatedAt
|
||||
}
|
||||
|
||||
return &models.GetCustomerTokensResponse{
|
||||
Status: "SUCCESS",
|
||||
Message: "Customer tokens retrieved successfully.",
|
||||
Data: &models.GetCustomerTokensResponseData{
|
||||
TotalTokens: totalTokens,
|
||||
TokensHistory: historyItems,
|
||||
LastUpdated: lastUpdated,
|
||||
},
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) {
|
||||
// Get total points
|
||||
totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID)
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer total points: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get total tokens
|
||||
totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID)
|
||||
data, err := p.walletQuery.Summary(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer total tokens: %w", err)
|
||||
return nil, fmt.Errorf("failed to get customer wallet: %w", err)
|
||||
}
|
||||
|
||||
// Get points history (last 5 records)
|
||||
pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 5)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer points history: %w", err)
|
||||
}
|
||||
|
||||
// Get tokens history (last 5 records)
|
||||
tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 5)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer tokens history: %w", err)
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
var pointsHistoryItems []models.PointsHistoryItem
|
||||
var tokensHistoryItems []models.TokensHistoryItem
|
||||
var lastUpdated time.Time
|
||||
|
||||
for _, point := range pointsHistory {
|
||||
pointsHistoryItems = append(pointsHistoryItems, models.PointsHistoryItem{
|
||||
ID: point.ID.String(),
|
||||
Points: point.Balance,
|
||||
Type: "BALANCE",
|
||||
Description: "Points balance",
|
||||
CreatedAt: point.CreatedAt,
|
||||
})
|
||||
if point.CreatedAt.After(lastUpdated) {
|
||||
lastUpdated = point.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
for _, token := range tokensHistory {
|
||||
tokensHistoryItems = append(tokensHistoryItems, models.TokensHistoryItem{
|
||||
ID: token.ID.String(),
|
||||
Tokens: token.Balance,
|
||||
Type: string(token.TokenType),
|
||||
Description: "Tokens balance",
|
||||
CreatedAt: token.CreatedAt,
|
||||
})
|
||||
if token.CreatedAt.After(lastUpdated) {
|
||||
lastUpdated = token.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
return &models.GetCustomerWalletResponse{
|
||||
Status: "SUCCESS",
|
||||
Message: "Customer wallet retrieved successfully.",
|
||||
Data: &models.GetCustomerWalletResponseData{
|
||||
TotalPoints: totalPoints,
|
||||
TotalTokens: totalTokens,
|
||||
PointsHistory: pointsHistoryItems,
|
||||
TokensHistory: tokensHistoryItems,
|
||||
LastUpdated: lastUpdated,
|
||||
},
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerWalletTransactionsAPI(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) {
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.walletQuery.Transactions(ctx, id, query)
|
||||
}
|
||||
|
||||
func parseWalletCustomerID(customerID string) (uuid.UUID, error) {
|
||||
id, err := uuid.Parse(customerID)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("%w: invalid customer id", ErrInvalidWalletQuery)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetCustomerGamesAPI gets active SPIN games for customers
|
||||
func (p *CustomerPointsProcessor) GetCustomerGamesAPI(ctx context.Context) (*models.GetCustomerGamesResponse, error) {
|
||||
// Get active SPIN games
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ErrInvalidWalletQuery wraps every rejection of a malformed history query, so the
|
||||
// handler can answer with a validation error rather than a server error.
|
||||
var ErrInvalidWalletQuery = errors.New("invalid wallet query")
|
||||
|
||||
const (
|
||||
walletRecentTransactions = 5
|
||||
walletLegacyHistorySize = 10
|
||||
walletTransactionsPageLimit = 20
|
||||
walletTransactionsMaxLimit = 100
|
||||
)
|
||||
|
||||
// walletDisplayLocation is where the customer's calendar days start and end, for
|
||||
// date filters. Same zone as the database connection and the expiry grouping.
|
||||
var walletDisplayLocation = func() *time.Location {
|
||||
loc, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
return time.FixedZone("WIB", 7*60*60)
|
||||
}
|
||||
return loc
|
||||
}()
|
||||
|
||||
// WalletQueryProcessor answers what the customer app shows about a wallet: balances,
|
||||
// history and upcoming expiry (docs/prd-point-coin.md F6). It only reads.
|
||||
type WalletQueryProcessor struct {
|
||||
repo repository.WalletQueryRepository
|
||||
settings repository.OrganizationSettingRepository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewWalletQueryProcessor(repo repository.WalletQueryRepository, settings repository.OrganizationSettingRepository) *WalletQueryProcessor {
|
||||
return &WalletQueryProcessor{repo: repo, settings: settings, now: time.Now}
|
||||
}
|
||||
|
||||
// Summary is GET /customer/wallet. A customer who has never had a wallet gets zeros.
|
||||
func (p *WalletQueryProcessor) Summary(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerWalletResponseData, error) {
|
||||
organizationID, err := p.repo.GetCustomerOrganizationID(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := p.now()
|
||||
|
||||
balances, err := p.repo.SpendableBalances(ctx, customerID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pointValue, err := p.pointValue(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expiring, err := p.repo.NearestExpiring(ctx, customerID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recent, err := p.recent(ctx, customerID, "", walletRecentTransactions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
points, err := p.legacyPoints(ctx, customerID, balances)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens, err := p.legacyTokens(ctx, customerID, balances)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := &models.GetCustomerWalletResponseData{
|
||||
PointBalance: balances[constants.WalletCurrencyPoint],
|
||||
CoinBalance: balances[constants.WalletCurrencyCoin],
|
||||
PointValue: pointValue,
|
||||
PointDiscountValue: balances[constants.WalletCurrencyPoint] * pointValue,
|
||||
RecentTransactions: recent,
|
||||
TotalPoints: points.TotalPoints,
|
||||
TotalTokens: tokens.TotalTokens,
|
||||
PointsHistory: points.PointsHistory,
|
||||
TokensHistory: tokens.TokensHistory,
|
||||
LastUpdated: points.LastUpdated,
|
||||
}
|
||||
if tokens.LastUpdated.After(data.LastUpdated) {
|
||||
data.LastUpdated = tokens.LastUpdated
|
||||
}
|
||||
for _, e := range expiring {
|
||||
entry := &models.CustomerWalletExpiring{Amount: e.Amount, Date: e.Date}
|
||||
switch e.Currency {
|
||||
case constants.WalletCurrencyPoint:
|
||||
data.NearestExpiring.Point = entry
|
||||
case constants.WalletCurrencyCoin:
|
||||
data.NearestExpiring.Coin = entry
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Transactions is GET /customer/wallet/transactions.
|
||||
func (p *WalletQueryProcessor) Transactions(ctx context.Context, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) {
|
||||
filter, page, err := walletTransactionFilter(customerID, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, total, err := p.repo.ListTransactions(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := p.toTransactions(ctx, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalPages := int((total + int64(filter.Limit) - 1) / int64(filter.Limit))
|
||||
return &models.PaginatedResponse[models.CustomerWalletTransaction]{
|
||||
Data: items,
|
||||
Pagination: models.Pagination{Page: page, Limit: filter.Limit, Total: total, TotalPages: totalPages},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LegacyPoints is the pre-wallet GET /customer/points, now read from the wallet.
|
||||
func (p *WalletQueryProcessor) LegacyPoints(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerPointsResponseData, error) {
|
||||
balances, err := p.repo.SpendableBalances(ctx, customerID, p.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.legacyPoints(ctx, customerID, balances)
|
||||
}
|
||||
|
||||
func (p *WalletQueryProcessor) legacyPoints(ctx context.Context, customerID uuid.UUID, balances map[string]int64) (*models.GetCustomerPointsResponseData, error) {
|
||||
recent, err := p.recent(ctx, customerID, constants.WalletCurrencyPoint, walletLegacyHistorySize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := &models.GetCustomerPointsResponseData{TotalPoints: balances[constants.WalletCurrencyPoint]}
|
||||
for _, tx := range recent {
|
||||
data.PointsHistory = append(data.PointsHistory, models.PointsHistoryItem{
|
||||
ID: tx.ID.String(), Points: tx.Amount, Type: tx.Type, Description: tx.Description, CreatedAt: tx.CreatedAt,
|
||||
})
|
||||
}
|
||||
if len(recent) > 0 {
|
||||
data.LastUpdated = recent[0].CreatedAt
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// LegacyTokens is the pre-wallet GET /customer/tokens. Tokens are EnakCoin now.
|
||||
func (p *WalletQueryProcessor) LegacyTokens(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerTokensResponseData, error) {
|
||||
balances, err := p.repo.SpendableBalances(ctx, customerID, p.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.legacyTokens(ctx, customerID, balances)
|
||||
}
|
||||
|
||||
func (p *WalletQueryProcessor) legacyTokens(ctx context.Context, customerID uuid.UUID, balances map[string]int64) (*models.GetCustomerTokensResponseData, error) {
|
||||
recent, err := p.recent(ctx, customerID, constants.WalletCurrencyCoin, walletLegacyHistorySize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := &models.GetCustomerTokensResponseData{TotalTokens: balances[constants.WalletCurrencyCoin]}
|
||||
for _, tx := range recent {
|
||||
data.TokensHistory = append(data.TokensHistory, models.TokensHistoryItem{
|
||||
ID: tx.ID.String(), Tokens: tx.Amount, Type: tx.Type, Description: tx.Description, CreatedAt: tx.CreatedAt,
|
||||
})
|
||||
}
|
||||
if len(recent) > 0 {
|
||||
data.LastUpdated = recent[0].CreatedAt
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *WalletQueryProcessor) recent(ctx context.Context, customerID uuid.UUID, currency string, limit int) ([]models.CustomerWalletTransaction, error) {
|
||||
rows, _, err := p.repo.ListTransactions(ctx, repository.WalletTransactionFilter{
|
||||
CustomerID: customerID, Currency: currency, Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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) {
|
||||
var credits []uuid.UUID
|
||||
for _, row := range rows {
|
||||
if row.Amount > 0 {
|
||||
credits = append(credits, row.ID)
|
||||
}
|
||||
}
|
||||
lots, err := p.repo.ListLotsBySourceTransactions(ctx, credits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lotsByTx := make(map[uuid.UUID][]entities.WalletLot, len(credits))
|
||||
for _, lot := range lots {
|
||||
lotsByTx[lot.SourceTransactionID] = append(lotsByTx[lot.SourceTransactionID], lot)
|
||||
}
|
||||
|
||||
items := make([]models.CustomerWalletTransaction, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ref := &models.CustomerWalletTransactionRef{Type: row.ReferenceType, ID: row.ReferenceID}
|
||||
item := models.CustomerWalletTransaction{
|
||||
ID: row.ID,
|
||||
Currency: row.Currency,
|
||||
Type: row.Type,
|
||||
Amount: row.Amount,
|
||||
BalanceAfter: row.BalanceAfter,
|
||||
Description: row.Description,
|
||||
OutletID: row.OutletID,
|
||||
ReversesTransactionID: row.ReversesTransactionID,
|
||||
GroupID: row.GroupID,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
// The counterparty's id, the admin and the metadata are left out on purpose:
|
||||
// the customer sees the masked name in the description (§8.1).
|
||||
if row.Amount > 0 {
|
||||
item.Source = ref
|
||||
for _, lot := range lotsByTx[row.ID] {
|
||||
item.Lots = append(item.Lots, models.CustomerWalletLot{
|
||||
Amount: lot.OriginalAmount, Remaining: lot.RemainingAmount, ExpiresAt: lot.ExpiresAt,
|
||||
})
|
||||
if lot.ExpiresAt != nil && (item.ExpiresAt == nil || lot.ExpiresAt.Before(*item.ExpiresAt)) {
|
||||
item.ExpiresAt = lot.ExpiresAt
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item.Destination = ref
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func walletTransactionFilter(customerID uuid.UUID, q models.ListCustomerWalletTransactionsQuery) (repository.WalletTransactionFilter, int, error) {
|
||||
invalid := func(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidWalletQuery, fmt.Sprintf(format, args...))
|
||||
}
|
||||
filter := repository.WalletTransactionFilter{CustomerID: customerID}
|
||||
|
||||
page := q.Page
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if page < 1 {
|
||||
return filter, 0, invalid("page must be at least 1")
|
||||
}
|
||||
filter.Limit = q.Limit
|
||||
if filter.Limit == 0 {
|
||||
filter.Limit = walletTransactionsPageLimit
|
||||
}
|
||||
if filter.Limit < 1 || filter.Limit > walletTransactionsMaxLimit {
|
||||
return filter, 0, invalid("limit must be between 1 and %d", walletTransactionsMaxLimit)
|
||||
}
|
||||
filter.Offset = (page - 1) * filter.Limit
|
||||
|
||||
if q.Currency != "" {
|
||||
currency := strings.ToUpper(q.Currency)
|
||||
if !constants.IsValidWalletCurrency(currency) {
|
||||
return filter, 0, invalid("currency must be POINT or COIN")
|
||||
}
|
||||
filter.Currency = currency
|
||||
}
|
||||
for _, t := range strings.Split(q.Type, ",") {
|
||||
t = strings.ToUpper(strings.TrimSpace(t))
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := walletTypeRules[t]; !ok {
|
||||
return filter, 0, invalid("unknown type %q", t)
|
||||
}
|
||||
filter.Types = append(filter.Types, t)
|
||||
}
|
||||
|
||||
if q.From != "" {
|
||||
from, err := time.ParseInLocation("2006-01-02", q.From, walletDisplayLocation)
|
||||
if err != nil {
|
||||
return filter, 0, invalid("from must be a date like 2026-01-31")
|
||||
}
|
||||
filter.From = &from
|
||||
}
|
||||
if q.To != "" {
|
||||
to, err := time.ParseInLocation("2006-01-02", q.To, walletDisplayLocation)
|
||||
if err != nil {
|
||||
return filter, 0, invalid("to must be a date like 2026-01-31")
|
||||
}
|
||||
// To is inclusive for the customer, exclusive in the query.
|
||||
end := to.AddDate(0, 0, 1)
|
||||
filter.To = &end
|
||||
}
|
||||
if filter.From != nil && filter.To != nil && !filter.From.Before(*filter.To) {
|
||||
return filter, 0, invalid("from must not be after to")
|
||||
}
|
||||
return filter, page, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user