From af5388c6df3c2540f6be6259617909022abb3cb6 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:19:33 -0500 Subject: [PATCH 1/5] fix: keep Admin org page accessible when Stripe customer is deleted (PM-38874) --- .../Controllers/OrganizationsController.cs | 19 ++++++++++-- .../OrganizationsControllerTests.cs | 29 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 1255ac76849f..d881fb454a83 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -19,6 +19,7 @@ using Bit.Core.AdminConsole.Utilities.v2; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.PlanMigration.Entities; using Bit.Core.Billing.Organizations.PlanMigration.Repositories; using Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; @@ -231,8 +232,22 @@ public async Task Edit(Guid id) policies = await _policyRepository.GetManyByOrganizationIdAsync(id); } var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(id); - var billingInfo = await _paymentService.GetBillingAsync(organization); - var billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(organization); + BillingInfo billingInfo = null; + BillingHistoryInfo billingHistoryInfo = null; + try + { + billingInfo = await _paymentService.GetBillingAsync(organization); + billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(organization); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Failed to load billing information for organization {OrganizationId}. The Stripe customer may have been deleted.", + id); + TempData["Warning"] = + "Billing information could not be loaded. The Stripe customer may have been deleted. " + + "You can still edit the organization and set a valid Gateway Customer ID."; + } var billingSyncConnection = _globalSettings.EnableCloudCommunication ? await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(id, OrganizationConnectionType.CloudBillingSync) : null; var secrets = organization.UseSecretsManager ? await _secretRepository.GetSecretsCountByOrganizationIdAsync(id) : -1; var projects = organization.UseSecretsManager ? await _projectRepository.GetProjectCountByOrganizationIdAsync(id) : -1; diff --git a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 319d9420b68b..16d1e6ca9b6c 100644 --- a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -13,6 +13,7 @@ using Bit.Core.Billing.Organizations.PlanMigration.Enums; using Bit.Core.Billing.Organizations.PlanMigration.Repositories; using Bit.Core.Billing.Providers.Services; +using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; @@ -24,6 +25,7 @@ using Microsoft.EntityFrameworkCore; using NSubstitute; using NSubstitute.ExceptionExtensions; +using Stripe; using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers; namespace Admin.Test.AdminConsole.Controllers; @@ -1911,5 +1913,32 @@ await sutProvider.GetDependency sutProvider) + { + // PM-38874: a deleted Stripe customer makes GetBillingAsync throw. The page must still + // render so an admin can correct the Gateway Customer ID rather than being locked out. + StubEditGetDependencies(sutProvider, organization, currentAssignment: null); + + sutProvider.GetDependency() + .GetBillingAsync(organization) + .ThrowsAsync(new StripeException("No such customer: 'cus_deleted'")); + + sutProvider.Sut.TempData = + new TempDataDictionary(new DefaultHttpContext(), Substitute.For()); + + var result = await sutProvider.Sut.Edit(organization.Id); + + var view = Assert.IsType(result); + var model = Assert.IsType(view.Model); + Assert.Null(model.BillingInfo); + Assert.Null(model.BillingHistoryInfo); + Assert.True(sutProvider.Sut.TempData.ContainsKey("Warning")); + } + #endregion } From ef2d8535c11e9cad4ea0ebec6785bde6f9c2acd7 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:22:37 -0500 Subject: [PATCH 2/5] fix: hide Admin billing section when billing info fails to load (PM-38874) --- src/Admin/AdminConsole/Views/Organizations/Edit.cshtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml index 690ee3d778c1..857ac8eceb6d 100644 --- a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml @@ -99,7 +99,7 @@ @await Html.PartialAsync("_ViewInformation", Model) } -@if (canViewBillingInformation) +@if (canViewBillingInformation && Model.BillingInfo != null) {

Billing Information

@await Html.PartialAsync("_BillingInformation", From 72570da502308c3e292250fefb4beb10bcd15a91 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:28:18 -0500 Subject: [PATCH 3/5] fix: render TempData Warning toast in Admin layout (PM-38874) --- src/Admin/Views/Shared/_Layout.cshtml | 8 ++++++++ .../Controllers/OrganizationsControllerTests.cs | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/src/Admin/Views/Shared/_Layout.cshtml b/src/Admin/Views/Shared/_Layout.cshtml index 490f01cdeb85..db0297796bb4 100644 --- a/src/Admin/Views/Shared/_Layout.cshtml +++ b/src/Admin/Views/Shared/_Layout.cshtml @@ -187,6 +187,14 @@ }); } + @if (TempData["Warning"] != null) + { + + } @RenderSection("Scripts", required: false) diff --git a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 16d1e6ca9b6c..2bf34627bd90 100644 --- a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -1938,6 +1938,10 @@ public async Task Edit_Get_BillingLoadThrows_StillRendersPageWithWarning( Assert.Null(model.BillingInfo); Assert.Null(model.BillingHistoryInfo); Assert.True(sutProvider.Sut.TempData.ContainsKey("Warning")); + Assert.Equal( + "Billing information could not be loaded. The Stripe customer may have been deleted. " + + "You can still edit the organization and set a valid Gateway Customer ID.", + (string)sutProvider.Sut.TempData["Warning"]); } #endregion From 7c79107e9491bd7c46e511b2b4ddbc64b3d7d044 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:28:34 -0500 Subject: [PATCH 4/5] fix: guard against partial billing-load failure on Admin org page (PM-38874) When GetBillingAsync succeeded but GetBillingHistoryAsync threw, the catch left BillingInfo non-null while BillingHistoryInfo stayed null. The view guard only checked BillingInfo, so _BillingInformation still rendered and dereferenced BillingHistoryInfo, throwing an NRE and returning a 500, the exact failure this fix set out to prevent. Reset both billing values in the catch so billing loads as a unit, and add BillingHistoryInfo to the view conditional as defense-in-depth. Add a test covering the GetBillingHistoryAsync-throws path. --- .../Controllers/OrganizationsController.cs | 2 + .../Views/Organizations/Edit.cshtml | 2 +- .../OrganizationsControllerTests.cs | 37 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index d881fb454a83..862193db2983 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -241,6 +241,8 @@ public async Task Edit(Guid id) } catch (Exception ex) { + billingInfo = null; + billingHistoryInfo = null; _logger.LogError(ex, "Failed to load billing information for organization {OrganizationId}. The Stripe customer may have been deleted.", id); diff --git a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml index 857ac8eceb6d..6f741e9ccc1a 100644 --- a/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml @@ -99,7 +99,7 @@ @await Html.PartialAsync("_ViewInformation", Model) } -@if (canViewBillingInformation && Model.BillingInfo != null) +@if (canViewBillingInformation && Model.BillingInfo != null && Model.BillingHistoryInfo != null) {

