ManagedCompany.ps1

function Switch-KeeperMC {
    <#
        .Synopsis
        Switch to managed company

        .Parameter Name
        Managed Company ID or Name
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true, Position = 0)][string] $Name
    )

    [Enterprise]$enterprise = getMspEnterprise

    $mc = @($enterprise.mspData.ManagedCompanies | Where-Object { ($_.EnterpriseId -eq $Name) })
    if ($mc.Count -eq 0) {
        $mc = @($enterprise.mspData.ManagedCompanies | Where-Object { ($_.EnterpriseName -like $Name + '*') })
    }
    if ($mc.Count -eq 0) {
        Write-Error -Message "Managed Company`"$Name`" not found" -ErrorAction Stop
    }
    elseif ($mc.Count -gt 1) {
        Write-Error -Message "Managed Company`"$Name`" is not unique. Use Company ID." -ErrorAction Stop
    }

    $Script:Context.ManagedCompanyId = $mc[0].EnterpriseId
    Sync-KeeperEnterprise

    Write-Host "Switched to Managed Company `"$($mc[0].EnterpriseName)`" (ID: $($mc[0].EnterpriseId))."
}
New-Alias -Name switch-to-mc -Value Switch-KeeperMC

function Switch-KeeperMSP {
    <#
        .Synopsis
        Switch to MSP
    #>

    [CmdletBinding()]

    [Enterprise]$enterprise = getMspEnterprise

    $Script:Context.ManagedCompanyId = 0
    Sync-KeeperEnterprise

    Write-Host "Switched to MSP."
}
New-Alias -Name switch-to-msp -Value Switch-KeeperMSP


function Get-KeeperManagedCompany {
    <#
    .SYNOPSIS
    MSP info and managed company list: restriction, pricing, or MC list.
    .DESCRIPTION
    One command for all MSP info: -Restriction (permits), -Pricing (BI pricing), or MC list (default). Use -Detailed for MC list with sorted names, display labels, and addon:seats. Supports -Format and -Output.
    .PARAMETER Restriction
    Display MSP restriction information (allowed products, add-ons, max file plan, unlimited licenses).
    .PARAMETER Pricing
    Display pricing information (BI subscription/mc_pricing).
    .PARAMETER Filter
    Managed Company ID or Name (optional partial filter; ignored when -ManagedCompany or -Restriction or -Pricing is used).
    .PARAMETER Detailed
    Detailed MC list: company_id, company_name, node, node_name, plan, storage, addons, allocated, active; sorted by name; addon:seats.
    .PARAMETER ManagedCompany
    Filter to a single managed company by exact name or ID (exact match). Use with -Detailed.
    .PARAMETER Format
    Output format: table (default), json, csv.
    .PARAMETER Output
    If supplied, write output to this file path.
    .EXAMPLE
    Get-KeeperManagedCompany
    Get-KeeperManagedCompany -Detailed
    Get-KeeperManagedCompany -Restriction
    Get-KeeperManagedCompany -Pricing -Format json -Output pricing.json
    Get-KeeperManagedCompany -Detailed -ManagedCompany "Acme"
    #>

    [CmdletBinding()]
    Param (
        [Parameter()][Alias('r')][switch] $Restriction,
        [Parameter()][Alias('p')][switch] $Pricing,
        [Parameter(Mandatory = $false)][string] $Filter,
        [Parameter()][Alias('v')][switch] $Detailed,
        [Parameter()][Alias('mc')][string] $ManagedCompany,
        [Parameter()][ValidateSet('table', 'json', 'csv')][string] $Format = 'table',
        [Parameter()][string] $Output
    )

    [Enterprise]$enterprise = getMspEnterprise
    $ed = $enterprise.enterpriseData

    if ($Restriction) {
        $permits = $ed.EnterpriseLicense.MspPermits
        if (-not $permits) {
            Write-Information 'MSP has no restrictions'
            return
        }
        $allProducts = @{ 'business' = 'Business'; 'businessplus' = 'Business Plus'; 'enterprise' = 'Enterprise'; 'enterprise_plus' = 'Enterprise Plus' }
        $allAddons = @{}
        $addonKeys = @($script:MspAddonDisplayNames.Keys)
        foreach ($k in $addonKeys) { $allAddons[$k.ToLower()] = $script:MspAddonDisplayNames[$k] }
        $allFilePlans = @{ '100gb' = '100GB'; '1tb' = '1TB'; '10tb' = '10TB' }
        $rows = [System.Collections.ArrayList]::new()
        [void]$rows.Add([PSCustomObject]@{ 'Permit Name' = 'Allow Unlimited Licenses'; 'Value' = $permits.AllowUnlimitedLicenses })
        $allowedProducts = @($permits.AllowedMcProducts | ForEach-Object { $p = $_.ToLower(); $d = $allProducts[$p]; if ($d) { "$_ ($d)" } else { $_ } })
        [void]$rows.Add([PSCustomObject]@{ 'Permit Name' = 'Allowed Products'; 'Value' = ($allowedProducts -join ', ') })
        $allowedAddons = @($permits.AllowedAddOns | ForEach-Object { $a = $_.ToLower(); $d = $allAddons[$a]; if ($d) { "$_ ($d)" } else { $_ } })
        [void]$rows.Add([PSCustomObject]@{ 'Permit Name' = 'Allowed Add-Ons'; 'Value' = ($allowedAddons -join ', ') })
        $maxFp = $permits.MaxFilePlanType
        $fpD = $allFilePlans[[string]$maxFp.ToLower()]
        [void]$rows.Add([PSCustomObject]@{ 'Permit Name' = 'Max File Storage plan'; 'Value' = $(if ($fpD) { $fpD } else { $maxFp }) })
        $result = @($rows)
        if ($Output) {
            if ($Format -eq 'json') { Set-Content -Path $Output -Value ($result | ConvertTo-Json -Depth 3) -Encoding utf8 }
            elseif ($Format -eq 'csv') { Set-Content -Path $Output -Value ($result | ConvertTo-Csv -NoTypeInformation) -Encoding utf8 }
            else { $result | Format-Table | Out-String -Width 8192 | Set-Content -Path $Output -Encoding utf8 }
        } else {
            if ($Format -eq 'table') { $result | Format-Table | Out-String -Width 8192 } else { $result }
        }
        return
    }

    if ($Pricing) {
        $auth = [KeeperSecurity.Authentication.IAuthentication]$Script:Context.Auth
        $urlMap = [KeeperSecurity.Authentication.AuthExtensions]::GetBiUrl($auth, 'mapping/addons')
        $rqMap = New-Object BI.MappingAddonsRequest
        $rsMap = $auth.ExecuteAuthRest($urlMap, $rqMap, [BI.MappingAddonsResponse]).GetAwaiter().GetResult()
        $addonNameById = @{}
        foreach ($a in $rsMap.Addons) { $addonNameById[$a.Id] = $a.Name }
        $filePlanNameById = @{ 4 = '100GB'; 7 = '1TB'; 8 = '10TB' }
        foreach ($fp in $rsMap.FilePlans) { $filePlanNameById[$fp.Id] = $fp.Name }
        $url = [KeeperSecurity.Authentication.AuthExtensions]::GetBiUrl($auth, 'subscription/mc_pricing')
        $rq = New-Object BI.SubscriptionMcPricingRequest
        $rs = $auth.ExecuteAuthRest($url, $rq, [BI.SubscriptionMcPricingResponse]).GetAwaiter().GetResult()
        $currencySymbol = @{ [int][BI.Currency]::Usd = '$'; [int][BI.Currency]::Eur = [char]0x20AC; [int][BI.Currency]::Gbp = [char]0x00A3; [int][BI.Currency]::Jpy = [char]0x00A5 }
        $unitLabel = @{ 0 = ''; 1 = 'month'; 2 = 'user/month' }
        $rows = [System.Collections.ArrayList]::new()
        foreach ($p in $rs.BasePlans) {
            $sym = $currencySymbol[[int]$p.Cost.Currency]; if (-not $sym) { $sym = '' }
            $unit = $unitLabel[[int]$p.Cost.AmountPer]; if (-not $unit) { $unit = 'month' }
            $name = $script:MspPlanNames[[int]$p.Id]; if (-not $name) { $name = "Plan$($p.Id)" }
            [void]$rows.Add([PSCustomObject]@{ Category = 'Product'; Name = $name; Code = $p.Id; Price = "$sym$($p.Cost.Amount)/$unit" })
        }
        foreach ($p in $rs.Addons) {
            $sym = $currencySymbol[[int]$p.Cost.Currency]; if (-not $sym) { $sym = '' }
            $unit = $unitLabel[[int]$p.Cost.AmountPer]; if (-not $unit) { $unit = 'month' }
            $name = $addonNameById[$p.Id]; if (-not $name) { $name = "Addon$($p.Id)" }
            [void]$rows.Add([PSCustomObject]@{ Category = 'Addon'; Name = $name; Code = $p.Id; Price = "$sym$($p.Cost.Amount)/$unit" })
        }
        foreach ($p in $rs.FilePlans) {
            $sym = $currencySymbol[[int]$p.Cost.Currency]; if (-not $sym) { $sym = '' }
            $unit = $unitLabel[[int]$p.Cost.AmountPer]; if (-not $unit) { $unit = 'month' }
            $name = $filePlanNameById[$p.Id]; if (-not $name) { $name = "FilePlan$($p.Id)" }
            [void]$rows.Add([PSCustomObject]@{ Category = 'File Plan'; Name = $name; Code = $p.Id; Price = "$sym$($p.Cost.Amount)/$unit" })
        }
        $result = @($rows)
        if ($Output) {
            if ($Format -eq 'json') { Set-Content -Path $Output -Value ($result | ConvertTo-Json -Depth 3) -Encoding utf8 }
            elseif ($Format -eq 'csv') { Set-Content -Path $Output -Value ($result | ConvertTo-Csv -NoTypeInformation) -Encoding utf8 }
            else { $result | Format-Table | Out-String -Width 8192 | Set-Content -Path $Output -Encoding utf8 }
        } else {
            if ($Format -eq 'table') { $result | Format-Table | Out-String -Width 8192 } else { $result }
        }
        return
    }

    $list = $enterprise.mspData.ManagedCompanies
    if (-not $list -or $list.Count -eq 0) {
        if ($Detailed) { Write-Information 'No Managed Companies' }
        return @()
    }

    if ($ManagedCompany) {
        $mcInput = $ManagedCompany.Trim()
        $isId = $false
        try { [long]::Parse($mcInput) | Out-Null; $isId = $true } catch { }
        $filtered = @(if ($isId) {
            @($list) | Where-Object { $_.EnterpriseId -eq [long]$mcInput }
        } else {
            @($list) | Where-Object { $_.EnterpriseName -and ($_.EnterpriseName.Trim().ToLower() -eq $mcInput.ToLower()) }
        })
        if ($filtered.Count -eq 0) {
            Write-Error "Managed Company `"$ManagedCompany`" not found" -ErrorAction Stop
        }
        $list = @($filtered)
    } elseif ($Filter) {
        $filterStr = $Filter.Trim()
        $list = @($list) | Where-Object {
            $_.EnterpriseId.ToString() -eq $filterStr -or
            ($_.EnterpriseName -and ($_.EnterpriseName -like '*' + $filterStr + '*'))
        }
    }

    if ($Detailed) {
        $list = @($list | Sort-Object { $_.EnterpriseName })
        $planDisplay = @{ 'business' = 'Business'; 'businessplus' = 'Business Plus'; 'enterprise' = 'Enterprise'; 'enterprise_plus' = 'Enterprise Plus' }
        $filePlanMap = @{ '100gb' = '100GB'; '1tb' = '1TB'; '10tb' = '10TB'; 'storage_100gb' = '100GB'; 'storage_1tb' = '1TB'; 'storage_10tb' = '10TB' }
        $result = [System.Collections.ArrayList]::new()
        foreach ($mc in $list) {
            $nodeId = if ($mc.ParentNodeId -le 0) { $ed.RootNode.Id } else { $mc.ParentNodeId }
            $nodePath = Get-MspNodePath -EnterpriseData $ed -NodeId $nodeId -OmitRoot $true
            if ([string]::IsNullOrEmpty($nodePath)) { $nodePath = $nodeId.ToString() }
            $nodeName = $nodePath
            $filePlan = $mc.FilePlanType
            if ($filePlan) { $fp = $filePlanMap[[string]$filePlan.ToLower()]; if ($fp) { $filePlan = $fp } }
            $addonList = [System.Collections.Generic.List[string]]::new()
            if ($mc.AddOns) {
                foreach ($ao in $mc.AddOns) {
                    $an = $ao.Name
                    if ($ao.Seats -and [int]$ao.Seats -gt 0) {
                        $s = $ao.Seats; if ($s -eq -1 -or $s -ge $script:McUnlimitedSeatsValue) { $s = -1 }
                        $addonList.Add("${an}:$s")
                    } else {
                        $addonList.Add($an)
                    }
                }
            }
            $addonsOut = $addonList -join ', '
            $plan = $mc.ProductId
            if ($plan) { $pd = $planDisplay[[string]$plan.ToLower()]; if ($pd) { $plan = $pd } }
            $seats = $mc.NumberOfSeats
            if ($seats -eq -1 -or $seats -ge $script:McUnlimitedSeatsValue) { $seats = -1 }
            $row = [ordered]@{
                company_id   = $mc.EnterpriseId
                company_name = $mc.EnterpriseName
                node         = $nodePath
                node_name    = $nodeName
                plan         = $plan
                storage      = $filePlan
                addons       = $addonsOut
                allocated    = $seats
                active       = $mc.NumberOfUsers
            }
            [void]$result.Add([PSCustomObject]$row)
        }
        $result = @($result)
    } else {
        $result = @($list | ForEach-Object {
            $mc = $_
            $addonsStr = ''
            if ($mc.AddOns -and $mc.AddOns.Count -gt 0) {
                $addonsStr = ($mc.AddOns | ForEach-Object { $_.Name }) -join ', '
            }
            $nodeName = $mc.ParentNodeId
            $node = $ed.Nodes | Where-Object { $_.Id -eq $mc.ParentNodeId } | Select-Object -First 1
            if ($node) {
                $nodeName = if ([string]::IsNullOrEmpty($node.DisplayName)) { $node.Id.ToString() } else { $node.DisplayName }
            }
            [PSCustomObject]@{
                EnterpriseId   = $mc.EnterpriseId
                EnterpriseName = $mc.EnterpriseName
                ProductId      = $mc.ProductId
                NumberOfSeats  = $mc.NumberOfSeats
                NumberOfUsers  = $mc.NumberOfUsers
                FilePlanType   = $mc.FilePlanType
                IsExpired      = $mc.IsExpired
                ParentNodeId   = $mc.ParentNodeId
                NodeName       = $nodeName
                Addons         = $addonsStr
            }
        })
    }

    if ($result.Count -eq 0) { return @() }
    if ($Output) {
        if ($Format -eq 'json') { Set-Content -Path $Output -Value ($result | ConvertTo-Json -Depth 4) -Encoding utf8 }
        elseif ($Format -eq 'csv') { Set-Content -Path $Output -Value ($result | ConvertTo-Csv -NoTypeInformation) -Encoding utf8 }
        else { $result | Format-Table | Out-String -Width 8192 | Set-Content -Path $Output -Encoding utf8 }
    } else {
        if ($Format -eq 'table') { $result | Format-Table | Out-String -Width 8192 } else { $result }
    }
}
New-Alias -Name kmc -Value Get-KeeperManagedCompany

