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 }