eNGBL.psm1

<# PRIVATE FUNCTIONS #>

function get-ServicePlanInfoFile {
<#
.SYNOPSIS
    license assignement information comes from a CSV file published on Microsoft site. this support function checks if the file is present
    in the local temp folder and if not, downloads it.
.DESCRIPTION
    force parameter ensures that the file is downloaded even if it exists. this is useful when the file on the server has changed.
    the file is downloaded to the local temp folder and imported as a CSV file.
.EXAMPLE
    get-ServicePlanInfoFile -force
 
    forces download of the file even if it exists. this is useful when the file on the server has changed.
.INPUTS
    None.
.OUTPUTS
    "$TempFolder\servicePlans.csv"
.LINK
    https://download.microsoft.com/download/e/3/e/e3e9faf2-f28b-490a-9ada-c6089a1fc5b0/Product%20names%20and%20service%20plan%20identifiers%20for%20licensing.csv
.LINK
    https://w-files.pl
.NOTES
    nExoR ::))o-
    version 250412
        last changes
        - 250412 initialized
 
    #TO|DO
#>

    [CmdletBinding()]
    param (
        #force download of the file even if it exists
        [switch]$force
    )

    $TempFolder = [System.IO.Path]::GetTempPath()
    $spFile = "$TempFolder\servicePlans.csv"
    [System.Uri]$url = "https://download.microsoft.com/download/e/3/e/e3e9faf2-f28b-490a-9ada-c6089a1fc5b0/Product%20names%20and%20service%20plan%20identifiers%20for%20licensing.csv"

    if(!(test-path $spFile) -or $force) {
        Write-Verbose "file containing plans list not found - downloading..."
        try {
            Invoke-WebRequest $url -OutFile $spFile
        } catch {
            throw "cannot download definitions the file."
        }
    } 
    $spInfo = import-csv $spFile -Delimiter ','
    return $spInfo
}
function connect-licenseGraph {
    [cmdletbinding()]
    param(
    )

    Write-Debug ("[Scope level: {0}]" -f $MyInvocation.ScopeDepth)

    $ctx = get-mgContext
    if(-not $ctx) {
        Write-Verbose "connection not found - connecting..."
        try {
            Connect-MgGraph -Scopes "Group.ReadWrite.All", "Directory.ReadWrite.All"
        } catch {
            throw "cannot connect to Microsoft Graph."
        }
        $ctx = get-mgContext
        if(-not $ctx) {
            throw "you need to be connected to continue."
        }
    } else {
        if($ctx.Scopes -notcontains 'Group.ReadWrite.All' -or $ctx.Scopes -notcontains 'Directory.ReadWrite.All') {
            Write-Verbose "connection found, but not valid for this module. trying to extend scope..."
            try {
                Connect-MgGraph -Scopes "Group.ReadWrite.All", "Directory.ReadWrite.All" -force
            } catch {
                throw "cannot connect to Microsoft Graph."
            }
            #retest to manage 'cancel'
            $ctx = get-mgContext
            if(-not $ctx) {
                throw "you need to be connected to continue."
            }
            Write-Verbose "connection found and valid."
        }
    }
    Write-host "connected as $($ctx.Account)"
    return $ctx
}
function convert-toShortString {
    param( 
        $licenseInfo
    ) 

    $htLicenses = @{}

    foreach($lic in $licenseInfo) {
        if($htLicenses.ContainsKey($lic.SkuId)) {
            $htLicenses[$lic.SkuId] = "[{0}]{1}" -f $lic.groupDisplayName,$htLicenses[$lic.SkuId]
        } else {
            $htLicenses[$lic.SkuId] = "[{0}]{1}" -f $lic.groupDisplayName,$lic.skuDisplayName
        }
    }
    return $htLicenses.Values -join ','
}
function convert-toShortStringExt {
    param( 
        $licenseInfo,
        [string]$skuId
    ) 

    $licInfo = ""

    foreach($lic in $licenseInfo|? SkuId -eq $skuId) {
        $licInfo += "[{0}]" -f $lic.groupDisplayName
    }
    if($licenseInfo.disabledPlanDisplayName) {
        $licInfo += ("({0})" -f ($licenseInfo.disabledPlanDisplayName -join '|') )
    }

    return $licInfo

}
<# PUBLIC FUNCTIOND #>

function find-SKU {
    [CmdletBinding()]
    param(
        #lookup the name (internal, displayname or GUID) of the SKU and shows all other values
        [Parameter(mandatory,position=0,ValueFromPipeline)]
            [string]$id,
        #limit lookup to EXACT name and shows other values
        [Parameter(position=1)]
            [switch]$exact,
        #force download new SKU file
        [Parameter(position=2)]
            [switch]$force    
    )

    Begin {
        $VerbosePreference = 'Continue'
        if($force) {
            $spInfo = get-ServicePlanInfoFile -force
        } else {
            $spInfo = get-ServicePlanInfoFile
        }
    }

    Process {
        if($exact) {
            $SKU = $spInfo | Where-Object { $_.Product_Display_Name -eq $id -or $_.String_Id -eq $id -or $_.GUID -eq $id }
        } else {
            $SKU = $spInfo | Where-Object { $_.Product_Display_Name -match $id -or $_.String_Id -match $id -or $_.GUID -match $id }
        }
        if($SKU) {
            return $SKU | Select-Object @{L='SKUFriendlyName';E={$_.Product_Display_Name}},@{L='SKUCodeName';E={$_.String_Id}},@{L='SKUGUID';E={$_.GUID}} -Unique
        } else {
            return $null
        }
    }
}

