Skip to content

Repository files navigation

M365LicenseOptimizer

A PowerShell module that audits Microsoft 365 licensing costs, identifies wasted licence spend, and generates a standalone HTML report with ZAR-converted costs, savings analysis, and upgrade / right-sizing recommendations.

Features

  • Full licence inventory — all subscribed SKUs with Microsoft NCE pricing in USD and ZAR, utilisation and (best-effort) renewal dates
  • Trial detection — SKUs whose live subscriptions are all trials (including user self-service trials such as "Teams Premium (for Departments)") are flagged IsTrial and excluded from cost figures, using the isTrial flag from Graph directory/subscriptions rather than guessing from the name
  • Wastage detection across four categories:
    • Shared mailboxes with paid licences (shared mailboxes need no licence up to 50 GB)
    • Disabled user accounts still consuming paid licences
    • Inactive users with no Exchange / Teams / OneDrive / SharePoint activity in the last 30/60/90 days
    • Unassigned seats — subscriptions where fewer seats are assigned than purchased
  • Potential savings calculator — monthly and annual savings if wasted licences are removed
  • Upgrade recommendations — suggests moves like Business Standard → Business Premium or E3 → E5 with per-user and total cost deltas
  • Right-sizing recommendations — usage-based downgrades (e.g. E3 users who only use Exchange) derived from the Office 365 activity report
  • Live ZAR exchange rate — fetched from exchangerate-api.com with automatic fallback
  • Zero-dependency standalone HTML report — light, print-friendly theme with sortable and searchable tables; works offline

Prerequisites

Requirement Notes
PowerShell 7.2+ Required
Microsoft.Graph.Authentication Required. All Graph calls go through Invoke-MgGraphRequest, so no other Graph sub-modules are needed.
ExchangeOnlineManagement Optional. Enables Get-EXOMailbox-based shared mailbox detection, which works in all regions (the Graph mailboxSettings fallback returns HTTP 501 in some regions such as South Africa North).
DLLPickle Optional. Prevents MSAL DLL version conflicts when both Graph and Exchange Online modules are loaded. The module warns if it is missing but never installs it for you.
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser -Force

# Optional
Install-Module ExchangeOnlineManagement, DLLPickle -Scope CurrentUser -Force

Installation

# Clone the repository or copy the module folder, then:
Import-Module .\M365LicenseOptimizer\M365LicenseOptimizer.psd1 -Force

Required Microsoft Graph Permissions

Permission Type Purpose
User.Read.All Application / Delegated Read all user profiles and assigned licences
Directory.Read.All Application / Delegated Read subscribed SKUs and directory objects
Organization.Read.All Application / Delegated Read tenant name and domain
Reports.Read.All Application / Delegated Read Office 365 usage activity reports
MailboxSettings.Read Application / Delegated Read mailbox type (shared vs user) when Exchange Online is not connected

For app-only auth with Exchange Online, the app registration also needs the Exchange.ManageAsApp application permission and the Exchange Administrator (or Global Reader) directory role.


Quick Start

Interactive sign-in (fastest way to try the module)

Import-Module .\M365LicenseOptimizer.psd1

# Authenticate interactively (also connects Exchange Online if the module is installed)
Connect-M365LicenseOptimizer -Interactive

# Run a full audit and open the HTML report automatically
Invoke-M365LicenseAudit -OutputPath 'C:\Reports' -InactivityDays 90

App registration / certificate authentication

Connect-M365LicenseOptimizer `
    -TenantId       'contoso.onmicrosoft.com' `
    -ClientId       'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' `
    -CertificateThumbprint 'AABBCCDDEEFF...'

Invoke-M365LicenseAudit -OutputPath 'C:\Reports'

Use auth.json (unattended / scheduled)

Copy Config\auth.json.example to Config\auth.json (gitignored) and populate it:

{
    "TenantId": "your-tenant-id",
    "ClientId": "your-app-client-id",
    "CertificateThumbprint": "your-cert-thumbprint"
}

Then simply call:

Connect-M365LicenseOptimizer   # loads auth.json automatically, Exchange Online uses the same certificate
Invoke-M365LicenseAudit -OutputPath 'C:\Reports' -NoOpen

Individual Functions

# Licence inventory only (add -IncludeFree to keep free / trial SKUs)
$inv = Get-M365LicenseInventory -ExchangeRate 19.00

# Wastage analysis only (shared mailboxes, disabled, inactive, unassigned seats)
$waste = Get-M365UnusedLicenses -InactivityDays 60 -LicenseInventory $inv

# Upgrade + right-sizing recommendations (right-sizing needs the wastage output)
$recs = Get-M365LicenseUpgradePaths -LicenseInventory $inv -UnusedLicenses $waste

# Generate a report from previously collected data
$auditData = Invoke-M365LicenseAudit -PassThru
New-M365LicenseReport -AuditData $auditData -OutputPath 'C:\Reports'

Parameters

Invoke-M365LicenseAudit

Parameter Default Description
-InactivityDays 90 Days of no activity to flag a user as inactive. Accepts 30, 60, or 90.
-OutputPath . (current dir) Directory for the HTML report.
-ExchangeRate Live rate Override USD→ZAR rate (e.g., -ExchangeRate 18.75).
-ReportTitle M365 License Cost Optimisation Report Custom HTML title.
-PassThru off Returns the raw audit data object.
-NoOpen off Suppresses automatic browser open after report generation.

Pricing Configuration

Prices are resolved in this order; later sources override earlier ones and only the keys you list are replaced:

  1. Built-in static table — NCE annual-commitment, annual-billing list prices (USD).
  2. PricingUrl in Config\settings.json (copy from settings.json.example) — a JSON file fetched at runtime, e.g. a GitHub Gist or internal share.
  3. Config\pricing.json — the bundled file applies the monthly-billing uplift (annual list × 1.05) and is the effective pricing for reports unless you change it.

Notes:

  • Keys must be exact skuPartNumber values as returned by GET /v1.0/subscribedSkus.
  • Keys starting with _ are ignored. The optional _basis key sets the pricing-basis label printed in the report footer.
  • Aliases of the same product (e.g. SPE_E3 and Microsoft_365_E3) are kept in sync automatically: override one and the others follow.
  • Actual prices depend on your agreement type (CSP, EA, MCA), region, and partner discounts. Always verify against invoices or the Microsoft 365 Admin Center before acting on the report.

Build & Test

.\build.ps1 -Task Analyze          # PSScriptAnalyzer
.\build.ps1 -Task Test             # Pester (network-bound tests excluded)
.\build.ps1 -Task Test -IncludeIntegration
.\build.ps1 -Task Build            # Copies the module to .\build\ (auth.json / settings.json are never copied)
.\build.ps1 -Task CI               # Analyze → Test → Build

Limitations

  • Shared mailbox detection prefers Get-EXOMailbox. Without Exchange Online it falls back to Graph mailboxSettings.userPurpose, which is unavailable in some Azure regions (HTTP 501).
  • Renewal dates and trial status come from directory/subscriptions (v1.0, falling back to beta). If that endpoint is unavailable, trial SKUs are only recognised by name patterns (_TRIAL, Trial, Free, and a known-free list).
  • Inactive-user and right-sizing analysis rely on the Office 365 Active User Detail report (30 or 90-day periods; 60 days is derived from the 90-day report). If the tenant conceals user names in reports, the module warns and these sections stay empty until the setting is turned off.
  • Users who receive mail without signing in still show Exchange activity and are not flagged as inactive.

License

MIT

About

A PowerShell module that audits Microsoft 365 licensing costs, identifies wasted licence spend

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages