Files
apskel-pos-backend/internal/processor/wallet_query_processor.go
T
efrilmandClaude Opus 5.5 39e47ff0e6 feat(loyalty): typed loyalty settings with change history
Adds LoyaltySettingsProcessor (docs/prd-point-coin.md F1, F2, F12, PC-109).

Reading returns typed settings for an outlet (earning per currency, paying
with EnakPoint) and for an organization (point value, exchange rate,
transfers, and the expiry settings awaiting note N4). A key that was never
set takes the PRD default. A stored value that is unusable, such as an
earn_per_amount of 0 that would divide by zero, also falls back to the
default and is logged, so a bad row never reaches a calculation.

Writing takes the whole settings struct, validates every rule in the PRD
before touching the database, and stores and records in
loyalty_setting_changes only the keys whose effective value changes: old
value (NULL while it was on its default), new value, and who changed it.
Clearing a limit deletes the stored value. Each save runs in one
transaction under an advisory lock per outlet or organization, so two saves
at once cannot both compute their change from the same old value. The
outlet must belong to the caller's organization.

Every key is described once (key, default, valid range, bound field), and
reading, validating and diffing all use that description.

GET /customer/wallet now reads the point value through this processor; the
minimal organization settings repository from PC-106 is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:23:08 +07:00

318 lines
11 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
}