$Keeper_MspAddonName = {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

    $result = @()
    $msp_addons = @('enterprise_breach_watch', 'compliance_report', 'enterprise_audit_and_reporting', 'msp_service_and_support', 'secrets_manager', 'connection_manager', 'chat')

    $toComplete = $wordToComplete += '*'
    foreach ($addon in $msp_addons) {
        if ($addon -like $toComplete) {
            $result += $addon
        }
    }
    if ($result.Count -gt 0) {
        return $result
    }
    else {
        return $null
    }
}

function New-KeeperManagedCompany {
    <#
        .Synopsis
        Adds new Managed Company
        .Parameter Name
        Managed Company Name
        .Parameter PlanId
        Managed Company Plan. ValidateSet casing (e.g. businessPlus)
        .Parameter MaximumSeats
        Maximum Number of Seats
        .Parameter Storage
        Storage Plan
        .Parameter Addons
        Addons
        .Parameter Node
        Node Name or ID
    #>

    [CmdletBinding(SupportsShouldProcess=$true)]
    Param (
        [Parameter(Mandatory = $true, Position = 0)][string] $Name,
        [Parameter(Mandatory = $true)][ValidateSet('business', 'businessPlus', 'enterprise', 'enterprisePlus')][string] $PlanId,
        [Parameter(Mandatory = $true)][int] $MaximumSeats,
        [Parameter(Mandatory = $false)][ValidateSet('100GB', '1TB', '10TB')][string] $Storage,
        [Parameter(Mandatory = $false)][string[]] $Addons,
        [Parameter(Mandatory = $false)][string] $Node
    )

    [Enterprise]$enterprise = getMspEnterprise

    $options = New-Object KeeperSecurity.Enterprise.ManagedCompanyOptions
    $options.Name = $Name
    $options.ProductId = $PlanId
    $options.NumberOfSeats = $MaximumSeats
    if ($Node) {
        $n = findEnterpriseNode $Node
        if ($n) {
            $options.NodeId = $n.Id
        }
        else {
            Write-Error -Message "Node ${Node} not found" -ErrorAction Stop
        }
    }
    else {
        $options.NodeId = $enterprise.enterpriseData.RootNode.Id
    }
    switch ($Storage) {
        '100GB' { $options.FilePlanType = [KeeperSecurity.Enterprise.ManagedCompanyConstants]::StoragePlan100GB }
        '1TB' { $options.FilePlanType = [KeeperSecurity.Enterprise.ManagedCompanyConstants]::StoragePlan1TB }
        '10TB' { $options.FilePlanType = [KeeperSecurity.Enterprise.ManagedCompanyConstants]::StoragePlan10TB }
    }
    if ($Addons) {
        $aons = @()
        foreach ($addon in $Addons) {
            $names = $addon -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
            foreach ($name in $names) {
                $parts = $name -split ':'
                $addonOption = New-Object KeeperSecurity.Enterprise.ManagedCompanyAddonOptions
                $addonOption.Addon = $parts[0].Trim()
                if ($parts.Length -gt 1) {
                    $addonOption.NumberOfSeats = $parts[1].Trim() -as [int]
                }
                $aons += $addonOption
            }
        }
        $options.Addons = $aons
    }


    if ($PSCmdlet.ShouldProcess($Name, "Creating Managed Company")) {
        return $enterprise.mspData.CreateManagedCompany($options).GetAwaiter().GetResult()
    }
}
New-Alias -Name kamc -Value New-KeeperManagedCompany
Register-ArgumentCompleter -CommandName New-KeeperManagedCompany -ParameterName Addons -ScriptBlock $Keeper_MspAddonName

