package processor import ( "context" "math" "strings" "time" "github.com/google/uuid" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) // enakGameAnalyticsMaxDays bounds a range, so one request cannot scan years of ledger. const enakGameAnalyticsMaxDays = 366 // EnakGameAnalyticsProcessor answers the EnakGame dashboards (docs/tasks-enakgame.md // EG-903, PRD ยง36) for an organization and a range of days in Asia/Jakarta. type EnakGameAnalyticsProcessor struct { analytics repository.EnakGameAnalyticsRepository } func NewEnakGameAnalyticsProcessor(analytics repository.EnakGameAnalyticsRepository) *EnakGameAnalyticsProcessor { return &EnakGameAnalyticsProcessor{analytics: analytics} } // analyticsRange reads from and to, both days included, as [start, end). func analyticsRange(q models.EnakGameAnalyticsQuery) (start, end time.Time, err error) { from, err := time.Parse("2006-01-02", strings.TrimSpace(q.From)) if err != nil { return start, end, enakGameRejected("from must be a date like 2026-10-01") } to, err := time.Parse("2006-01-02", strings.TrimSpace(q.To)) if err != nil { return start, end, enakGameRejected("to must be a date like 2026-10-31") } if to.Before(from) { return start, end, enakGameRejected("to cannot be before from") } if daysBetween(from, to)+1 > enakGameAnalyticsMaxDays { return start, end, enakGameRejected("the range can span at most %d days", enakGameAnalyticsMaxDays) } return jakartaMidnight(from), jakartaMidnight(to.AddDate(0, 0, 1)), nil } // Games is how the organization's games were played, by the day each session // started. func (p *EnakGameAnalyticsProcessor) Games(ctx context.Context, organizationID uuid.UUID, q models.EnakGameAnalyticsQuery) (*models.EnakGameAnalytics, error) { start, end, err := analyticsRange(q) if err != nil { return nil, err } var gameID *uuid.UUID if s := strings.TrimSpace(q.GameID); s != "" { id, err := uuid.Parse(s) if err != nil { return nil, enakGameRejected("game_id must be a UUID") } gameID = &id } rows, err := p.analytics.SessionStats(ctx, organizationID, start, end, gameID) if err != nil { return nil, err } out := &models.EnakGameAnalytics{From: strings.TrimSpace(q.From), To: strings.TrimSpace(q.To), Games: []models.EnakGameGameStats{}} for _, row := range rows { if row.GameID == nil { out.Totals = gameStats(row) continue } g := models.EnakGameGameStats{GameID: *row.GameID, EnakGameStats: gameStats(row)} if row.GameName != nil { g.GameName = *row.GameName } out.Games = append(out.Games, g) } return out, nil } func gameStats(r repository.GameSessionStats) models.EnakGameStats { s := models.EnakGameStats{ Plays: r.Plays, Completed: r.Completed, Refunded: r.Refunded, Expired: r.Expired, Flagged: r.Flagged, Players: r.Players, AverageScore: r.AverageScore, CoinIssued: r.CoinIssued, EntryCostPaid: r.EntryCost, CoinRefunded: r.CoinRefunded, } if r.Completed > 0 { s.AverageReward = ratio(r.CoinIssued, r.Completed) } if r.Plays > 0 { s.RewardPerPlay = ratio(r.CoinIssued, r.Plays) } return s } // ratio is part / whole with two decimals. func ratio(part, whole int64) float64 { return math.Round(float64(part)*100/float64(whole)) / 100 } // Economy is how EnakCoin and EnakPoint moved in the organization, from the ledger. func (p *EnakGameAnalyticsProcessor) Economy(ctx context.Context, organizationID uuid.UUID, q models.EnakGameAnalyticsQuery) (*models.EnakGameEconomyAnalytics, error) { start, end, err := analyticsRange(q) if err != nil { return nil, err } flows, err := p.analytics.WalletFlows(ctx, organizationID, start, end) if err != nil { return nil, err } balances, err := p.analytics.BalancesAt(ctx, organizationID, end) if err != nil { return nil, err } out := economy(flows, balances) out.From, out.To = strings.TrimSpace(q.From), strings.TrimSpace(q.To) return out, nil } // economy turns ledger totals into the headline numbers. Transfers move value between // customers and count in none of them; ByType still lists them. func economy(flows []repository.WalletFlow, balances map[string]int64) *models.EnakGameEconomyAnalytics { type key struct{ currency, txType string } byKey := make(map[key]repository.WalletFlow, len(flows)) out := &models.EnakGameEconomyAnalytics{ByType: make([]models.WalletFlowTotals, 0, len(flows))} for _, f := range flows { byKey[key{f.Currency, f.Type}] = f out.ByType = append(out.ByType, models.WalletFlowTotals{ Currency: f.Currency, Type: f.Type, Credit: f.Credit, Debit: f.Debit, Transactions: f.Transactions, }) } coin := func(txType string) repository.WalletFlow { return byKey[key{constants.WalletCurrencyCoin, txType}] } point := func(txType string) repository.WalletFlow { return byKey[key{constants.WalletCurrencyPoint, txType}] } c := &out.Coin c.GameRewards = coin(constants.WalletTxTypeGameReward).Credit c.Generated = c.GameRewards + coin(constants.WalletTxTypeEarn).Credit - coin(constants.WalletTxTypeEarnReversal).Debit + coin(constants.WalletTxTypeMigration).Credit + coin(constants.WalletTxTypeAdjustment).Credit c.SpentOnGames = coin(constants.WalletTxTypeGameSpend).Debit - coin(constants.WalletTxTypeGameSpendRefund).Credit c.Exchanged = coin(constants.WalletTxTypeExchangeOut).Debit c.Spent = c.SpentOnGames + c.Exchanged c.Expired = coin(constants.WalletTxTypeExpire).Debit c.Outstanding = balances[constants.WalletCurrencyCoin] pt := &out.Point pt.Earned = point(constants.WalletTxTypeEarn).Credit - point(constants.WalletTxTypeEarnReversal).Debit pt.Exchanged = point(constants.WalletTxTypeExchangeIn).Credit pt.Redeemed = point(constants.WalletTxTypeRewardRedeem).Debit - point(constants.WalletTxTypeRewardRedeemRefund).Credit pt.Expired = point(constants.WalletTxTypeExpire).Debit pt.Balance = balances[constants.WalletCurrencyPoint] return out }