Public/Get-LicenseSavingsReport.ps1
|
function Get-LicenseSavingsReport { <# .SYNOPSIS Aggregates all license optimization findings into a savings summary. .DESCRIPTION Calls Get-LicenseInventory, Get-UnderutilizedLicenses, and Get-InactiveLicensedUsers to build a comprehensive savings report. Calculates total current monthly spend, potential savings from removing inactive user licenses, savings from downgrade recommendations, and total potential monthly and annual savings. .PARAMETER DaysInactive Number of days without sign-in to consider a user inactive. Default: 90. .EXAMPLE Get-LicenseSavingsReport -DaysInactive 60 Generates a savings report with a 60-day inactivity threshold. .EXAMPLE Get-LicenseSavingsReport | Format-Table -AutoSize Displays the savings breakdown in a formatted table. .OUTPUTS PSCustomObject with properties: Category, Action, Count, MonthlySavings, AnnualSavings #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [ValidateRange(1, 365)] [int]$DaysInactive = 90 ) Write-Verbose "Collecting license inventory..." $inventory = Get-LicenseInventory Write-Verbose "Analyzing underutilized licenses..." $underutilized = Get-UnderutilizedLicenses -DaysInactive $DaysInactive Write-Verbose "Identifying inactive licensed users..." $inactive = Get-InactiveLicensedUsers -DaysInactive $DaysInactive -IncludeGuests # Calculate current monthly spend from inventory $currentMonthlySpend = ($inventory | Measure-Object -Property MonthlyCost -Sum).Sum if (-not $currentMonthlySpend) { $currentMonthlySpend = 0 } $currentMonthlySpend = [math]::Round($currentMonthlySpend, 2) # Savings from inactive / disabled user license removal $inactiveUsers = @($inactive | Where-Object { $_.Finding -eq 'INACTIVE LICENSED USER' }) $disabledUsers = @($inactive | Where-Object { $_.Finding -eq 'DISABLED WITH LICENSE' }) $neverSignedIn = @($inactive | Where-Object { $_.Finding -eq 'NEVER SIGNED IN' }) $guestLicensed = @($inactive | Where-Object { $_.Finding -eq 'GUEST WITH LICENSE' }) $inactiveSavings = [math]::Round(($inactiveUsers | Measure-Object -Property LicenseCost -Sum).Sum, 2) $disabledSavings = [math]::Round(($disabledUsers | Measure-Object -Property LicenseCost -Sum).Sum, 2) $neverSignSavings = [math]::Round(($neverSignedIn | Measure-Object -Property LicenseCost -Sum).Sum, 2) $guestSavings = [math]::Round(($guestLicensed | Measure-Object -Property LicenseCost -Sum).Sum, 2) if (-not $inactiveSavings) { $inactiveSavings = 0 } if (-not $disabledSavings) { $disabledSavings = 0 } if (-not $neverSignSavings) { $neverSignSavings = 0 } if (-not $guestSavings) { $guestSavings = 0 } # Savings from downgrade recommendations $downgradeCandidates = @($underutilized | Where-Object { $_.Finding -eq 'DOWNGRADE CANDIDATE' }) $noSignInCandidates = @($underutilized | Where-Object { $_.Finding -eq 'NO SIGN-IN' }) $downgradeSavings = [math]::Round(($downgradeCandidates | Measure-Object -Property MonthlySavings -Sum).Sum, 2) $noSignInSavings = [math]::Round(($noSignInCandidates | Measure-Object -Property MonthlySavings -Sum).Sum, 2) if (-not $downgradeSavings) { $downgradeSavings = 0 } if (-not $noSignInSavings) { $noSignInSavings = 0 } # Over-provisioned SKU savings (unused purchased licenses) $overProvisioned = @($inventory | Where-Object { $_.Finding -eq 'OVER-PROVISIONED' }) $overProvisionedCount = ($overProvisioned | Measure-Object -Property AvailableLicenses -Sum).Sum if (-not $overProvisionedCount) { $overProvisionedCount = 0 } # Estimate over-provisioned savings: average cost of unassigned licenses $overProvisionedSavings = 0 foreach ($sku in $overProvisioned) { if ($sku.AssignedLicenses -gt 0) { $costPerLicense = $sku.MonthlyCost / $sku.AssignedLicenses $overProvisionedSavings += $costPerLicense * $sku.AvailableLicenses } } $overProvisionedSavings = [math]::Round($overProvisionedSavings, 2) # Total savings (deduplicated: use the larger of inactive removal vs underutilized no-sign-in) $removalSavings = $inactiveSavings + $disabledSavings + $neverSignSavings + $guestSavings $totalMonthlySavings = $removalSavings + $downgradeSavings + $overProvisionedSavings $totalMonthlySavings = [math]::Round($totalMonthlySavings, 2) $totalAnnualSavings = [math]::Round($totalMonthlySavings * 12, 2) # Build summary objects $report = @() $report += [PSCustomObject]@{ Category = 'Current Spend' Action = 'Total estimated monthly license cost' Count = ($inventory | Measure-Object -Property AssignedLicenses -Sum).Sum MonthlySavings = $currentMonthlySpend AnnualSavings = [math]::Round($currentMonthlySpend * 12, 2) } $report += [PSCustomObject]@{ Category = 'Inactive Users' Action = "Remove licenses from users inactive $DaysInactive+ days" Count = $inactiveUsers.Count MonthlySavings = $inactiveSavings AnnualSavings = [math]::Round($inactiveSavings * 12, 2) } $report += [PSCustomObject]@{ Category = 'Disabled Accounts' Action = 'Remove licenses from disabled accounts' Count = $disabledUsers.Count MonthlySavings = $disabledSavings AnnualSavings = [math]::Round($disabledSavings * 12, 2) } $report += [PSCustomObject]@{ Category = 'Never Signed In' Action = 'Remove licenses from users who never signed in' Count = $neverSignedIn.Count MonthlySavings = $neverSignSavings AnnualSavings = [math]::Round($neverSignSavings * 12, 2) } if ($guestLicensed.Count -gt 0) { $report += [PSCustomObject]@{ Category = 'Guest Users' Action = 'Remove licenses from guest/external users' Count = $guestLicensed.Count MonthlySavings = $guestSavings AnnualSavings = [math]::Round($guestSavings * 12, 2) } } $report += [PSCustomObject]@{ Category = 'License Downgrades' Action = 'Downgrade overprovisioned users (E5->E3, E3->E1)' Count = $downgradeCandidates.Count MonthlySavings = $downgradeSavings AnnualSavings = [math]::Round($downgradeSavings * 12, 2) } if ($overProvisionedSavings -gt 0) { $report += [PSCustomObject]@{ Category = 'Over-Provisioned SKUs' Action = 'Reduce purchased license counts to match usage' Count = $overProvisionedCount MonthlySavings = $overProvisionedSavings AnnualSavings = [math]::Round($overProvisionedSavings * 12, 2) } } $report += [PSCustomObject]@{ Category = 'TOTAL POTENTIAL SAVINGS' Action = 'Combined savings from all recommendations' Count = '-' MonthlySavings = $totalMonthlySavings AnnualSavings = $totalAnnualSavings } # Print summary Write-Host "" Write-Host "========================================" -ForegroundColor DarkYellow Write-Host " M365 LICENSE OPTIMIZATION SUMMARY" -ForegroundColor DarkYellow Write-Host "========================================" -ForegroundColor DarkYellow Write-Host "" Write-Host " Current Monthly Spend: " -NoNewline; Write-Host ('${0:N2}' -f $currentMonthlySpend) -ForegroundColor White Write-Host " Potential Monthly Savings: " -NoNewline; Write-Host ('${0:N2}' -f $totalMonthlySavings) -ForegroundColor Green Write-Host " Potential Annual Savings: " -NoNewline; Write-Host ('${0:N2}' -f $totalAnnualSavings) -ForegroundColor Green Write-Host "" Write-Host " Breakdown:" -ForegroundColor DarkYellow Write-Host " Inactive user removals: $($inactiveUsers.Count) users = " -NoNewline Write-Host ('${0:N2}/mo' -f $inactiveSavings) -ForegroundColor Yellow Write-Host " Disabled account cleanup: $($disabledUsers.Count) users = " -NoNewline Write-Host ('${0:N2}/mo' -f $disabledSavings) -ForegroundColor Yellow Write-Host " Never signed in cleanup: $($neverSignedIn.Count) users = " -NoNewline Write-Host ('${0:N2}/mo' -f $neverSignSavings) -ForegroundColor Yellow Write-Host " License downgrades: $($downgradeCandidates.Count) users = " -NoNewline Write-Host ('${0:N2}/mo' -f $downgradeSavings) -ForegroundColor Yellow if ($overProvisionedSavings -gt 0) { Write-Host " Over-provisioned SKUs: $overProvisionedCount licenses = " -NoNewline Write-Host ('${0:N2}/mo' -f $overProvisionedSavings) -ForegroundColor Yellow } Write-Host "" Write-Host " Note: Costs based on list pricing. EA/CSP pricing may differ." -ForegroundColor DarkGray Write-Host "========================================" -ForegroundColor DarkYellow Write-Host "" return $report } |