function Remove-KeeperManagedCompany {
    <#
        .Synopsis
        Removes Managed Company
        .Parameter Name
        Managed Company Id or Name
    #>

    [CmdletBinding(SupportsShouldProcess=$true)]
    Param (
        [Parameter(Position = 0, Mandatory = $true)][string] $Name
    )

    [Enterprise]$enterprise = getMspEnterprise
    $mc = findManagedCompany $Name
    if (-not $mc) {
        Write-Error -Message "Managed Company ${Name} not found" -ErrorAction Stop
    }

    if ($PSCmdlet.ShouldProcess($mc.EnterpriseName, "Removing Managed Company")) {
        $enterprise.mspData.RemoveManagedCompany($mc.EnterpriseId).GetAwaiter().GetResult() | Out-Null
        Write-Host "`"$($mc.EnterpriseName)`" MSP removed successfully."
    }
}
New-Alias -Name krmc -Value Remove-KeeperManagedCompany

function Edit-KeeperManagedCompany {
    <#
    .SYNOPSIS
    Update a Managed Company.
    .DESCRIPTION
    Modify MC name, plan, seats, storage, node, or addons. Use -AddAddon / -RemoveAddon to add/remove individual addons, or -Addons to set the full addon list. -MaximumSeats -1 = unlimited.
    .PARAMETER Id
    Managed Company name or ID (required).
    .PARAMETER Name
    New managed company name.
    .PARAMETER PlanId
    License plan: business, businessPlus, enterprise, enterprisePlus.
    .PARAMETER MaximumSeats
    Max licenses; use -1 for unlimited.
    .PARAMETER Storage
    File storage plan: 100GB, 1TB, 10TB. Cannot be lower than the plan's default (e.g. Enterprise Plus defaults to 1TB).
    .PARAMETER Node
    Node name or ID to move the MC to.
    .PARAMETER Addons
    Full addon list (replaces existing). Each item: AddonName or AddonName:Seats (e.g. connection_manager:5).
    .PARAMETER AddAddon
    Add (or update) addon(s); can repeat. Format: AddonName or AddonName:Seats.
    .PARAMETER RemoveAddon
    Remove addon(s); can repeat.
    .EXAMPLE
    Edit-KeeperManagedCompany -Id "Acme" -Name "Acme Corp" -MaximumSeats 100
    Edit-KeeperManagedCompany -Id 3862 -AddAddon "connection_manager:5" -RemoveAddon "secrets_manager"
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    Param (
        [Parameter(Position = 0, Mandatory = $true)][string] $Id,
        [Parameter(Mandatory = $false)][string] $Name,
        [Parameter(Mandatory = $false)][ValidateSet('business', 'businessPlus', 'enterprise', 'enterprisePlus')][string] $PlanId,
        [Parameter(Mandatory = $false)][int] $MaximumSeats = [int]::MinValue,
        [Parameter(Mandatory = $false)][ValidateSet('100GB', '1TB', '10TB')][string] $Storage,
        [Parameter(Mandatory = $false)][string] $Node,
        [Parameter(Mandatory = $false)][string[]] $Addons,
        [Parameter(Mandatory = $false)][string[]] $AddAddon,
        [Parameter(Mandatory = $false)][string[]] $RemoveAddon
    )

    [Enterprise]$enterprise = getMspEnterprise
    $mc = findManagedCompany $Id
    if (-not $mc) {
        Write-Error -Message "Managed Company `"$Id`" not found" -ErrorAction Stop
    }

    $options = New-Object KeeperSecurity.Enterprise.ManagedCompanyOptions
    if ($Name) { $options.Name = $Name }
    if ($PlanId) { $options.ProductId = $PlanId }
    if ($MaximumSeats -ne [int]::MinValue) {
        $options.NumberOfSeats = if ($MaximumSeats -gt 1) { $MaximumSeats } else { -1 }
    }
    if ($Storage) {
        switch ($Storage.Trim().ToUpper()) {
            '100GB' { $options.FilePlanType = [KeeperSecurity.Enterprise.ManagedCompanyConstants]::StoragePlan100GB }
            '1TB' { $options.FilePlanType = [KeeperSecurity.Enterprise.ManagedCompanyConstants]::StoragePlan1TB }
            '10TB' { $options.FilePlanType = [KeeperSecurity.Enterprise.ManagedCompanyConstants]::StoragePlan10TB }
        }
    }
    if ($Node) {
        $n = findEnterpriseNode $Node
        if ($n) { $options.NodeId = $n.Id }
        else { Write-Error -Message "Node `"$Node`" not found" -ErrorAction Stop }
    }

    $addonsToSend = $null
    if ($Addons) {
        $addonsToSend = [System.Collections.ArrayList]::new()
        foreach ($addon in $Addons) {
            foreach ($item in ($addon -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) {
                $parts = $item -split ':', 2
                $addonOption = New-Object KeeperSecurity.Enterprise.ManagedCompanyAddonOptions
                $addonOption.Addon = $parts[0].Trim().ToLower()
                if ($parts.Length -gt 1 -and $parts[1]) {
                    $s = $parts[1].Trim()
                    $addonOption.NumberOfSeats = if ($s -eq '-1') { -1 } else { [int]$s }
                }
                [void]$addonsToSend.Add($addonOption)
            }
        }
    } elseif ($AddAddon -or $RemoveAddon) {
        $addonDict = [System.Collections.Generic.Dictionary[string, object]]::new([StringComparer]::OrdinalIgnoreCase)
        if ($mc.AddOns) {
            foreach ($ao in $mc.AddOns) {
                if (-not $ao.IsEnabled) { continue }
                $addonOption = New-Object KeeperSecurity.Enterprise.ManagedCompanyAddonOptions
                $addonOption.Addon = $ao.Name
                if ($ao.Seats -gt 0) { $addonOption.NumberOfSeats = if ($ao.Seats -eq -1 -or $ao.Seats -ge $script:McUnlimitedSeatsValue) { -1 } else { $ao.Seats } }
                $addonDict[$ao.Name] = $addonOption
            }
        }
        foreach ($ra in @($RemoveAddon)) {
            foreach ($a in ($ra -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) {
                $addonDict.Remove($a) | Out-Null
            }
        }
        foreach ($aa in @($AddAddon)) {
            foreach ($item in ($aa -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) {
                $parts = $item -split ':', 2
                $addonName = $parts[0].Trim().ToLower()
                $addonOption = New-Object KeeperSecurity.Enterprise.ManagedCompanyAddonOptions
                $addonOption.Addon = $addonName
                if ($parts.Length -gt 1 -and $parts[1]) {
                    $s = $parts[1].Trim()
                    $addonOption.NumberOfSeats = if ($s -eq '-1') { -1 } else { [int]$s }
                }
                $addonDict[$addonName] = $addonOption
            }
        }
        $addonsToSend = [System.Collections.ArrayList]::new()
        foreach ($v in $addonDict.Values) { [void]$addonsToSend.Add($v) }
    }
    if ($addonsToSend -and $addonsToSend.Count -gt 0) {
        $options.Addons = @($addonsToSend)
    }

    if ($PSCmdlet.ShouldProcess($mc.EnterpriseName, "Updating Managed Company")) {
        $enterprise.mspData.UpdateManagedCompany($mc.EnterpriseId, $options).GetAwaiter().GetResult()
    }
}
New-Alias -Name kemc -Value Edit-KeeperManagedCompany
Register-ArgumentCompleter -CommandName Edit-KeeperManagedCompany -ParameterName Addons -ScriptBlock $Keeper_MspAddonName

function Copy-KeeperMCRole {
    <#
    .SYNOPSIS
    Copy role(s) with enforcements from MSP to one or more Managed Companies.
    .DESCRIPTION
    Each specified role (by name or ID): finds or creates a role with the same name in each target MC
    and syncs enforcements from the source role (add/update to match source, remove any not in source).
    Requires MSP account. Does not change current context (MSP or MC).
    .PARAMETER Role
    Source role name or ID. Can be repeated. Roles are resolved in the current MSP enterprise.
    .PARAMETER ManagedCompany
    Target Managed Company name or ID. Can be repeated. Each MC will receive a copy of each role's enforcements.
    .EXAMPLE
    Copy-KeeperMCRole -Role "Keeper Administrator" -ManagedCompany "Acme Corp", 3862
    Copy-KeeperMCRole -Role "Auditor", "Help Desk" -ManagedCompany "Acme"
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    Param (
        [Parameter(Mandatory = $true)][string[]] $Role,
        [Parameter(Mandatory = $true)][string[]] $ManagedCompany
    )

    [Enterprise]$mspEnterprise = getMspEnterprise
    $mspRd = $mspEnterprise.roleData
    $mspLoader = $mspEnterprise.loader

    $sourceRoles = [System.Collections.Generic.List[object]]::new()
    foreach ($rInput in $Role) {
        $rInput = $rInput.Trim()
        $matched = $null
        $idParsed = 0L
        if ([long]::TryParse($rInput, [ref]$idParsed)) {
            $matched = @($mspRd.Roles | Where-Object { $_.Id -eq $idParsed })
        }
        if (-not $matched -or $matched.Count -eq 0) {
            $matched = @($mspRd.Roles | Where-Object { $_.DisplayName -and ($_.DisplayName.Trim() -eq $rInput) })
        }
        if (-not $matched -or $matched.Count -eq 0) {
            Write-Error "Role `"$rInput`" not found" -ErrorAction Stop
        }
        if ($matched.Count -gt 1) {
            Write-Error "Multiple roles match `"$rInput`". Use Role ID." -ErrorAction Stop
        }
        $sourceRoles.Add($matched[0]) | Out-Null
    }

    $enforcementByRole = @{}
    foreach ($sr in $sourceRoles) {
        $roleName = $sr.DisplayName
        if ([string]::IsNullOrWhiteSpace($roleName)) {
            Write-Warning "Skipping role with ID $($sr.Id) (no display name)"
            continue
        }
        $dict = Get-RoleEnforcementDictionary -RoleData $mspRd -RoleId $sr.Id
        $enforcementByRole[$roleName] = @{ Role = $sr; Enforcements = $dict }
    }

    if ($enforcementByRole.Count -eq 0) {
        Write-Warning "No roles with display name to copy."
        return
    }

    $seenMcIds = [System.Collections.Generic.HashSet[int]]::new()
    $mcs = [System.Collections.Generic.List[object]]::new()
    foreach ($mcInput in $ManagedCompany) {
        $mc = findManagedCompany $mcInput.Trim()
        if (-not $mc) {
            Write-Warning "Managed Company `"$mcInput`" not found; skipping."
            continue
        }
        $eid = [int]$mc.EnterpriseId
        if ($seenMcIds.Add($eid)) {
            $mcs.Add($mc) | Out-Null
        }
    }

    foreach ($mc in $mcs) {
        if (-not $PSCmdlet.ShouldProcess("$($mc.EnterpriseName) (ID: $($mc.EnterpriseId))", "Copy role(s) to Managed Company")) { continue }

        $authMc = New-Object KeeperSecurity.Enterprise.ManagedCompanyAuth
        $authMc.LoginToManagedCompany($mspLoader, $mc.EnterpriseId).GetAwaiter().GetResult() | Out-Null

        $edMc = New-Object KeeperSecurity.Enterprise.EnterpriseData
        $rdMc = New-Object KeeperSecurity.Enterprise.RoleData
        $daMc = New-Object KeeperSecurity.Enterprise.DeviceApprovalData
        $plugins = [KeeperSecurity.Enterprise.EnterpriseDataPlugin[]]@($edMc, $rdMc, $daMc)
        $loaderMc = New-Object KeeperSecurity.Enterprise.EnterpriseLoader($authMc, $plugins)
        $loaderMc.Load().GetAwaiter().GetResult() | Out-Null

        $rootNodeId = $edMc.RootNode.Id

        foreach ($roleName in $enforcementByRole.Keys) {
            $srcData = $enforcementByRole[$roleName]
            $srcRole = $srcData.Role
            $srcEnforcements = $srcData.Enforcements

            $mcRoles = @($rdMc.Roles | Where-Object { $_.DisplayName -and ($_.DisplayName.Trim() -eq $roleName) })
            if ($mcRoles.Count -gt 1) {
                Write-Warning "MC $($mc.EnterpriseId): Multiple roles named `"$roleName`". Skipping."
                continue
            }

            $mcRole = $null
            if ($mcRoles.Count -eq 0) {
                $mcRole = $rdMc.CreateRole($roleName, $rootNodeId, $srcRole.NewUserInherit).GetAwaiter().GetResult()
                if (-not $mcRole) {
                    Write-Warning "MC $($mc.EnterpriseId): Failed to create role `"$roleName`"."
                    continue
                }
            } else {
                $mcRole = $mcRoles[0]
            }

            $mcEnforcementDict = Get-RoleEnforcementDictionary -RoleData $rdMc -RoleId $mcRole.Id

            $toAdd = [System.Collections.Generic.Dictionary[KeeperSecurity.Enterprise.RoleEnforcementPolicies, string]]::new()
            $toUpdate = [System.Collections.Generic.Dictionary[KeeperSecurity.Enterprise.RoleEnforcementPolicies, string]]::new()
            $toRemove = [System.Collections.Generic.List[KeeperSecurity.Enterprise.RoleEnforcementPolicies]]::new()

            foreach ($srcKvp in $srcEnforcements.GetEnumerator()) {
                $policy = $srcKvp.Key
                $srcVal = $srcKvp.Value
                $mcHas = $mcEnforcementDict.ContainsKey($policy)
                if (-not $mcHas) {
                    $toAdd[$policy] = $srcVal
                } else {
                    $mcVal = $mcEnforcementDict[$policy]
                    if ([string]::Compare($srcVal, $mcVal, [StringComparison]::OrdinalIgnoreCase) -ne 0) {
                        $toUpdate[$policy] = $srcVal
                    }
                }
            }
            foreach ($mcKvp in $mcEnforcementDict.GetEnumerator()) {
                if (-not $srcEnforcements.ContainsKey($mcKvp.Key)) {
                    $toRemove.Add($mcKvp.Key) | Out-Null
                }
            }

            if ($toRemove.Count -gt 0) {
                $rdMc.RoleEnforcementRemoveBatch($mcRole, $toRemove).GetAwaiter().GetResult() | Out-Null
            }
            if ($toAdd.Count -gt 0) {
                $rdMc.RoleEnforcementAddBatch($mcRole, $toAdd).GetAwaiter().GetResult() | Out-Null
            }
            if ($toUpdate.Count -gt 0) {
                $rdMc.RoleEnforcementUpdateBatch($mcRole, $toUpdate).GetAwaiter().GetResult() | Out-Null
            }
        }

        Write-Information "MC $($mc.EnterpriseId) ($($mc.EnterpriseName)): Roles are in sync."
    }
}
New-Alias -Name msp-copy-role -Value Copy-KeeperMCRole

