From 5ac8293a55dad899933a3245b0232a5ae45b92a1 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 25 Feb 2026 08:25:18 -0600 Subject: [PATCH] fix(billing): return null subscription when resource_missing (#7068) --- .../Billing/Commands/BaseBillingCommand.cs | 2 +- src/Core/Billing/Constants/StripeConstants.cs | 20 ++++++----- .../Queries/GetBitwardenSubscriptionQuery.cs | 36 ++++++++++++++----- .../GetBitwardenSubscriptionQueryTests.cs | 24 +++++++++++++ 4 files changed, 63 insertions(+), 19 deletions(-) diff --git a/src/Core/Billing/Commands/BaseBillingCommand.cs b/src/Core/Billing/Commands/BaseBillingCommand.cs index b3e938548d..a0c10a9d37 100644 --- a/src/Core/Billing/Commands/BaseBillingCommand.cs +++ b/src/Core/Billing/Commands/BaseBillingCommand.cs @@ -31,7 +31,7 @@ public abstract class BaseBillingCommand( { return await function(); } - catch (StripeException stripeException) when (ErrorCodes.Get().Contains(stripeException.StripeError.Code)) + catch (StripeException stripeException) when (ErrorCodes.InputErrors().Contains(stripeException.StripeError.Code)) { return stripeException.StripeError.Code switch { diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 51eba8e1f6..a518e05901 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -1,6 +1,4 @@ -using System.Reflection; - -namespace Bit.Core.Billing.Constants; +namespace Bit.Core.Billing.Constants; public static class StripeConstants { @@ -51,14 +49,18 @@ public static class StripeConstants public const string PaymentMethodMicroDepositVerificationAttemptsExceeded = "payment_method_microdeposit_verification_attempts_exceeded"; public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch"; public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout"; + public const string ResourceMissing = "resource_missing"; public const string TaxIdInvalid = "tax_id_invalid"; - public static string[] Get() => - typeof(ErrorCodes) - .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) - .Where(fi => fi is { IsLiteral: true, IsInitOnly: false } && fi.FieldType == typeof(string)) - .Select(fi => (string)fi.GetValue(null)!) - .ToArray(); + public static string[] InputErrors() => + [ + CustomerTaxLocationInvalid, + InvoiceUpcomingNone, + PaymentMethodMicroDepositVerificationAttemptsExceeded, + PaymentMethodMicroDepositVerificationDescriptorCodeMismatch, + PaymentMethodMicroDepositVerificationTimeout, + TaxIdInvalid + ]; } public static class InvoiceStatus diff --git a/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs b/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs index f1ebcfb986..739e9f86b0 100644 --- a/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs +++ b/src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs @@ -46,16 +46,12 @@ public class GetBitwardenSubscriptionQuery( return null; } - var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions + var subscription = await FetchSubscriptionAsync(user); + + if (subscription == null) { - Expand = - [ - "customer.discount.coupon.applies_to", - "discounts.coupon.applies_to", - "items.data.price.product", - "test_clock" - ] - }); + return null; + } var cart = await GetPremiumCartAsync(subscription); @@ -248,5 +244,27 @@ public class GetBitwardenSubscriptionQuery( return (cartLevel.FirstOrDefault(), productLevel); } + private async Task FetchSubscriptionAsync(User user) + { + try + { + return await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions + { + Expand = + [ + "customer.discount.coupon.applies_to", + "discounts.coupon.applies_to", + "items.data.price.product", + "test_clock" + ] + }); + } + catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.ResourceMissing) + { + logger.LogError("Subscription ({SubscriptionID}) for User ({UserID}) was not found", user.GatewaySubscriptionId, user.Id); + return null; + } + } + #endregion } diff --git a/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs b/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs index 643941da33..2fbaf058d7 100644 --- a/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs +++ b/test/Core.Test/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQueryTests.cs @@ -55,6 +55,30 @@ public class GetBitwardenSubscriptionQueryTests await _stripeAdapter.DidNotReceive().GetSubscriptionAsync(Arg.Any(), Arg.Any()); } + [Fact] + public async Task Run_StripeSubscriptionNotFound_ReturnsNull() + { + var user = CreateUser(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .ThrowsAsync(new StripeException { StripeError = new StripeError { Code = ErrorCodes.ResourceMissing } }); + + var result = await _query.Run(user); + + Assert.Null(result); + } + + [Fact] + public async Task Run_StripeExceptionNotResourceMissing_Throws() + { + var user = CreateUser(); + + _stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any()) + .ThrowsAsync(new StripeException { StripeError = new StripeError { Code = "api_error" } }); + + await Assert.ThrowsAsync(() => _query.Run(user)); + } + [Fact] public async Task Run_IncompleteStatus_ReturnsBitwardenSubscriptionWithSuspension() {