Files
apskel-pos-backend/internal/processor/wallet_query_processor.go
T
efrilmandClaude Opus 5.5 a6d5a8b056 feat(wallet): customer wallet and manual adjustments in the dashboard
Adds the dashboard side of a customer's wallet (docs/prd-point-coin.md F7,
PC-107), under /marketing for admins and managers:

- GET /marketing/customers/:id/wallet returns the customer, the ledger and
  spendable balances, every lot that still holds something (flagged when
  expired), and a page of history. Unlike the customer's own view, each row
  carries the real names behind it: the transfer counterparty, the admin or
  cashier, and the outlet, plus the reason and metadata.
- POST /marketing/customers/:id/wallet/adjust takes a signed amount and a
  required reason. It writes an ADJUSTMENT pointing at the admin through the
  wallet engine, refuses to take more than the customer can spend, and
  accepts an optional idempotency key so a retried request adjusts once.
  Reasons describing a cash-out are refused (K7).

The customer must belong to the caller's organization; otherwise both
endpoints answer 404. Positive adjustments create non-expiring lots until
the expiry model is decided (F12, note N4).

The mapping from ledger rows to what the apps show is now shared between the
customer and dashboard views.

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

330 lines
11 KiB
Go

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) {
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
}