Public/Get-M365LicenseReport.ps1

function Get-M365LicenseReport {
    <#
    .SYNOPSIS
        Reports Microsoft 365 license SKU usage and per-user license assignments.

    .DESCRIPTION
        Connects to Microsoft Graph with read-only scopes and produces two CSV files:
          - LicenseSkuSummary_<date>.csv : each subscribed SKU with enabled, consumed and available units.
          - UserLicenseAssignments_<date>.csv : one row per user per assigned SKU.

    .PARAMETER OutputPath
        Folder for the CSV files. Created if it does not exist. Defaults to the current folder.

    .PARAMETER SkipDisconnect
        Leave the Graph session connected when the script finishes.

    .EXAMPLE
        Get-M365LicenseReport -OutputPath .\reports

    .NOTES
        Author : Anthony Buhnerkemper
        Requires: Microsoft.Graph.Authentication, Microsoft.Graph.Users, Microsoft.Graph.Identity.DirectoryManagement
        Scopes : Organization.Read.All, User.Read.All
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$OutputPath = (Get-Location).Path,

        [Parameter()]
        [switch]$SkipDisconnect
    )

    # Runtime dependency (not enforced at import): Microsoft.Graph.Authentication, Microsoft.Graph.Users, Microsoft.Graph.Identity.DirectoryManagement

    $ErrorActionPreference = 'Stop'

    # Least-privilege, read-only scopes
    Connect-MgGraph -Scopes 'Organization.Read.All', 'User.Read.All' -NoWelcome

    if (-not (Test-Path -Path $OutputPath)) {
        New-Item -Path $OutputPath -ItemType Directory | Out-Null
    }
    $stamp = Get-Date -Format 'yyyyMMdd'

    # --- SKU summary -----------------------------------------------------------
    Write-Verbose 'Retrieving subscribed SKUs...'
    $skus = Get-MgSubscribedSku -All

    # Lookup table SkuId -> SkuPartNumber for the per-user section
    $skuLookup = @{}
    foreach ($sku in $skus) { $skuLookup[$sku.SkuId.ToString()] = $sku.SkuPartNumber }

    $skuSummary = foreach ($sku in $skus) {
        [pscustomobject]@{
            SkuPartNumber = $sku.SkuPartNumber
            SkuId         = $sku.SkuId
            Enabled       = $sku.PrepaidUnits.Enabled
            Suspended     = $sku.PrepaidUnits.Suspended
            Warning       = $sku.PrepaidUnits.Warning
            Consumed      = $sku.ConsumedUnits
            Available     = $sku.PrepaidUnits.Enabled - $sku.ConsumedUnits
        }
    }
    $skuFile = Join-Path $OutputPath "LicenseSkuSummary_$stamp.csv"
    $skuSummary | Sort-Object SkuPartNumber | Export-Csv -Path $skuFile -NoTypeInformation

    # --- Per-user assignments --------------------------------------------------
    Write-Verbose 'Retrieving users and license assignments...'
    $users = Get-MgUser -All -Property 'id,displayName,userPrincipalName,accountEnabled,department,assignedLicenses' |
        Where-Object { $_.AssignedLicenses.Count -gt 0 }

    $assignments = foreach ($user in $users) {
        foreach ($lic in $user.AssignedLicenses) {
            $id = $lic.SkuId.ToString()
            [pscustomobject]@{
                DisplayName       = $user.DisplayName
                UserPrincipalName = $user.UserPrincipalName
                AccountEnabled    = $user.AccountEnabled
                Department        = $user.Department
                SkuPartNumber     = if ($skuLookup.ContainsKey($id)) { $skuLookup[$id] } else { $id }
                DisabledPlans     = $lic.DisabledPlans.Count
            }
        }
    }
    $userFile = Join-Path $OutputPath "UserLicenseAssignments_$stamp.csv"
    $assignments | Sort-Object UserPrincipalName | Export-Csv -Path $userFile -NoTypeInformation

    Write-Host "SKU summary : $skuFile ($($skuSummary.Count) SKUs)"
    Write-Host "Assignments : $userFile ($($assignments.Count) rows)"

    if (-not $SkipDisconnect) { Disconnect-MgGraph | Out-Null }
}