Private/Write-AACCostPdf.ps1

function Write-AACCostPdf {
    <#
    .SYNOPSIS
        Writes Show-AACCost's results as a landscape A4 PDF report.
    .DESCRIPTION
        Layout:
          1. Summary: title, account, tenant and period, then per billing
             currency: tiles (month to date, last month, period total,
             subscriptions), the subscription-by-month table with a total
             column and row, and this month's top services and resource
             groups with their share.
          2. One page per subscription with cost: its services and its
             resource groups month by month (the top 15 of each, the rest
             summed), with totals.
          3. Subscriptions that couldn't be read, with the reason.
 
        -Cost is Show-AACCost's AAC.SubscriptionCost objects, -Detail its
        per-month, per-service, per-resource-group rows, -MonthStart the
        first day of each month covered. -Path must be a full path; see
        Save-AACPdfDocument.
    #>

    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]] $Cost,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]] $Detail,

        [Parameter(Mandatory)]
        [datetime[]] $MonthStart,

        [Parameter(Mandatory)]
        [string] $Path,

        [Parameter(Mandatory)]
        [string] $Title,

        [string] $Period
    )

    $invariant = [cultureinfo]::InvariantCulture
    $monthKeys = @($MonthStart | ForEach-Object { $_.ToString('yyyy-MM', $invariant) })
    $thisMonthKey = $monthKeys[-1]
    $sum = { param($Items, [string] $Property) $t = 0.0; foreach ($i in @($Items)) { $v = $i.$Property; if ($null -ne $v) { $t += [double]$v } }; $t }
    $money = { param([double] $Value) '{0:N2}' -f $Value }

    $pdf = New-AACPdfDocument -Title $Title -Subject "Azure cost for $($Cost.Count) subscription(s)" -Landscape
    $section = $pdf.Section
    $colors = $pdf.Colors
    $pt = $pdf.Pt
    $right = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Right

    # A right-aligned amount; zero in grey, optionally bold.
    $addAmount = {
        param($Cell, [double] $Value, [switch] $Bold)
        $paragraph = $Cell.AddParagraph((& $money $Value))
        $paragraph.Format.Alignment = $right
        if ($Value -eq 0) { $paragraph.Format.Font.Color = $colors.Muted }
        if ($Bold) { $paragraph.Format.Font.Name = 'Segoe UI Semibold' }
    }
    # A table of rows (Name + one value per month) with a total column; the
    # widths share the page between the name and the months.
    $addMonthTable = {
        param([object[]] $Rows, [string] $NameHeader, [switch] $TotalRow)
        $nameWidth = 6.0
        $valueWidth = ($pdf.PageWidth - $nameWidth) / ($MonthStart.Count + 1)
        $table = & $pdf.NewTable (@($nameWidth) + @(1..($MonthStart.Count + 1) | ForEach-Object { $valueWidth }))
        $headersList = @($NameHeader) + @($MonthStart | ForEach-Object { "$($_.ToString('MMM yyyy'))$(if ($_.ToString('yyyy-MM', $invariant) -eq $thisMonthKey) { ' *' })" }) + 'Total'
        & $pdf.AddHeaderRow $table $headersList @(1..($MonthStart.Count + 1))
        foreach ($item in $Rows) {
            $row = & $pdf.AddBodyRow $table
            $row.Cells[0].AddParagraph([string]$item.Name) | Out-Null
            for ($i = 0; $i -lt $monthKeys.Count; $i++) { & $addAmount $row.Cells[$i + 1] ([double]$item.($monthKeys[$i])) }
            & $addAmount $row.Cells[$monthKeys.Count + 1] ([double]$item.Total) -Bold
        }
        if ($TotalRow -and $Rows.Count -gt 1) {
            $row = & $pdf.AddBodyRow $table
            $row.Shading.Color = $colors.Panel
            $row.Cells[0].AddParagraph('Total').Format.Font.Name = 'Segoe UI Semibold'
            for ($i = 0; $i -lt $monthKeys.Count; $i++) { & $addAmount $row.Cells[$i + 1] (& $sum $Rows $monthKeys[$i]) -Bold }
            & $addAmount $row.Cells[$monthKeys.Count + 1] (& $sum $Rows 'Total') -Bold
        }
    }
    # Detail rows grouped by one field into Name + a value per month + Total,
    # largest total first; beyond -Top the rest are summed into one row.
    $pivot = {
        param([object[]] $Rows, [string] $Field, [int] $Top, [string] $Empty)
        $pivoted = @($Rows | Group-Object -Property $Field | ForEach-Object {
                $entry = [ordered]@{ Name = $(if ($_.Name) { $_.Name } else { $Empty }) }
                foreach ($key in $monthKeys) { $entry[$key] = & $sum @($_.Group | Where-Object Month -eq $key) 'Cost' }
                $entry['Total'] = & $sum $_.Group 'Cost'
                [pscustomobject]$entry
            } | Where-Object Total -ne 0 | Sort-Object Total -Descending)
        if ($pivoted.Count -gt $Top) {
            $rest = @($pivoted | Select-Object -Skip $Top)
            $entry = [ordered]@{ Name = "$($rest.Count) other(s)" }
            foreach ($key in $monthKeys) { $entry[$key] = & $sum $rest $key }
            $entry['Total'] = & $sum $rest 'Total'
            $pivoted = @($pivoted | Select-Object -First $Top) + [pscustomobject]$entry
        }
        $pivoted
    }
    # This month's top 10 services and resource groups, with their share,
    # side by side in one table (MigraDoc can't nest tables).
    $addShareTables = {
        param([object[]] $Rows)
        $top = {
            param([string] $Field, [string] $Empty)
            @($Rows | Where-Object Month -eq $thisMonthKey | Group-Object -Property $Field | ForEach-Object {
                    [pscustomobject]@{ Name = $(if ($_.Name) { $_.Name } else { $Empty }); Cost = (& $sum $_.Group 'Cost') }
                } | Where-Object Cost -gt 0 | Sort-Object Cost -Descending | Select-Object -First 10)
        }
        $sides = @((& $top 'Service' '(no service)'), (& $top 'ResourceGroup' '(no resource group)'))
        $count = [Math]::Max($sides[0].Count, $sides[1].Count)
        if ($count -eq 0) { return }
        $table = & $pdf.NewTable @(7.4, 2.6, 1.6, 1.0, 7.4, 2.6, 1.6)
        & $pdf.AddHeaderRow $table @('Service', 'Month to date', 'Share', '', 'Resource group', 'Month to date', 'Share') @(1, 2, 5, 6)
        $table.Rows[0].Cells[3].Shading.Color = $colors.White
        for ($i = 0; $i -lt $count; $i++) {
            $row = & $pdf.AddBodyRow $table
            $row.Cells[3].Borders.Bottom.Width = 0
            for ($side = 0; $side -lt 2; $side++) {
                $items = $sides[$side]
                if ($i -ge $items.Count) { continue }
                $offset = $side * 4
                $total = & $sum $items 'Cost'
                $row.Cells[$offset].AddParagraph($items[$i].Name) | Out-Null
                & $addAmount $row.Cells[$offset + 1] $items[$i].Cost
                $share = $row.Cells[$offset + 2].AddParagraph(('{0:N0}%' -f (100 * $items[$i].Cost / [Math]::Max($total, 0.000001))))
                $share.Format.Alignment = $right
                $share.Format.Font.Color = $colors.Muted
            }
        }
    }

    # --- 1. Summary ------------------------------------------------------------------------------
    & $pdf.AddTitle "$($Cost.Count) subscription(s) · actual cost$(if ($Period) { " · $Period" }) · generated $($pdf.Generated.ToString('dddd d MMMM yyyy, HH:mm'))"
    $facts = [ordered]@{}
    if ($script:AACSession) {
        $facts['Azure account'] = [string]$script:AACSession.Account
        $facts['Tenant'] = [string]$script:AACSession.TenantId
    }
    if ($Period) { $facts['Period'] = "$Period (the last month is to date)" }
    $facts['Amounts'] = 'Actual cost as Azure Cost Management reports it, in each subscription''s billing currency - never converted. It can lag usage by a day or so.'
    $factTable = & $pdf.NewTable @(4.0, ($pdf.PageWidth - 4.0))
    foreach ($key in $facts.Keys) {
        $row = & $pdf.AddBodyRow $factTable
        $row.Cells[0].AddParagraph($key).Format.Font.Color = $colors.Muted
        $row.Cells[1].AddParagraph($facts[$key]) | Out-Null
    }

    $read = @($Cost | Where-Object Status -eq 'OK')
    foreach ($currencyGroup in @($read | Group-Object Currency | Sort-Object Count -Descending)) {
        $currency = $currencyGroup.Name
        $group = @($currencyGroup.Group)
        $ids = @($group | ForEach-Object { $_.SubscriptionId })
        $rows = @($Detail | Where-Object { $_.SubscriptionId -in $ids })

        $section.AddParagraph("Summary ($(if ($currency) { $currency } else { 'no currency' }))", 'Heading2') | Out-Null
        $tileData = @(
            @{ Value = (& $money (& $sum $group 'MonthToDate')); Label = "month to date ($currency)" }
            @{ Value = $(if ($monthKeys.Count -gt 1) { & $money (& $sum $group $monthKeys[-2]) } else { '-' }); Label = "last month ($currency)" }
            @{ Value = (& $money (& $sum $group 'Total')); Label = "last $($monthKeys.Count) month(s) ($currency)" }
            @{ Value = ('{0:N0}' -f $group.Count); Label = 'subscriptions' }
        )
        $tileWidth = $pdf.PageWidth / $tileData.Count
        $tiles = & $pdf.NewTable @(1..$tileData.Count | ForEach-Object { $tileWidth })
        $tiles.TopPadding = & $pt 8
        $tiles.BottomPadding = & $pt 8
        $tileRow = $tiles.AddRow()
        for ($i = 0; $i -lt $tileData.Count; $i++) {
            $cell = $tileRow.Cells[$i]
            $cell.Shading.Color = $colors.Panel
            $cell.Borders.Left.Width = $(if ($i -gt 0) { 2 } else { 0 })
            $cell.Borders.Left.Color = $colors.White
            $number = $cell.AddParagraph($tileData[$i].Value)
            $number.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Center
            $number.Format.Font.Size = 16
            $number.Format.Font.Name = 'Segoe UI Semibold'
            $caption = $cell.AddParagraph($tileData[$i].Label)
            $caption.Format.Alignment = [MigraDoc.DocumentObjectModel.ParagraphAlignment]::Center
            $caption.Format.Font.Size = 8
            $caption.Format.Font.Color = $colors.Muted
        }

        $section.AddParagraph("By subscription and month ($currency)", 'Heading3') | Out-Null
        $subscriptionRows = @($group | Sort-Object Total -Descending | ForEach-Object {
                $entry = [ordered]@{ Name = $_.SubscriptionName }
                foreach ($key in $monthKeys) { $entry[$key] = $_.$key }
                $entry['Total'] = $_.Total
                [pscustomobject]$entry
            })
        & $addMonthTable $subscriptionRows 'Subscription' -TotalRow
        $note = $section.AddParagraph('* month to date')
        $note.Format.Font.Size = 7.5
        $note.Format.Font.Color = $colors.Muted

        $section.AddParagraph("Top services and resource groups this month ($currency)", 'Heading3') | Out-Null
        & $addShareTables $rows
    }
    if ($read.Count -eq 0) {
        $none = $section.AddParagraph('No costs could be read for these subscriptions.')
        $none.Format.Font.Color = $colors.Muted
    }

    # --- 2. A page per subscription ------------------------------------------------------------------
    foreach ($item in @($read | Where-Object Total -ne 0 | Sort-Object Total -Descending)) {
        $rows = @($Detail | Where-Object SubscriptionId -eq $item.SubscriptionId)
        $section.AddPageBreak()
        $section.AddParagraph($item.SubscriptionName, 'Heading1') | Out-Null
        $about = $section.AddParagraph("$($item.SubscriptionId) · $($item.Currency) · month to date $(& $money $item.MonthToDate) · last $($monthKeys.Count) month(s) $(& $money $item.Total)")
        $about.Format.Font.Color = $colors.Muted
        $about.Format.SpaceAfter = & $pt 4

        $section.AddParagraph('By service', 'Heading3') | Out-Null
        & $addMonthTable (& $pivot $rows 'Service' 15 '(no service)') 'Service' -TotalRow
        $section.AddParagraph('By resource group', 'Heading3') | Out-Null
        & $addMonthTable (& $pivot $rows 'ResourceGroup' 15 '(no resource group)') 'Resource group' -TotalRow
    }

    # --- 3. What couldn't be read ---------------------------------------------------------------------
    $failed = @($Cost | Where-Object Status -ne 'OK')
    if ($failed) {
        $section.AddParagraph('Subscriptions that could not be read', 'Heading2') | Out-Null
        $table = & $pdf.NewTable @(7.0, ($pdf.PageWidth - 7.0))
        & $pdf.AddHeaderRow $table @('Subscription', 'Reason')
        foreach ($item in $failed) {
            $row = & $pdf.AddBodyRow $table
            $row.Cells[0].AddParagraph($item.SubscriptionName) | Out-Null
            $row.Cells[1].AddParagraph([string]$item.Status).Format.Font.Color = $colors.Muted
        }
    }

    Save-AACPdfDocument -Pdf $pdf -Path $Path
}