Public/Show-AACCost.ps1

function Show-AACCost {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Shows what your Azure subscriptions cost - month to date and the
        last few months - as colourful Spectre.Console charts, with optional
        CSV, PDF and interactive HTML exports of the detail.
    .DESCRIPTION
        Asks the Azure Cost Management Query API, over REST with the
        Connect-AAC sign-in (no Az modules), for each subscription's actual
        cost over the last -Months months (this month so far included),
        broken down by month, service and resource group - one query per
        subscription. The view:
 
          ── Azure Admin Console :: Azure cost ──────────────────────────
          account · tenant · subscriptions · period · when
          [ month to date ] [ last month ] [ period total ] [ subscriptions ] [ top service ]
 
          Month to date by subscription (when there is more than one)
          Month to date by service (one bar split by service)
          Month to date by resource group (top 10)
          Last N months (one bar per month)
          Actual cost by subscription and month - with a total column and,
          for several subscriptions, a total row
 
        Amounts are in each subscription's billing currency and never
        converted; subscriptions billed in different currencies get charts
        of their own. When Cost Management reports no cost at all for a
        period, the view says so instead of drawing empty charts.
 
        Subscriptions Cost Management can't report on (some offer types,
        such as sponsorships, or missing permission) are listed with the
        reason under the charts rather than failing the run. A subscription
        with no cost in the period (new, empty, or billed elsewhere) is not
        an error either: its Status is 'No cost', it is left out of the
        charts and totals, and every output lists it as having no cost. Cost Management
        allows only a few queries a minute, so a progress display shows each
        subscription as it is read, and throttled requests are retried.
 
        Exports:
          -CsvPath the detail: one row per subscription, month, resource
                      group and service - SubscriptionName, SubscriptionId,
                      Month, ResourceGroup, Service, Cost, Currency - ready
                      for an Excel pivot table
          -PdfPath a landscape A4 report: a summary (totals, the
                      subscription-by-month table, top services and
                      resource groups this month), then a page per
                      subscription with its services and resource groups
                      month by month
          -HtmlPath a self-contained, interactive HTML report: tiles and
                      charts (by month, subscription, service, resource
                      group) that filter the detail, a subscription-by-month
                      table and every detail row, with search, filters,
                      grouping, totals of what is shown and a CSV download
          -PassThru one AAC.SubscriptionCost object per subscription:
                      MonthToDate, one property per month ('2026-07', ...),
                      Total, TopServices and Status ('OK', 'No cost', or
                      why the subscription couldn't be read)
 
        When any of -CsvPath, -PdfPath or -HtmlPath is given, the console
        shows only the progress and the files written - the report is in
        the files. Otherwise the view is paged when it is longer than the
        terminal: press any key for the next page, or A for the rest
        (-NoPaging turns that off).
 
        Reading costs needs Cost Management Reader (or Reader) on the
        subscriptions. Costs are "actual cost" as Cost Management reports
        it, which can lag usage by a day or so. PDF export needs Windows and
        PowerShell 7.4 or later.
    .PARAMETER SubscriptionId
        Only these subscriptions. Defaults to every enabled subscription the
        signed-in account can see.
    .PARAMETER Months
        How many months to cover, this month included (default 6, up to 12).
    .PARAMETER Top
        How many services the month-to-date breakdown shows (default 8); the
        rest are summed as "other services".
    .PARAMETER CsvPath
        Also write the detail - one row per subscription, month, resource
        group and service - to this CSV file. An existing file is
        overwritten; missing folders are created.
    .PARAMETER PdfPath
        Also write a PDF report to this file. An existing file is
        overwritten; missing folders are created.
    .PARAMETER HtmlPath
        Also write an interactive HTML report to this file. An existing file
        is overwritten; missing folders are created.
    .PARAMETER Title
        The PDF and HTML report's title. Defaults to 'Azure cost'.
    .PARAMETER NoPaging
        Show the whole view at once instead of a screen at a time.
    .PARAMETER PassThru
        Also return the costs as AAC.SubscriptionCost objects.
    .EXAMPLE
        Connect-AAC
        Show-AACCost
        Month to date and the last 6 months for every subscription you can see.
    .EXAMPLE
        Show-AACCost -Months 12 -CsvPath .\out\Cost.csv -PdfPath .\out\Cost.pdf
        The last year as a detail CSV file and a PDF report.
    .EXAMPLE
        Show-AACCost -Months 12 -HtmlPath .\out\Cost.html
        The last year in an interactive HTML report.
    .EXAMPLE
        Show-AACCost -SubscriptionId '00000000-0000-0000-0000-000000000000' -Months 3
        One subscription over the last three months.
    .EXAMPLE
        Show-AACCost -PassThru | Export-Csv .\CostSummary.csv -NoTypeInformation
        One row per subscription, with a column per month.
    .OUTPUTS
        AAC.SubscriptionCost (with -PassThru)
    #>

    [CmdletBinding()]
    [OutputType('AAC.SubscriptionCost')]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [ValidateRange(1, 12)]
        [int] $Months = 6,

        [ValidateRange(1, 20)]
        [int] $Top = 8,

        [string] $CsvPath,

        [string] $PdfPath,

        [string] $HtmlPath,

        [string] $Title = 'Azure cost',

        [switch] $NoPaging,

        [switch] $PassThru
    )

    # A failure anywhere below ends as a Spectre.Console error panel and this
    # command's own terminating error, not a line inside the module.
    trap { $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    # Resolve paths now, relative to the caller's location, so a bad path
    # fails before any Azure call.
    $csvFullPath = if ($CsvPath) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($CsvPath) }
    $pdfFullPath = if ($PdfPath) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($PdfPath) }
    $htmlFullPath = if ($HtmlPath) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($HtmlPath) }

    # --- The period and helpers ------------------------------------------------------------------
    $invariant = [cultureinfo]::InvariantCulture
    $today = (Get-Date).Date
    $thisMonth = [datetime]::new($today.Year, $today.Month, 1)
    $monthStarts = @(for ($i = $Months - 1; $i -ge 0; $i--) { $thisMonth.AddMonths(-$i) })
    $monthKey = { param([datetime] $Date) $Date.ToString('yyyy-MM', $invariant) }
    $thisMonthKey = & $monthKey $thisMonth
    $lastMonthKey = & $monthKey $thisMonth.AddMonths(-1)

    # Cost Management's month column, whatever shape it arrives in: a
    # [datetime] (Invoke-RestMethod converts ISO dates), an ISO string, or a
    # number like 20260701. Always read in the invariant culture - a date
    # turned into text in the local culture ('01/07/2026') matches nothing.
    $toMonthKey = {
        param($Value)
        if ($null -eq $Value) { return }
        if ($Value -is [datetime]) { return $Value.ToString('yyyy-MM', $invariant) }
        if ($Value -is [datetimeoffset]) { return $Value.UtcDateTime.ToString('yyyy-MM', $invariant) }
        $text = ([string]$Value).Trim()
        if ($text -match '^(\d{4})(\d{2})\d{2}$') { return "$($Matches[1])-$($Matches[2])" }
        if ($text -match '^(\d{4})-(\d{2})') { return "$($Matches[1])-$($Matches[2])" }
        $parsed = [datetime]::MinValue
        if ([datetime]::TryParse($text, $invariant, [System.Globalization.DateTimeStyles]::None, [ref]$parsed)) { return $parsed.ToString('yyyy-MM', $invariant) }
    }
    # The sum of one property over some objects; 0 for none (Measure-Object
    # returns nothing at all for an empty list).
    $sum = {
        param($Items, [string] $Property)
        $total = 0.0
        foreach ($item in @($Items)) {
            $value = Get-AACPropertyValue -InputObject $item -Name $Property
            if ($null -ne $value) { $total += [double]$value }
        }
        $total
    }

    # One query per subscription: every month of the period, by service and
    # resource group. Month to date is simply this month's share of it.
    $query = @{
        type       = 'ActualCost'
        timeframe  = 'Custom'
        timePeriod = @{ from = $monthStarts[0].ToString('yyyy-MM-ddT00:00:00Z', $invariant); to = $today.ToString('yyyy-MM-ddT23:59:59Z', $invariant) }
        dataset    = @{
            granularity = 'Monthly'
            aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } }
            grouping    = @(
                @{ type = 'Dimension'; name = 'ServiceName' }
                @{ type = 'Dimension'; name = 'ResourceGroupName' }
            )
        }
    }

    # --- Read and export behind one progress display, as every command does -------------------
    # The title first; then a line per step - subscriptions, costs (naming
    # each subscription as it is read), CSV and PDF - each finishing with
    # what it found; then the report.
    Write-AACRule -Title 'Azure Admin Console :: Azure cost' -Color 'deepskyblue3_1'
    $headers = @{ Authorization = "Bearer $(Get-AACAccessToken)" }
    $state = Invoke-AACProgress -ScriptBlock {
        Update-AACProgress -Id 'subscriptions' -Description 'Finding subscriptions in Azure Resource Graph' -Indeterminate
        $subscriptions = @(Invoke-AACResourceGraphQuery -Headers $headers -Query "resourcecontainers | where type =~ 'microsoft.resources/subscriptions' | project subscriptionId, name, state = tostring(properties.state)")
        if ($SubscriptionId) {
            $wanted = @($SubscriptionId | ForEach-Object { $_.ToLowerInvariant() })
            $subscriptions = @($subscriptions | Where-Object { $_.subscriptionId.ToLowerInvariant() -in $wanted })
            foreach ($id in $wanted | Where-Object { $_ -notin @($subscriptions | ForEach-Object { $_.subscriptionId.ToLowerInvariant() }) }) {
                $subscriptions += [pscustomobject]@{ subscriptionId = $id; name = $id; state = 'Enabled' }
            }
        }
        else {
            $subscriptions = @($subscriptions | Where-Object { -not $_.state -or $_.state -eq 'Enabled' })
        }
        $subscriptions = @($subscriptions | Sort-Object name)
        Update-AACProgress -Id 'subscriptions' -Complete -Description ('Found {0} subscription(s)' -f $subscriptions.Count)

        # Cost Management allows only a few queries a minute: one per
        # subscription, each named as it is read.
        Update-AACProgress -Id 'cost' -Total $subscriptions.Count -Description "Reading costs for $($subscriptions.Count) subscription(s)"
        $index = 0
        $results = @(foreach ($subscription in $subscriptions) {
                $index++
                $name = if ($subscription.name) { $subscription.name } else { $subscription.subscriptionId }
                Update-AACProgress -Id 'cost' -Description "Reading costs: $name ($index of $($subscriptions.Count))"
                $rows = @()
                $status = 'OK'
                try {
                    $rows = @(Invoke-AACCostQuery -SubscriptionId $subscription.subscriptionId -Body $query)
                }
                catch {
                    $status = [string]$_.Exception.Message
                }
                Update-AACProgress -Id 'cost' -Increment 1
                @{ Subscription = $subscription; Name = $name; Rows = $rows; Status = $status }
            })
        $unreadable = @($results | Where-Object { $_.Status -ne 'OK' }).Count
        $costless = @($results | Where-Object { $_.Status -eq 'OK' -and -not @($_.Rows | Where-Object { [double](Get-AACPropertyValue -InputObject $_ -Name 'Cost') -ne 0 }).Count }).Count
        Update-AACProgress -Id 'cost' -Complete -Description ('Cost Management: {0} subscription(s) - {1} read{2}{3}' -f $results.Count, ($results.Count - $unreadable), $(if ($costless) { " ($costless with no cost)" }), $(if ($unreadable) { ", $unreadable could not be read" }))

        # --- The detail: one row per subscription, month, resource group and service -------------
        $detail = [System.Collections.Generic.List[object]]::new()
        $costs = @(foreach ($result in $results) {
                $status = $result.Status
                $rows = @(foreach ($row in $result.Rows) {
                        $month = Get-AACPropertyValue -InputObject $row -Name 'BillingMonth'
                        if ($null -eq $month) { $month = Get-AACPropertyValue -InputObject $row -Name 'UsageDate' }
                        [pscustomobject]@{
                            PSTypeName       = 'AAC.CostDetail'
                            SubscriptionName = $result.Name
                            SubscriptionId   = $result.Subscription.subscriptionId
                            Month            = & $toMonthKey $month
                            ResourceGroup    = [string](Get-AACPropertyValue -InputObject $row -Name 'ResourceGroupName')
                            Service          = [string](Get-AACPropertyValue -InputObject $row -Name 'ServiceName')
                            Cost             = [Math]::Round([double](Get-AACPropertyValue -InputObject $row -Name 'Cost'), 4)
                            Currency         = [string](Get-AACPropertyValue -InputObject $row -Name 'Currency')
                        }
                    })
                # Never show a silent zero: rows whose month can't be read are an error.
                $unplaced = @($rows | Where-Object { -not $_.Month })
                if ($unplaced.Count -gt 0 -and $status -eq 'OK') {
                    $status = "$($unplaced.Count) of $($rows.Count) cost row(s) had a month that couldn't be read"
                }
                $rows = @($rows | Where-Object { $_.Month })
                foreach ($row in $rows) { $detail.Add($row) }

                $currency = @($rows | ForEach-Object { $_.Currency } | Where-Object { $_ } | Select-Object -First 1)
                $object = [ordered]@{
                    PSTypeName       = 'AAC.SubscriptionCost'
                    SubscriptionName = $result.Name
                    SubscriptionId   = $result.Subscription.subscriptionId
                    Currency         = if ($currency) { [string]$currency[0] } else { '' }
                    MonthToDate      = [Math]::Round((& $sum @($rows | Where-Object Month -eq $thisMonthKey) 'Cost'), 2)
                }
                foreach ($start in $monthStarts) {
                    $key = & $monthKey $start
                    $object[$key] = [Math]::Round((& $sum @($rows | Where-Object Month -eq $key) 'Cost'), 2)
                }
                $object['Total'] = [Math]::Round((& $sum $rows 'Cost'), 2)
                $services = @($rows | Where-Object Month -eq $thisMonthKey | Group-Object Service | ForEach-Object {
                        [pscustomobject]@{ Name = $_.Name; Cost = (& $sum $_.Group 'Cost') }
                    } | Sort-Object Cost -Descending)
                $object['TopServices'] = (@($services | Select-Object -First 3 | ForEach-Object { '{0} ({1:N2})' -f $_.Name, $_.Cost })) -join '; '
                # Read, but nothing to show: a state of its own, not an error -
                # kept out of the charts and totals, and listed as such.
                if ($status -eq 'OK' -and -not @($rows | Where-Object Cost -NE 0).Count) {
                    $status = 'No cost'
                }
                $object['Status'] = $status
                [pscustomobject]$object
            })

        $period = "$($monthStarts[0].ToString('MMM yyyy')) - $($today.ToString('d MMM yyyy'))"
        $null = Invoke-AACExport -CsvPath $csvFullPath -CsvObject @($detail | Sort-Object SubscriptionName, Month, ResourceGroup, Service) -Noun 'detail row' -PdfPath $pdfFullPath -WritePdf {
            Write-AACCostPdf -Cost $costs -Detail $detail.ToArray() -MonthStart $monthStarts -Path $pdfFullPath -Title $Title -Period $period
        } -HtmlPath $htmlFullPath -WriteHtml {
            $scope = [ordered]@{ Subscriptions = if ($SubscriptionId) { $SubscriptionId -join ', ' } else { 'every enabled subscription the account can see' }; Period = $period }
            Write-AACCostHtml -Cost $costs -CostDetail $detail.ToArray() -MonthStart $monthStarts -Path $htmlFullPath -Title $Title -Period $period -Detail $scope
        }

        @{ Subscriptions = $subscriptions; Costs = $costs; Detail = $detail }
    }
    $subscriptions = @($state.Subscriptions)
    $costs = @($state.Costs)
    $detail = $state.Detail

    # An export means the report is in the files: no view.
    if ($CsvPath -or $PdfPath -or $HtmlPath) {
        Show-AACCostNotice -Cost $costs -Since $monthStarts[0]
        if ($PassThru) { $costs }
        return
    }

    # --- The view, a page at a time ----------------------------------------------------------------
    Invoke-AACPagedOutput -NoPaging:$NoPaging -ScriptBlock {
        $escape = { param($Text) [Spectre.Console.Markup]::Escape([string]$Text) }
        # Unicode symbols, or ASCII in a console that isn't UTF-8.
        $glyph = Get-AACGlyph
        $money = { param([double] $Value, [string] $Currency) ('{0:N2} {1}' -f $Value, $Currency).Trim() }
        $monthLabel = { param([datetime] $Start) "$($Start.ToString('MMM yyyy'))$(if ($Start -eq $thisMonth) { ' (to date)' })" }
        $facts = [System.Collections.Generic.List[string]]::new()
        if ($script:AACSession) {
            $facts.Add("[white]$(& $escape $script:AACSession.Account)[/]")
            $facts.Add("tenant $(& $escape $script:AACSession.TenantId)")
        }
        $facts.Add("$($subscriptions.Count) subscription(s)")
        $facts.Add("actual cost $($monthStarts[0].ToString('MMM yyyy')) - $($today.ToString('d MMM yyyy'))")
        Write-AACMarkup "[grey58]$($facts -join " $($glyph.Dot) ")[/]"
        [Spectre.Console.AnsiConsole]::WriteLine()

        $read = @($costs | Where-Object Status -eq 'OK')
        if ($read.Count -eq 0 -and @($costs | Where-Object Status -eq 'No cost').Count) {
            Show-AACPanel -Content "[bold]Cost Management reports no cost[/] [grey58]for $(if ($costs.Count -eq 1) { 'this subscription' } else { 'these subscriptions' }) from $($monthStarts[0].ToString('MMM yyyy')) to today. Its usage may be billed to another subscription, be covered by credits, or not be processed yet.[/]" -BorderColor 'grey50' -AllowMarkup
            [Spectre.Console.AnsiConsole]::WriteLine()
        }
        elseif ($read.Count -eq 0) {
            Show-AACPanel -Content '[bold]No costs could be read[/] [grey58]for these subscriptions - see the reasons below.[/]' -BorderColor 'grey50' -AllowMarkup
        }
        $currencyGroups = @($read | Group-Object -Property Currency | Sort-Object -Property Count -Descending)
        foreach ($currencyGroup in $currencyGroups) {
            $currency = $currencyGroup.Name
            $group = @($currencyGroup.Group)
            $ids = @($group | ForEach-Object { $_.SubscriptionId })
            $rows = @($detail | Where-Object { $_.SubscriptionId -in $ids })
            if ($currencyGroups.Count -gt 1) {
                Write-AACRule -Title "[bold]$(& $escape $(if ($currency) { $currency } else { 'no currency' }))[/]" -Color 'grey50'
            }

            $monthToDate = & $sum $group 'MonthToDate'
            $periodTotal = & $sum $group 'Total'
            $thisMonthRows = @($rows | Where-Object Month -eq $thisMonthKey)
            $byService = @($thisMonthRows | Group-Object Service | ForEach-Object { [pscustomobject]@{ Name = $(if ($_.Name) { $_.Name } else { '(no service)' }); Cost = (& $sum $_.Group 'Cost') } } | Where-Object Cost -gt 0 | Sort-Object Cost -Descending)
            $byGroup = @($thisMonthRows | Group-Object ResourceGroup | ForEach-Object { [pscustomobject]@{ Name = $(if ($_.Name) { $_.Name } else { '(no resource group)' }); Cost = (& $sum $_.Group 'Cost') } } | Where-Object Cost -gt 0 | Sort-Object Cost -Descending)

            Show-AACTileRow -Tile @(
                @{ Value = (& $money $monthToDate $currency); Caption = 'month to date'; Color = 'springgreen2' }
                @{ Value = $(if ($Months -gt 1) { & $money (& $sum $group $lastMonthKey) $currency } else { '-' }); Caption = 'last month'; Color = 'deepskyblue1' }
                @{ Value = (& $money $periodTotal $currency); Caption = "last $Months month$(if ($Months -ne 1) { 's' })"; Color = 'mediumpurple2' }
                @{ Value = '{0:N0}' -f $group.Count; Caption = 'subscriptions'; Color = 'gold1' }
                @{ Value = $(if ($byService) { $byService[0].Name } else { '-' }); Caption = 'top service this month'; Color = 'hotpink' }
            )
            [Spectre.Console.AnsiConsole]::WriteLine()

            if ($periodTotal -eq 0) {
                Show-AACPanel -Content "[bold]Cost Management reports no cost[/] [grey58]for $(if ($group.Count -eq 1) { 'this subscription' } else { 'these subscriptions' }) from $($monthStarts[0].ToString('MMM yyyy')) to today. Its usage may be billed to another subscription, be covered by credits, or not be processed yet.[/]" -BorderColor 'grey50' -AllowMarkup
                [Spectre.Console.AnsiConsole]::WriteLine()
                continue
            }

            if ($group.Count -gt 1) {
                $bars = @($group | Sort-Object -Property MonthToDate -Descending | ForEach-Object { @{ Label = $_.SubscriptionName; Value = $_.MonthToDate } })
                Show-AACBarChart -Item $bars -Title "Month to date by subscription ($currency)" -Format 'N2'
                [Spectre.Console.AnsiConsole]::WriteLine()
            }
            if ($byService) {
                $slices = [ordered]@{}
                foreach ($service in @($byService | Select-Object -First $Top)) { $slices[[string]$service.Name] = [Math]::Round($service.Cost, 2) }
                $other = @($byService | Select-Object -Skip $Top)
                if ($other) { $slices['other services'] = [Math]::Round((& $sum $other 'Cost'), 2) }
                Show-AACBreakdownChart -Data $slices -Title "Month to date by service ($currency)" -Width 120
                [Spectre.Console.AnsiConsole]::WriteLine()
            }
            if ($byGroup) {
                $bars = @($byGroup | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Name; Value = [Math]::Round($_.Cost, 2) } })
                Show-AACBarChart -Item $bars -Title "Month to date by resource group ($currency, top $([Math]::Min(10, $byGroup.Count)) of $($byGroup.Count))" -Format 'N2'
                [Spectre.Console.AnsiConsole]::WriteLine()
            }
            if ($Months -gt 1) {
                $monthBars = @(foreach ($start in $monthStarts) {
                        @{ Label = (& $monthLabel $start); Value = (& $sum $group (& $monthKey $start)); Color = $(if ($start -eq $thisMonth) { 'springgreen2' } else { 'deepskyblue1' }) }
                    })
                Show-AACBarChart -Item $monthBars -Title "Last $Months months ($currency)" -Format 'N2'
                [Spectre.Console.AnsiConsole]::WriteLine()
            }

            # Every subscription month by month, with a total column (and row).
            $table = [Spectre.Console.Table]::new()
            $table.Border = [Spectre.Console.TableBorder]::Rounded
            $table.BorderStyle = [Spectre.Console.Style]::Parse('grey35')
            $table.Title = [Spectre.Console.TableTitle]::new("[bold]Actual cost by subscription and month ($(& $escape $currency))[/]")
            $table.AddColumn([Spectre.Console.TableColumn]::new('[grey62]Subscription[/]')) | Out-Null
            foreach ($header in @($monthStarts | ForEach-Object { "$($_.ToString('MMM yy'))$(if ($_ -eq $thisMonth) { '*' })" }) + 'Total') {
                $column = [Spectre.Console.TableColumn]::new("[grey62]$header[/]")
                $column.Alignment = [Spectre.Console.Justify]::Right
                $table.AddColumn($column) | Out-Null
            }
            $cell = { param([double] $Value, [string] $Style) $text = '{0:N2}' -f $Value; [Spectre.Console.Markup]::new($(if ($Value -eq 0) { "[grey42]$text[/]" } elseif ($Style) { "[$Style]$text[/]" } else { $text })) }
            foreach ($item in @($group | Sort-Object -Property Total -Descending)) {
                $values = @($monthStarts | ForEach-Object { [double]$item.(& $monthKey $_) })
                $peak = ($values | Measure-Object -Maximum).Maximum
                $cells = @([Spectre.Console.Markup]::new("[bold]$(& $escape $item.SubscriptionName)[/]"))
                foreach ($value in $values) { $cells += & $cell $value $(if ($value -eq $peak -and $value -gt 0) { 'bold gold1' }) }
                $cells += & $cell $item.Total 'bold'
                [Spectre.Console.TableExtensions]::AddRow($table, [Spectre.Console.Rendering.IRenderable[]]$cells) | Out-Null
            }
            if ($group.Count -gt 1) {
                $cells = @([Spectre.Console.Markup]::new('[bold]Total[/]'))
                foreach ($start in $monthStarts) { $cells += & $cell (& $sum $group (& $monthKey $start)) 'bold' }
                $cells += & $cell $periodTotal 'bold'
                $table.ShowFooters = $false
                [Spectre.Console.TableExtensions]::AddEmptyRow($table) | Out-Null
                [Spectre.Console.TableExtensions]::AddRow($table, [Spectre.Console.Rendering.IRenderable[]]$cells) | Out-Null
            }
            [Spectre.Console.AnsiConsole]::Write($table)
            Write-AACMarkup "[grey42]* month to date. Each subscription's highest month is in gold.[/]"
            [Spectre.Console.AnsiConsole]::WriteLine()
        }

        Show-AACCostNotice -Cost $costs -Since $monthStarts[0]
    }

    if ($PassThru) {
        $costs
    }
}