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.
- 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
IsTrialand excluded from cost figures, using theisTrialflag from Graphdirectory/subscriptionsrather 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
| 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# Clone the repository or copy the module folder, then:
Import-Module .\M365LicenseOptimizer\M365LicenseOptimizer.psd1 -Force| 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.
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 90Connect-M365LicenseOptimizer `
-TenantId 'contoso.onmicrosoft.com' `
-ClientId 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' `
-CertificateThumbprint 'AABBCCDDEEFF...'
Invoke-M365LicenseAudit -OutputPath 'C:\Reports'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# 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'| 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. |
Prices are resolved in this order; later sources override earlier ones and only the keys you list are replaced:
- Built-in static table — NCE annual-commitment, annual-billing list prices (USD).
PricingUrlinConfig\settings.json(copy fromsettings.json.example) — a JSON file fetched at runtime, e.g. a GitHub Gist or internal share.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
skuPartNumbervalues as returned byGET /v1.0/subscribedSkus. - Keys starting with
_are ignored. The optional_basiskey sets the pricing-basis label printed in the report footer. - Aliases of the same product (e.g.
SPE_E3andMicrosoft_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.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- Shared mailbox detection prefers
Get-EXOMailbox. Without Exchange Online it falls back to GraphmailboxSettings.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.