eNGBL.psm1

function get-ServicePlanInfoFile {

    [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 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]$name,
        #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 $name -or $_.String_Id -eq $name -or $_.GUID -eq $name }
        } else {
            $SKU = $spInfo | Where-Object { $_.Product_Display_Name -match $name -or $_.String_Id -match $name -or $_.GUID -match $name }
        }
        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 SKUs containing given Service Plan. plan name may be partial and be a code name, friendly name or GUID
        [Parameter(ParameterSetName = 'byName',mandatory,position=0)]
            [string]$name,
        #limit lookup to EXACT the name (internal, displayname) and shows details.
        [Parameter(ParameterSetName = 'byName',position=1)]
            [switch]$exact
    )

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

    Process {
        if($exact) {
            Write-debug 'exact search'
            $foundSKUs = $spInfo | Where-Object { $_.Service_Plans_Included_Friendly_Names -eq $name -or $_.Service_Plan_Name -eq $name -or $_.Service_Plan_Id -eq $name } 
        } else {
            $spInfo | Where-Object { $_.Service_Plans_Included_Friendly_Names -match $name -or $_.Service_Plan_Name -match $name -or $_.Service_Plan_Id -match $name } 
        }
        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
    }
}

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]$name,
        #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 -name $name | 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 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position=0)]
            [string]$SKU1,

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

    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 -name $SKU1 -exact
    $sku2Obj = find-SKU -name $SKU2 -exact

    if (-not $sku1Obj -or -not $sku2Obj) {
        throw "one or both SKUs not found."
    }

    # Filter service plans for each SKU
    $plans1 = $spInfo | Where-Object { $_.GUID -eq $sku1Obj.SKUGUID }
    $plans2 = $spInfo | Where-Object { $_.GUID -eq $sku2Obj.SKUGUID }

    # Build sets by GUID
    $guids1 = $plans1.Service_Plan_Id
    $guids2 = $plans2.Service_Plan_Id

    $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 }

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

}

function set-GroupLicense {
#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 = get-mgContext
    if(-not $ctx) {
        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."
        }
    }
    Write-Verbose "connected as $($ctx.Account)"

    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 -name $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

}