function find-ServicePlan {
    [CmdletBinding(DefaultParameterSetName = 'byName')]
    param(
        #finds all Service Plan details. plan name may be partial and be a code name, friendly name or GUID
        [Parameter(ParameterSetName = 'byName',mandatory,position=0)]
        [Parameter(ParameterSetName = 'SKUs',mandatory,position=0)]
            [string]$id,
        #limit lookup to EXACT the name (internal, displayname) and shows details.
        [Parameter(ParameterSetName = 'byName',position=1)]
        [Parameter(ParameterSetName = 'SKUs',mandatory,position=1)]
            [switch]$exact,
        #provides a list of all SKUs containing the Service Plan name.
        [Parameter(ParameterSetName = 'SKUs',position=2)]
            [switch]$showSKUs
    )

    Begin {
        $VerbosePreference = 'Continue'
        $spInfo = get-ServicePlanInfoFile
    }

    Process {
        if($exact) {
            Write-debug 'exact search'
            $foundSKUs = $spInfo | Where-Object { $_.Service_Plans_Included_Friendly_Names -eq $id -or $_.Service_Plan_Name -eq $id -or $_.Service_Plan_Id -eq $id } 
        } else {
            $foundSKUs = $spInfo | Where-Object { $_.Service_Plans_Included_Friendly_Names -match $id -or $_.Service_Plan_Name -match $id -or $_.Service_Plan_Id -match $id } 
        }
        if($showSKUs) {
            return $foundSKUs | Select-Object @{L='ServicePlanFriendlyName';E={$_.Service_Plans_Included_Friendly_Names}},
                @{L='ServicePlanCodeName';E={$_.Service_Plan_Name}},
                @{L='ServicePlanGUID';E={$_.Service_Plan_Id}},
                @{L='SKUFriendlyName';E={$_.Product_Display_Name}},
                @{L='SKUCodeName';E={$_.String_Id}},
                @{L='SKUGUID';E={$_.GUID}} -Unique
        } else {
            return $foundSKUs | Select-Object @{L='ServicePlanFriendlyName';E={$_.Service_Plans_Included_Friendly_Names}},
                @{L='ServicePlanCodeName';E={$_.Service_Plan_Name}},
                @{L='ServicePlanGUID';E={$_.Service_Plan_Id}} -Unique
        }
    }
}

function show-ServicePlans {
<#
.SYNOPSIS
    display information on Service Plans
.DESCRIPTION
    constant problems I encounter with licenses (called 'products') are:
    - does this or that license contain some service plan?
    - what is given SKU - since I have technical output and interace shows different name?
    - which service plans are included in given licence?
    this script addresses exactly these question. it downloads current SKU name listing from Microsoft doc and
    lookup the names.
    this is very simple script - if you want to refresh SKU names (e.g. new CSV appeard on the docs) simply remove
    'servicePlans.csv' file.
.EXAMPLE
    get-ServicePlanInfo -lookupName EOP_ENTERPRISE_PREMIUM
     
    shows friendly name of EOP_ENTERPRISE_PREMIUM. works for both - Service Plans and License names, may be partial.
.EXAMPLE
    get-ServicePlanInfo -lookupName 'Business Standard'
     
    looks up for all licenses containing 'Business Standard' in their name. here - friendly name will match. may be partial.
.EXAMPLE
    get-ServicePlanInfo -findPlan 'INTUNE'
     
    shows all licenses/products that include any service plan containing 'INTUNE' in the name. you can use either
    SKU name or Friendly name for plans. may be partial.
.EXAMPLE
    get-ServicePlanInfo -lookupName 'business basic'
 
Product_Display_Name String_Id GUID
-------------------- --------- ----
Microsoft 365 Business Basic O365_BUSINESS_ESSENTIALS 3b555118-da6a-4418-894f-7df1e2096870
Microsoft 365 Business Basic SMB_BUSINESS_ESSENTIALS dab7782a-93b1-4074-8bb1-0e61318bea0b
Microsoft 365 Business Basic EEA (no Teams) Microsoft_365_Business_Basic_EEA_(no_Teams) b1f3042b-a390-4b56-ab61-b88e7e767a97
    .\get-ServicePlanInfo.ps1 -productServicePlans O365_BUSINESS_ESSENTIALS
 
SKU Friendly Name Service_Plan_Id
--- ------------- ---------------
BPOS_S_TODO_1 To-Do (Plan 1) 5e62787c-c316-451f-b873-1d05acd4d12c
EXCHANGE_S_STANDARD EXCHANGE ONLINE (PLAN 1) 9aaf7827-d63c-4b61-89c3-182f06f82e5c
FLOW_O365_P1 FLOW FOR OFFICE 365 0f9b09cb-62d1-4ff4-9129-43f4996f83f4
FORMS_PLAN_E1 MICROSOFT FORMS (PLAN E1) 159f4cd6-e380-449f-a816-af1a9ef76344
MCOSTANDARD SKYPE FOR BUSINESS ONLINE (PLAN 2) 0feaeb32-d00e-4d66-bd5a-43b5b83db82c
OFFICEMOBILE_SUBSCRIPTION OFFICEMOBILE_SUBSCRIPTION c63d4d19-e8cb-460e-b37c-4d6c34603745
POWERAPPS_O365_P1 POWERAPPS FOR OFFICE 365 92f7a6f3-b89b-4bbd-8c30-809e6da5ad1c
PROJECTWORKMANAGEMENT MICROSOFT PLANNE b737dad2-2f6c-4c65-90e3-ca563267e8b9
SHAREPOINTSTANDARD SHAREPOINTSTANDARD c7699d2e-19aa-44de-8edf-1736da088ca1
SHAREPOINTWAC OFFICE ONLINE e95bec33-7c88-4a70-8e19-b10bd9d0c014
SWAY SWAY a23b959c-7ce8-4e57-9140-b90eb88a9e97
TEAMS1 TEAMS1 57ff2da0-773e-42df-b2af-ffb7a2317929
YAMMER_ENTERPRISE YAMMER_ENTERPRISE 7547a3fe-08ee-4ccb-b430-5077c5041653
     
    productServicePlans require an exact name to limit the output, so in the first step lookUp function was used to find
    proper license name, then it was provided for productServicePlans to show all Service Plans included in the license.
.LINK
    https://w-files.pl
.LINK
    https://docs.microsoft.com/en-us/azure/active-directory/enterprise-users/licensing-service-plan-reference
.NOTES
    nExoR ::))o-
    version 250331
        last changes
        - v2
 
    #TO|DO
