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
}