Billing Information

@await Html.PartialAsync("_BillingInformation", diff --git a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 2bf34627bd90..9f57e33f062a 100644 --- a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -9,6 +9,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.PlanMigration.Entities; using Bit.Core.Billing.Organizations.PlanMigration.Enums; using Bit.Core.Billing.Organizations.PlanMigration.Repositories; @@ -1944,5 +1945,41 @@ public async Task Edit_Get_BillingLoadThrows_StillRendersPageWithWarning( (string)sutProvider.Sut.TempData["Warning"]); } + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_Get_BillingHistoryLoadThrows_StillRendersPageWithWarning( + Organization organization, + BillingInfo billingInfo, + SutProvider sutProvider) + { + // PM-38874: GetBillingAsync can succeed while GetBillingHistoryAsync throws. The catch must + // reset both values so the billing section is hidden and the page renders, rather than + // falling through with a non-null BillingInfo and a null BillingHistoryInfo (which would NRE). + StubEditGetDependencies(sutProvider, organization, currentAssignment: null); + + sutProvider.GetDependency() + .GetBillingAsync(organization) + .Returns(billingInfo); + sutProvider.GetDependency() + .GetBillingHistoryAsync(organization) + .ThrowsAsync(new StripeException("No such customer: 'cus_deleted'")); + + sutProvider.Sut.TempData = + new TempDataDictionary(new DefaultHttpContext(), Substitute.For()); + + var result = await sutProvider.Sut.Edit(organization.Id); + + var view = Assert.IsType(result); + var model = Assert.IsType(view.Model); + Assert.Null(model.BillingInfo); + Assert.Null(model.BillingHistoryInfo); + Assert.True(sutProvider.Sut.TempData.ContainsKey("Warning")); + Assert.Equal( + "Billing information could not be loaded. The Stripe customer may have been deleted. " + + "You can still edit the organization and set a valid Gateway Customer ID.", + (string)sutProvider.Sut.TempData["Warning"]); + } + #endregion } From 06c96d75d385cc241a5bd74880331de3cf282e28 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:39:06 -0500 Subject: [PATCH 5/5] fix: distinguish missing Stripe customer from generic billing-load failure on Admin org page (PM-38874) --- .../Controllers/OrganizationsController.cs | 17 ++++++- .../OrganizationsControllerTests.cs | 44 ++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 862193db2983..03a98aa0d7f2 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -17,6 +17,7 @@ using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Utilities.v2; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; @@ -38,6 +39,7 @@ using Bit.Core.Vault.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Stripe; namespace Bit.Admin.AdminConsole.Controllers; @@ -239,17 +241,28 @@ public async Task Edit(Guid id) billingInfo = await _paymentService.GetBillingAsync(organization); billingHistoryInfo = await _paymentService.GetBillingHistoryAsync(organization); } - catch (Exception ex) + catch (StripeException ex) when (ex.StripeError?.Code == StripeConstants.ErrorCodes.ResourceMissing) { billingInfo = null; billingHistoryInfo = null; _logger.LogError(ex, - "Failed to load billing information for organization {OrganizationId}. The Stripe customer may have been deleted.", + "Billing information for organization {OrganizationId} could not be loaded because the Stripe customer was not found. It may have been deleted.", id); TempData["Warning"] = "Billing information could not be loaded. The Stripe customer may have been deleted. " + "You can still edit the organization and set a valid Gateway Customer ID."; } + catch (Exception ex) + { + billingInfo = null; + billingHistoryInfo = null; + _logger.LogError(ex, + "Failed to load billing information for organization {OrganizationId}.", + id); + TempData["Error"] = + "Billing information could not be loaded. You can still edit the organization or try reloading the page. " + + "Contact support if the problem persists."; + } var billingSyncConnection = _globalSettings.EnableCloudCommunication ? await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(id, OrganizationConnectionType.CloudBillingSync) : null; var secrets = organization.UseSecretsManager ? await _secretRepository.GetSecretsCountByOrganizationIdAsync(id) : -1; var projects = organization.UseSecretsManager ? await _projectRepository.GetProjectCountByOrganizationIdAsync(id) : -1; diff --git a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 9f57e33f062a..2381654372ca 100644 --- a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.PlanMigration.Entities; @@ -1927,7 +1928,10 @@ public async Task Edit_Get_BillingLoadThrows_StillRendersPageWithWarning( sutProvider.GetDependency() .GetBillingAsync(organization) - .ThrowsAsync(new StripeException("No such customer: 'cus_deleted'")); + .ThrowsAsync(new StripeException + { + StripeError = new StripeError { Code = StripeConstants.ErrorCodes.ResourceMissing } + }); sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For()); @@ -1963,7 +1967,10 @@ public async Task Edit_Get_BillingHistoryLoadThrows_StillRendersPageWithWarning( .Returns(billingInfo); sutProvider.GetDependency() .GetBillingHistoryAsync(organization) - .ThrowsAsync(new StripeException("No such customer: 'cus_deleted'")); + .ThrowsAsync(new StripeException + { + StripeError = new StripeError { Code = StripeConstants.ErrorCodes.ResourceMissing } + }); sutProvider.Sut.TempData = new TempDataDictionary(new DefaultHttpContext(), Substitute.For()); @@ -1981,5 +1988,38 @@ public async Task Edit_Get_BillingHistoryLoadThrows_StillRendersPageWithWarning( (string)sutProvider.Sut.TempData["Warning"]); } + [BitAutoData] + [SutProviderCustomize] + [Theory] + public async Task Edit_Get_BillingLoadThrowsUnexpectedError_StillRendersPageWithErrorToast( + Organization organization, + SutProvider sutProvider) + { + // PM-38874: a billing-load failure that is NOT a missing Stripe customer (resource_missing) + // must fall through to the generic catch, which surfaces a neutral error toast rather than + // asserting the customer was deleted. + StubEditGetDependencies(sutProvider, organization, currentAssignment: null); + + sutProvider.GetDependency() + .GetBillingAsync(organization) + .ThrowsAsync(new StripeException { StripeError = new StripeError { Code = "api_error" } }); + + sutProvider.Sut.TempData = + new TempDataDictionary(new DefaultHttpContext(), Substitute.For()); + + var result = await sutProvider.Sut.Edit(organization.Id); + + var view = Assert.IsType(result); + var model = Assert.IsType(view.Model); + Assert.Null(model.BillingInfo); + Assert.Null(model.BillingHistoryInfo); + Assert.False(sutProvider.Sut.TempData.ContainsKey("Warning")); + Assert.True(sutProvider.Sut.TempData.ContainsKey("Error")); + Assert.Equal( + "Billing information could not be loaded. You can still edit the organization or try reloading the page. " + + "Contact support if the problem persists.", + (string)sutProvider.Sut.TempData["Error"]); + } + #endregion }