#>

    [CmdletBinding(DefaultParameterSetName = 'bySKU')]
    param(
        #name of the SKU to show Service Plans for
        [Parameter(ParameterSetName = 'byName',mandatory,position=0)]
            [string]$id,
        #SKU object from find-SKU to show Service Plans for
        [Parameter(ParameterSetName = 'bySKU',mandatory,position=0,ValueFromPipeline)]
            [PSObject]$SKU
    )

    Begin {
        $VerbosePreference = 'Continue'
        $spInfo = get-ServicePlanInfoFile
    }

    Process {
        if($PSCmdlet.ParameterSetName -eq 'byName') {
            $SKU = find-SKU -id $id | select-object -first 1 #some licenses have multiple names - strange, but true
        }
        write-verbose "SKU Friendly name: $($SKU.SKUFriendlyName); ID: $($SKU.SKUCodeName); GUID: $($SKU.SKUGUID) contains following Service Plans:"
        $ret = $spInfo | Where-Object {$_.Product_Display_Name -eq $SKU.SKUFriendlyName} | 
            Select-Object @{L='ServicePlanFriendlyName';E={$_.Service_Plans_Included_Friendly_Names}},
                @{L='ServicePlanCodeName';E={$_.Service_Plan_Name}},
                @{L='ServicePlanGUID';E={$_.Service_Plan_Id}} 
        return $ret
    }
}

function compare-SKUs {
<#
.SYNOPSIS
    compares plans fot two SKUs showing which are the same and which are different.
    SKU may be provided as GUID, code name or displayname.
.DESCRIPTION
    allows to copare plans based on Microsoft CSV info but this file does not cover all plans. for some reason
    Microsoft is not updating it often enough. the other option is to copmpare some plan from CSV with object taken
    from get-mgSubscribedSku
.EXAMPLE
    compare-eNGBLSKUs -SKU1 'Office 365 E3' -SKU2 'Microsoft 365 E3'
 
    shows differences between the two licenses
.EXAMPLE
    $skus = get-mgSubscribedSku -all
    compare-eNGBLSKUs -SKU1 52ea0e27-ae73-4983-a08f-13561ebdb823 -objSKU ($skus|? SkuPartNumber -eq 'Microsoft_Teams_Enterprise_New')
 
    SKU1 is a 'Teams Premium (for Departments)'
    Microsoft_Teams_Enterprise_New does not exisit in Microsoft file (at the date of writing that) and I still want to understand the difference.
    so I get the object from tenant - it contains SKU Ids of all Service Plans included, so may compare the two.
.INPUTS
    None.
.OUTPUTS
    None.
.LINK
    https://w-files.pl
.NOTES
    nExoR ::))o-
    version 250414
        last changes
        - 250414 initialized
 
    #TO|DO
