Reapply "feat(loyalty): EnakPoint & EnakCoin" (#32)
This reverts commit 4e24f9bbb0.
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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 pointValueReader
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// pointValueReader gives the rupiah value of one EnakPoint in an organization.
|
||||
// LoyaltySettingsProcessor is one.
|
||||
type pointValueReader interface {
|
||||
PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error)
|
||||
}
|
||||
|
||||
func NewWalletQueryProcessor(repo repository.WalletQueryRepository, settings pointValueReader) *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.settings.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)
|
||||
}
|
||||
|
||||
func (p *WalletQueryProcessor) toTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) {
|
||||
return buildWalletTransactions(ctx, p.repo, rows)
|
||||
}
|
||||
|
||||
// buildWalletTransactions turns ledger rows into what the apps show, in the same
|
||||
// order: where each row came from or went to, and the lots each credit created.
|
||||
func buildWalletTransactions(ctx context.Context, repo repository.WalletQueryRepository, 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 := 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
|
||||
}
|
||||
|
||||
// Expiring is GET /customer/wallet/expiring: what will expire, grouped by day (F6).
|
||||
func (p *WalletQueryProcessor) Expiring(ctx context.Context, customerID uuid.UUID) (*models.CustomerWalletExpiringList, error) {
|
||||
rows, err := p.repo.ExpiringByDay(ctx, customerID, p.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := &models.CustomerWalletExpiringList{Point: []models.CustomerWalletExpiring{}, Coin: []models.CustomerWalletExpiring{}}
|
||||
for _, row := range rows {
|
||||
item := models.CustomerWalletExpiring{Amount: row.Amount, Date: row.Date}
|
||||
switch row.Currency {
|
||||
case constants.WalletCurrencyPoint:
|
||||
list.Point = append(list.Point, item)
|
||||
case constants.WalletCurrencyCoin:
|
||||
list.Coin = append(list.Coin, item)
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
Reference in New Issue
Block a user