package repository import ( "context" "fmt" "time" "github.com/google/uuid" "gorm.io/gorm" "apskel-pos-be/internal/entities" ) // walletDisplayTimeZone is the zone expiry dates are grouped in for display, the same // one the database connection uses (config.Database.DSN). const walletDisplayTimeZone = "Asia/Jakarta" // WalletTransactionFilter narrows a customer's ledger. Zero values mean no filter. type WalletTransactionFilter struct { CustomerID uuid.UUID Currency string Types []string // From is inclusive and To is exclusive. From *time.Time To *time.Time Offset int Limit int } // WalletExpiringAmount is how much of one currency expires on one day. type WalletExpiringAmount struct { Currency string // A calendar date in walletDisplayTimeZone, formatted YYYY-MM-DD. Date string Amount int64 } // WalletQueryRepository serves the read side of the wallet: balances, history and // upcoming expiry for display. Nothing here changes a balance. type WalletQueryRepository interface { // GetCustomerOrganizationID returns ErrWalletNotFound when the customer does not exist. GetCustomerOrganizationID(ctx context.Context, customerID uuid.UUID) (uuid.UUID, error) // SpendableBalances sums, per currency, the lots that still have balance and have // not expired at asOf. It differs from the wallet balance only for lots that have // expired but that the expiry job has not processed yet. SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) // NearestExpiring returns, per currency, the earliest day after asOf on which some // balance expires, and how much expires that day. NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) // ExpiringByDay returns, per currency and day, everything that expires after asOf, // soonest first. ExpiringByDay(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) // ListTransactions returns a page of the ledger, newest first, and the total count. ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) // OrganizationOutstanding sums every wallet balance of an organization. OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (points, coins int64, err error) // ListLotsBySourceTransactions returns the lots created by the given credits. ListLotsBySourceTransactions(ctx context.Context, transactionIDs []uuid.UUID) ([]entities.WalletLot, error) } type walletQueryRepository struct { db *gorm.DB } func NewWalletQueryRepository(db *gorm.DB) WalletQueryRepository { return &walletQueryRepository{db: db} } func (r *walletQueryRepository) GetCustomerOrganizationID(ctx context.Context, customerID uuid.UUID) (uuid.UUID, error) { var ids []string err := DBFromContext(ctx, r.db).WithContext(ctx). Table("customers"). Where("id = ?", customerID). Limit(1). Pluck("organization_id::text", &ids).Error if err != nil { return uuid.Nil, fmt.Errorf("failed to get customer organization: %w", err) } if len(ids) == 0 { return uuid.Nil, ErrWalletNotFound } return uuid.Parse(ids[0]) } func (r *walletQueryRepository) SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) { var rows []struct { Currency string Amount int64 } err := DBFromContext(ctx, r.db).WithContext(ctx). Model(&entities.WalletLot{}). Select("currency, SUM(remaining_amount) AS amount"). Where("customer_id = ? AND remaining_amount > 0", customerID). Where("(expires_at IS NULL OR expires_at > ?)", asOf). Group("currency"). Scan(&rows).Error if err != nil { return nil, fmt.Errorf("failed to sum spendable wallet balance: %w", err) } balances := make(map[string]int64, len(rows)) for _, row := range rows { balances[row.Currency] = row.Amount } return balances, nil } func (r *walletQueryRepository) NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) { var rows []WalletExpiringAmount err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` WITH by_day AS ( SELECT currency, (expires_at AT TIME ZONE ?)::date AS day, SUM(remaining_amount) AS amount FROM wallet_lots WHERE customer_id = ? AND remaining_amount > 0 AND expires_at > ? GROUP BY currency, day ) SELECT DISTINCT ON (currency) currency, to_char(day, 'YYYY-MM-DD') AS date, amount FROM by_day ORDER BY currency, day`, walletDisplayTimeZone, customerID, asOf). Scan(&rows).Error if err != nil { return nil, fmt.Errorf("failed to get nearest expiring wallet balance: %w", err) } return rows, nil } func (r *walletQueryRepository) ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) { query := DBFromContext(ctx, r.db).WithContext(ctx). Model(&entities.WalletTransaction{}). Where("customer_id = ?", filter.CustomerID) if filter.Currency != "" { query = query.Where("currency = ?", filter.Currency) } if len(filter.Types) > 0 { query = query.Where("type IN ?", filter.Types) } if filter.From != nil { query = query.Where("created_at >= ?", *filter.From) } if filter.To != nil { query = query.Where("created_at < ?", *filter.To) } var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count wallet transactions: %w", err) } var transactions []entities.WalletTransaction err := query. Order("created_at DESC, id DESC"). Offset(filter.Offset). Limit(filter.Limit). Find(&transactions).Error if err != nil { return nil, 0, fmt.Errorf("failed to list wallet transactions: %w", err) } return transactions, total, nil } func (r *walletQueryRepository) ListLotsBySourceTransactions(ctx context.Context, transactionIDs []uuid.UUID) ([]entities.WalletLot, error) { var lots []entities.WalletLot if len(transactionIDs) == 0 { return lots, nil } err := DBFromContext(ctx, r.db).WithContext(ctx). Where("source_transaction_id IN ?", transactionIDs). Order("expires_at NULLS LAST, created_at, id"). Find(&lots).Error if err != nil { return nil, fmt.Errorf("failed to list wallet lots: %w", err) } return lots, nil } func (r *walletQueryRepository) OrganizationOutstanding(ctx context.Context, organizationID uuid.UUID) (int64, int64, error) { var totals struct{ Points, Coins int64 } err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` SELECT COALESCE(SUM(point_balance), 0) AS points, COALESCE(SUM(coin_balance), 0) AS coins FROM customer_wallets WHERE organization_id = ?`, organizationID).Scan(&totals).Error if err != nil { return 0, 0, fmt.Errorf("failed to sum outstanding balances: %w", err) } return totals.Points, totals.Coins, nil } func (r *walletQueryRepository) ExpiringByDay(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) { var rows []WalletExpiringAmount err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` SELECT currency, to_char((expires_at AT TIME ZONE ?)::date, 'YYYY-MM-DD') AS date, SUM(remaining_amount) AS amount FROM wallet_lots WHERE customer_id = ? AND remaining_amount > 0 AND expires_at > ? GROUP BY currency, date ORDER BY date, currency`, walletDisplayTimeZone, customerID, asOf). Scan(&rows).Error if err != nil { return nil, fmt.Errorf("failed to list expiring wallet balance: %w", err) } return rows, nil }