#>


    [CmdletBinding(DefaultParameterSetName='string')]
    param(
        [Parameter(ParameterSetName='string',Mandatory, Position=0)]
        [Parameter(ParameterSetName='object',Mandatory, Position=0)]
            [string]$SKU1,

        [Parameter(ParameterSetName='string',Mandatory, Position=1)]
            [string]$SKU2,

        [Parameter(ParameterSetName='object',Mandatory, Position=1)]
            $objSKU
    )

    function Show-Plans {
        param (
            [array]$plans,
            [string]$header,
            [string]$color
        )
        Write-Host "`n$header" -ForegroundColor $color
        $plans | Sort-Object Service_Plans_Included_Friendly_Names | Select-Object `
            @{L='ServicePlanFriendlyName';E={$_.Service_Plans_Included_Friendly_Names}},
            @{L='ServicePlanCodeName';E={$_.Service_Plan_Name}},
            @{L='ServicePlanGUID';E={$_.Service_Plan_Id}} | Format-Table | Out-Host
    }

    $spInfo = get-ServicePlanInfoFile

    # Resolve SKU info using your existing find-SKU
    $sku1Obj = find-SKU -id $SKU1 -exact
    if (-not $sku1Obj) {
        throw "SKU-1 not found."
    }

    if($PSCmdlet.ParameterSetName -eq 'string') {
        $sku2Obj = find-SKU -id $SKU2 -exact
        if (-not $sku2Obj) {
            throw "SKU-1 not found."
        }
    } else {
        $objSKU
        if(-not $objSKU.ServicePlans.ServicePlanId) {
            throw "wrong object for SKU2"
        }
    }

    # Filter service plans for each SKU
    $plans1 = $spInfo | Where-Object { $_.GUID -eq $sku1Obj.SKUGUID }
    if($PSCmdlet.ParameterSetName -eq 'string') {
        $plans2 = $spInfo | Where-Object { $_.GUID -eq $sku2Obj.SKUGUID }
    }

    # Build sets by GUID
    $guids1 = $plans1.Service_Plan_Id
    if($PSCmdlet.ParameterSetName -eq 'string') {
        $guids2 = $plans2.Service_Plan_Id
    } else {
        $guids2 = $objSKU.ServicePlans.ServicePlanId
    }
    $onlyIn1 = $plans1 | Where-Object { $_.Service_Plan_Id -notin $guids2 }
    $onlyIn2 = $plans2 | Where-Object { $_.Service_Plan_Id -notin $guids1 }
    $inBoth  = $plans1 | Where-Object { $_.Service_Plan_Id -in $guids2 }

    if($PSCmdlet.ParameterSetName -eq 'string') {
        $sku2FriendlyName = $($sku2Obj.SKUFriendlyName)
    } else {
        $sku2FriendlyName = $objSKU.SkuPartNumber
    }

    Show-Plans -plans $onlyIn1 -header "Plans in $($sku1Obj.SKUFriendlyName) but not in $sku2FriendlyName :" -color 'Magenta'
    Show-Plans -plans $onlyIn2 -header "Plans in $sku2FriendlyName but not in $($sku1Obj.SKUFriendlyName):" -color 'Yellow'
    Show-Plans -plans $inBoth  -header "Plans common to both SKUs:" -color 'Green'

}

function set-GroupLicense {
    <#
.SYNOPSIS
    assignes license (SKU) to a group allowing to choose disabled Service Plans.
    uses gridview component to facilietate selection of group and SKU.
.DESCRIPTION
    here be dragons
.EXAMPLE
    set-eNGBLGroupLicense
 
    runs the function allowing to choose all elements from the Grid View list.
.EXAMPLE
    set-eNGBLGroupLicense -GroupID 12345678-1234-1234-1234-123456789012 -SKUId 12345678-1234-1234-1234-123456789012
 
    skips searching the group and SKU using provided values and displays only Service Plans to be disabled from SKU.
.INPUTS
    None.
.OUTPUTS
    None.
.LINK
    https://w-files.pl
.NOTES
    nExoR ::))o-
    version 250412
        last changes
        - 250412 initialized
 
    #TO|DO
    - disabled service plans are lacking otpion for automation...
    #>

    
#requires -module Microsoft.Graph.Authentication, Microsoft.Graph.Groups, Microsoft.Graph.Identity.DirectoryManagement
    [CmdletBinding()]
    param(
        [Parameter(Position=0)]
            [string]$GroupID,
        [Parameter(Position=1)]
            [string]$SKUId,
        [Parameter(Position=2)]
            [switch]$Force
    )

    $VerbosePreference = 'Continue'
    $ctx = connect-LicenseGraph

    if(-not $GroupID) {
        Write-debug 'grp'
        $group = get-mgGroup -Filter "securityEnabled eq true" -Property DisplayName,Id,AssignedLicenses | 
            Select-Object DisplayName,Id,@{L='licenses';E={($_.AssignedLicenses.SkuId | find-SKU).SKUFriendlyName }} | 
            Sort-Object DisplayName |
            Out-GridView -Title "Choose group" -OutputMode Single
        if($null -eq $group) {
            throw "No group selected."
        }
    } else {
        try {
            $group = get-mgGroup -GroupId $GroupID | Select-Object DisplayName,Id
        } catch {
            throw "group with ID $GroupID not found."
        }
    }
    Write-Verbose "group chosen: $($group.DisplayName):$($group.Id)"

    #$spInfo = get-ServicePlanInfoFile
    if(-not $SKUId) {
        write-debug 'skuid'
        $sku = Get-MgSubscribedSku | 
            Select-Object @{L='License';E={(find-SKU $_.SkuPartNumber -exact).SKUFriendlyName}},
                SkuPartNumber,
                @{L='AvailableLicenses';E={$_.PrepaidUnits.Enabled}},
                consumedUnits,SkuId | 
            Sort-Object License |
            Out-GridView -Title "Choose SKU" -OutputMode Single
        if($null -eq $sku) {
            throw "No SKU selected."
        }
    } else {
        try {
            $sku = Get-MgSubscribedSku -Id $SKUId | 
                Select-Object @{L='License';E={(find-SKU $_.SkuPartNumber -exact).SKUFriendlyName}},
                    SkuPartNumber, 
                    @{L='AvailableLicenses';E={$_.PrepaidUnits.Enabled}},
                    consumedUnits,SkuId
        } catch {
            throw "SKU with ID $SKUId not found."
        }
    }
    Write-Verbose "SKU chosen: $($sku.SkuPartNumber)"

    $disabledPlans = show-ServicePlans -id $sku.SkuPartNumber | Out-GridView -title "show plans to disable or cancel to assign full license" -OutputMode Multiple
    if($disabledPlans) {
        $licenses = @{
            AddLicenses = @(@{
                SkuId = $sku.SkuId
                DisabledPlans = $disabledPlans.ServicePlanGUID
            })
            RemoveLicenses = @()
        }
    } else {
        Write-Verbose "no plans to disable"
        $licenses = @{
            AddLicenses = @(@{
                SkuId = $sku.SkuId
            })
            RemoveLicenses = @()
        }
    }

    write-host "`nGroup '$($group.DisplayName)' will be assigned '$($sku.License)'" -ForegroundColor Magenta
    if($disabledPlans) {
        write-host "Disabled plans:" -ForegroundColor Yellow
        $disabledPlans | Format-Table
    }

    if (-not $Force) {
        $answer = Read-Host "Do you want to continue with assignment? [Y/N]"
        if ($answer -notin @('Y','y','Yes','yes')) {
            Write-Host "Operation cancelled." -ForegroundColor Yellow
            return
        }
    }
    set-MgGroupLicense -GroupId $group.Id -BodyParameter $licenses

}

