337 lines
12 KiB
Go
337 lines
12 KiB
Go
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
|
|
}
|