Investigate GDAP CSP Partner Licenses for Overage

<#
.SYNOPSIS
    For every customer in your GDAP tenant list, finds which ones already have
    Windows Server or SQL Server licensing sold to them via Partner Center CSP
    subscriptions.

.DESCRIPTION
    Reads the customer tenant list produced by Get-GDAPCustomerTenants.ps1,
    signs into Partner Center ONCE as you/the partner (not per customer
    tenant - Partner Center scopes calls by -CustomerId, unlike the Azure
    RBAC/GDAP side which needs a context switch per tenant), and for each
    customer lists their CSP subscriptions, keeping the ones whose offer name
    matches a Server OS or SQL Server keyword and whose BillingType is
    'License' (the model Windows Server/SQL Server licensing is sold under in
    Partner Center today).

    Each kept row is also tagged LicenseModel = 'Perpetual' or 'Subscription'
    (see Get-LicenseCoreDetails). This matters downstream: Azure Hybrid Benefit
    requires Software Assurance or an equivalent Software Subscription - a
    straight Perpetual purchase does NOT qualify as BYOL cover, so
    Test-LicenseCompliance-AllCustomers.ps1 and
    Find-HybridBenefitOpportunities-AllCustomers.ps1 only count Subscription
    rows toward AHB-eligible licensing on file.

    Run in its own PowerShell process, kept separate from Az.Accounts and
    Microsoft.Graph, to avoid the known assembly conflicts between these
    SDKs' shared Azure.Identity/MSAL dependencies (same reason
    Get-GDAPCustomerTenants.ps1 runs standalone).

    Sign-in acquires one raw access token (New-PartnerAccessToken, using the
    same well-known Microsoft public client ID Connect-PartnerCenter uses
    internally) and feeds it into Connect-PartnerCenter, rather than calling
    Connect-PartnerCenter interactively - doing both interactively in one
    process throws "An item with the same key has already been added" from
    the module's token cache. Keeping the raw token also lets this script
    fall back to calling the Partner Center REST API directly for any
    customer whose subscription list contains a status value the module's
    typed model doesn't have an enum member for ("Requested value 'X' was
    not found" - seen in the wild for 'Disabled', 'Expired', 'Benefit').
    That's a module bug, not a per-customer issue: one unrecognized status
    on ANY subscription throws for the customer's ENTIRE subscription list,
    so without this fallback such a customer looks identical to "no license"
    when the truth is "we don't know."

    A customer erroring here (e.g. "not found" / access denied) usually means
    they're not your direct Partner Center customer of record - e.g. a 2-tier
    CSP customer sitting under a different top-tier distributor. Logged to
    the errors CSV and skipped, not a script bug.

    VALIDATE BEFORE TRUSTING AT SCALE: run this once, then check its output
    against 2-3 customers you *know* have Windows Server/SQL Server licensing
    sold, to confirm the offer-name keywords actually catch your catalog's
    naming before relying on the full run's negative results.

.PARAMETER CustomerCsv
    Path to the customer tenant list from Get-GDAPCustomerTenants.ps1.

.PARAMETER OutputCsv
    Path to write the per-customer licensing findings to. Deliberately not
    timestamped - it's a stable input consumed by
    Find-HybridBenefitOpportunities-AllCustomers.ps1, same as GDAPCustomers.csv.

.PARAMETER ServerOSKeywords
    Offer-name substrings (case-insensitive) that count as Server OS licensing.

.PARAMETER SqlKeywords
    Offer-name substrings (case-insensitive) that count as SQL Server licensing.

.PARAMETER ExcludeSuspended
    By default a Suspended subscription still counts as "licensing on file"
    (the record exists, it's just currently suspended). Pass this switch to
    count only Active subscriptions. Deleted subscriptions never count.
#>

[CmdletBinding()]
param(
    [string]$CustomerCsv,
    [string]$OutputCsv,
    [string[]]$ServerOSKeywords = @('Windows Server'),
    [string[]]$SqlKeywords      = @('SQL Server'),
    [switch]$ExcludeSuspended,
    # Default is WAM (native Windows sign-in, supports passkey/Windows Hello) - only
    # needed when this script is launched by automation with no window handle for WAM's
    # broker UI to attach to (fails with "A window handle must be configured"). Running
    # this yourself in your own terminal should just work without this switch.
    [switch]$DeviceCode
)

$ErrorActionPreference = 'Stop'

# $PSScriptRoot can come back empty for -File invocations in this environment
# (reproduced directly - it's not just this script); fall back to $MyInvocation,
# which is reliable at script-body scope regardless of how the script was invoked.
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Definition }
if (-not $CustomerCsv) { $CustomerCsv = Join-Path $ScriptDir 'GDAPCustomers.csv' }
if (-not $OutputCsv)   { $OutputCsv   = Join-Path $ScriptDir 'PartnerCenterLicensing.csv' }

if (-not (Test-Path $CustomerCsv)) {
    throw "Customer list not found at '$CustomerCsv'. Run Get-GDAPCustomerTenants.ps1 first."
}

Import-Module PartnerCenter -RequiredVersion 3.0.10 -ErrorAction Stop