function get-LicenseAssignment {
<#
.SYNOPSIS
    user license reporting function.
    available reporting options:
    - all users and their license information
    - only users with certain license assigned
    - only particular users by providing their Ids
    - standard report (compact) and extended version
    report may be returned as object to support other functions or exported to CSV file.
.DESCRIPTION
    here be dragons
.EXAMPLE
    get-eNGBLUserLicenseInfo
 
    prepares full list of users with their license information.
.INPUTS
    None.
.OUTPUTS
    None.
.LINK
    https://w-files.pl
.NOTES
    nExoR ::))o-
    version 250414
        last changes
        - 250414 extended version
        - 250412 initialized
 
    #TO|DO
    - add detailed reporting including expluded SP - each license as a seperate column?
    - main function showing and analysing license assignment - neends options such as 'dupes', userId, process()
#>


    [cmdletbinding()]
    param(
        #by default report is exported to CSV file. use this switch if you want to return it as object.
        [Parameter(mandatory=$false,position=0)]
            [switch]$asObject,
        #return only users with certain license assigned. you may use SKU friendly name, code name or GUID.
        [Parameter(mandatory=$false,position=1)]
            [string]$SKUId,
        #limit to certain account(s) by providing their object IDs.
        [Parameter(mandatory=$false,position=2)]
            [string[]]$userId,
        #extended report
        [Parameter(position=3)]
            [switch]$extendedReport
    )

    begin {
        function fetch-nameCache {
            param(
                [parameter(mandatory,position=0)]
                    [string]$objectID,
                [parameter(position=1)]
                    [validateSet('Group','SKU','SP')]
                    [string]$objectType = 'Group'
            )
    
            #check if object has already been queried
            if($displayNameCache.ContainsKey($objectID)) {
                Write-Debug "existing : $objectType : $($displayNameCache[$objectID])"
                return $displayNameCache[$objectID]
            } else {
                switch($objectType) {
                    'Group' {
                        try {
                            $group = Get-MgGroup -GroupId $objectID
                            #cache the group for later use
                            $global:displayNameCache[$objectID] = $group.DisplayName
                        } catch {
                            Write-Error "Group with ID $objectID not found."
                        }
                    }
                    'SKU' {
                        try {
                            $sku = find-SKU -id $objectID -exact
                            #cache the SKU for later use
                            $global:displayNameCache[$objectID] = $sku ? $sku.SKUFriendlyName : ($tenantSKUs | Where-Object { $_.SkuId -eq $objectID } | Select-Object -ExpandProperty SkuPartNumber)
                        } catch {
                            Write-Error "SKU with ID $objectID not found."
                            $_.Exception
                        }
                    }
                    'SP' {
                        try {
                            $sp = find-ServicePlan -id $objectID -exact
                            #cache the SKU for later use
                            $global:displayNameCache[$objectID] = $sp[0].ServicePlanFriendlyName
                        } catch {
                            Write-Error "Service Plan with ID $objectID not found."
                        }
                    }
                }
                if(-not $displayNameCache[$objectID]) {
                    $displayNameCache[$objectID] = $objectID
                }
                return $displayNameCache[$objectID]
            }
        }
        $allUserLicenses = @()
        $global:displayNameCache = @{}
        $ctx = connect-licenseGraph
        $domain = $ctx.Account.Split('@')[1]
        $outFile = "{0}-userLicenses-{1}.csv" -f $domain,(get-date).ToString('yyyyMMdd-HHmmss')
        $tenantSKUs = Get-MgSubscribedSku -All -ErrorAction Stop | Select-Object SkuId, SkuPartNumber,ConsumedUnits

        #check if SKUId exists in the tenant
        if($SKUId) {
            Write-Debug "looking for SKU $SKUId"
            $skuFilter = find-SKU -id $SKUId -exact
            if($null -eq $skuFilter) {
                Write-Error "SKU with ID $SKUId not found."
                return
            }
        } else {
            write-debug 'no skuid chosen'
        }

        #prepare user list
        if($userId) {
            Write-Debug "looking for user(s) $userId"
            $eIDUsersToProcess = @()
            # Retrieve all users in the tenant with required properties
            foreach($uid in $userId) {
                try {
                    $eIDUser = Get-MgUser -userId $uid -Property AssignedLicenses, LicenseAssignmentStates, DisplayName, Id, UserPrincipalName, AccountEnabled -ErrorAction Stop
                    $eIDUsersToProcess += $eIDUser
                } catch {
                    Write-Error "User with ID $uid not found."
                }
            }
        } else {
            write-debug "getting all tenant users"
            # Retrieve all users in the tenant with required properties
            $eIDUsersToProcess = Get-MgUser -All -Property AssignedLicenses, LicenseAssignmentStates, DisplayName, Id, UserPrincipalName, AccountEnabled -ErrorAction Stop
        }
        #ensure the is at least one user to process (;
        if(!$eIDUsersToProcess) {
            write-host ("there are no users having '{0}' ({1}) license assigned." -f $SKUId,$skuFilter.SKUFriendlyName)
            return
        }

        #post-filtering - only users with certain license assigned
        if($SKUId) {
            # Retrieve all users in the tenant with required properties
            $eIDUsersToProcess = $eIDUsersToProcess | Where-Object { $_.AssignedLicenses.SkuId -contains $skuFilter.SKUGUID }
            if(!$eIDUsersToProcess) {
                write-host ("there are no users having '{0}' ({1}) license assigned." -f $SKUId,$skuFilter.SKUFriendlyName)
                return
            }
        }
    }

    process {
        #expand license infrormation with human readable names
        foreach ($user in $eIDUsersToProcess) {
            # Add the user's license information to the array
            # Construct a custom object to store the user's license information
            $extendedUserInfo = [PSCustomObject]@{
                UserId = $user.Id
                UserDisplayName = $user.DisplayName
                UserPrincipalName = $user.UserPrincipalName
                AccountEnabled = $user.AccountEnabled
                licenseInfo = @()
            }
            if(-not [string]::IsNullOrEmpty($user.LicenseAssignmentStates) ) {
    
                # Loop through license assignment states
                foreach ($assignment in $user.LicenseAssignmentStates) {
                    $customLicensesAssignment = @{
                        AssignedByGroup = $assignment.AssignedByGroup
                        DisabledPlans = $assignment.DisabledPlans
                        Error = $assignment.Error
                        LastUpdatedDateTime = $assignment.LastUpdatedDateTime
                        SkuId = $assignment.SkuId
                        State = $assignment.State
                        groupDisplayName = ""
                        skuDisplayName = ""
                        disabledPlanDisplayName = @()
                    }
    # fetch-nameCache -objectID $assignment.SkuId -objectType 'SKU'

                    #get SKU displayName
                    $customLicensesAssignment.skuDisplayName = fetch-nameCache -objectID $assignment.SkuId -objectType 'SKU'

                    #check group name
                    $assignedByGroup = $assignment.AssignedByGroup
                    $customLicensesAssignment.groupDisplayName = if ($null -ne $assignedByGroup) {
                        # If the license was assigned by a group, get the group name
                        fetch-nameCache -objectID $assignedByGroup -objectType 'Group'
                    } else {
                        # If the license was assigned directly by the user
                        "User"
                    }

                    #get disabled plans display names
                    if($assignment.DisabledPlans) {
                        foreach ($plan in $assignment.DisabledPlans) {
                            $planDisplayName = fetch-nameCache -objectID $plan -objectType 'SP'
                            $customLicensesAssignment.disabledPlanDisplayName += $planDisplayName
                        }
                    } 
                    $extendedUserInfo.licenseInfo += $customLicensesAssignment
                }
            }
            $allUserLicenses += $extendedUserInfo
            
        }    
    }
    end {
        #filter users - only with certain SKU assigned
        if($extendedReport) {
            $extendedUserReport = @()
            foreach($entry in $allUserLicenses) {
                $extendedEntry = [PSCustomObject]@{
                    UserDisplayName = $entry.UserDisplayName
                    UserPrincipalName = $entry.UserPrincipalName
                    AccountEnabled = $entry.AccountEnabled
                    UserId = $entry.UserId
                }
                foreach($sku in $tenantSKUs) {
                    #check if user has this licence
                    $p = $entry.licenseInfo|? skuid -eq $sku.SkuId
                    $extendedEntry | Add-Member -MemberType NoteProperty -Name $sku.SkuPartNumber -Value ($p ? (convert-toShortStringExt -licenseInfo $p -skuId $sku.SkuId) : "" ) -Force
                }
                $extendedUserReport += $extendedEntry
            }
            $allUserLicenses = $extendedUserReport
        } else {
            $allUserLicenses = $allUserLicenses | 
                Select-Object UserDisplayName,UserPrincipalName,AccountEnabled,UserId, @{L='Licenses';E={ convert-toShortString $_.licenseInfo }} 
        }
        if($asObject) { 
            return $allUserLicenses
        } 
        
        $allUserLicenses | Export-Csv -Path $outFile -NoTypeInformation -Force -Encoding UTF8
        Write-Verbose "exported to $outFile. converting to XLSX..."
        convert-CSV2XLS -CSVfileName $outFile -openOnConversion

    }
}

