From e03233e5a1f59005f40e388f1c438e4041168e11 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 18 Mar 2026 12:17:12 -0400 Subject: [PATCH 1/7] feat(stripe): add checkout session constants and settings --- src/Core/Billing/Constants/StripeConstants.cs | 26 +++++++++++++++++++ src/Core/Settings/GlobalSettings.cs | 2 ++ src/Core/Settings/IGlobalSettings.cs | 1 + 3 files changed, 29 insertions(+) diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index a3e314198fb9..80b1c8844095 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -1,4 +1,5 @@ using Bit.Core.Billing.Enums; +using Stripe; namespace Bit.Core.Billing.Constants; @@ -90,6 +91,8 @@ public static class MetadataKeys public const string RetiredBraintreeCustomerId = "btCustomerId_old"; public const string UserId = "userId"; public const string StorageReconciled2025 = "storage_reconciled_2025"; + public const string OriginatingPlatform = "originatingPlatform"; + public const string OriginatingAppVersion = "originatingAppVersion"; } public static class PaymentBehavior @@ -191,5 +194,28 @@ public static class ProductIDs }; } + public static class CheckoutSession + { + public static class Modes + { + public const string Subscription = "subscription"; + public const string Payment = "payment"; + public const string Setup = "setup"; + } + + // https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-customer_update-address + // Determines whether the customer's address should be updated during checkout session or not. + public static class CustomerUpdateAddressOptions + { + public const string Auto = "auto"; + public const string Never = "never"; + } + + public static class Platforms + { + public const string Ios = "ios"; + public const string Android = "android"; + } + } } diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 1eb7d28b9cf7..14f10438af00 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -706,6 +706,8 @@ public class StripeSettings { public string ApiKey { get; set; } public int MaxNetworkRetries { get; set; } = 2; + public string PremiumCheckoutSuccessUrl { get; set; } + public string PremiumCheckoutCancelUrl { get; set; } } public class DistributedIpRateLimitingSettings diff --git a/src/Core/Settings/IGlobalSettings.cs b/src/Core/Settings/IGlobalSettings.cs index 8c93d0156bf6..d0bb1b7f44e4 100644 --- a/src/Core/Settings/IGlobalSettings.cs +++ b/src/Core/Settings/IGlobalSettings.cs @@ -26,6 +26,7 @@ public interface IGlobalSettings ILaunchDarklySettings LaunchDarkly { get; set; } string DatabaseProvider { get; set; } GlobalSettings.SqlSettings SqlServer { get; set; } + GlobalSettings.StripeSettings Stripe { get; set; } string DevelopmentDirectory { get; set; } IWebPushSettings WebPush { get; set; } GlobalSettings.EventLoggingSettings EventLogging { get; set; } From 6c409e89bbd3162b1483e9796f26b811a20b1f49 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 18 Mar 2026 12:17:12 -0400 Subject: [PATCH 2/7] feat(billing): integrate Stripe Checkout Session adapter --- src/Core/Billing/Services/IStripeAdapter.cs | 2 ++ .../Services/Implementations/StripeAdapter.cs | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Core/Billing/Services/IStripeAdapter.cs b/src/Core/Billing/Services/IStripeAdapter.cs index d7d14432caf9..ab7f982e47cd 100644 --- a/src/Core/Billing/Services/IStripeAdapter.cs +++ b/src/Core/Billing/Services/IStripeAdapter.cs @@ -3,6 +3,7 @@ using Bit.Core.Models.BitStripe; using Stripe; +using Stripe.Checkout; using Stripe.Tax; namespace Bit.Core.Billing.Services; @@ -52,4 +53,5 @@ Task CreateCustomerBalanceTransactionAsync(string cu Task GetCouponAsync(string couponId, CouponGetOptions options = null); Task> ListProductsAsync(ProductListOptions options = null); Task> ListSubscriptionsAsync(SubscriptionListOptions options = null); + Task CreateCheckoutSessionAsync(SessionCreateOptions options); } diff --git a/src/Core/Billing/Services/Implementations/StripeAdapter.cs b/src/Core/Billing/Services/Implementations/StripeAdapter.cs index 5672c6ca4d0f..157866022370 100644 --- a/src/Core/Billing/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Billing/Services/Implementations/StripeAdapter.cs @@ -4,10 +4,12 @@ using Bit.Core.Models.BitStripe; using Stripe; +using Stripe.Checkout; using Stripe.Tax; using Stripe.TestHelpers; using CustomerService = Stripe.CustomerService; using RefundService = Stripe.RefundService; +using SessionService = Stripe.Checkout.SessionService; namespace Bit.Core.Billing.Services.Implementations; @@ -29,6 +31,7 @@ public class StripeAdapter : IStripeAdapter private readonly RegistrationService _taxRegistrationService; private readonly CouponService _couponService; private readonly ProductService _productService; + private readonly SessionService _checkoutSessionsService; public StripeAdapter() { @@ -48,6 +51,7 @@ public StripeAdapter() _taxRegistrationService = new RegistrationService(); _couponService = new CouponService(); _productService = new ProductService(); + _checkoutSessionsService = new SessionService(); } /************** @@ -95,6 +99,9 @@ public Task UpdateSubscriptionAsync(string id, public Task CancelSubscriptionAsync(string id, SubscriptionCancelOptions options = null) => _subscriptionService.CancelAsync(id, options); + public Task> ListSubscriptionsAsync(SubscriptionListOptions options = null) => + _subscriptionService.ListAsync(options); + /************* ** INVOICE ** *************/ @@ -229,9 +236,9 @@ public Task GetCouponAsync(string couponId, CouponGetOptions options = n public async Task> ListProductsAsync(ProductListOptions options = null) => (await _productService.ListAsync(options)).Data; - /**************** - ** SUBSCRIPTION ** - ****************/ - public Task> ListSubscriptionsAsync(SubscriptionListOptions options = null) => - _subscriptionService.ListAsync(options); + /*********************** + ** CHECKOUT SESSION ** + ***********************/ + public Task CreateCheckoutSessionAsync(SessionCreateOptions options) => + _checkoutSessionsService.CreateAsync(options); } From c5b7ee9cd421aec4c771c7ad71ab08737db7d86c Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 18 Mar 2026 12:17:12 -0400 Subject: [PATCH 3/7] feat(billing): define premium checkout session DTOs --- .../CreatePremiumCheckoutSessionRequest.cs | 21 +++++++++++++++++++ .../PremiumCheckoutSessionResponseModel.cs | 4 ++++ 2 files changed, 25 insertions(+) create mode 100644 src/Api/Billing/Models/Requests/Premium/CreatePremiumCheckoutSessionRequest.cs create mode 100644 src/Core/Billing/Models/Api/Response/Premium/PremiumCheckoutSessionResponseModel.cs diff --git a/src/Api/Billing/Models/Requests/Premium/CreatePremiumCheckoutSessionRequest.cs b/src/Api/Billing/Models/Requests/Premium/CreatePremiumCheckoutSessionRequest.cs new file mode 100644 index 000000000000..bf8b491cd7e8 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Premium/CreatePremiumCheckoutSessionRequest.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Constants; + +namespace Bit.Api.Billing.Models.Requests.Premium; + +public class CreatePremiumCheckoutSessionRequest : IValidatableObject +{ + [Required] + public required string Platform { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Platform is not (StripeConstants.CheckoutSession.Platforms.Ios + or StripeConstants.CheckoutSession.Platforms.Android)) + { + yield return new ValidationResult( + $"Platform must be '{StripeConstants.CheckoutSession.Platforms.Ios}' or '{StripeConstants.CheckoutSession.Platforms.Android}'.", + [nameof(Platform)]); + } + } +} diff --git a/src/Core/Billing/Models/Api/Response/Premium/PremiumCheckoutSessionResponseModel.cs b/src/Core/Billing/Models/Api/Response/Premium/PremiumCheckoutSessionResponseModel.cs new file mode 100644 index 000000000000..f86390bce6ec --- /dev/null +++ b/src/Core/Billing/Models/Api/Response/Premium/PremiumCheckoutSessionResponseModel.cs @@ -0,0 +1,4 @@ + +namespace Bit.Core.Billing.Models.Api.Response.Premium; + +public record PremiumCheckoutSessionResponseModel(string CheckoutSessionUrl); From b18e242b2fb1f491310f326b856be7bbd4126d83 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 18 Mar 2026 12:17:12 -0400 Subject: [PATCH 4/7] feat(billing): implement CreatePremiumCheckoutSessionCommand --- src/Core/Billing/Payment/Registrations.cs | 2 + .../CreatePremiumCheckoutSessionCommand.cs | 141 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 src/Core/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommand.cs diff --git a/src/Core/Billing/Payment/Registrations.cs b/src/Core/Billing/Payment/Registrations.cs index 1e37ac1e8119..57fb4cdc3893 100644 --- a/src/Core/Billing/Payment/Registrations.cs +++ b/src/Core/Billing/Payment/Registrations.cs @@ -1,6 +1,7 @@ using Bit.Core.Billing.Payment.Clients; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.DiscountAudienceFilters; using Bit.Core.Billing.Services.Implementations; @@ -17,6 +18,7 @@ public static void AddPaymentOperations(this IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Discount services services.AddScoped(); diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommand.cs new file mode 100644 index 000000000000..b40934afe165 --- /dev/null +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommand.cs @@ -0,0 +1,141 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models.Api.Response.Premium; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Services; +using Bit.Core.Settings; +using Microsoft.Extensions.Logging; +using Stripe; +using Stripe.Checkout; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; + +namespace Bit.Core.Billing.Premium.Commands; + +/// +/// Creates a Stripe Checkout Session for a user to purchase a premium subscription. +/// +public interface ICreatePremiumCheckoutSessionCommand +{ + /// + /// Creates a Stripe Checkout Session for a user to purchase a premium subscription. + /// + /// The user for whom the Checkout Session is being created. + /// The version of the application initiating the Checkout Session. + /// The platform (e.g., ios, android) from which the Checkout Session is initiated. + /// The url of the created Checkout Session. + Task> Run(User user, string originatingAppVersion, string originatingPlatform); +} + +public class CreatePremiumCheckoutSessionCommand( + IStripeAdapter stripeAdapter, + IPricingClient pricingClient, + ISubscriberService subscriberService, + IUserService userService, + IGlobalSettings globalSettings, + ILogger logger +) : BaseBillingCommand(logger), ICreatePremiumCheckoutSessionCommand +{ + private readonly IStripeAdapter stripeAdapter = stripeAdapter; + private readonly IPricingClient pricingClient = pricingClient; + private readonly ISubscriberService subscriberService = subscriberService; + private readonly IUserService userService = userService; + private readonly IGlobalSettings globalSettings = globalSettings; + + public Task> + Run(User user, string originatingAppVersion, string originatingPlatform) => HandleAsync(async () => + { + if (user.Premium) + { + return new BadRequest("User is already a premium user."); + } + + // If the user doesn't have a Stripe customer ID, create one. + var customer = string.IsNullOrWhiteSpace(user.GatewayCustomerId) + ? await CreateCustomerAsync(user) + : await subscriberService.GetCustomerOrThrow(user); + + var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); + + var sessionOptions = CreateSessionOptions( + user, + customer, + premiumPlan, + originatingAppVersion, + originatingPlatform); + + var session = await stripeAdapter.CreateCheckoutSessionAsync(sessionOptions); + + return new PremiumCheckoutSessionResponseModel(session.Url); + }); + + /// + /// Creates a Stripe customer for the user. + /// + /// The user for whom the Stripe customer is being created. + /// The created Stripe customer for the user. + private async Task CreateCustomerAsync(User user) + { + var customerCreateOptions = new CustomerCreateOptions + { + Description = user.Name, + Email = user.Email, + Metadata = new Dictionary() + { + [StripeConstants.MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion, + } + }; + + var customer = await stripeAdapter.CreateCustomerAsync(customerCreateOptions); + + user.GatewayCustomerId = customer.Id; + await userService.SaveUserAsync(user); + + return customer; + } + + /// + /// Creates the options for creating a Stripe Checkout Session. + /// + /// The user for whom the Checkout Session is being created. + /// The Stripe customer associated with the user. + /// The premium plan for which the Checkout Session is being created. + /// The version of the application initiating the Checkout Session. + /// The platform (e.g., ios, android) from which the Checkout Session is initiated. + /// The created SessionCreateOptions for Stripe Checkout Session creation. + private SessionCreateOptions CreateSessionOptions( + User user, + Customer customer, + PremiumPlan premiumPlan, + string originatingAppVersion, + string originatingPlatform) + { + return new SessionCreateOptions + { + Customer = customer.Id, + CustomerUpdate = new SessionCustomerUpdateOptions + { + Address = StripeConstants.CheckoutSession.CustomerUpdateAddressOptions.Auto, + }, + Mode = StripeConstants.CheckoutSession.Modes.Subscription, + LineItems = + [ + new SessionLineItemOptions { Price = premiumPlan.Seat.StripePriceId, Quantity = 1 } + ], + SubscriptionData = new SessionSubscriptionDataOptions + { + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.UserId] = user.Id.ToString(), + [StripeConstants.MetadataKeys.OriginatingPlatform] = originatingPlatform, + [StripeConstants.MetadataKeys.OriginatingAppVersion] = originatingAppVersion, + } + }, + SuccessUrl = globalSettings.Stripe.PremiumCheckoutSuccessUrl, + CancelUrl = globalSettings.Stripe.PremiumCheckoutCancelUrl, + AutomaticTax = new SessionAutomaticTaxOptions { Enabled = true }, + PaymentMethodTypes = [StripeConstants.PaymentMethodTypes.Card], + }; + } +} From 2bbe6fc187d7a801fe3dc9e2704bcdb86b8b241b Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 18 Mar 2026 12:17:12 -0400 Subject: [PATCH 5/7] feat(billing): add premium checkout session API endpoint --- .../VNext/AccountBillingVNextController.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index 9facdd9b24de..885e9ade1923 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -22,6 +22,7 @@ namespace Bit.Api.Billing.Controllers.VNext; [SelfHosted(NotSelfHostedOnly = true)] public class AccountBillingVNextController( ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, + ICreatePremiumCheckoutSessionCommand createPremiumCheckoutSessionCommand, ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand, IGetBitwardenSubscriptionQuery getBitwardenSubscriptionQuery, IGetCreditQuery getCreditQuery, @@ -42,6 +43,22 @@ public async Task GetCreditAsync( return TypedResults.Ok(credit); } + [HttpPost("premium/checkout")] + [InjectUser] + public async Task CreatePremiumCheckoutSessionAsync( + [BindNever] User user, + [FromBody] CreatePremiumCheckoutSessionRequest request, + [FromHeader(Name = "Bitwarden-Client-Version")] string? appVersion) + { + if (string.IsNullOrWhiteSpace(appVersion)) + { + return Error.BadRequest("Bitwarden-Client-Version header is required."); + } + + var result = await createPremiumCheckoutSessionCommand.Run(user, appVersion, request.Platform); + return Handle(result); + } + [HttpPost("credit/bitpay")] [InjectUser] public async Task AddCreditViaBitPayAsync( From 0dd6fd6bc3cee8177aea612cb5831c4e7da68a46 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 18 Mar 2026 12:17:12 -0400 Subject: [PATCH 6/7] test(billing): add premium checkout session tests --- .../AccountBillingVNextControllerTests.cs | 83 ++++++++ ...reatePremiumCheckoutSessionCommandTests.cs | 200 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 test/Core.Test/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommandTests.cs diff --git a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs index 706b3ae21967..a69f7b0879f1 100644 --- a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs @@ -1,11 +1,15 @@ using Bit.Api.Billing.Controllers.VNext; +using Bit.Api.Billing.Models.Requests.Premium; using Bit.Api.Billing.Models.Requests.Storage; +using Bit.Api.Billing.Models.Responses; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Licenses.Queries; using Bit.Core.Billing.Models.Api.Response; +using Bit.Core.Billing.Models.Api.Response.Premium; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Premium.Models; using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Billing.Subscriptions.Queries; using Bit.Core.Entities; @@ -21,6 +25,7 @@ namespace Bit.Api.Test.Billing.Controllers.VNext; public class AccountBillingVNextControllerTests { + private readonly ICreatePremiumCheckoutSessionCommand _createPremiumCheckoutSessionCommand; private readonly IUpdatePremiumStorageCommand _updatePremiumStorageCommand; private readonly IGetUserLicenseQuery _getUserLicenseQuery; private readonly IUpgradePremiumToOrganizationCommand _upgradePremiumToOrganizationCommand; @@ -29,6 +34,7 @@ public class AccountBillingVNextControllerTests public AccountBillingVNextControllerTests() { + _createPremiumCheckoutSessionCommand = Substitute.For(); _updatePremiumStorageCommand = Substitute.For(); _getUserLicenseQuery = Substitute.For(); _upgradePremiumToOrganizationCommand = Substitute.For(); @@ -36,6 +42,7 @@ public AccountBillingVNextControllerTests() _sut = new AccountBillingVNextController( Substitute.For(), + _createPremiumCheckoutSessionCommand, Substitute.For(), Substitute.For(), Substitute.For(), @@ -257,6 +264,82 @@ public async Task UpdateStorageAsync_NullPaymentSecret_Success(User user) await _updatePremiumStorageCommand.Received(1).Run(user, 5); } + [Theory, BitAutoData] + public async Task CreatePremiumCheckoutSessionAsync_MissingAppVersionHeader_ReturnsBadRequest( + User user, + CreatePremiumCheckoutSessionRequest request) + { + // Act + var result = await _sut.CreatePremiumCheckoutSessionAsync(user, request, null); + + // Assert + Assert.IsType>(result); + await _createPremiumCheckoutSessionCommand.DidNotReceive().Run(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreatePremiumCheckoutSessionAsync_ReturnsOk( + User user, + CreatePremiumCheckoutSessionRequest request) + { + // Arrange + var appVersion = "2024.1.0"; + var response = new PremiumCheckoutSessionResponseModel("https://checkout.stripe.com/c/pay/cs_123"); + + _createPremiumCheckoutSessionCommand + .Run(user, appVersion, request.Platform) + .Returns(new BillingCommandResult(response)); + + // Act + var result = await _sut.CreatePremiumCheckoutSessionAsync(user, request, appVersion); + + // Assert + var okResult = Assert.IsType>(result); + Assert.Equal(response.CheckoutSessionUrl, okResult.Value!.CheckoutSessionUrl); + await _createPremiumCheckoutSessionCommand.Received(1).Run(user, appVersion, request.Platform); + } + + [Theory, BitAutoData] + public async Task CreatePremiumCheckoutSessionAsync_UserIsPremium_ReturnsBadRequest( + User user, + CreatePremiumCheckoutSessionRequest request) + { + // Arrange + var appVersion = "2024.1.0"; + var errorMessage = "User is already a premium user."; + + _createPremiumCheckoutSessionCommand + .Run(user, appVersion, request.Platform) + .Returns(new BadRequest(errorMessage)); + + // Act + var result = await _sut.CreatePremiumCheckoutSessionAsync(user, request, appVersion); + + // Assert + Assert.IsType>(result); + await _createPremiumCheckoutSessionCommand.Received(1).Run(user, appVersion, request.Platform); + } + + [Theory, BitAutoData] + public async Task CreatePremiumCheckoutSessionAsync_ReturnsServerError( + User user, + CreatePremiumCheckoutSessionRequest request) + { + // Arrange + var appVersion = "2024.1.0"; + + _createPremiumCheckoutSessionCommand + .Run(user, appVersion, request.Platform) + .Returns(new Unhandled()); + + // Act + var result = await _sut.CreatePremiumCheckoutSessionAsync(user, request, appVersion); + + // Assert + Assert.IsType>(result); + await _createPremiumCheckoutSessionCommand.Received(1).Run(user, appVersion, request.Platform); + } + [Theory, BitAutoData] public async Task GetApplicableDiscountsAsync_NoEligibleDiscounts_ReturnsOkWithEmptyArray(User user) { diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommandTests.cs new file mode 100644 index 000000000000..a752b8cc6c67 --- /dev/null +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommandTests.cs @@ -0,0 +1,200 @@ +using Bit.Core.Billing; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Stripe; +using Stripe.Checkout; +using Xunit; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; +using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable; + + +namespace Bit.Core.Test.Billing.Premium.Commands; + +public class CreatePremiumCheckoutSessionCommandTests +{ + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly ISubscriberService _subscriberService = Substitute.For(); + private readonly IUserService _userService= Substitute.For(); + private readonly IGlobalSettings _globalSettings = Substitute.For(); + private readonly ILogger _logger = Substitute.For>(); + private readonly ICreatePremiumCheckoutSessionCommand _command; + + private const string _successUrl = "success/url"; + private const string _cancelUrl = "cancel/url"; + + public CreatePremiumCheckoutSessionCommandTests() + { + var stripeSettings = new GlobalSettings.StripeSettings + { + PremiumCheckoutSuccessUrl = _successUrl, + PremiumCheckoutCancelUrl = _cancelUrl + }; + _globalSettings.Stripe.Returns(stripeSettings); + + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new PremiumPurchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually }, + Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal } + }; + + _pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan); + + _command = new CreatePremiumCheckoutSessionCommand( + _stripeAdapter, + _pricingClient, + _subscriberService, + _userService, + _globalSettings, + _logger); + } + + [Theory] + [BitAutoData] + public async Task Run_UserNotPremium_UserDoesNotHaveExistingStripeCustomer_ReturnsCheckoutSessionUrl(User user) + { + // Arrange + user.Premium = false; + user.Name = "Test User"; + user.Email = "test@example.com"; + user.GatewayCustomerId = null; + const string appVersion = "1.0.0"; + const string platform = "iOS"; + + const string checkoutSessionUrl = "https://checkout.stripe.com/session/123"; + _stripeAdapter.CreateCheckoutSessionAsync(Arg.Any()).Returns(new Session { Url = checkoutSessionUrl }); + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(new Customer { Id = "cus_123" }); + + // Act + var result = await _command.Run(user, appVersion, platform); + + // Assert + Assert.True(result.Success); + Assert.Equal(checkoutSessionUrl, result.AsT0.CheckoutSessionUrl); + await _stripeAdapter.Received(1).CreateCheckoutSessionAsync(Arg.Is(options => + options.Customer == user.GatewayCustomerId + && options.Mode == StripeConstants.CheckoutSession.Modes.Subscription + && options.LineItems[0].Price == StripeConstants.Prices.PremiumAnnually + && options.LineItems[0].Quantity == 1 + && options.AutomaticTax.Enabled == true + && options.SuccessUrl == _successUrl + && options.CancelUrl == _cancelUrl + && options.SubscriptionData.Metadata[StripeConstants.MetadataKeys.UserId] == user.Id.ToString() + && options.SubscriptionData.Metadata[StripeConstants.MetadataKeys.OriginatingAppVersion] == appVersion + && options.SubscriptionData.Metadata[StripeConstants.MetadataKeys.OriginatingPlatform] == platform)); + await _stripeAdapter.Received(1).CreateCustomerAsync(Arg.Is(options => + options.Email == user.Email + && options.Description == user.Name + && options.Metadata[StripeConstants.MetadataKeys.Region] == _globalSettings.BaseServiceUri.CloudRegion)); + await _userService.Received(1).SaveUserAsync(Arg.Is(savedUser => + savedUser.GatewayCustomerId == "cus_123")); + } + + [Theory] + [BitAutoData] + public async Task Run_UserNotPremium_UserHasExistingStripeCustomer_ReturnsCheckoutSessionUrl(User user) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = "cus_existing"; + const string appVersion = "2.0.0"; + const string platform = "Android"; + + var existingCustomer = new Customer { Id = "cus_existing" }; + _subscriberService.GetCustomerOrThrow(user).Returns(existingCustomer); + + const string checkoutSessionUrl = "https://checkout.stripe.com/session/456"; + _stripeAdapter.CreateCheckoutSessionAsync(Arg.Any()).Returns(new Session { Url = checkoutSessionUrl }); + + // Act + var result = await _command.Run(user, appVersion, platform); + + // Assert + Assert.True(result.Success); + Assert.Equal(checkoutSessionUrl, result.AsT0.CheckoutSessionUrl); + await _stripeAdapter.DidNotReceive().CreateCustomerAsync(Arg.Any()); + await _userService.DidNotReceive().SaveUserAsync(Arg.Any()); + await _stripeAdapter.Received(1).CreateCheckoutSessionAsync(Arg.Is(options => + options.Customer == existingCustomer.Id + && options.Mode == StripeConstants.CheckoutSession.Modes.Subscription + && options.LineItems[0].Price == StripeConstants.Prices.PremiumAnnually + && options.LineItems[0].Quantity == 1 + && options.AutomaticTax.Enabled == true + && options.SuccessUrl == _successUrl + && options.CancelUrl == _cancelUrl + && options.SubscriptionData.Metadata[StripeConstants.MetadataKeys.UserId] == user.Id.ToString() + && options.SubscriptionData.Metadata[StripeConstants.MetadataKeys.OriginatingAppVersion] == appVersion + && options.SubscriptionData.Metadata[StripeConstants.MetadataKeys.OriginatingPlatform] == platform)); + } + + [Theory] + [BitAutoData] + public async Task Run_UserIsPremium_ReturnsBadRequest(User user) + { + // Arrange + user.Premium = true; + + // Act + var result = await _command.Run(user, "1.0.0", "iOS"); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("User is already a premium user.", badRequest.Response); + await _stripeAdapter.DidNotReceive().CreateCustomerAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().CreateCheckoutSessionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task Run_GetCustomerOrThrowThrows_ReturnsUnhandled(User user) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = "cus_existing"; + + _subscriberService.GetCustomerOrThrow(user).ThrowsAsync(new BillingException()); + + // Act + var result = await _command.Run(user, "1.0.0", "iOS"); + + // Assert + Assert.True(result.IsT3); + Assert.IsType(result.AsT3.Exception); + await _stripeAdapter.DidNotReceive().CreateCheckoutSessionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task Run_GetAvailablePremiumPlanThrows_ReturnsUnhandled(User user) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + + _stripeAdapter.CreateCustomerAsync(Arg.Any()).Returns(new Customer { Id = "cus_123" }); + _pricingClient.GetAvailablePremiumPlan().ThrowsAsync(); + + // Act + var result = await _command.Run(user, "1.0.0", "iOS"); + + // Assert + Assert.True(result.IsT3); // UnhandledException + Assert.IsType(result.AsT3.Exception); + await _stripeAdapter.DidNotReceive().CreateCheckoutSessionAsync(Arg.Any()); + } + +} From 0986f388a34e056f48f1e71050cda6f828b78104 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 18 Mar 2026 12:35:10 -0400 Subject: [PATCH 7/7] fix(billing): run dotnet format --- .../Requests/Premium/CreatePremiumCheckoutSessionRequest.cs | 2 +- src/Core/Billing/Constants/StripeConstants.cs | 1 - .../Api/Response/Premium/PremiumCheckoutSessionResponseModel.cs | 2 +- .../Controllers/VNext/AccountBillingVNextControllerTests.cs | 2 -- .../Commands/CreatePremiumCheckoutSessionCommandTests.cs | 2 +- 5 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Api/Billing/Models/Requests/Premium/CreatePremiumCheckoutSessionRequest.cs b/src/Api/Billing/Models/Requests/Premium/CreatePremiumCheckoutSessionRequest.cs index bf8b491cd7e8..30fcd05a495d 100644 --- a/src/Api/Billing/Models/Requests/Premium/CreatePremiumCheckoutSessionRequest.cs +++ b/src/Api/Billing/Models/Requests/Premium/CreatePremiumCheckoutSessionRequest.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Constants; namespace Bit.Api.Billing.Models.Requests.Premium; diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 80b1c8844095..60b877cb3bb3 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -1,5 +1,4 @@ using Bit.Core.Billing.Enums; -using Stripe; namespace Bit.Core.Billing.Constants; diff --git a/src/Core/Billing/Models/Api/Response/Premium/PremiumCheckoutSessionResponseModel.cs b/src/Core/Billing/Models/Api/Response/Premium/PremiumCheckoutSessionResponseModel.cs index f86390bce6ec..9a5437a42972 100644 --- a/src/Core/Billing/Models/Api/Response/Premium/PremiumCheckoutSessionResponseModel.cs +++ b/src/Core/Billing/Models/Api/Response/Premium/PremiumCheckoutSessionResponseModel.cs @@ -1,4 +1,4 @@ - + namespace Bit.Core.Billing.Models.Api.Response.Premium; public record PremiumCheckoutSessionResponseModel(string CheckoutSessionUrl); diff --git a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs index a69f7b0879f1..97560ab8c6bc 100644 --- a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs @@ -1,7 +1,6 @@ using Bit.Api.Billing.Controllers.VNext; using Bit.Api.Billing.Models.Requests.Premium; using Bit.Api.Billing.Models.Requests.Storage; -using Bit.Api.Billing.Models.Responses; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Licenses.Queries; using Bit.Core.Billing.Models.Api.Response; @@ -9,7 +8,6 @@ using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Premium.Commands; -using Bit.Core.Billing.Premium.Models; using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Billing.Subscriptions.Queries; using Bit.Core.Entities; diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommandTests.cs index a752b8cc6c67..00306cb4c2e2 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommandTests.cs @@ -25,7 +25,7 @@ public class CreatePremiumCheckoutSessionCommandTests private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly IPricingClient _pricingClient = Substitute.For(); private readonly ISubscriberService _subscriberService = Substitute.For(); - private readonly IUserService _userService= Substitute.For(); + private readonly IUserService _userService = Substitute.For(); private readonly IGlobalSettings _globalSettings = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); private readonly ICreatePremiumCheckoutSessionCommand _command;