$customers = Import-Csv -Path $CustomerCsv
if (-not $customers -or $customers.Count -eq 0) {
    Write-Warning "No customer tenants in '$CustomerCsv'. Nothing to check."
    return
}

$timestamp  = Get-Date -Format 'yyyyMMdd_HHmmss'
$errorsPath = Join-Path (Split-Path $OutputCsv -Parent) "PartnerCenterLicensing_Errors_$timestamp.csv"

$findings = [System.Collections.Generic.List[object]]::new()
$errors   = [System.Collections.Generic.List[object]]::new()
$statusesToKeep = if ($ExcludeSuspended) { @('Active') } else { @('Active', 'Suspended') }

function Add-ErrorRow {
    param($TenantId, $TenantName, $Message)
    $errors.Add([pscustomobject]@{
        TenantId   = $TenantId
        TenantName = $TenantName
        Message    = $Message
    })
}

function Get-LicenseCategory {
    param([string]$OfferName)
    if (-not $OfferName) { return $null }
    foreach ($kw in $ServerOSKeywords) { if ($OfferName -match [regex]::Escape($kw)) { return 'ServerOS' } }
    foreach ($kw in $SqlKeywords)      { if ($OfferName -match [regex]::Escape($kw)) { return 'SQL' } }
    return $null
}

# Parses core-license detail out of the offer name for the compliance math in
# Test-LicenseCompliance-AllCustomers.ps1: edition (Standard/Datacenter/Enterprise),
# whether it's a CAL (never a core license - CALs are a separate requirement and
# must never be counted toward the per-VM core minimum), cores-per-unit (null
# when the offer name doesn't state a per-core count at all, e.g. legacy
# Server+CAL-model SKUs - those need manual review, not a guessed number), and
# LicenseModel (Perpetual vs Subscription - see below).
#
# LicenseModel matters because Azure Hybrid Benefit (BYOL) requires Software
# Assurance or an equivalent Software Subscription - a straight Perpetual purchase
# (no SA) does NOT qualify, per Microsoft's AHB terms. In the CSP catalog the two
# commerce models are distinguishable from the offer name alone: Software
# Subscription SKUs are explicitly annual and carry a "1 Year"/"1 year" suffix
# (confirmed against the live data: those rows also carry a real ~1-year-out
# CommitmentEndDate); Perpetual SKUs have no such suffix and report the
# 31/12/9999 sentinel (or a blank) CommitmentEndDate, meaning no recurring term
# at all. CALs are excluded from this either way since they're never core
# licenses regardless of commerce model.
function Get-LicenseCoreDetails {
    param([string]$OfferName)
    $isCal = $OfferName -match '\bCAL\b'
    $edition =
        if ($OfferName -match 'Datacenter') { 'Datacenter' }
        elseif ($OfferName -match 'Enterprise') { 'Enterprise' }
        elseif ($OfferName -match 'Standard') { 'Standard' }
        else { 'Unknown' }
    $coreMatch = [regex]::Match($OfferName, '(\d+)\s*[Cc]ore')
    $coresPerUnit = if ($coreMatch.Success -and -not $isCal) { [int]$coreMatch.Groups[1].Value } else { $null }
    $licenseModel = if ($OfferName -match '\b1\s*[Yy]ear\b') { 'Subscription' } else { 'Perpetual' }
    [pscustomobject]@{ IsCal = $isCal; Edition = $edition; CoresPerUnit = $coresPerUnit; LicenseModel = $licenseModel }
}

# The well-known Microsoft public client ID Connect-PartnerCenter itself uses for interactive
# sign-in (found via reflection on Commands.ConnectPartnerCenter::PowerShellApplicationId).
# Reused here so the raw-REST fallback authenticates as the same client.
$partnerCenterAppId = '04b07795-8ddb-461a-bbee-02f9e1bf7b46'

function Get-CustomerSubscriptionsRaw {
    param([string]$CustomerId, [string]$AccessToken)
    $headers = @{
        Authorization      = "Bearer $AccessToken"
        'MS-RequestId'     = [guid]::NewGuid().ToString()
        'MS-CorrelationId' = [guid]::NewGuid().ToString()
        Accept             = 'application/json'
    }
    $resp = Invoke-RestMethod -Uri "https://api.partnercenter.microsoft.com/v1/customers/$CustomerId/subscriptions" -Headers $headers -Method Get -ErrorAction Stop
    return $resp.items | ForEach-Object {
        [pscustomobject]@{
            OfferId           = $_.offerId
            OfferName         = $_.offerName
            BillingType       = $_.billingType
            Status            = $_.status
            Quantity          = $_.quantity
            SubscriptionId    = $_.id
            CommitmentEndDate = $_.commitmentEndDate
        }
    }
}

