src/collect/Get-ScoutBillingEvidence.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#+
.SYNOPSIS
    Collects the caller-visible Azure billing hierarchy, billing roles, and billing benefits.
 
.DESCRIPTION
    Uses read-only Azure Resource Manager endpoints. Billing access is independent of subscription
    Reader, so every request records Success, Empty, or Unavailable. Returned wire objects are kept
    intact under properties.Raw and stamped with the exact source request.
#>

function Get-ScoutBillingEvidence {
    [CmdletBinding()]
    param()

    $resources = [System.Collections.Generic.List[object]]::new()
    $sourceOperations = [System.Collections.Generic.List[object]]::new()
    $collectionHealth = [System.Collections.Generic.List[object]]::new()

    function Get-BillingValue {
        param([AllowNull()][object] $InputObject, [Parameter(Mandatory)][string] $Path)
        $current = $InputObject
        foreach ($segment in $Path.Split('.')) {
            if ($null -eq $current) { return $null }
            if ($current -is [System.Collections.IDictionary]) {
                $key = @($current.Keys | Where-Object { [string]$_ -ieq $segment } | Select-Object -First 1)
                if ($key.Count -eq 0) { return $null }
                $current = $current[$key[0]]
                continue
            }
            $property = @($current.PSObject.Properties | Where-Object Name -ieq $segment | Select-Object -First 1)
            if ($property.Count -eq 0) { return $null }
            $current = $property[0].Value
        }
        return $current
    }

    function Get-BillingStatusCode {
        param([System.Management.Automation.ErrorRecord] $ErrorRecord)
        if ($ErrorRecord.Exception.Data.Contains('StatusCode')) {
            try { return [int]$ErrorRecord.Exception.Data['StatusCode'] } catch { return $null }
        }
        if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) {
            $status = $ErrorRecord.Exception.Response.PSObject.Properties['StatusCode']
            if ($status) { try { return [int]$status.Value } catch { return $null } }
        }
        if ([string]$ErrorRecord.Exception.Message -match '(?i)(?:status(?: code)?|HTTP)\D*(\d{3})') { return [int]$Matches[1] }
        return $null
    }

    function Add-BillingEnvelope {
        param(
            [Parameter(Mandatory)][string] $Dataset,
            [Parameter(Mandatory)][object] $Raw,
            [Parameter(Mandatory)][string] $Uri,
            [Parameter(Mandatory)][string] $Method,
            [Parameter(Mandatory)][datetime] $CollectedAt
        )
        $rawId = [string](Get-BillingValue -InputObject $Raw -Path 'id')
        $rawName = [string](Get-BillingValue -InputObject $Raw -Path 'name')
        if ([string]::IsNullOrWhiteSpace($rawName)) { $rawName = "$Dataset-$($resources.Count + 1)" }
        if ([string]::IsNullOrWhiteSpace($rawId)) { $rawId = "/providers/AzureScout/billing/$Dataset/$([uri]::EscapeDataString($rawName))" }
        $resources.Add([pscustomobject][ordered]@{
                id = "$rawId/providers/AzureScout/evidence"
                name = $rawName
                type = "AZSC/Billing/$Dataset"
                properties = [pscustomobject][ordered]@{ Raw = $Raw }
                AZSC = [pscustomobject][ordered]@{
                    Source = 'Azure Resource Manager Billing API'
                    Dataset = $Dataset
                    Operation = $Method
                    Uri = $Uri
                    ApiVersion = if ($Uri -match '(?i)[?&]api-version=([^&]+)') { [uri]::UnescapeDataString($Matches[1]) } else { $null }
                    CollectedAt = $CollectedAt.ToString('o')
                }
            })
    }

    function Invoke-BillingList {
        param(
            [Parameter(Mandatory)][string] $Dataset,
            [Parameter(Mandatory)][string] $Uri,
            [ValidateSet('GET', 'POST')][string] $Method = 'GET',
            [switch] $OptionalForAgreement
        )
        $items = [System.Collections.Generic.List[object]]::new()
        $seenUris = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        $currentUri = $Uri
        $currentMethod = $Method
        while ($currentUri) {
            if (-not $seenUris.Add($currentUri)) { throw "Billing API returned a repeated nextLink '$currentUri'." }
            $startedAt = Get-Date
            try {
                $request = @{ Method = $currentMethod; ErrorAction = 'Stop' }
                if ([uri]::IsWellFormedUriString($currentUri, [System.UriKind]::Absolute)) { $request.Uri = $currentUri } else { $request.Path = $currentUri }
                if ($currentMethod -eq 'POST') { $request.Payload = '{}' }
                $response = Invoke-AzRestMethod @request
                if ($null -eq $response) { throw 'ARM returned no response.' }
                $statusProperty = $response.PSObject.Properties['StatusCode']
                if ($statusProperty -and ([int]$statusProperty.Value -lt 200 -or [int]$statusProperty.Value -ge 300)) {
                    throw "ARM returned HTTP $($statusProperty.Value)."
                }
                $contentProperty = $response.PSObject.Properties['Content']
                $content = if ($contentProperty) { $contentProperty.Value } else { $null }
                if ($content -is [string] -and -not [string]::IsNullOrWhiteSpace($content)) { $content = $content | ConvertFrom-Json }
                [object[]]$pageItems = @()
                if ($null -ne $content) {
                    if ($content.PSObject.Properties['value']) {
                        $pageItems = @($content.value | Where-Object { $null -ne $_ })
                    }
                    else {
                        $pageItems = @($content)
                    }
                }
                $collectedAt = Get-Date
                foreach ($item in $pageItems) {
                    $items.Add($item)
                    Add-BillingEnvelope -Dataset $Dataset -Raw $item -Uri $currentUri -Method $currentMethod -CollectedAt $collectedAt
                }
                $sourceOperations.Add([pscustomobject][ordered]@{
                        Source = 'Azure Resource Manager Billing API'
                        Dataset = $Dataset
                        Operation = $currentMethod
                        Uri = $currentUri
                        Status = if ($pageItems.Count -gt 0) { 'Success' } else { 'Empty' }
                        Count = $pageItems.Count
                        Reason = $null
                        StartedAt = $startedAt.ToString('o')
                        CompletedAt = $collectedAt.ToString('o')
                    })
                $nextLink = if ($null -ne $content -and $content.PSObject.Properties['nextLink']) { [string]$content.nextLink } else { $null }
                $currentUri = if ([string]::IsNullOrWhiteSpace($nextLink)) { $null } else { $nextLink }
                $currentMethod = 'GET'
            }
            catch {
                $statusCode = Get-BillingStatusCode -ErrorRecord $_
                $expectedAgreementGap = $OptionalForAgreement -and $statusCode -in @(400, 404, 409)
                $status = if ($expectedAgreementGap) { 'NotApplicable' } else { 'Unavailable' }
                $reason = $_.Exception.Message
                $sourceOperations.Add([pscustomobject][ordered]@{
                        Source = 'Azure Resource Manager Billing API'
                        Dataset = $Dataset
                        Operation = $currentMethod
                        Uri = $currentUri
                        Status = $status
                        Count = 0
                        Reason = $reason
                        StartedAt = $startedAt.ToString('o')
                        CompletedAt = (Get-Date).ToString('o')
                    })
                if (-not $expectedAgreementGap) {
                    $collectionHealth.Add([pscustomobject]@{
                            Dataset = "Billing/$Dataset"
                            Operation = $currentMethod
                            Status = 'Unavailable'
                            Reason = $reason
                            ResourceTypes = @("AZSC/Billing/$Dataset")
                        })
                }
                break
            }
        }
        return @($items)
    }

    $billingAccounts = @(Invoke-BillingList -Dataset 'BillingAccounts' -Uri '/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01')
    foreach ($account in $billingAccounts) {
        $accountName = [string](Get-BillingValue -InputObject $account -Path 'name')
        if ([string]::IsNullOrWhiteSpace($accountName)) { continue }
        $accountPath = "/providers/Microsoft.Billing/billingAccounts/$accountName"
        $agreementType = [string](Get-BillingValue -InputObject $account -Path 'properties.agreementType')

        $null = Invoke-BillingList -Dataset 'BillingRoleAssignments' -Uri "$accountPath/billingRoleAssignments?api-version=2024-04-01"
        $null = Invoke-BillingList -Dataset 'BillingRoleDefinitions' -Uri "$accountPath/billingRoleDefinitions?api-version=2024-04-01"

        if ($agreementType -in @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) {
            $profiles = @(Invoke-BillingList -Dataset 'BillingProfiles' -Uri "$accountPath/billingProfiles?api-version=2024-04-01")
            foreach ($profile in $profiles) {
                $profileName = [string](Get-BillingValue -InputObject $profile -Path 'name')
                if ($profileName) {
                    $null = Invoke-BillingList -Dataset 'InvoiceSections' -Uri "$accountPath/billingProfiles/$profileName/invoiceSections?api-version=2024-04-01"
                }
            }
        }
        elseif ($agreementType -eq 'EnterpriseAgreement') {
            $null = Invoke-BillingList -Dataset 'Departments' -Uri "$accountPath/departments?api-version=2024-04-01"
            $null = Invoke-BillingList -Dataset 'EnrollmentAccounts' -Uri "$accountPath/enrollmentAccounts?api-version=2024-04-01"
        }

        if ($agreementType -eq 'MicrosoftCustomerAgreement') {
            $null = Invoke-BillingList -Dataset 'SubscriptionCreationPermissions' -Uri "$accountPath/listInvoiceSectionsWithCreateSubscriptionPermission?api-version=2024-04-01" -Method POST
        }
    }

    $null = Invoke-BillingList -Dataset 'ReservationOrders' -Uri '/providers/Microsoft.Capacity/reservationOrders?api-version=2022-11-01'
    $null = Invoke-BillingList -Dataset 'SavingsPlanOrders' -Uri '/providers/Microsoft.BillingBenefits/savingsPlanOrders?api-version=2022-11-01'

    # Subscription vending is an organizational process, not an Azure control-plane object. Keep
    # that evidence gap explicit rather than inventing an answer from role assignments.
    $collectionHealth.Add([pscustomobject]@{
            Dataset = 'Billing/SubscriptionVendingProcess'
            Operation = 'Organizational process evidence'
            Status = 'NotAssessed'
            Reason = 'Subscription vending workflow requires operator-supplied process evidence; Azure APIs expose permissions but not the ticket/IaC/portal process used.'
            ResourceTypes = @('AZSC/Billing/SubscriptionVendingProcess')
        })

    [pscustomobject]@{
        Resources = @($resources)
        SourceOperations = @($sourceOperations)
        CollectionHealth = @($collectionHealth)
    }
}