diff --git a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs index 1255ac76849f..03a98aa0d7f2 100644 --- a/src/Admin/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Admin/AdminConsole/Controllers/OrganizationsController.cs @@ -17,8 +17,10 @@ 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; using Bit.Core.Billing.Organizations.PlanMigration.Entities; using Bit.Core.Billing.Organizations.PlanMigration.Repositories; using Bit.Core.Billing.Organizations.PlanMigration.ValueObjects; @@ -37,6 +39,7 @@ using Bit.Core.Vault.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Stripe; namespace Bit.Admin.AdminConsole.Controllers; @@ -231,8 +234,35 @@ 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 (StripeException ex) when (ex.StripeError?.Code == StripeConstants.ErrorCodes.ResourceMissing) + { + billingInfo = null; + billingHistoryInfo = null; + _logger.LogError(ex, + "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/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml b/src/Admin/AdminConsole/Views/Organizations/Edit.cshtml index 690ee3d778c1..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) +@if (canViewBillingInformation && Model.BillingInfo != null && Model.BillingHistoryInfo != null) {

Billing Information

@await Html.PartialAsync("_BillingInformation", 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 319d9420b68b..2381654372ca 100644 --- a/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Admin.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -8,11 +8,14 @@ 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; 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 +27,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 +1915,111 @@ 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 + { + StripeError = new StripeError { Code = StripeConstants.ErrorCodes.ResourceMissing } + }); + + 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"]); + } + + [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 + { + StripeError = new StripeError { Code = StripeConstants.ErrorCodes.ResourceMissing } + }); + + 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"]); + } + + [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 }