$script:McUnlimitedSeatsValue = [int]::MaxValue

$script:MspPlanNames = @{
    1 = 'business'; 2 = 'businessPlus'; 10 = 'enterprise'; 11 = 'enterprisePlus'
}

$script:MspFilePlanNames = @{
    '100gb' = '100GB'; '1tb' = '1TB'; '10tb' = '10TB'
}

$script:MspAddonDisplayNames = @{
    'keeper_endpoint_privilege_manager' = 'KEPM'
    'remote_browser_isolation' = 'Remote Browser Isolation'
    'connection_manager' = 'Connection Manager'
    'enterprise_breach_watch' = 'Breach Watch'
    'compliance_report' = 'Compliance Report'
    'enterprise_audit_and_reporting' = 'Audit & Reporting'
    'msp_service_and_support' = 'MSP Service & Support'
    'secrets_manager' = 'Secrets Manager'
    'chat' = 'Chat'
}

function Script:Get-MspNodePath {
    param([object]$EnterpriseData, [long]$NodeId, [bool]$OmitRoot = $false)
    $parts = [System.Collections.Generic.List[string]]::new()
    $n = $null
    if (-not $EnterpriseData.TryGetNode($NodeId, [ref]$n)) {
        return ''
    }
    while ($n) {
        $name = if ($n.ParentNodeId -le 0) { $EnterpriseData.RootNode.DisplayName } else { $n.DisplayName }
        if ([string]::IsNullOrEmpty($name)) { $name = $n.Id.ToString() }
        $parts.Insert(0, $name)
        if ($n.ParentNodeId -le 0) { break }
        $next = $null
        if (-not $EnterpriseData.TryGetNode($n.ParentNodeId, [ref]$next)) { break }
        $n = $next
    }
    if ($OmitRoot -and $parts.Count -gt 1) { $parts.RemoveAt(0) }
    $parts -join ' / '
}

