diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index 50bba2964..c1282af70 100644 --- a/docs-mslearn/toolkit/changelog.md +++ b/docs-mslearn/toolkit/changelog.md @@ -3,7 +3,7 @@ title: FinOps toolkit changelog description: Review the latest features and enhancements in the FinOps toolkit, including updates to FinOps hubs, Power BI reports, and more. author: MSBrett ms.author: brettwil -ms.date: 08/13/2026 +ms.date: 08/17/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -59,6 +59,11 @@ The following section lists features and enhancements that are currently in deve - **Changed** - Switched the reservations and benefits workbooks from the retired `ccmstorageprod` isfratioblob.csv to the FinOps toolkit [Instance size flexibility](open-data.md#instance-size-flexibility) open data file ([#2090](https://github.com/microsoft/finops-toolkit/issues/2090)). +### [PowerShell module](powershell/powershell-commands.md) + +- **Fixed** + - Fixed [Start-FinOpsCostExport](powershell/cost/start-finopscostexport.md) exporting the wrong period for anyone running in a positive UTC offset. `-StartDate` and `-EndDate` are now treated as UTC calendar dates instead of being time zone converted, so the days you request are the days that get exported. Previously, local midnight converted to the previous UTC day, which moved the period back a day and made `-Backfill` run one extra month ([#2255](https://github.com/microsoft/finops-toolkit/issues/2255)). + ### [Open data](open-data.md) updates **[Instance size flexibility](open-data.md#instance-size-flexibility)** diff --git a/docs-mslearn/toolkit/powershell/cost/start-finopscostexport.md b/docs-mslearn/toolkit/powershell/cost/start-finopscostexport.md index 1f04ced70..c289e68fd 100644 --- a/docs-mslearn/toolkit/powershell/cost/start-finopscostexport.md +++ b/docs-mslearn/toolkit/powershell/cost/start-finopscostexport.md @@ -3,7 +3,7 @@ title: Start-FinOpsCostExport command description: Initiate a Cost Management export run for the most recent period using the Start-FinOpsCostExport command in the FinOpsToolkit module. author: flanakin ms.author: micflan -ms.date: 04/01/2026 +ms.date: 08/17/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -39,14 +39,14 @@ Start-FinOpsCostExport ` ## Parameters -| Name | Description | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `‑Name` | Required. Name of the export. | -| `‑Scope` | Optional. Resource ID of the scope to export data for. If empty, defaults to current subscription context. | -| `‑StartDate` | Optional. Day to start pulling the data for. If not set, the export uses the dates defined in the export configuration. | -| `‑EndDate` | Optional. Last day to pull data for. If not set and -StartDate is set, -EndDate uses the last day of the month. If not set and -StartDate isn't set, the export uses the dates defined in the export configuration. | -| `‑Backfill` | Optional. Number of months to export the data for. Make note of throttling (429) errors. It only runs once. Failed exports aren't reattempted. Default = 0. | -| `‑ApiVersion` | Optional. API version to use when calling the Cost Management Exports API. Default = 2025-03-01. | +| Name | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `‑Name` | Required. Name of the export. | +| `‑Scope` | Optional. Resource ID of the scope to export data for. If empty, defaults to current subscription context. | +| `‑StartDate` | Optional. Day to start pulling the data for. Interpreted as a UTC calendar date, so the day you specify is the day that gets exported, regardless of your local time zone. If not set, the export uses the dates defined in the export configuration. | +| `‑EndDate` | Optional. Last day to pull data for. Interpreted as a UTC calendar date, so the day you specify is the day that gets exported, regardless of your local time zone. If not set and -StartDate is set, -EndDate uses the last day of the month. If not set and -StartDate isn't set, the export uses the dates defined in the export configuration. | +| `‑Backfill` | Optional. Number of months to export the data for. Make note of throttling (429) errors. It only runs once. Failed exports aren't reattempted. Default = 0. | +| `‑ApiVersion` | Optional. API version to use when calling the Cost Management Exports API. Default = 2025-03-01. |
diff --git a/src/powershell/Public/Start-FinOpsCostExport.ps1 b/src/powershell/Public/Start-FinOpsCostExport.ps1 index 5c0293d74..87a651c86 100644 --- a/src/powershell/Public/Start-FinOpsCostExport.ps1 +++ b/src/powershell/Public/Start-FinOpsCostExport.ps1 @@ -21,10 +21,10 @@ Optional. Resource ID of the scope to export data for. If empty, defaults to current subscription context. .PARAMETER StartDate - Optional. Day to start pulling the data for. If not set, the export will use the dates defined in the export configuration. + Optional. Day to start pulling the data for. Interpreted as a UTC calendar date, so the day you specify is the day that is exported, regardless of the local time zone. If not set, the export will use the dates defined in the export configuration. .PARAMETER EndDate - Optional. Last day to pull data for. If not set and -StartDate is set, -EndDate will use the last day of the month. If not set and -StartDate is not set, the export will use the dates defined in the export configuration. + Optional. Last day to pull data for. Interpreted as a UTC calendar date, so the day you specify is the day that is exported, regardless of the local time zone. If not set and -StartDate is set, -EndDate will use the last day of the month. If not set and -StartDate is not set, the export will use the dates defined in the export configuration. .PARAMETER Backfill Optional. Number of months to export the data for. Make note of throttling (429) errors. This is only run once. Failed exports are not re-attempted. Default = 0. @@ -91,6 +91,19 @@ function Start-FinOpsCostExport $runpath = "$($export.Id)/run?api-version=$ApiVersion" + # -StartDate and -EndDate are calendar dates, not instants. Cost Management export periods + # are UTC and day-granular, so keep the day the caller named and tag it as UTC. Converting + # with ToUniversalTime() would move the period back a day for every caller east of UTC, + # where local midnight falls on the previous UTC day. + if ($StartDate) + { + $StartDate = [datetime]::SpecifyKind($StartDate.Date, [DateTimeKind]::Utc) + } + if ($EndDate) + { + $EndDate = [datetime]::SpecifyKind($EndDate.Date, [DateTimeKind]::Utc) + } + # Set start date if using -Backfill if ($Backfill -gt 0) { @@ -99,33 +112,33 @@ function Start-FinOpsCostExport # If -StartDate is not set, assume the current month if (-not $StartDate) { - $StartDate = (Get-Date -Day 1 -Hour 0 -Minute 0 -Second 0 -Millisecond 0).ToUniversalTime().Date + $utcToday = (Get-Date).ToUniversalTime().Date + $StartDate = $utcToday.AddDays(1 - $utcToday.Day) } # If -EndDate is not set, assume 1 month if (-not $EndDate) { - $EndDate = $StartDate.AddMonths(1).AddMilliseconds(-1) + $EndDate = $StartDate.AddMonths(1).AddDays(-1) } # Move start date to account for the backfill period $StartDate = $StartDate.AddMonths($Backfill * -1) - Write-Verbose "Backfill $Backfill months = $($StartDate.ToUniversalTime().ToString('yyyy-MM-dd"T"HH:mm:ss"Z"')) to $($EndDate.ToUniversalTime().ToString('yyyy-MM-dd"T"HH:mm:ss"Z"'))" + Write-Verbose "Backfill $Backfill months = $($StartDate.ToString('yyyy-MM-dd"T"HH:mm:ss"Z"')) to $($EndDate.ToString('yyyy-MM-dd"T"HH:mm:ss"Z"'))" } # Remove time + set end date if ($StartDate) { - $StartDate = $StartDate.ToUniversalTime().Date if ($EndDate) { - $EndDate = $EndDate.ToUniversalTime().Date + $EndDate = $EndDate.Date } else { - $EndDate = $StartDate.ToUniversalTime().Date.AddMonths(1).AddDays(-1) + $EndDate = $StartDate.AddMonths(1).AddDays(-1) } - Write-Verbose "Updated dates = $($StartDate.ToUniversalTime().ToString('yyyy-MM-dd"T"HH:mm:ss"Z"')) to $($EndDate.ToUniversalTime().ToString('yyyy-MM-dd"T"HH:mm:ss"Z"'))" + Write-Verbose "Updated dates = $($StartDate.ToString('yyyy-MM-dd"T"HH:mm:ss"Z"')) to $($EndDate.ToString('yyyy-MM-dd"T"HH:mm:ss"Z"'))" } # Start measuring progress diff --git a/src/powershell/Tests/Unit/Start-FinOpsCostExport.Tests.ps1 b/src/powershell/Tests/Unit/Start-FinOpsCostExport.Tests.ps1 index b9d55edbd..55a48b550 100644 --- a/src/powershell/Tests/Unit/Start-FinOpsCostExport.Tests.ps1 +++ b/src/powershell/Tests/Unit/Start-FinOpsCostExport.Tests.ps1 @@ -59,15 +59,15 @@ Describe 'Start-FinOpsCostExport' { $params = @{ Name = $exportName Scope = $scope - StartDate = Get-Date -Month 1 -Day 1 -Hour 0 -Minute 0 -Second 0 -Millisecond 0 -AsUTC + StartDate = [datetime]'2024-01-01' } # Act $success = Start-FinOpsCostExport @params # Assert - Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 ` - -ParameterFilter { $body.timePeriod.from -eq $params.StartDate.ToUniversalTime().ToString("yyyy-01-01'T'00:00:00'Z'") -and $body.timePeriod.to -eq $params.StartDate.ToUniversalTime().ToString("yyyy-01-31'T'00:00:00'Z'") } + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -Exactly ` + -ParameterFilter { $body.timePeriod.from -eq '2024-01-01T00:00:00Z' -and $body.timePeriod.to -eq '2024-01-31T00:00:00Z' } $success | Should -Be $true } @@ -86,8 +86,32 @@ Describe 'Start-FinOpsCostExport' { $success = Start-FinOpsCostExport @params # Assert - Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 ` - -ParameterFilter { $body.timePeriod.from -eq $params.StartDate.ToUniversalTime().Date.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") -and $body.timePeriod.to -eq $params.EndDate.ToUniversalTime().Date.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") } + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -Exactly ` + -ParameterFilter { $body.timePeriod.from -eq '2024-06-10T00:00:00Z' -and $body.timePeriod.to -eq '2024-06-20T00:00:00Z' } + $success | Should -Be $true + } + + It 'Should use the requested calendar dates regardless of the local time zone' { + # Arrange + Mock -ModuleName FinOpsToolkit -CommandName 'Get-FinOpsCostExport' { $mockExport } + Mock -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' { @{ Success = $true } } + + # Local midnight converts to the previous day in UTC for any positive offset, which used + # to shift the exported period back a day for callers east of UTC. Cover every DateTimeKind + # the parameter binder can produce so the dates are never time zone converted. + $params = @{ + Name = $exportName + Scope = $scope + StartDate = [datetime]::SpecifyKind([datetime]'2024-03-01', [DateTimeKind]::Local) + EndDate = [datetime]::SpecifyKind([datetime]'2024-03-31', [DateTimeKind]::Unspecified) + } + + # Act + $success = Start-FinOpsCostExport @params + + # Assert + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -Exactly ` + -ParameterFilter { $body.timePeriod.from -eq '2024-03-01T00:00:00Z' -and $body.timePeriod.to -eq '2024-03-31T00:00:00Z' } $success | Should -Be $true } @@ -96,7 +120,7 @@ Describe 'Start-FinOpsCostExport' { Mock -ModuleName FinOpsToolkit -CommandName 'Get-FinOpsCostExport' { $mockExport } Mock -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' { @{ Success = $true } } $today = (Get-Date).ToUniversalTime().Date - $startOfMonth = (Get-Date -Day 1 -Hour 0 -Minute 0 -Second 0 -Millisecond 0 -AsUTC) + $startOfMonth = $today.AddDays(1 - $today.Day) $params = @{ Name = $exportName Scope = $scope @@ -107,16 +131,15 @@ Describe 'Start-FinOpsCostExport' { $success = Start-FinOpsCostExport @params # Assert - Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times ($params.Backfill + 1) - Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { - $startDate = $startOfMonth.ToUniversalTime().Date - $body.timePeriod.from -eq $startDate.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") ` + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times ($params.Backfill + 1) -Exactly + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -Exactly -ParameterFilter { + $body.timePeriod.from -eq $startOfMonth.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") ` -and $body.timePeriod.to -eq $today.AddDays(-1).ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") } foreach ($i in 1..($params.Backfill)) { - Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -ParameterFilter { - $startDate = $startOfMonth.AddMonths($i * -1).ToUniversalTime().Date + Should -Invoke -ModuleName FinOpsToolkit -CommandName 'Invoke-Rest' -Times 1 -Exactly -ParameterFilter { + $startDate = $startOfMonth.AddMonths($i * -1) $body.timePeriod.from -eq $startDate.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") ` -and $body.timePeriod.to -eq $startDate.AddMonths(1).AddMilliseconds(-1).ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") } @@ -148,7 +171,7 @@ Describe 'Start-FinOpsCostExport' { # Set up dates for current month $today = (Get-Date).ToUniversalTime().Date - $firstDayOfCurrentMonth = (Get-Date -Day 1 -Hour 0 -Minute 0 -Second 0 -Millisecond 0).ToUniversalTime().Date + $firstDayOfCurrentMonth = $today.AddDays(1 - $today.Day) $lastDayOfCurrentMonth = $firstDayOfCurrentMonth.AddMonths(1).AddDays(-1) # If testing in the last day of the month, this test might not be relevant