functions/license/Disable-PSEntraIDUserLicenseServicePlan.ps1

function Disable-PSEntraIDUserLicenseServicePlan {
    <#
    .SYNOPSIS
        Disable service plan of users's sku subscription.

    .DESCRIPTION
        Disable service plan of users's sku subscription.

    .PARAMETER InputObject
        PSMicrosoftEntraID.Users.User object in tenant/directory.

    .PARAMETER Identity
        UserPrincipalName, Mail or Id of the user attribute populated in tenant/directory.

    .PARAMETER SkuId
        Office 365 product GUID is identified using a GUID of subscribedSku.

    .PARAMETER SkuPartNumber
        Friendly name Office 365 product of subscribedSku.

    .PARAMETER ServicePlanId
        Service plan Id of subscribedSku.

    .PARAMETER ServicePlanName
        Friendly service plan name of subscribedSku.

    .PARAMETER EnableException
        This parameter disables user-friendly warnings and enables the throwing of exceptions. This is less user friendly, but allows catching exceptions in calling scripts.

    .PARAMETER WhatIf
        Enables the function to simulate what it will do instead of actually executing.

    .PARAMETER Force
        Suppresses the confirmation prompt, for unattended use.

        An explicitly bound -Confirm wins over it, whatever its value: -Confirm:$true
        prompts even with -Force present. The two are therefore alternatives rather
        than a pair - passing both says nothing the second one does not already say.

        Without either, whether the command prompts is left to its ConfirmImpact and
        the session ConfirmPreference, which is the PowerShell default behaviour.

    .PARAMETER Confirm
        Prompts for confirmation before the command makes a change. -Confirm:$false
        suppresses that prompt.

        Bound explicitly it wins over -Force, whatever its value - so -Confirm:$true
        prompts even alongside -Force, and the two are alternatives rather than a pair.

        Left unbound, the decision belongs to this command's ConfirmImpact and the
        session ConfirmPreference, which is the PowerShell default behaviour.

    .PARAMETER PassThru
        When specified, the cmdlet will not execute the action but will instead
        return a `PSMicrosoftEntraID.Batch.Request` object for batch processing.

    .EXAMPLE
        PS C:\> Disable-PSEntraIDUserLicenseServicePlan -Identity username@contoso.com -SkuPartNumber ENTERPRISEPACK -ServicePlanName @('OFFICESUBSCRIPTION','EXCHANGE_S_ENTERPRISE')

        Disable service plan Office Pro Plus, Exchange Online of subscription ENTERPRISEPACK for user username@contoso.com

    .NOTES
        Graph's assignLicense REPLACES the disabledPlans list on every call - there is no
        server-side merge and no ETag. This cmdlet therefore reads the user's current
        disabled plans and merges the request into them. With -Identity that read happens
        inside the call, and a read seconds after a previous write can hit a directory
        replica the write has not reached yet - in which case the merge starts from stale
        state and the earlier change is lost. Two deterministic patterns avoid it:
        name every plan in ONE call (see the example above), or pass -InputObject with a
        user object you hold, whose AssignedLicenses reflect the state you mean to merge
        into. Verified against a live tenant: back-to-back -Identity calls occasionally
        lose the first change; either pattern above never does.

    #>

    [OutputType([PSMicrosoftEntraID.Batch.Request])]
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High', DefaultParameterSetName = 'InputObjectSkuPartNumberPlanName')]
    param ([Parameter(Mandatory = $True, ValueFromPipeline = $true, ParameterSetName = 'InputObjectSkuIdServicePlanId')]
        [Parameter(Mandatory = $True, ValueFromPipeline = $true, ParameterSetName = 'InputObjectSkuIdServicePlanName')]
        [Parameter(Mandatory = $True, ValueFromPipeline = $true, ParameterSetName = 'InputObjectSkuPartNumberPlanId')]
        [Parameter(Mandatory = $True, ValueFromPipeline = $true, ParameterSetName = 'InputObjectSkuPartNumberPlanName')]
        [PSMicrosoftEntraID.Users.User[]] $InputObject,
        [Parameter(Mandatory = $True, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'IdentitySkuIdServicePlanId')]
        [Parameter(Mandatory = $True, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'IdentitySkuIdServicePlanName')]
        [Parameter(Mandatory = $True, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'IdentitySkuPartNumberPlanId')]
        [Parameter(Mandatory = $True, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'IdentitySkuPartNumberPlanName')]
        [Alias("Id", "UserPrincipalName", "Mail")]
        [ValidateUserIdentity()]
        [string[]] $Identity,
        [Parameter(Mandatory = $True, ParameterSetName = 'InputObjectSkuIdServicePlanId')]
        [Parameter(Mandatory = $True, ParameterSetName = 'InputObjectSkuIdServicePlanName')]
        [Parameter(Mandatory = $True, ParameterSetName = 'IdentitySkuIdServicePlanId')]
        [Parameter(Mandatory = $True, ParameterSetName = 'IdentitySkuIdServicePlanName')]
        [ValidateGuid()]
        [string] $SkuId,
        [Parameter(Mandatory = $True, ParameterSetName = 'InputObjectSkuPartNumberPlanId')]
        [Parameter(Mandatory = $True, ParameterSetName = 'InputObjectSkuPartNumberPlanName')]
        [Parameter(Mandatory = $True, ParameterSetName = 'IdentitySkuPartNumberPlanId')]
        [Parameter(Mandatory = $True, ParameterSetName = 'IdentitySkuPartNumberPlanName')]
        [ValidateNotNullOrEmpty()]
        [string] $SkuPartNumber,
        [Parameter(Mandatory = $True, ParameterSetName = 'InputObjectSkuPartNumberPlanId')]
        [Parameter(Mandatory = $True, ParameterSetName = 'InputObjectSkuIdServicePlanId')]
        [Parameter(Mandatory = $True, ParameterSetName = 'IdentitySkuPartNumberPlanId')]
        [Parameter(Mandatory = $True, ParameterSetName = 'IdentitySkuIdServicePlanId')]
        [ValidateGuid()]
        [string[]] $ServicePlanId,
        [Parameter(Mandatory = $True, ParameterSetName = 'InputObjectSkuIdServicePlanName')]
        [Parameter(Mandatory = $True, ParameterSetName = 'InputObjectSkuPartNumberPlanName')]
        [Parameter(Mandatory = $True, ParameterSetName = 'IdentitySkuIdServicePlanName')]
        [Parameter(Mandatory = $True, ParameterSetName = 'IdentitySkuPartNumberPlanName')]
        [ValidateNotNullOrEmpty()]
        [string[]] $ServicePlanName,
        [Parameter()]
        [switch] $EnableException,
        [Parameter()]
        [switch] $Force,
        [Parameter()]
        [switch]$PassThru
    )
    begin {
        [string] $service = Get-PSFConfigValue -FullName ('{0}.Settings.DefaultService' -f $script:ModuleName)
        Assert-EntraConnection -Service $service -Cmdlet $PSCmdlet
        [int] $commandRetryCount = Get-PSFConfigValue -FullName ('{0}.Settings.Command.RetryCount' -f $script:ModuleName)
        [System.TimeSpan] $commandRetryWait = New-TimeSpan -Seconds (Get-PSFConfigValue -FullName ('{0}.Settings.Command.RetryWaitInSeconds' -f $script:ModuleName))
        [hashtable] $header = @{
            'Content-Type' = 'application/json'
        }
        [hashtable] $cmdLetConfirm = Resolve-PSEntraIDConfirmPreference -BoundParameters $PSBoundParameters -Force:$Force -Confirm:$Confirm
        # Branched on $PSBoundParameters, NOT on $PSCmdlet.ParameterSetName.
        #
        # With pipeline input the parameter set is not resolved until the first object
        # arrives in process - InputObject and Identity both bind from the pipeline, so
        # in begin the ambiguity is still open and ParameterSetName names neither. The
        # switch that used to live here matched nothing, $bodySkuId stayed empty, every
        # piped user failed the SKU match, and the cmdlet reported 'not assigned' for a
        # licence the user held. Piped input never worked, and the test that covered it
        # only counted an intermediate call, so nothing noticed.
        #
        # SkuId/SkuPartNumber/ServicePlanId/ServicePlanName are never pipeline-bound, so
        # $PSBoundParameters answers in begin what ParameterSetName cannot.
        if ($PSBoundParameters.ContainsKey('SkuId')) {
            [string] $bodySkuId = $SkuId
            [string] $skuTarget = $SkuId
        }
        else {
            # Materialised, NOT piped into Select-Object -First 1: that stops the
            # pipeline, the protected read inside reports a failed action, and
            # $matchedSku comes back empty. The same hazard is documented in this
            # module's own help.
            [PSMicrosoftEntraID.License.SubscriptionSku[]] $subscribedSkus = @(Get-PSEntraIDSubscribedSku)
            [PSMicrosoftEntraID.License.SubscriptionSku] $matchedSku = @($subscribedSkus | Where-Object { $_.SkuPartNumber -eq $SkuPartNumber })[0]
            [string] $bodySkuId = $matchedSku.SkuId
            [string] $skuTarget = $SkuPartNumber
        }
        if ($PSBoundParameters.ContainsKey('ServicePlanId')) {
            [string] $servicePlanTarget = ($ServicePlanId | ForEach-Object { "'{0}'" -f $_ } | Join-String -Separator ',')
            [string[]] $bodyServicePlanId = $ServicePlanId
        }
        else {
            [string] $servicePlanTarget = ($ServicePlanName | ForEach-Object { "'{0}'" -f $_ } | Join-String -Separator ',')
            [string[]] $bodyServicePlanId = (Get-PSEntraIDSubscribedSku | Where-Object -Property SkuId -EQ -Value $bodySkuId |
                Select-Object -ExpandProperty ServicePlans |
                Where-Object { $ServicePlanName -Contains $PSItem.ServicePlanName }).ServicePlanId
        }
    }
    process {
        switch -Regex ($PSCmdlet.ParameterSetName) {
            'InputObject\w' {
                foreach ($itemInputObject in  $InputObject) {
                    [System.Collections.Generic.List[object]] $bodyDisabledServicePlanList = [System.Collections.Generic.List[object]]::new()

                    # Existing disabled plans come from assignedLicenses.disabledPlans -
                    # the field this very cmdlet writes - NOT from licenseDetails'
                    # provisioningStatus. The two answer different questions:
                    # disabledPlans is the assignment and is visible the moment it is
                    # written; provisioningStatus is what has been PROVISIONED and trails
                    # the write by seconds to minutes. Reading the laggy one meant a plan
                    # disabled a moment ago was not seen as disabled, so the next call
                    # rebuilt the list without it and silently RE-ENABLED it - verified
                    # against a live tenant, where two disables in a row kept only the
                    # second. The group cmdlets have always read disabledPlans, which is
                    # why they never had this defect.
                    $assignedLicense = @($itemInputObject.AssignedLicenses | Where-Object { ([string]$PSItem.SkuId) -eq $bodySkuId })[0]
                    if (-not ([object]::Equals($assignedLicense, $null))) {
                        [string[]] $existingDisabledServicePlanList = @($assignedLicense.DisabledPlans | ForEach-Object { [string]$_ })
                        [string[]] $bodyNewDisabledServicePlanList = $bodyServicePlanId |
                        Where-Object { $PSItem -notin $existingDisabledServicePlanList }
                        $existingDisabledServicePlanList | ForEach-Object { [void] $bodyDisabledServicePlanList.Add($PSItem) }
                        $bodyNewDisabledServicePlanList | ForEach-Object { [void] $bodyDisabledServicePlanList.Add($PSItem) }
                    }
                    else {
                        # Not an error: the SKU simply is not on this user. Say so, though -
                        # silence here reads as success and it is not.
                        Write-PSFMessage -Level Warning -String 'License.Sku.NotAssigned' -StringValues $skuTarget, $(if ($itemInputObject.UserPrincipalName) { $itemInputObject.UserPrincipalName } else { $itemInputObject.Id })
                    }

                    [hashtable] $body = @{
                        addLicenses    = @(
                            @{
                                disabledPlans = @($bodyDisabledServicePlanList | Select-Object -Unique)
                                skuId         = $bodySkuId
                            }
                        )
                        removeLicenses = @()
                    }
                    [string] $path = ("users/{0}/{1}" -f $itemInputObject.Id, 'assignLicense')
                    if ($PassThru.IsPresent) {
                        if ($bodyDisabledServicePlanList.Count -gt 0) {
                            [PSMicrosoftEntraID.Batch.Request]@{ Method = 'POST'; Url = ('/{0}' -f $path); Body = $body; Headers = $header }
                        }
                    }
                    else {
                        if ($bodyDisabledServicePlanList.Count -gt 0) {
                            Invoke-PSFProtectedCommand -ActionString 'LicenseServicePlan.Disable' -ActionStringValues $servicePlanTarget, $skuTarget -Target $itemInputObject.UserPrincipalName -ScriptBlock {
                                [void] (Invoke-EntraRequest -Service $service -Path $path -Header $header -Body $body -Method Post -ErrorAction Stop)
                            } -EnableException:$EnableException @cmdLetConfirm -PSCmdlet $PSCmdlet -Continue -RetryCount $commandRetryCount -RetryWait $commandRetryWait
                            if (Test-PSFFunctionInterrupt) { return }
                        }
                    }
                }
            }
            'Identity\w' {
                foreach ($user in  $Identity) {
                    [System.Collections.Generic.List[object]] $bodyDisabledServicePlanList = [System.Collections.Generic.List[object]]::new()
                    [PSMicrosoftEntraID.Users.User] $aADUser = Get-PSEntraIDUser -Identity $user
                    if ([object]::Equals($aADUser, $null)) {
                        if ($EnableException.IsPresent) {
                            Invoke-TerminatingException -Cmdlet $PSCmdlet -Message ((Get-PSFLocalizedString -Module $script:ModuleName -Name User.Get.Failed) -f $user)
                        }
                    }
                    else {
                    # assignedLicenses.disabledPlans, not provisioningStatus - see the
                    # InputObject branch for why. $aADUser was just read, so the field is
                    # as fresh as Graph will give.
                    $assignedLicense = @($aADUser.AssignedLicenses | Where-Object { ([string]$PSItem.SkuId) -eq $bodySkuId })[0]
                    if (-not ([object]::Equals($assignedLicense, $null))) {
                        [string[]] $existingDisabledServicePlanList = @($assignedLicense.DisabledPlans | ForEach-Object { [string]$_ })
                        [string[]] $bodyNewDisabledServicePlanList = $bodyServicePlanId |
                        Where-Object { $PSItem -notin $existingDisabledServicePlanList }
                        $existingDisabledServicePlanList | ForEach-Object { [void] $bodyDisabledServicePlanList.Add($PSItem) }
                        $bodyNewDisabledServicePlanList | ForEach-Object { [void] $bodyDisabledServicePlanList.Add($PSItem) }
                    }
                    else {
                        Write-PSFMessage -Level Warning -String 'License.Sku.NotAssigned' -StringValues $skuTarget, $user
                    }

                    [hashtable] $body = @{
                        addLicenses    = @(
                            @{
                                disabledPlans = @($bodyDisabledServicePlanList | Select-Object -Unique)
                                skuId         = $bodySkuId
                            }
                        )
                        removeLicenses = @()
                    }

                    [string] $path = ("users/{0}/{1}" -f $aADUser.Id, 'assignLicense')
                    if ($PassThru.IsPresent) {
                        if ($bodyDisabledServicePlanList.Count -gt 0) {
                            [PSMicrosoftEntraID.Batch.Request]@{ Method = 'POST'; Url = ('/{0}' -f $path); Body = $body; Headers = $header }
                        }
                    }
                    else {
                        if (($bodyDisabledServicePlanList.Count -gt 0)) {
                            Invoke-PSFProtectedCommand -ActionString 'LicenseServicePlan.Disable' -ActionStringValues $servicePlanTarget, $skuTarget -Target $user -ScriptBlock {
                                [void] (Invoke-EntraRequest -Service $service -Path $path -Header $header -Body $body -Method Post -ErrorAction Stop)
                            } -EnableException:$EnableException @cmdLetConfirm -PSCmdlet $PSCmdlet -Continue -RetryCount $commandRetryCount -RetryWait $commandRetryWait
                            if (Test-PSFFunctionInterrupt) { return }
                        }
                    }
                    }
                }
            }
        }
    }
    end
    {}
}