function Get-MspBillingReport {
    <#
    .Synopsis
    Generate MSP Consumption Billing Statement.
    .Parameter Month
    Report month as 1-12 (numeric) or YYYY-MM (e.g. 2022-02). If omitted, previous calendar month is used.
    .Parameter Year
    Report year (e.g. 2022). Used when Month is numeric only.
    .Parameter ShowDate
    Breakdown report by date.
    .Parameter ShowCompany
    Breakdown report by managed company.
    .Parameter Format
    Output format: table (default), json, or csv.
    .Parameter Output
    If supplied, save the report to the given file path.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $false, Position = 0)]
        [object] $Month,
        [Parameter(Mandatory = $false, Position = 1)]
        [int] $Year = 0,
        [Parameter(Mandatory = $false)]
        [Alias('d')]
        [switch] $ShowDate,
        [Parameter(Mandatory = $false)]
        [Alias('c')]
        [switch] $ShowCompany,
        [Parameter(Mandatory = $false)]
        [ValidateSet('table', 'json', 'csv')]
        [string] $Format = 'table',
        [Parameter(Mandatory = $false)]
        [string] $Output
    )

    $dt = Get-Date
    $apiMonth = -1
    $apiYear = -1

    $monthNum = $null
    if ($null -ne $Month) {
        if ($Month -is [string] -and $Month -match '^(\d{4})-(\d{2})$') {
        } elseif ($Month -is [int] -and $Month -ge 1 -and $Month -le 12) {
            $monthNum = $Month
        } else {
            $tryNum = 0
            if ([int]::TryParse([string]$Month, [ref]$tryNum) -and $tryNum -ge 1 -and $tryNum -le 12) { $monthNum = $tryNum }
            else { $Month = $null }
        }
    }

    if ($null -ne $Month -and $Month -is [string] -and $Month -match '^(\d{4})-(\d{2})$') {
        $apiYear = [int]$Matches[1]
        $m = [int]$Matches[2]
        if ($m -lt 1 -or $m -gt 12) {
            Write-Error "Month in YYYY-MM must be 01-12 (got $($Matches[2]))" -ErrorAction Stop
        }
        $apiMonth = $m - 1
    } elseif ($Year -ne 0 -or $null -ne $monthNum) {
        if ($Year -eq 0) { $Year = $dt.Year }
        $apiYear = $Year
        if ($null -ne $monthNum -and $monthNum -ge 1 -and $monthNum -le 12) {
            $apiMonth = $monthNum - 1
        } else {
            $apiMonth = $dt.Month - 1
            if ($apiMonth -le 0) {
                $apiYear -= 1
                $apiMonth = 11
            }
        }
    } else {
        $apiYear = $dt.Year
        $apiMonth = $dt.Month - 2
        if ($apiMonth -lt 0) {
            $apiMonth += 12
            $apiYear -= 1
        }
    }

    $auth = [KeeperSecurity.Authentication.IAuthentication]$Script:Context.Auth

    $url = [KeeperSecurity.Authentication.AuthExtensions]::GetBiUrl($auth, 'mapping/addons')
    $rqAddons = New-Object BI.MappingAddonsRequest
    $rsAddons = $auth.ExecuteAuthRest($url, $rqAddons, [BI.MappingAddonsResponse]).GetAwaiter().GetResult()
    $filePlanMap = @{ 4 = '100GB'; 7 = '1TB'; 8 = '10TB' }
    foreach ($fp in $rsAddons.FilePlans) { $filePlanMap[$fp.Id] = $fp.Name }
    $addonById = @{}
    foreach ($a in $rsAddons.Addons) { $addonById[$a.Id] = $a.Name }

    $urlPricing = [KeeperSecurity.Authentication.AuthExtensions]::GetBiUrl($auth, 'subscription/mc_pricing')
    $rqPricing = New-Object BI.SubscriptionMcPricingRequest
    $rsPricing = $auth.ExecuteAuthRest($urlPricing, $rqPricing, [BI.SubscriptionMcPricingResponse]).GetAwaiter().GetResult()
    $rateByProductId = @{}
    $currencySymbol = @{ [int][BI.Currency]::Usd = '$'; [int][BI.Currency]::Eur = [char]0x20AC; [int][BI.Currency]::Gbp = [char]0x00A3; [int][BI.Currency]::Jpy = [char]0x00A5 }
    foreach ($p in $rsPricing.BasePlans) {
        $sym = $currencySymbol[[int]$p.Cost.Currency]
        if (-not $sym) { $sym = '' }
        $rateByProductId[$p.Id] = "$sym$($p.Cost.Amount)"
    }
    foreach ($p in $rsPricing.FilePlans) {
        $sym = $currencySymbol[[int]$p.Cost.Currency]
        if (-not $sym) { $sym = '' }
        $rateByProductId[($p.Id * 100)] = "$sym$($p.Cost.Amount)"
    }
    foreach ($p in $rsPricing.Addons) {
        $sym = $currencySymbol[[int]$p.Cost.Currency]
        if (-not $sym) { $sym = '' }
        $rateByProductId[($p.Id * 10000)] = "$sym$($p.Cost.Amount)"
    }

    $url = [KeeperSecurity.Authentication.AuthExtensions]::GetBiUrl($auth, 'reporting/daily_snapshot')
    $rq = New-Object BI.ReportingDailySnapshotRequest
    $rq.Month = [Math]::Max(1, [Math]::Min(12, $apiMonth + 1))
    $rq.Year = $apiYear
    $rs = $auth.ExecuteAuthRest($url, $rq, [BI.ReportingDailySnapshotResponse]).GetAwaiter().GetResult()
    $mcNames = @{}
    foreach ($mc in $rs.McEnterprises) { $mcNames[$mc.Id] = $mc.Name }

    $dailySnapshots = @{}
    foreach ($rec in $rs.Records) {
        $recDate = [KeeperSecurity.Utils.DateTimeOffsetExtensions]::FromUnixTimeMilliseconds($rec.Date).UtcDateTime.Date
        $dateOrdinal = $recDate.Ticks / [TimeSpan]::TicksPerDay
        $key = "$($rec.McEnterpriseId)_$dateOrdinal"
        if (-not $dailySnapshots[$key]) { $dailySnapshots[$key] = @{ McId = $rec.McEnterpriseId; DateOrdinal = $dateOrdinal; Units = @{} } }
        $u = $dailySnapshots[$key].Units
        if ($rec.MaxLicenseCount -gt 0) {
            if ($rec.MaxBasePlanId -gt 0) { $u[$rec.MaxBasePlanId] = $rec.MaxLicenseCount }
            if ($rec.MaxFilePlanTypeId -gt 0) { $u[$rec.MaxFilePlanTypeId * 100] = $rec.MaxLicenseCount }
        }
        foreach ($addon in $rec.Addons) {
            if ($addon.MaxAddonId -gt 0) { $u[$addon.MaxAddonId * 10000] = $addon.Units }
        }
    }

    $merged = @{}
    foreach ($k in $dailySnapshots.Keys) {
        $ds = $dailySnapshots[$k]
        $mc = if ($ShowCompany) { $ds.McId } else { 0 }
        $d = if ($ShowDate) { $ds.DateOrdinal } else { 0 }
        $mergeKey = "${mc}_$d"
        if (-not $merged[$mergeKey]) {
            $merged[$mergeKey] = @{ McId = $mc; DateOrdinal = $d; QtyDays = @{} }
        }
        $qd = $merged[$mergeKey].QtyDays
        foreach ($productId in $ds.Units.Keys) {
            $q = $ds.Units[$productId]
            if (-not $qd[$productId]) { $qd[$productId] = @(0, 0) }
            $qd[$productId][0] += $q
            $qd[$productId][1] += 1
        }
    }

    $numReportedDays = 30
    $allDates = @($dailySnapshots.Values | ForEach-Object { $_.DateOrdinal } | Sort-Object -Unique)
    if ($allDates.Count -gt 0) {
        $numReportedDays = [int](($allDates[-1] - $allDates[0]) + 1)
    }

    $getCountId = {
        param([long]$productKey)
        if ($productKey -gt 0 -and $productKey -lt 100) { return [int]$productKey }
        if ($productKey -ge 100 -and $productKey -lt 10000) { return [int]($productKey / 100) }
        if ($productKey -ge 10000) { return [int]($productKey / 10000) }
        return 0
    }
    $getProductName = {
        param([long]$productKey)
        $cid = & $getCountId $productKey
        if ($productKey -gt 0 -and $productKey -lt 100) {
            $name = $script:MspPlanNames[[int]$productKey]
            if ($name) { return $name }
            return "Plan #$cid"
        }
        if ($productKey -ge 100 -and $productKey -lt 10000) {
            $name = $filePlanMap[$cid]
            if ($name) { return $name }
            return "Storage #$cid"
        }
        if ($productKey -ge 10000) {
            $name = $addonById[$cid]
            if ($name) { return $name }
            return "Addon #$cid"
        }
        return "Product $productKey"
    }
    $getRate = { param([long]$productKey) $r = $rateByProductId[$productKey]; if ($r) { return $r }; return '' }

    $startEndByMc = @{}
    $mcIds = @($dailySnapshots.Values | ForEach-Object { $_.McId } | Sort-Object -Unique)
    foreach ($mid in $mcIds) {
        $dates = @($dailySnapshots.GetEnumerator() | Where-Object { $_.Value.McId -eq $mid } | ForEach-Object { $_.Value.DateOrdinal } | Sort-Object -Unique)
        if ($dates.Count -eq 0) { continue }
        $minD = $dates[0]; $maxD = $dates[-1]
        $startUnits = @{}; $endUnits = @{}
        foreach ($kv in $dailySnapshots.GetEnumerator()) {
            $v = $kv.Value
            if ($v.McId -ne $mid) { continue }
            if ($v.DateOrdinal -eq $minD) {
                foreach ($p in $v.Units.Keys) { $startUnits[$p] = ($startUnits[$p] + 0) + $v.Units[$p] }
            }
            if ($v.DateOrdinal -eq $maxD) {
                foreach ($p in $v.Units.Keys) { $endUnits[$p] = ($endUnits[$p] + 0) + $v.Units[$p] }
            }
        }
        $startEndByMc[$mid] = @{ Start = $startUnits; End = $endUnits }
    }
    $globalStart = @{}; $globalEnd = @{}
    foreach ($kv in $dailySnapshots.GetEnumerator()) {
        $v = $kv.Value
        $isMin = ($allDates.Count -gt 0 -and $v.DateOrdinal -eq $allDates[0])
        $isMax = ($allDates.Count -gt 0 -and $v.DateOrdinal -eq $allDates[-1])
        if ($isMin) { foreach ($p in $v.Units.Keys) { $globalStart[$p] = ($globalStart[$p] + 0) + $v.Units[$p] } }
        if ($isMax) { foreach ($p in $v.Units.Keys) { $globalEnd[$p] = ($globalEnd[$p] + 0) + $v.Units[$p] } }
    }

    $maxByProductMc = @{}
    foreach ($mid in @(0) + @($mcIds)) {
        $maxByProductMc[$mid] = @{}
        $datesToSum = if ($mid -eq 0) { $allDates } else { @($dailySnapshots.GetEnumerator() | Where-Object { $_.Value.McId -eq $mid } | ForEach-Object { $_.Value.DateOrdinal } | Sort-Object -Unique) }
        foreach ($d in $datesToSum) {
            $dayTotal = @{}
            foreach ($kv in $dailySnapshots.GetEnumerator()) {
                $v = $kv.Value
                if ($mid -ne 0 -and $v.McId -ne $mid) { continue }
                if ($v.DateOrdinal -ne $d) { continue }
                foreach ($p in $v.Units.Keys) { $dayTotal[$p] = ($dayTotal[$p] + 0) + $v.Units[$p] }
            }
            foreach ($p in $dayTotal.Keys) {
                if (-not $maxByProductMc[$mid][$p]) { $maxByProductMc[$mid][$p] = 0 }
                if ($dayTotal[$p] -gt $maxByProductMc[$mid][$p]) { $maxByProductMc[$mid][$p] = $dayTotal[$p] }
            }
        }
    }

    $table = [System.Collections.ArrayList]::new()
    $calendarMonth = [Math]::Max(1, [Math]::Min(12, $apiMonth + 1))
    $monthName = (Get-Date -Year $apiYear -Month $calendarMonth -Day 1).ToString('MMMM')
    $title = "Consumption Billing Statement: $monthName $apiYear"

    foreach ($mergeKey in ($merged.Keys | Sort-Object)) {
        $point = $merged[$mergeKey]
        $mc = $point.McId
        $dateOrd = $point.DateOrdinal
        $dayStr = ''
        if ($ShowDate -and $dateOrd -ne 0) {
            $dayStr = [DateTime]::new([long]($dateOrd * [TimeSpan]::TicksPerDay)).ToString('yyyy-MM-dd')
        }
        $company = ''
        $companyId = ''
        if ($ShowCompany -and $mc -ne 0) {
            $company = $mcNames[$mc]
            if (-not $company) { $company = "MC $mc" }
            $companyId = $mc
        }
        $daysForPoint = if ($ShowDate -and $dateOrd -ne 0) { 1 } else { $numReportedDays }
        if ($ShowCompany -and $mc -ne 0 -and -not $ShowDate) {
            $mcDates = @($dailySnapshots.GetEnumerator() | Where-Object { $_.Value.McId -eq $mc } | ForEach-Object { $_.Value.DateOrdinal } | Sort-Object -Unique)
            if ($mcDates.Count -gt 0) { $daysForPoint = [int](($mcDates[-1] - $mcDates[0]) + 1) }
        }
        $startUnits = if ($mc -eq 0) { $globalStart } else { $startEndByMc[$mc].Start }
        $endUnits = if ($mc -eq 0) { $globalEnd } else { $startEndByMc[$mc].End }
        $maxUnits = $maxByProductMc[$mc]

        $productIds = $point.QtyDays.Keys | Sort-Object
        foreach ($productKey in $productIds) {
            $qtyDays = $point.QtyDays[$productKey]
            $count = $qtyDays[0]
            $days = if ($ShowCompany -and $mc -ne 0) { $qtyDays[1] } else { $daysForPoint }
            $productName = & $getProductName $productKey
            $rateText = & $getRate $productKey
            $row = [ordered]@{}
            if ($ShowDate) { $row['Date'] = $dayStr }
            if ($ShowCompany) { $row['Company'] = $company; $row['CompanyId'] = $companyId }
            $row['Product'] = $productName
            $row['Licenses'] = $count
            $row['Rate'] = $rateText
            $row['AvgPerDay'] = if ($days -gt 0) { [math]::Round($count / [double]$days, 2) } else { 0 }
            if ($ShowDate -and $dateOrd -ne 0) {
                $startCount = $count; $endCount = $count; $maxCount = $count
            } else {
                $startCount = 0; if ($startUnits -and $startUnits[$productKey]) { $startCount = $startUnits[$productKey] }
                $endCount = 0; if ($endUnits -and $endUnits[$productKey]) { $endCount = $endUnits[$productKey] }
                $maxCount = 0; if ($maxUnits -and $maxUnits[$productKey]) { $maxCount = $maxUnits[$productKey] }
            }
            $row['InitialLicenses'] = $startCount
            $row['FinalLicenses'] = $endCount
            $row['MaxLicenses'] = $maxCount
            [void]$table.Add([PSCustomObject]$row)
        }
    }

    $out = $null
    switch ($Format) {
        'json' { $out = @{ title = $title; rows = $table } | ConvertTo-Json -Depth 5 }
        'csv' { $out = $table | ConvertTo-Csv -NoTypeInformation }
        'table' { $out = $table | Format-Table | Out-String -Width 8192 }
    }

    if ($Output) {
        if ($Format -eq 'table') {
            $tableStr = $table | Format-Table | Out-String -Width 8192
            Set-Content -Path $Output -Value ($title + "`n`n" + $tableStr) -Encoding utf8
        } else {
            Set-Content -Path $Output -Value $out -Encoding utf8
        }
    } else {
        if ($Format -eq 'table') {
            Write-Host $title
            Write-Host ''
        }
        $out
    }
}

