src/collect/Get-ScoutCostInventory.ps1

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

<#
.SYNOPSIS
    Per-subscription actual-cost data, grouped by type/resource group/location/service --
    ported into src/collect from Modules/Private/Extraction/Get-AZTICostInventory.ps1
    (AB#5639/5647).
 
.DESCRIPTION
    Cost Management is an opt-in Az module (`Az.CostManagement`) that is deliberately NOT in
    this repo's auto-installed dependency list (AzureScout.psm1's own `.NOTES` explain why:
    installing it drags in a newer `Az.Accounts`, and two `Az.Accounts` versions side by side
    crash every subsequent module import with a stack overflow). Cost data is therefore
    OPTIONAL ENRICHMENT that must never cost the caller their run.
 
.PARAMETER Subscriptions
    Subscription objects with `.id` and `.name`.
 
.PARAMETER Days
    When >= 365, queries the full previous calendar year (Jan 1 -- Dec 31). Otherwise queries
    from the start of two months ago through end-of-today, matching the legacy function.
 
.PARAMETER Granularity
    Passed straight through to `Invoke-AzCostManagementQuery -DatasetGranularity` (e.g.
    'Monthly', 'Daily').
 
.OUTPUTS
    One `[pscustomobject]` per subscription: `SubscriptionId`, `SubscriptionName`, `CostData`
    (the `Invoke-AzCostManagementQuery` result rows, or `@()` if cost data could not be
    retrieved for that subscription -- NEVER `$null`, so a caller's `@($x.CostData).Count`
    is always safe under StrictMode).
 
.NOTES
    Tracks ADO AB#5639 (Task AB#5647, Epic AB#5638).
 
    THE NON-FATAL GUARANTEE IS THE POINT (mirrors the fix already shipped in the legacy
    function for AB#5636): a missing `Az.CostManagement` module, or any other Cost
    Management failure, degrades that ONE subscription's `CostData` to `@()` with a
    `Write-Warning` naming the cause -- it never throws uncaught, and it never aborts
    collection for any OTHER subscription. The historical defect this guards against was a
    bare rethrow of the caught exception message, followed by an empty-collection fallback on
    the next line that could therefore never run. Every catch block here ends in an
    assignment, and an AST test in tests/CostInventoryResilience.Tests.ps1 fails the build if
    a throw statement reappears inside any catch in this file.
#>

function Get-ScoutCostInventory {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object[]] $Subscriptions,
        [int]    $Days = 60,
        [string] $Granularity = 'Monthly'
    )

    $today = Get-Date
    $endDate = Get-Date -Year $today.Year -Month $today.Month -Day $today.Day -Hour 23 -Minute 59 -Second 59 -Millisecond 0

    if ($Days -ge 365) {
        $startDate = Get-Date -Year $endDate.AddYears(-1).Year -Month 1 -Day 1 -Hour 0 -Minute 0 -Second 0 -Millisecond 1
        $endDate   = Get-Date -Year $startDate.Year -Month 12 -Day 31 -Hour 23 -Minute 59 -Second 59 -Millisecond 0
    }
    else {
        $startDate = (Get-Date -Day 1).AddMonths(-2)
    }

    $grouping = @(
        @{ Name = 'ResourceType'; Type = 'Dimension' }
        @{ Name = 'ResourceGroup'; Type = 'Dimension' }
        @{ Name = 'ResourceLocation'; Type = 'Dimension' }
        @{ Name = 'ServiceName'; Type = 'Dimension' }
    )
    $aggregation = @{ totalCost = @{ name = 'PreTaxCost'; function = 'Sum' } }

    $costManagementAvailable = [bool](Get-Command Invoke-AzCostManagementQuery -ErrorAction SilentlyContinue)
    if (-not $costManagementAvailable) {
        Write-Warning "Get-ScoutCostInventory: the Az.CostManagement module is not installed -- every subscription's CostData will be empty. Install it with 'Install-Module Az.CostManagement -Scope CurrentUser' to include cost data."
    }

    $result = foreach ($subscription in $Subscriptions) {
        $subId   = $subscription.id
        $subName = if ($subscription.PSObject.Properties['name']) { $subscription.name } else { $subId }
        $scope   = "/subscriptions/$subId/"
        $costs   = @()

        if ($costManagementAvailable) {
            try {
                # NOT wrapped in @(): Invoke-AzCostManagementQuery returns ONE query-result
                # object carrying a .Row collection, and the legacy function assigned it
                # unwrapped. Get-ScoutCostAnomaly reads the raw shape with
                # `$item.CostData.PSObject.Properties['Row']`, which is $null on an ARRAY --
                # so wrapping it silently produced zero anomaly records rather than an error.
                # v1's shape is the contract here. (AB#5648)
                $costs = Invoke-AzCostManagementQuery -Type ActualCost -Scope $scope -Timeframe Custom `
                    -DatasetGranularity $Granularity -DatasetGrouping $grouping -DatasetAggregation $aggregation `
                    -TimePeriodFrom $startDate -TimePeriodTo $endDate -ErrorAction Stop
                if ($null -eq $costs) { $costs = @() }
            }
            catch {
                # Cost data is optional enrichment -- it must never cost the caller their
                # inventory (AB#5636's defect class: a bare `throw` here previously aborted
                # the whole run and made the `$costs = @()` fallback unreachable).
                $costs = @()

                # Two ways the dependency can be missing, and the operator needs the same
                # actionable hint for both (carried over from the legacy function, AB#5648):
                # the up-front Get-Command probe above catches an absent module, but a command
                # that RESOLVES and then throws CommandNotFound at invocation time -- a broken
                # or partially-loaded Az.CostManagement -- only surfaces here.
                if ($_.Exception.Message -like '*Invoke-AzCostManagementQuery*not recognized*') {
                    Write-Warning ("Get-ScoutCostInventory: cost data skipped -- the Az.CostManagement module is not available. " +
                        "Install it with 'Install-Module Az.CostManagement -Scope CurrentUser' and rerun with -IncludeCosts.")
                }
                else {
                    Write-Warning "Get-ScoutCostInventory: cost data skipped for subscription '$subName': $($_.Exception.Message)"
                }

                # The legacy function also recorded the failure in the run log, which is what an
                # operator reads after the fact. Guarded by Get-Command because this file is
                # dot-sourced standalone by its own tests, where the logging function is absent.
                if (Get-Command Write-AZSCLog -ErrorAction SilentlyContinue) {
                    Write-AZSCLog -Message ("Cost extraction failed for '" + $subName + "': " + $_.Exception.Message) -Level 'WARN'
                }
            }
        }

        # Carried over from the legacy function: Cost Management is aggressively rate-limited,
        # and a tenant with many subscriptions issues one query per subscription back to back.
        Start-Sleep -Milliseconds 100

        [pscustomobject]@{
            SubscriptionId   = $subId
            SubscriptionName = $subName
            CostData         = $costs
        }
    }

    return @($result)
}