package analytics import ( "context" "encoding/base64" "errors" "fmt" "strconv" "time" "github.com/jackc/pgx/v5" ) type DetailFilter struct { View string FailureStage string TerminalID string Cursor string Limit int } type Attempt struct { Sequence int `json:"sequence"` Status string `json:"status"` CreatedAt *time.Time `json:"createdAt"` InitTimeMS *int `json:"initTimeMs"` VerifyTimeMS *int `json:"verifyTimeMs"` } type Detail struct { SessionID string `json:"sessionId"` TerminalID string `json:"terminalId"` SessionStatus string `json:"sessionStatus"` AmountRial int64 `json:"amountRial"` FeeAmountRial int64 `json:"feeAmountRial"` CreatedAt time.Time `json:"createdAt"` ExpiresAt *time.Time `json:"expiresAt"` VerifiedAt *time.Time `json:"verifiedAt"` SettledAt *time.Time `json:"settledAt"` AttemptCount int `json:"attemptCount"` FailureStage *string `json:"failureStage"` Attempts []Attempt `json:"attempts"` } func (s *Service) Details(ctx context.Context, merchantID int64, period Period, filter DetailFilter) ([]Detail, string, error) { if filter.Limit <= 0 { filter.Limit = 25 } if filter.Limit > 100 { return nil, "", errors.New("analytics: detail limit exceeds 100") } offset := 0 if filter.Cursor != "" { decoded, err := base64.RawURLEncoding.DecodeString(filter.Cursor) if err != nil { return nil, "", errors.New("analytics: invalid cursor") } offset, err = strconv.Atoi(string(decoded)) if err != nil || offset < 0 { return nil, "", errors.New("analytics: invalid cursor") } } statusPredicate := "ps.status='Paid'" switch filter.View { case "UNVERIFIED_PAID": statusPredicate = "ps.status='Paid'" case "FAILED": statusPredicate = "ps.status='Failed'" case "RETRIED": statusPredicate = "(SELECT count(*) FROM payment_attempts x WHERE x.session_id=ps.id)>1" default: return nil, "", errors.New("analytics: invalid detail view") } query := `SELECT ps.id,ps.source_key,t.source_key,ps.status,ps.amount_rial,ps.fee_amount_rial,ps.created_at,ps.expires_at,ps.verified_at,ps.settled_at, count(pa.id)::int,coalesce(bool_or(pa.status='InBank'),false) FROM payment_sessions ps JOIN terminals t ON t.id=ps.terminal_id LEFT JOIN payment_attempts pa ON pa.session_id=ps.id WHERE ps.merchant_id=$1 AND ps.created_at>=$2 AND ps.created_at<$3 AND ` + statusPredicate + ` AND ($4='' OR t.source_key=$4) GROUP BY ps.id,t.source_key ORDER BY ps.created_at DESC,ps.id DESC LIMIT $5 OFFSET $6` rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, filter.TerminalID, filter.Limit+1, offset) if err != nil { return nil, "", fmt.Errorf("querying session details: %w", err) } defer rows.Close() details := make([]Detail, 0, filter.Limit) ids := make([]int64, 0, filter.Limit) for rows.Next() { var id int64 var reachedBank bool var d Detail if err := rows.Scan(&id, &d.SessionID, &d.TerminalID, &d.SessionStatus, &d.AmountRial, &d.FeeAmountRial, &d.CreatedAt, &d.ExpiresAt, &d.VerifiedAt, &d.SettledAt, &d.AttemptCount, &reachedBank); err != nil { return nil, "", fmt.Errorf("scanning session detail: %w", err) } d.SessionID = "ses_" + d.SessionID d.Attempts = []Attempt{} if d.SessionStatus == "Failed" { stage := "OTHER_OBSERVED" if d.AttemptCount == 0 { stage = "NO_ATTEMPT" } else if reachedBank { stage = "REACHED_BANK" } d.FailureStage = &stage if filter.FailureStage != "" && filter.FailureStage != stage { continue } } if len(details) < filter.Limit { details = append(details, d) ids = append(ids, id) } } if err := rows.Err(); err != nil { return nil, "", fmt.Errorf("iterating session details: %w", err) } for i, id := range ids { attemptRows, err := s.pool.Query(ctx, `SELECT sequence,status,created_at,init_time_ms,verify_time_ms FROM payment_attempts WHERE session_id=$1 ORDER BY sequence`, id) if err != nil { return nil, "", fmt.Errorf("querying attempts: %w", err) } attempts, err := pgx.CollectRows(attemptRows, func(row pgx.CollectableRow) (Attempt, error) { var a Attempt err := row.Scan(&a.Sequence, &a.Status, &a.CreatedAt, &a.InitTimeMS, &a.VerifyTimeMS) return a, err }) if err != nil { return nil, "", fmt.Errorf("collecting attempts: %w", err) } details[i].Attempts = attempts } next := "" if len(details) == filter.Limit { next = base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset + filter.Limit))) } return details, next, nil }