function Script:ParseMspDateInput([string]$value, [string]$paramName) {
    $numeric = [long]0
    if ([long]::TryParse($value, [ref]$numeric)) {
        return [DateTimeOffset]::FromUnixTimeSeconds($numeric).LocalDateTime
    }
    $parsed = [DateTime]::MinValue
    if ([DateTime]::TryParseExact($value, 'yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None, [ref]$parsed)) {
        return $parsed
    }
    Write-Error "Cannot parse -${paramName} date: '$value'. Use YYYY-MM-dd or Unix timestamp." -ErrorAction Stop
}

function Get-KeeperMspLegacyReport {
    <#
    .Synopsis
    Generate MSP legacy billing report.

    .Description
    Retrieves the MSP legacy license adjustment log from the Keeper server.
    Supports predefined date ranges or custom from/to dates.
    Results can be output as table, CSV, or JSON.

    .Parameter Range
    Pre-defined date range. Choices: today, yesterday, last_7_days, last_30_days,
    month_to_date, last_month, year_to_date, last_year. Default: last_30_days.

    .Parameter From
    Custom start date. ISO 8601 format (YYYY-MM-dd) or Unix timestamp.
    Use with -To for a custom date range. When used alone with -Range, the range window
    is computed forward from this date (e.g. -Range last_7_days -From "2026-02-01"
    gives 2026-02-01 through 2026-02-07; -Range month_to_date gives From through end
    of that month; -Range year_to_date gives From through end of that year).

    .Parameter To
    Custom end date. ISO 8601 format (YYYY-MM-dd) or Unix timestamp.
    Use with -From for a custom date range. When used alone with -Range, the range window
    is computed backward from this date (e.g. -Range last_7_days -To "2026-02-28"
    gives 2026-02-21 through 2026-02-28; -Range month_to_date gives 1st of To's month
    through To; -Range year_to_date gives Jan 1 of To's year through To).

    .Parameter Format
    Output format: table (default), csv, json.

    .Parameter Output
    File path to save the report.

    .Example
    Get-KeeperMspLegacyReport
    Returns legacy billing log for the last 30 days.

    .Example
    Get-KeeperMspLegacyReport -Range last_7_days
    Returns legacy billing log for the last 7 days.

    .Example
    Get-KeeperMspLegacyReport -From "2025-01-01" -To "2025-06-30" -Format csv -Output "report.csv"
    Returns legacy billing log for a custom date range, saved as CSV.

    .Example
    Get-KeeperMspLegacyReport -From "2026-02-01" -To "2026-02-28"
    Returns legacy billing log for February 2026.

    .Example
    Get-KeeperMspLegacyReport -Range last_7_days -From "2026-02-01"
    Returns legacy billing log for 7 days starting from 2026-02-01 (through 2026-02-07).

    .Example
    Get-KeeperMspLegacyReport -Range last_7_days -To "2026-02-28"
    Returns legacy billing log for the 7-day window ending on 2026-02-28 (from 2026-02-21).

    .Example
    Get-KeeperMspLegacyReport -Range month_to_date -To "2026-02-15"
    Returns legacy billing log from 2026-02-01 (start of month) through 2026-02-15.

    .Example
    Get-KeeperMspLegacyReport -Range year_to_date -From "2026-03-01"
    Returns legacy billing log from 2026-03-01 through 2026-12-31 (end of that year).
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $false)]
        [ValidateSet('today', 'yesterday', 'last_7_days', 'last_30_days', 'month_to_date', 'last_month', 'year_to_date', 'last_year')]
        [string] $Range = 'last_30_days',

        [Parameter(Mandatory = $false)]
        [string] $From,

        [Parameter(Mandatory = $false)]
        [string] $To,

        [Parameter(Mandatory = $false)]
        [ValidateSet('table', 'json', 'csv')]
        [string] $Format = 'table',

        [Parameter(Mandatory = $false)]
        [string] $Output
    )

    $hasFrom = [bool]$From
    $hasTo = [bool]$To
    $rangeExplicit = $PSBoundParameters.ContainsKey('Range')

    if ($rangeExplicit -and $hasFrom -and $hasTo) {
        Write-Error "When -Range is specified, provide only one anchor date: either -From or -To." -ErrorAction Stop
    }

    if ((($hasFrom -and -not $hasTo) -or ($hasTo -and -not $hasFrom)) -and -not $rangeExplicit) {
        Write-Error "Both -From and -To must be specified for a custom date range." -ErrorAction Stop
    }

    $anchorableRanges = @('last_7_days', 'last_30_days', 'month_to_date', 'year_to_date')
    if ($rangeExplicit -and ($hasFrom -or $hasTo) -and ($Range -notin $anchorableRanges)) {
        Write-Error "Anchored date ranges are supported only for: $($anchorableRanges -join ', ')." -ErrorAction Stop
    }

    try {
        [Enterprise]$enterprise = getMspEnterprise
    }
    catch {
        Write-Error "Failed to load MSP enterprise context: $($_.Exception.Message)" -ErrorAction Stop
    }
    $auth = $enterprise.loader.Auth

    $fromDate = $null
    $toDate = $null

    if ($hasFrom -and $hasTo) {
        $fromDate = ParseMspDateInput $From 'From'
        $toDate = ParseMspDateInput $To 'To'
        $toDate = $toDate.Date.AddDays(1).AddTicks(-1)
    } elseif ($rangeExplicit -and ($hasFrom -or $hasTo)) {
        if ($hasFrom) {
            $fromDate = (ParseMspDateInput $From 'From').Date

            switch ($Range) {
                'last_7_days' {
                    $toDate = $fromDate.AddDays(7).AddTicks(-1)
                }
                'last_30_days' {
                    $toDate = $fromDate.AddDays(30).AddTicks(-1)
                }
                'month_to_date' {
                    $toDate = [DateTime]::new($fromDate.Year, $fromDate.Month, [DateTime]::DaysInMonth($fromDate.Year, $fromDate.Month)).AddDays(1).AddTicks(-1)
                }
                'year_to_date' {
                    $toDate = [DateTime]::new($fromDate.Year, 12, 31).AddDays(1).AddTicks(-1)
                }
            }
        } else {
            $toDate = (ParseMspDateInput $To 'To').Date.AddDays(1).AddTicks(-1)

            switch ($Range) {
                'last_7_days' {
                    $fromDate = $toDate.Date.AddDays(-7)
                }
                'last_30_days' {
                    $fromDate = $toDate.Date.AddDays(-30)
                }
                'month_to_date' {
                    $fromDate = [DateTime]::new($toDate.Year, $toDate.Month, 1)
                }
                'year_to_date' {
                    $fromDate = [DateTime]::new($toDate.Year, 1, 1)
                }
            }
        }
    } else {
        $now = Get-Date
        $todayStart = $now.Date
        $todayEnd = $now.Date.AddDays(1).AddTicks(-1)

        switch ($Range) {
            'today' {
                $fromDate = $todayStart
                $toDate = $todayEnd
            }
            'yesterday' {
                $fromDate = $todayStart.AddDays(-1)
                $toDate = $todayEnd.AddDays(-1)
            }
            'last_7_days' {
                $fromDate = $todayStart.AddDays(-7)
                $toDate = $todayEnd
            }
            'last_30_days' {
                $fromDate = $todayStart.AddDays(-30)
                $toDate = $todayEnd
            }
            'month_to_date' {
                $fromDate = [DateTime]::new($now.Year, $now.Month, 1)
                $toDate = $todayEnd
            }
            'last_month' {
                $lastMonth = $now.AddMonths(-1)
                $fromDate = [DateTime]::new($lastMonth.Year, $lastMonth.Month, 1)
                $lastDay = [DateTime]::DaysInMonth($lastMonth.Year, $lastMonth.Month)
                $toDate = [DateTime]::new($lastMonth.Year, $lastMonth.Month, $lastDay).AddDays(1).AddTicks(-1)
            }
            'year_to_date' {
                $fromDate = [DateTime]::new($now.Year, 1, 1)
                $toDate = $todayEnd
            }
            'last_year' {
                $fromDate = [DateTime]::new($now.Year - 1, 1, 1)
                $toDate = [DateTime]::new($now.Year - 1, 12, 31).AddDays(1).AddTicks(-1)
            }
        }
    }

    if ($null -eq $fromDate -or $null -eq $toDate) {
        Write-Error "Unable to determine date range. Check -Range, -From, and -To parameters." -ErrorAction Stop
    }

    $fromTimestampMs = [long]([DateTimeOffset]::new($fromDate).ToUnixTimeMilliseconds())
    $toTimestampMs = [long]([DateTimeOffset]::new($toDate).ToUnixTimeMilliseconds())

    $rq = New-Object KeeperSecurity.Commands.GetMcLicenseAdjustmentLogCommand
    $rq.From = $fromTimestampMs
    $rq.To = $toTimestampMs

    try {
        $rs = [KeeperSecurity.Commands.GetMcLicenseAdjustmentLogResponse]$auth.ExecuteAuthCommand($rq, [KeeperSecurity.Commands.GetMcLicenseAdjustmentLogResponse], $true).GetAwaiter().GetResult()
    } catch {
        Write-Error "Failed to retrieve MSP legacy report: $($_.Exception.Message)" -ErrorAction Stop
    }

    if (-not $rs.Log -or $rs.Log.Count -eq 0) {
        Write-Warning 'No legacy billing log entries found.'
        return
    }

    $table = [System.Collections.Generic.List[PSCustomObject]]::new()
    foreach ($log in $rs.Log) {
        $table.Add([PSCustomObject]@{
            'ID'                    = $log.Id
            'Time'                  = $log.Date
            'Company ID'            = $log.EnterpriseId
            'Company Name'          = $log.EnterpriseName
            'Status'                = $log.Status
            'Number Of Allocations' = $log.NewNumberOfSeats
            'Plan'                  = $log.NewProductType
            'Transaction Notes'     = $log.Note
            'Price Estimate'        = $log.Price
        })
    }

    $out = $null
    switch ($Format) {
        'json'  { $out = $table | ConvertTo-Json -Depth 5 }
        'csv'   { $out = $table | ConvertTo-Csv -NoTypeInformation }
        'table' { $out = $table | Format-Table -AutoSize | Out-String -Width 8192 }
    }

    if ($Output) {
        try {
            Set-Content -Path $Output -Value $out -Encoding utf8
            Write-Information "Report saved to: $Output"
        } catch {
            Write-Error "Failed to save report to '$Output': $($_.Exception.Message)" -ErrorAction Stop
        }
    } else {
        $out
    }
}
New-Alias -Name 'msp-legacy-report' -Value Get-KeeperMspLegacyReport

