Private/ANF/New-SilkTCOANFCostArray.ps1

<#
    .SYNOPSIS
    Builds both cost bases for Azure NetApp Files capacity pools.

    .DESCRIPTION
    Two independent numbers, deliberately kept in separate columns:

      ProvisionedCost*USD - Azure Retail Prices API rate x pool size. No auth, no
                            billing lag. This is the apples to apples number.
      ActualCost*USD - the Cost Details report for the pool resource, which is what
                            ANF bills against (not the account or the volumes).

    Each comes out twice: PeriodUSD covers the -days window (so -days 1 is one day's
    cost, matching every other collector in this module) and MonthlyUSD is the same
    thing scaled to 30 days. ActualCostPeriodUSD is the only measured figure on the row.

    Either source can fail on its own without killing the other. A big gap between the two
    usually means reserved capacity, an EA/MCA negotiated rate, or cool access tiering -
    all of which matter, because a customer 40% under list needs comparing at their rate.
#>


function New-SilkTCOANFCostArray {
    param(
        [Parameter(Mandatory)]
        [array] $anflist,
        [Parameter()]
        [int] $days = 1,
        [Parameter()]
        [int] $offsetDays = 1,
        [Parameter()]
        [switch] $skipActualCost,
        [Parameter()]
        [ValidateSet('AmortizedCost', 'ActualCost')]
        [string] $costMetric = 'AmortizedCost'
    )

    $costReport = @()
    $retailCache = @{}

    # ---------------------------------------------------------------
    # list price, straight off the public retail api. no module or auth needed
    # ---------------------------------------------------------------
    function Get-AnfRetailRate {
        param(
            [string] $armRegion,
            [string] $serviceLevel
        )

        $cacheKey = "$armRegion-$serviceLevel"
        if ($retailCache.ContainsKey($cacheKey)) {
            return $retailCache[$cacheKey]
        }

        try {
            $filter = "serviceName eq 'Azure NetApp Files' and armRegionName eq '$armRegion'"
            $uri = "https://prices.azure.com/api/retail/prices?`$filter=$([uri]::EscapeDataString($filter))"

            $items = @()
            $page = 0
            while ($uri -and $page -lt 10) {
                $resp = Invoke-RestMethod -Uri $uri -Method Get -ErrorAction Stop
                if ($resp.Items) { $items += $resp.Items }
                $uri = $resp.NextPageLink
                $page++
            }

            # want the plain capacity meter for this tier, not snapshot/backup/replication ones
            $meter = $items | Where-Object {
                $_.meterName -match "^$serviceLevel\s+Capacity$" -and $_.type -eq 'Consumption'
            } | Select-Object -First 1

            # deliberately NO fuzzy fallback here. a loose match happily grabs
            # '<tier> Storage with Cool Access Capacity' or '<tier> Double Encrypted Capacity',
            # and cool access is ~60% cheaper - you'd silently underprice the whole pool.
            # no exact meter = report nothing and say so.

            if ($meter) {
                # anf meters come through hourly (1 GiB/Hour), so scale to a 730h month
                $gibMonth = if ($meter.unitOfMeasure -match 'Hour') {
                    [decimal]$meter.retailPrice * 730
                } else {
                    [decimal]$meter.retailPrice
                }

                $result = New-Object psobject
                $result | Add-Member -MemberType NoteProperty -Name RateGiBMonth -Value ([Math]::Round($gibMonth, 6))
                $result | Add-Member -MemberType NoteProperty -Name MeterName -Value $meter.meterName
                $result | Add-Member -MemberType NoteProperty -Name Currency -Value $meter.currencyCode

                $retailCache[$cacheKey] = $result
                return $result
            }

            Write-Warning "No ANF retail meter matched for $serviceLevel in $armRegion."
        } catch {
            Write-Warning "Azure Retail Prices lookup failed for $armRegion/$serviceLevel : $($_.Exception.Message)"
        }

        return $null
    }

    # ---------------------------------------------------------------
    # actual billed spend, off the shared cost details report
    # ---------------------------------------------------------------
    $actualByResource = @{}

    if (-not $skipActualCost) {
        $cd = Get-SilkTCOAzureCostDetails -days $days -offsetDays $offsetDays -metric $costMetric
        if ($cd) {
            foreach ($r in $cd.Rows) {
                if (-not $r.ResourceId.Contains('/providers/microsoft.netapp/')) { continue }
                if (-not $actualByResource.ContainsKey($r.ResourceId)) { $actualByResource[$r.ResourceId] = 0.0 }
                $actualByResource[$r.ResourceId] += $r.Cost
            }
            Write-Verbose "Matched $($actualByResource.Keys.Count) NetApp resource(s) in cost data." -Verbose
        }
    }

    # ---------------------------------------------------------------
    # stitch both onto each pool
    # ---------------------------------------------------------------
    foreach ($pool in $anflist) {
        if ($pool.IsBackupVault) { continue }

        $notes = @()

        $retail = Get-AnfRetailRate -armRegion $pool.Region -serviceLevel $pool.ServiceLevel
        $listRate = $null
        $provisionedMonthly = $null
        $provisionedPeriod = $null
        if ($retail) {
            $listRate = $retail.RateGiBMonth
            # azure bills capacity by the hour and a day is 24 of them. a 30th of a 730 hour
            # month is 24.3h, which put list 1.4% above what actually gets billed
            $provisionedPeriod = [Math]::Round(($listRate / 730) * 24 * $pool.ProvisionedGiB * $days, 4)
            $provisionedMonthly = [Math]::Round(($provisionedPeriod / $days) * 30, 2)
        } else {
            $notes += 'retail rate unavailable'
        }

        $actualPeriod = $null
        $actualMonthly = $null
        if ($pool.ResourceId) {
            $key = ([string]$pool.ResourceId).ToLower()
            if ($actualByResource.ContainsKey($key)) {
                # what actually got billed across the window. this one is measured, not
                # derived - everything else on the row comes off a rate card
                $actualPeriod = [Math]::Round($actualByResource[$key], 4)
                # window total -> monthly equivalent
                $actualMonthly = [Math]::Round(($actualByResource[$key] / $days) * 30, 2)
            }
        }
        if ($null -eq $actualMonthly) {
            $notes += 'no billed cost matched'
        }

        # compare the same window both ways, so on-list billing reads as 0%
        $variance = $null
        if ($provisionedPeriod -and $null -ne $actualPeriod -and $provisionedPeriod -gt 0) {
            $variance = [Math]::Round((($actualPeriod - $provisionedPeriod) / $provisionedPeriod) * 100, 2)
            if ($variance -lt -10) {
                $notes += 'billed well under list - check reserved capacity or negotiated rate'
            }
        }

        $c = New-Object psobject
        $c | Add-Member -MemberType NoteProperty -Name ResourceId -Value $pool.ResourceId
        $c | Add-Member -MemberType NoteProperty -Name PoolName -Value $pool.PoolName
        $c | Add-Member -MemberType NoteProperty -Name Days -Value $days
        $c | Add-Member -MemberType NoteProperty -Name ListRateGiBMonthUSD -Value $listRate
        $c | Add-Member -MemberType NoteProperty -Name ProvisionedCostPeriodUSD -Value $provisionedPeriod
        $c | Add-Member -MemberType NoteProperty -Name ProvisionedCostMonthlyUSD -Value $provisionedMonthly
        $c | Add-Member -MemberType NoteProperty -Name ActualCostPeriodUSD -Value $actualPeriod
        $c | Add-Member -MemberType NoteProperty -Name ActualCostMonthlyUSD -Value $actualMonthly
        $c | Add-Member -MemberType NoteProperty -Name CostVariancePct -Value $variance
        $c | Add-Member -MemberType NoteProperty -Name CostNotes -Value $(if ($notes) { $notes -join '; ' } else { $null })
        $costReport += $c
    }

    return $costReport
}