function move-betaUserstoGroupLicense {
    <#
.SYNOPSIS
    ::this function is in early beta - may greatly change in the future::
    find users with certain license, assign license to a chosen group, add person to a group, remove direct assignment
.DESCRIPTION
    here be dragons
.EXAMPLE
    move-eNGBLbetaUsersToGroupLicense
 
    run the script
.INPUTS
    None.
.OUTPUTS
    None.
.LINK
    https://learn.microsoft.com/en-us/entra/identity/users/licensing-powershell-graph-examples
.LINK
    https://w-files.pl
.NOTES
    nExoR ::))o-
    version 250403
        last changes
        - 250403 initialized
 
    #TO|DO
    - missing option to disable service plans
    - function currently to migrate licenses users to GBL.
#>


    [CmdletBinding()]
    param (
    )
    $VerbosePreference = 'SilentlyContinue'
    $ctx = connect-licenseGraph

    # Retrieve all SKUs in the tenant
    $SKUs = Get-MgSubscribedSku -All | Select-Object SkuId, SkuPartNumber
    $chosenSKU = $SKUs | Out-GridView -Title "choose license to migrate to GBL" -OutputMode Single
    if($null -eq $chosenSKU) { 
        Write-Host 'cancelled.'
        return
    }

    #prepare list of groups with their assigned licenses and allows selection
    $allGroups = Get-MgGroup -All -Property DisplayName, GroupTypes, Description, AssignedLicenses, Id
    $GBLgroup = $allGroups | 
        Sort-Object DisplayName | 
        Select-Object DisplayName, @{L='licenses';E={$_.AssignedLicenses.skuId -join ','}},Id,Description | 
        Out-GridView -Title "choose GBL group to assign license to" -OutputMode Single
    if($null -eq $GBLgroup) {
        write-host 'cancelled.'
        return
    } 

    #checks if group already has a license - if no, assign it
    if($GBLgroup.licenses -match $chosenSKU.skuId) {
        Write-Warning ("group '{0}' already has '{1}' license assigned." -f $GBLgroup.displayName, $chosenSKU.SkuId)
    } else {
        Write-Verbose ("assigning '{0}' license to group '{1}'" -f $chosenSKU.SkuId,$GBLgroup.displayName)
        $params = @{
            AddLicenses = @(
                @{
                    SkuId = $chosenSKU.skuId
                }
            )
            # Keep the RemoveLicenses key empty as we don't need to remove any licenses
            RemoveLicenses = @()
        }
        #ERROR HANDLING - do not continue if error occurs
        <#
        | License assignment failed because service plan 4828c8ec-dc2e-4779-b502-87ac9ce28ab7 depends on the service plan(s)
        #>

        Set-MgGroupLicense -GroupId $GBLgroup.Id -BodyParameter $params
    }    

    # Retrieve all users in the tenant with required properties
    $eIDUsersToProcess = Get-MgUser -All -Property AssignedLicenses, LicenseAssignmentStates, DisplayName, Id, UserPrincipalName 
    $licensedUsers = $eIDUsersToProcess | Where-Object {
        $_.AssignedLicenses.SkuId -contains $chosenSKU.SkuId
    }
    if(!$licensedUsers) {
        write-host ("there are no users having '{0}' ({1}) license assigned." -f $chosenSKU.SkuId,$chosenSKU.SkuPartNumber)
        return
    }

    #although users are filtered to only licensed users, we don't know assignemnt source - GBL or direct. let's get the details
    $allUserLicenses = @()
    foreach ($user in $licensedUsers) {
        $assignmentMethods = @()
        # Loop through license assignment states
        foreach ($assignment in $user.LicenseAssignmentStates|? SkuId -eq $chosenSKU.skuId) {
            $skuId = $assignment.SkuId
            $assignedByGroup = $assignment.AssignedByGroup
            $assignmentMethod = if ($null -ne $assignedByGroup) {
                # If the license was assigned by a group, get the group name
                $group = Get-MgGroup -GroupId $assignedByGroup
                if ($group) { $group.DisplayName } else { "Unknown Group" }
            } else {
                # If the license was assigned directly by the user
                "User"
            }
            $assignmentMethods += $assignmentMethod
        }
    
        # Construct a custom object to store the user's license information
        $userLicenseInfo = [PSCustomObject]@{
            UserId = $user.Id
            UserDisplayName = $user.DisplayName
            UserPrincipalName = $user.UserPrincipalName
            SkuId = $skuId
            SkuPartNumber = $chosenSKU
            AssignedBy = $assignmentMethods -join ','
        }

        # Add the user's license information to the array
        $allUserLicenses += $userLicenseInfo
    }

    # Export the results to a CSV file
    #$path = Join-path $env:LOCALAPPDATA ("UserLicenseAssignments_" + [string](Get-Date -UFormat %Y%m%d) + ".csv")
    $usersToMove = $allUserLicenses | 
        Sort-Object UserDisplayName | 
        Out-GridView -Title 'choose users to move to GBL group' -OutputMode Multiple
    if($null -eq $usersToMove) {
        write-host 'no users chosen.'
        return
    } 
    foreach($user in $usersToMove) {
        #add to group first
        try {
            New-MgGroupMember -GroupId $GBLgroup.Id -DirectoryObjectId $user.UserId
        } catch {
            Write-Error $_.Exception
            continue
        }
        #then remove direct license
        try {
            Set-MgUserLicense -UserId $user.UserId -RemoveLicenses $chosenSKU.SKUId -AddLicenses @() -ErrorAction Stop | Out-Null
            write-verbose "user '{0}' has a '{1}' license removed." -f $user.UserDisplayName,$chosenSKU.SKUId
        } catch {
            $_.Exception
        }
    }
    # Display the location of the CSV file
    #Write-Host "CSV file generated at: $((Get-Item $path).FullName)"
    write-host -fore Green 'done.'
}