function findManagedCompany {
    Param (
        [string]$mc
    )
    $enterprise = getMspEnterprise
    $trimmed = $mc.Trim()
    $id = [long]0
    if ([long]::TryParse($trimmed, [ref]$id)) {
        $enterprise.mspData.ManagedCompanies | Where-Object { $_.EnterpriseId -eq $id } | Select-Object -First 1
    } else {
        $enterprise.mspData.ManagedCompanies | Where-Object { $_.EnterpriseName -eq $trimmed } | Select-Object -First 1
    }
}

function findEnterpriseNode {
    Param (
        [string]$node
    )
    $enterprise = getEnterprise
    if ($node -eq $enterprise.loader.EnterpriseName) {
        return $enterprise.enterpriseData.RootNode
    }
    $enterprise.enterpriseData.Nodes | Where-Object { ($_.Id -eq $node) -or ($_.DisplayName -eq $node) } | Select-Object -First 1
}

function getMspEnterprise {
    [Enterprise] $enterprise = $Script:Context.Enterprise
    if (-not $enterprise) {
        $enterprise = getEnterprise
    }
    if ($enterprise.enterpriseData.EnterpriseLicense -and $enterprise.enterpriseData.EnterpriseLicense.LicenseStatus -like "msp*") {
        return $enterprise
    }
    Write-Error -Message "Not a MSP (Managed Service Provider)" -ErrorAction Stop
}

