From 3ea191f96329c08a22504f8eb65250a0a1841a72 Mon Sep 17 00:00:00 2001 From: Roland Krummenacher Date: Mon, 17 Aug 2026 08:58:20 +0200 Subject: [PATCH 1/2] fix(powershell): treat Start-FinOpsCostExport dates as UTC calendar dates Start-FinOpsCostExport converted -StartDate and -EndDate with ToUniversalTime() before truncating them to a day. For any positive UTC offset, local midnight falls on the previous UTC day, so .Date moved the requested period back by one day. The defect is asymmetric: at UTC and negative offsets local midnight converts to a later hour on the same UTC day, so .Date is unchanged and behavior is identical before and after this change. CI runs on UTC, which is why it never reproduced there. Cost Management export periods are UTC and day-granular, and the help text describes both parameters as days, so the parameters are now treated as calendar dates: the day the caller names is tagged as UTC rather than converted. The -Backfill default start date is derived from the current UTC month for the same reason; previously it was one day early, which made -Backfill run one extra month (at UTC+2, -Backfill 3 issued 5 export runs covering April through August instead of 4 covering May through August). The three tests that encoded the shifted behavior built their expected values with the same conversion on both sides, so they failed on any machine east of UTC. They now assert literal dates, which is what makes them time zone independent, and a new test covers each DateTimeKind the parameter binder can produce. Fixes #2255 Co-Authored-By: Claude Opus 5 (1M context) --- docs-mslearn/toolkit/changelog.md | 7 ++- .../powershell/cost/start-finopscostexport.md | 18 +++---- .../Public/Start-FinOpsCostExport.ps1 | 29 ++++++++--- .../Unit/Start-FinOpsCostExport.Tests.ps1 | 49 ++++++++++++++----- 4 files changed, 72 insertions(+), 31 deletions(-) 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..eaad80246 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,7 +112,8 @@ 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 @@ -110,22 +124,21 @@ function Start-FinOpsCostExport # 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 From 1d7829289cf3eb71cc4e052122d631d2c67336e4 Mon Sep 17 00:00:00 2001 From: Roland Krummenacher Date: Mon, 17 Aug 2026 09:16:21 +0200 Subject: [PATCH 2/2] refactor(powershell): drop redundant millisecond subtraction in backfill end date The -Backfill default end date subtracted a millisecond to land on the last day of the window, then had the time truncated away by .Date a few lines later. For a midnight start date -- always the case here, since both -StartDate sources are midnight -- AddMonths(1).AddMilliseconds(-1).Date and AddMonths(1).AddDays(-1) are identical, so this is a no-op. Using AddDays(-1) also makes the two default-end-date paths read the same; the non-backfill branch below already computes it that way. Note this is not the same as 'the last day of the month': with an explicit -StartDate that is not the first (for example -StartDate '2026-03-15' -Backfill 2), the window stays one month from the start date and ends 2026-04-14, not 2026-03-31. Co-Authored-By: Claude Opus 5 (1M context) --- src/powershell/Public/Start-FinOpsCostExport.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/powershell/Public/Start-FinOpsCostExport.ps1 b/src/powershell/Public/Start-FinOpsCostExport.ps1 index eaad80246..87a651c86 100644 --- a/src/powershell/Public/Start-FinOpsCostExport.ps1 +++ b/src/powershell/Public/Start-FinOpsCostExport.ps1 @@ -119,7 +119,7 @@ function Start-FinOpsCostExport # 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