function remove-betaUserLicenseAssignment {
<#
.SYNOPSIS
    ::this function is in early beta - may greatly change in the future::
    Helps remove duplicate direct licenses - when coming from two sources (group, direct)
.DESCRIPTION
    here be dragons
.EXAMPLE
    remove-betaDuplicateAssignemts
     
.INPUTS
    None.
.OUTPUTS
    None.
.LINK
    https://learn.microsoft.com/en-us/entra/identity/users/licensing-powershell-graph-examples
.LINK
    https://w-files.pl
.NOTES
    nExoR ::))o-
    version 250403
        last changes
        - 250403 initialized
 
    #TO|DO
    - needs to be deviced on two separate function: reporting function and solely removal function. then if will be
    combination of both:
        find-dupeAssignments -skuid | remove-dupeAssignments
        find-dupeAssignments #displays all duplicate assignments - this might be get-tenantLicenseAssignments with parameter.
 
    - to different cleanups - how to design 'clean dupes'? it's easy for all but not with a choice -> each dupe as separate row
        - other cleanup is per skuid
        - pseudo-dupe: define sku1 and sku2 - show all accounts that have both SKUs assigned.
#>


    [CmdletBinding(DefaultParameterSetName = 'all')]
    param (
        #default - simply show all duplicate licenses
        [Parameter(ParameterSetName='all',mandatory=$false,position=0)]
            [switch]$all=$true,
        #SKUid to filter cleanup to only single SKUid
        [Parameter(ParameterSetName='bySKU',mandatory=$false,position=0)]
            [string]$SKUId,
        #limit cleanup to dupe assignments only
        [Parameter(ParameterSetName='bySKU',mandatory=$false,position=1)]
            [switch]$dupes
    )
    $VerbosePreference =  'Continue'

    $ctx = connect-licenseGraph
    Write-Verbose "getting tenant SKUs..."
    $tenantSKUs = Get-MgSubscribedSku | 
        Select-Object @{L='SKUFriendlyName';E={$fn = (find-SKU $_.SkuPartNumber -exact).SKUFriendlyName; $fn ? $fn : $_.SkuPartNumber}},
            SkuPartNumber,
            @{L='AvailableLicenses';E={$_.PrepaidUnits.Enabled}},
            consumedUnits,SkuId |
        Sort-Object SKUFriendlyName

    #which license you want to remove?
    if(-not $SKUId) {
        $chosenSKU = $tenantSKUs | Out-GridView -Title "Choose SKU" -OutputMode Single
        if($null -eq $chosenSKU) {
            throw "No SKU selected."
        }
        $SKUId = $chosenSKU.SkuId
    } else {
        $chosenSKU = $tenantSKUs | Where-Object { $_.SkuId -eq $SKUId } | Select-Object -First 1
    }

    #look for users with provided SKU assigned
    $licensesToCleanup = get-LicenseAssignment -asObject -SKUId $SKUId
    if($null -eq $licensesToCleanup) {
        write-host "no assignments with provided parameters found."
        return
    } 
    #split the view on the license we currently work on and all the rest (informationally only)
    $tmpUserLicesenses = @()
    foreach($userLic in $licensesToCleanup) {
        $groupedLicenses = $userLic.licenseInfo | Group-Object SKUId 
        if($dupes) {
            if( ($groupedLicenses | ? name -eq $SKUId).count -lt 2 ) { continue }
        } 
        $tmpUserLicesenses += $userLic | Select-Object UserDisplayName,UserPrincipalName,AccountEnabled,`
            @{L='searched license';E={ convert-toShortString ($groupedLicenses | ? name -eq $SKUId).group }}, `
            @{L='other licenses';E={ convert-toShortString ($groupedLicenses | ? name -ne $SKUId).group }}, `
            UserId
    }
    #there might be no (dupe) assignments
    if(-not $tmpUserLicesenses) {
        $str = ""
        if($dupes) { $str = 'duplicated' }
        write-host "no users with $str '$($chosenSKU.SKUFriendlyName)' license found."
        return
    }
    $licensesToCleanup = $tmpUserLicesenses | Sort-Object UserDisplayName | Out-GridView -Title "choose users for license removal" -OutputMode Multiple
    if($null -eq $licensesToCleanup) {
        write-host "choice cancelled."
        return
    } 
    Write-Warning "DO YOU WANT TO REMOVE '$($chosenSKU.SKUFriendlyName)' LICENSE FROM BELOW USERS?"
    $licensesToCleanup | select-object UserDisplayName,AccountEnabled,'searched license' | Out-Host
    $answer = get-answerBox -OKButtonText "continue" -title 'choose users to clean' -message 'are you sure you want to remove user license assignements?' -icon Exclamation 
    if (-not $answer) { return }
    
    foreach($user in $licensesToCleanup) {
        Write-Verbose "processing user '$($user.UserDisplayName)'..."
        #remove direct license
        try {
            Set-MgUserLicense -UserId $user.UserId -RemoveLicenses $chosenSKU.SKUId -AddLicenses @{} -ErrorAction Stop | Out-Null
            write-verbose ("user '{0}' has a '{1}' license removed." -f $user.UserDisplayName,$chosenSKU.SKUId)
        } catch {
            write-error ("{0};{1}" -f $_.Exception.HResult,$_.Exception.Message)
        }
    }
    # Display the location of the CSV file
    #Write-Host "CSV file generated at: $((Get-Item $path).FullName)"
    write-host -fore Green 'done.'
}