# SIG # Begin signature block
# MIInvgYJKoZIhvcNAQcCoIInrzCCJ6sCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAIJMOpeVcMont2
# gshh5sFkJ3iSPQ7qlbQvqxwo/W2ahqCCITswggWNMIIEdaADAgECAhAOmxiO+dAt
# 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV
# BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa
# Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy
# dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD
# ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
# ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E
# MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy
# unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF
# xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1
# 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB
# MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR
# WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6
# nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB
# YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S
# UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x
# q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB
# NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP
# TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC
# AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp
# Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv
# bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0
# aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB
# LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc
# Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov
# Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy
# oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW
# juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF
# mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z
# twGpn1eqXijiuZQwggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0GCSqG
# SIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx
# GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy
# dXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTlaMGkx
# CzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4
# RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEzODQg
# MjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C0Cit
# eLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce2vnS
# 1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0daE6ZM
# swEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6TSXBC
# Mo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoAFdE3
# /hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7OhD26j
# q22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM1bL5
# OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z8ujo
# 7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05huzU
# tw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNYmtwm
# KwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP/2NP
# TLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0TAQH/
# BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYDVR0j
# BBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1Ud
# JQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0
# cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0
# cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8E
# PDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVz
# dGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATANBgkq
# hkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95RysQDK
# r2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HLIvda
# qpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5BtfQ/g+
# lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnhOE7a
# brs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIhdXNS
# y0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV9zeK
# iwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/jwVYb
# KyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYHKi8Q
# xAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmCXBVm
# zGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l/aCn
# HwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZWeE4w
# gga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0GCSqGSIb3DQEBCwUAMGIx
# CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3
# dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH
# NDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTlaMGkxCzAJBgNVBAYTAlVT
# MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1
# c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwggIi
# MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHTCphBcr48RsAcrHXbo0Zo
# dLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPhof6pvF4uGjwjqNjfEvUi
# 6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mAxAHeHYNnQxqXmRinvuNg
# xVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBvMgEdgkFiDNYiOTx4OtiF
# cMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps0wjUjsZvkgFkriK9tUKJ
# m/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF83bRVFLeGkuAhHiGPMvS
# GmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXiUOeSLsJygoLPp66bkDX1
# ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOMCZIVNSaz7BX8VtYGqLt9
# MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydPpOjL6s36czwzsucuoKs7
# Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrUG2ZdSoQbU2rMkpLiQ6bG
# RinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+sdFUeEY0qVjPKOWug/G6
# X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAd
# BgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYDVR0jBBgwFoAU7NfjgtJx
# XWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUF
# BwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGln
# aWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5j
# b20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJo
# dHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNy
# bDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQEL
# BQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WIGjB/T8ObXAZz8OjuhUxj
# aaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+IQhQE7jU/kXjjytJgnn0
# hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8Myb9rEVKChHyfpzee5kH0
# F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2th9y1IsA0QF8dTXqvcnT
# mpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjajV/gxdEkMx1NKU4uHQcKf
# ZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2Lr3ty9qIijanrUR3anzE
# wlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFzeGxcytL5TTLL4ZaoBdqbh
# OhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG7uEBYTptMSbhdhGQDpOX
# gpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+NJpud/v4+7RWsWCiKi9EO
# LLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckTetiSuEtQvLsNz3Qbp7wG
# WqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszWkPZPubdcMIIG7TCCBNWg
# AwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0BAQsFADBpMQswCQYDVQQG
# EwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0
# IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUgQ0Ex
# MB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVowYzELMAkGA1UEBhMCVVMx
# FzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBTSEEy
# NTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIwMjUgMTCCAiIwDQYJKoZI
# hvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5gVrMrV7pvUf+GcAoB38o3
# zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN+vo8dE2/pPvOx/Vj8Tch
# TySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qome7MrxVyfQO9sMx6ZAWj
# FDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ//nBZZREr4h/GI6Dxb2Uo
# yrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouTMYFOnHoRh6+86Ltc5zjP
# KHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8DD+nigNJFmt6LAHvH3KS
# uNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnpJeItK/DhKbPxTTuGoX7w
# JNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP51ho1zBp+xUIZkpSFA8vW
# doUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49kPmk8iyyizNDIXj//cOg
# rY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5PWPsWeupWs7NpChUk555K
# 096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7YufAkprxMiXAJQ1XCmnCf
# gPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTkO/zy
# Me39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK6eQGfHrK4pBW9i/USezL
# TjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwgZUGCCsG
# AQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5j
# b20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdp
# Q2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNy
# dDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGln
# aUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2U0hBMjU2MjAyNUNBMS5j
# cmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9bAcBMA0GCSqGSIb3DQEB
# CwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP2zWLpQq1b4URGnwWBdEZ
# D9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O6Lgjg8K8elC4+oWCqnU/
# ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskgiC3QYIUP2S3HQvHG1FDu
# +WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMUBaB5bdrPbF6MRYs03h4o
# bEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDFkxUGtMTaiLR9wjxUxu2h
# ECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+zJNEsuEB7O7/cuvTQasn
# M9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/lwd6UArb+BOVAkg2oOvol
# /DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxlRcGG0LIhp6GvReQGgMgY
# xQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2zRWVFjF7mcr4C34Mj3oc
# CVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJgbaP5t2nGj/ULLi49xTcB
# ZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOCIUjsarfNZzCCB0kwggUx
# oAMCAQICEAHdzU+FVN9jCMv0HhHagNUwDQYJKoZIhvcNAQELBQAwaTELMAkGA1UE
# BhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2Vy
# dCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENB
# MTAeFw0yNjA2MDUwMDAwMDBaFw0yNzA2MDQyMzU5NTlaMIHRMRMwEQYLKwYBBAGC
# NzwCAQMTAlVTMRkwFwYLKwYBBAGCNzwCAQITCERlbGF3YXJlMR0wGwYDVQQPDBRQ
# cml2YXRlIE9yZ2FuaXphdGlvbjEQMA4GA1UEBRMHMzQwNzk4NTELMAkGA1UEBhMC
# VVMxETAPBgNVBAgTCElsbGlub2lzMRAwDgYDVQQHEwdDaGljYWdvMR0wGwYDVQQK
# ExRLZWVwZXIgU2VjdXJpdHkgSW5jLjEdMBsGA1UEAxMUS2VlcGVyIFNlY3VyaXR5
# IEluYy4wggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCb4DRTV0sNQsa1
# 0YRh+bliabmLOVYr6S0+BSVvRJAN3SHP6x52i1Dkpki5xVDIH06ZnnsToVrgvTv+
# QxGwsn9SAPHEZ/PIJRFxbMR4ShDaptYyL4f0u4k/3HwRzIleWE4mTUonYH8BdgLw
# /F53B7wa7VTDHtxXltYTibEOwJxYCOi4Zr2FYQhjw14/CHcqS3FSMs6YYU2T56+g
# w819hQM3K0YlwTNOFoIm1v7/ZZZiJGH8uGDsvy1makh1Xyyo/wN8EbQ1nbslmePT
# roPm9w7WqiP/yiq+CZHiuTk9JK5bEgkWG3ns+v25cI251WidJx3SU7IZnX0OTd6/
# ZdKhprD5Gcfy5GBbJdcYw2WycQRW0PT5BEt55xRE0heufkpDaTUN6RdOuJdXbkl0
# hV91IZIuhueEMCk3h5mDTlU5gImxqj0R/TbAxjSSGTKCeuYFkQIRqytSabdrZZ48
# kW5hOIZMVDY1f4kpPJa8UeEvDZXT3vrtj36aSJrwez2uh4FMNlkCAwEAAaOCAgIw
# ggH+MB8GA1UdIwQYMBaAFGg34Ou2O/hfEYb7/mF7CIhl9E5CMB0GA1UdDgQWBBT1
# SmCYU/7Yrz1fX66Ur5nSzlSYOzA9BgNVHSAENjA0MDIGBWeBDAEDMCkwJwYIKwYB
# BQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMC
# B4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMwgbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0
# cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25p
# bmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5jcmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRp
# Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNI
# QTM4NDIwMjFDQTEuY3JsMIGUBggrBgEFBQcBAQSBhzCBhDAkBggrBgEFBQcwAYYY
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFwGCCsGAQUFBzAChlBodHRwOi8vY2Fj
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JT
# QTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJBgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUA
# A4ICAQBcavcUHNFEg872HDRq2+hRlnvaghCXv7X/6h9HSzjAQP3rt95BZty3ASqi
# 2MYyGQLGdDl4DToe/WhajtEOBOYa83agW6tBvrfcKRrDrwJOMPTbwNYvn+GuiL4T
# CKzXaytWiJJbrc5odc7Ecat2ZvJylpPmNainr4Q0LzzH23Gea/Mm/hIJTN4IGgrH
# hrXiTIIW/ZUzrY6g8b3RZB4BA497n43wNdSqP+C3ntFw6NiGB4Z25SW4YntIxYPv
# Kf37OVhF0xqxLC1sK/XxgK0EGQ6iaj8Ncpr2C5vSNZqfW2MndxOA1W67pgDpg83k
# UWG+/YJeGhqOTF82/0kIzQXeI/lIqbnL/IJAJqSm/ROSpsGUKVbzk03cpTD55ZQX
# WjM0fLirypBqY05T8gnh1L0fSwxr/SwJZ8OddivgyK1YOMn02nnsEG5kxBt9cMX4
# JCYABhypmAVDRvyYifEVdoFWv2gAXXW+PPRvlNa6E4aMCZrVcoKHiyeMAXOi1IC9
# mHvC2+foTSMFueq3AdnYfeKnZnAiKXKRhXcdHbQYcR2A7AIzIcqahPYr4FNEgb/E
# /y/kypAkf0rMHlYl1kNqLs2Nv1UnMEHYT5YmDVLO63+1Trcw4zTZ70zuqIqeID/d
# nbOlgtyG6DSRCL7f0E7kP18f4RoX5i1PkfeO4VJHsAuCeNG1qjGCBdkwggXVAgEB
# MH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYD
# VQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNI
# QTM4NCAyMDIxIENBMQIQAd3NT4VU32MIy/QeEdqA1TANBglghkgBZQMEAgEFAKCB
# hDAYBgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEE
# AYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJ
# BDEiBCC/E7MlVJ8NHi6MXhAvC/x24K9bF7rtM+cuUlvE3igEuTANBgkqhkiG9w0B
# AQEFAASCAYAUqqpkQV58Ibk6efioPajQoABFZ98zxst5uW7RnAWedR26lkP+ZfaJ
# b27dLF4uMyhFukbJVQzWv8tscY6Fi3CmXRnECmLg0J7K5DID0aXbEWyUG8EX6BH9
# KFHHwDJ4VqDNnZ3Zs8owFlYKdgXShsTkFIwA5GsCcbrEQcb2LPTNnuqWsxJy/4ni
# SXNSKKNauOFD75u5etxID2/ZYipj5PjTftQx6fDYIpJdly/AnTRzkwyPrMLHouR0
# GMNk13U/OFm3cuNu78XxMFVH8Xzj1NtmRETkOrLqeAfT57ItINH4BJn490Qhe8zp
# 4ieOiHpBSzK1wBSuXm7P6HNTE4/jPr40ZFHpA3bES9iUQJ+pcAe1NUfwP7hwQKh6
# J7184vS/LCiVBIpgBq1gEN70Rw70cZfLfzBrkdWs3TfQ6WRiDj+2n1+UY8xXJE9x
# l88AUWtbKu5IxdA/2FxIa1ilEDwYUxy84NDhku7jdwxPgirOaJOxK29aJFm6d69J
# +ZmRd1hQul6hggMmMIIDIgYJKoZIhvcNAQkGMYIDEzCCAw8CAQEwfTBpMQswCQYD
# VQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lD
# ZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUg
# Q0ExAhAKgO8YS43xBYLRxHanlXRoMA0GCWCGSAFlAwQCAQUAoGkwGAYJKoZIhvcN
# AQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMjYwNjEzMDA1NjMwWjAv
# BgkqhkiG9w0BCQQxIgQgFbBfJlXmhygUw+4GCXzDRMLvoWXAtm8LZJBWLqw3bdIw
# DQYJKoZIhvcNAQEBBQAEggIAUh+kMJL4hrdQUb1MDp7L+DkdMID280h3k+2qXPRE
# M/fpyMQYcF/aXsTmlNKDm/m7sukqyVe8oVqL4FEX9AEFqjT4KOSOWWiqnTbDw27P
# o/a+X4CnbugwvyFdmdK41MJwT9Es+b5+QL35ltRfb/9khnr5p4TRpsh/jn+Pj4qh
# coQ/qWHEfrNSGV/O3twJkjB8WwPWVz601InccSQI3QUNVYEztuRTM/A/cAhhmGrj
# SgJSylb5YfVFt1+rGzOXRSXEnRmYPvQjlaGvW6v9RqCaEmJgAP99ld0lwRxKWNab
# uFTrOB7Xod1Xvd3ox9d+tjQF3DcoqihhMl9OlCRAZ6hFKOPF9F9T0Y7sMJv/w93x
# PzsslQ0Zk2qxZpnBMjfQnrgAkQrf6p5/MMsIQlioKRFxR+9hjpgzcUXdMWRGzZwi
# AJmZa5QActTSMA3IViblteeiCUF+/e2DSLg26dOxdstqerhAiX0W6xRNftB7mXVA
# mUvz916HlgCn+E4FVA8KEkGgX8ex06QFeQrlV3bLHKNrKeMlkiULOZY3sgkQUur1
# 3n2eUzKgu1xyi5xfLPOTseeIU1GU7djQUTgWSvm2LzPt/EvL6ugsOQaxll54Nehq
# ucBVz36eRHqWevl9W6f9it1ucGafz/SYf7TCLb39YeJHaLF1/7/8/Xf8ONt0CBco
# fQA=
# SIG # End signature block