Write-Host "`n=== Sign in to Partner Center ===" -ForegroundColor Cyan
Write-Host "This is ONE sign-in as you/the partner - not per customer tenant." -ForegroundColor Cyan
if ($DeviceCode) {
    Write-Host "You'll get a device code - open the printed URL in your normal browser and enter it there." -ForegroundColor Cyan
    Write-Host "(A passkey/Windows Hello option should be available on that sign-in page if you have one registered.)" -ForegroundColor Cyan
    $partnerToken = New-PartnerAccessToken -ApplicationId $partnerCenterAppId `
        -Scopes 'https://api.partnercenter.microsoft.com/user_impersonation' -UseDeviceAuthentication
} else {
    Write-Host "A browser sign-in window will open - use passkey/Windows Hello there if you have one registered." -ForegroundColor Cyan
    $partnerToken = New-PartnerAccessToken -ApplicationId $partnerCenterAppId `
        -Scopes 'https://api.partnercenter.microsoft.com/user_impersonation' -UseAuthorizationCode
}
Connect-PartnerCenter -AccessToken $partnerToken.AccessToken | Out-Null
Write-Host "Signed in to Partner Center." -ForegroundColor Green
Write-Host "Customer tenants to check: $($customers.Count)" -ForegroundColor Green

$idx = 0
$recoveredCount = 0
foreach ($customer in $customers) {
    $idx++
    $tenantLabel = if ($customer.TenantName) { $customer.TenantName } else { $customer.TenantId }
    Write-Host "[$idx/$($customers.Count)] $tenantLabel" -ForegroundColor Yellow

    $subs = $null
    try {
        $subs = Get-PartnerCustomerSubscription -CustomerId $customer.TenantId -ErrorAction Stop
    } catch {
        if ($_.Exception.Message -match "Requested value '.*' was not found") {
            # Known PartnerCenter module bug: one subscription with a status value the typed
            # model doesn't recognize throws for the customer's ENTIRE subscription list.
            # Fall back to the raw REST API, which returns status as a plain string.
            try {
                $subs = Get-CustomerSubscriptionsRaw -CustomerId $customer.TenantId -AccessToken $partnerToken.AccessToken
                $recoveredCount++
                Write-Host "  Recovered via raw REST fallback (module couldn't parse a subscription status)" -ForegroundColor DarkCyan
            } catch {
                Write-Host "  Skipped - raw REST fallback also failed: $($_.Exception.Message)" -ForegroundColor DarkGray
                Add-ErrorRow -TenantId $customer.TenantId -TenantName $tenantLabel -Message "Typed call failed (unrecognized status); REST fallback also failed: $($_.Exception.Message)"
                continue
            }
        } else {
            Write-Host "  Skipped - $($_.Exception.Message)" -ForegroundColor DarkGray
            Add-ErrorRow -TenantId $customer.TenantId -TenantName $tenantLabel -Message $_.Exception.Message
            continue
        }
    }

    foreach ($sub in $subs) {
        # -ne is case-insensitive by default in PowerShell, so this matches both the typed
        # model's 'License' and the raw REST fallback's lowercase 'license'.
        if ($sub.BillingType.ToString() -ne 'License') { continue }
        if ($sub.Status.ToString() -notin $statusesToKeep) { continue }

        $category = Get-LicenseCategory -OfferName $sub.OfferName
        if (-not $category) { continue }

        $core = Get-LicenseCoreDetails -OfferName $sub.OfferName
        $totalCores = if ($core.CoresPerUnit) { $core.CoresPerUnit * [int]$sub.Quantity } else { $null }
        $isLapsed = $false
        if ($sub.CommitmentEndDate) {
            try { $isLapsed = ([datetime]$sub.CommitmentEndDate) -lt (Get-Date) } catch {}
        }

        $findings.Add([pscustomobject]@{
            TenantId          = $customer.TenantId
            TenantName        = $tenantLabel
            LicenseCategory   = $category
            OfferId           = $sub.OfferId
            OfferName         = $sub.OfferName
            Edition           = $core.Edition
            IsCal             = $core.IsCal
            CoresPerUnit      = $core.CoresPerUnit
            LicenseModel      = $core.LicenseModel
            Quantity          = $sub.Quantity
            TotalCores        = $totalCores
            Status            = $sub.Status
            IsLapsed          = $isLapsed
            SubscriptionId    = $sub.SubscriptionId
            CommitmentEndDate = $sub.CommitmentEndDate
        })
        Write-Host "    [$category] $($sub.OfferName) x$($sub.Quantity) ($($sub.Status))" -ForegroundColor Green
    }
}

$findings | Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8
$errors   | Export-Csv -Path $errorsPath -NoTypeInformation -Encoding UTF8

Write-Host "`n=== Done ===" -ForegroundColor Cyan
Write-Host "Customers checked                      : $($customers.Count)" -ForegroundColor Green
Write-Host "Matching Server OS/SQL license records  : $($findings.Count)" -ForegroundColor Green
Write-Host "Recovered via REST fallback             : $recoveredCount" -ForegroundColor DarkCyan
Write-Host "Customers with a lookup error           : $($errors.Count)" -ForegroundColor Yellow
Write-Host "Licensing file (feeds the next script)  : $OutputCsv" -ForegroundColor Green
Write-Host "Errors log                              : $errorsPath" -ForegroundColor Yellow

Disconnect-PartnerCenter | Out-Null
(No Ratings Yet)