From e53a7a35ba63d1436e79c43ef44b5144eb75bb3c Mon Sep 17 00:00:00 2001 From: Cy Okeke Date: Mon, 27 Apr 2026 14:29:53 +0100 Subject: [PATCH 1/2] Implementation desktop and browser checkout --- .../CreatePremiumCheckoutSessionRequest.cs | 9 +- src/Core/Billing/Constants/StripeConstants.cs | 2 + .../CreatePremiumCheckoutSessionCommand.cs | 26 +++- src/Core/Settings/GlobalSettings.cs | 4 + ...reatePremiumCheckoutSessionRequestTests.cs | 42 +++++++ ...reatePremiumCheckoutSessionCommandTests.cs | 113 ++++++++++++++++-- 6 files changed, 183 insertions(+), 13 deletions(-) create mode 100644 test/Api.Test/Billing/Models/Requests/CreatePremiumCheckoutSessionRequestTests.cs diff --git a/src/Api/Billing/Models/Requests/Premium/CreatePremiumCheckoutSessionRequest.cs b/src/Api/Billing/Models/Requests/Premium/CreatePremiumCheckoutSessionRequest.cs index 30fcd05a495d..e618a790faa7 100644 --- a/src/Api/Billing/Models/Requests/Premium/CreatePremiumCheckoutSessionRequest.cs +++ b/src/Api/Billing/Models/Requests/Premium/CreatePremiumCheckoutSessionRequest.cs @@ -11,10 +11,15 @@ public class CreatePremiumCheckoutSessionRequest : IValidatableObject public IEnumerable Validate(ValidationContext validationContext) { if (Platform is not (StripeConstants.CheckoutSession.Platforms.Ios - or StripeConstants.CheckoutSession.Platforms.Android)) + or StripeConstants.CheckoutSession.Platforms.Android + or StripeConstants.CheckoutSession.Platforms.Browser + or StripeConstants.CheckoutSession.Platforms.Desktop)) { yield return new ValidationResult( - $"Platform must be '{StripeConstants.CheckoutSession.Platforms.Ios}' or '{StripeConstants.CheckoutSession.Platforms.Android}'.", + $"Platform must be '{StripeConstants.CheckoutSession.Platforms.Ios}', " + + $"'{StripeConstants.CheckoutSession.Platforms.Android}', " + + $"'{StripeConstants.CheckoutSession.Platforms.Browser}', " + + $"or '{StripeConstants.CheckoutSession.Platforms.Desktop}'.", [nameof(Platform)]); } } diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 81dd310d866a..762634b9621f 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -232,6 +232,8 @@ public static class Platforms { public const string Ios = "ios"; public const string Android = "android"; + public const string Browser = "browser"; + public const string Desktop = "desktop"; } } diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommand.cs index 7c271fbca5e0..20a514dd2257 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommand.cs @@ -22,7 +22,7 @@ public interface ICreatePremiumCheckoutSessionCommand /// /// 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 platform (e.g., ios, android, browser, desktop) from which the Checkout Session is initiated. /// The url of the created Checkout Session. Task> Run(User user, string originatingAppVersion, string originatingPlatform); } @@ -73,7 +73,7 @@ public Task> /// 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 platform (e.g., ios, android, browser, desktop) from which the Checkout Session is initiated. /// The created SessionCreateOptions for Stripe Checkout Session creation. private SessionCreateOptions CreateSessionOptions( User user, @@ -82,6 +82,8 @@ private SessionCreateOptions CreateSessionOptions( string originatingAppVersion, string originatingPlatform) { + var (successUrl, cancelUrl) = GetCheckoutUrls(originatingPlatform); + return new SessionCreateOptions { Customer = customer.Id, @@ -103,10 +105,26 @@ private SessionCreateOptions CreateSessionOptions( [StripeConstants.MetadataKeys.OriginatingAppVersion] = originatingAppVersion, } }, - SuccessUrl = globalSettings.Stripe.PremiumCheckoutSuccessUrl, - CancelUrl = globalSettings.Stripe.PremiumCheckoutCancelUrl, + SuccessUrl = successUrl, + CancelUrl = cancelUrl, AutomaticTax = new SessionAutomaticTaxOptions { Enabled = true }, PaymentMethodTypes = [StripeConstants.PaymentMethodTypes.Card] }; } + + private (string successUrl, string cancelUrl) GetCheckoutUrls(string platform) => + platform switch + { + StripeConstants.CheckoutSession.Platforms.Ios or + StripeConstants.CheckoutSession.Platforms.Android => + (globalSettings.Stripe.PremiumCheckoutSuccessUrl, + globalSettings.Stripe.PremiumCheckoutCancelUrl), + StripeConstants.CheckoutSession.Platforms.Browser => + (globalSettings.Stripe.BrowserPremiumCheckoutSuccessUrl, + globalSettings.Stripe.BrowserPremiumCheckoutCancelUrl), + StripeConstants.CheckoutSession.Platforms.Desktop => + (globalSettings.Stripe.DesktopPremiumCheckoutSuccessUrl, + globalSettings.Stripe.DesktopPremiumCheckoutCancelUrl), + _ => throw new InvalidOperationException($"Unsupported platform: {platform}") + }; } diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 14f10438af00..a385315f251f 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -708,6 +708,10 @@ public class StripeSettings public int MaxNetworkRetries { get; set; } = 2; public string PremiumCheckoutSuccessUrl { get; set; } public string PremiumCheckoutCancelUrl { get; set; } + public string BrowserPremiumCheckoutSuccessUrl { get; set; } + public string BrowserPremiumCheckoutCancelUrl { get; set; } + public string DesktopPremiumCheckoutSuccessUrl { get; set; } + public string DesktopPremiumCheckoutCancelUrl { get; set; } } public class DistributedIpRateLimitingSettings diff --git a/test/Api.Test/Billing/Models/Requests/CreatePremiumCheckoutSessionRequestTests.cs b/test/Api.Test/Billing/Models/Requests/CreatePremiumCheckoutSessionRequestTests.cs new file mode 100644 index 000000000000..bf74b0b1f782 --- /dev/null +++ b/test/Api.Test/Billing/Models/Requests/CreatePremiumCheckoutSessionRequestTests.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Premium; +using Xunit; + +namespace Bit.Api.Test.Billing.Models.Requests; + +public class CreatePremiumCheckoutSessionRequestTests +{ + [Theory] + [InlineData("ios")] + [InlineData("android")] + [InlineData("browser")] + [InlineData("desktop")] + public void Validate_SupportedPlatform_ReturnsNoErrors(string platform) + { + // Arrange + var sut = new CreatePremiumCheckoutSessionRequest { Platform = platform }; + + // Act + var results = sut.Validate(new ValidationContext(sut)).ToList(); + + // Assert + Assert.Empty(results); + } + + [Theory] + [InlineData("web")] + [InlineData("unknown")] + [InlineData("")] + public void Validate_UnsupportedPlatform_ReturnsValidationError(string platform) + { + // Arrange + var sut = new CreatePremiumCheckoutSessionRequest { Platform = platform }; + + // Act + var results = sut.Validate(new ValidationContext(sut)).ToList(); + + // Assert + Assert.Single(results); + Assert.Contains(nameof(CreatePremiumCheckoutSessionRequest.Platform), results[0].MemberNames); + } +} diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommandTests.cs index aa5cd7d20554..01deabe170bc 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCheckoutSessionCommandTests.cs @@ -30,13 +30,21 @@ public class CreatePremiumCheckoutSessionCommandTests private const string _successUrl = "success/url"; private const string _cancelUrl = "cancel/url"; + private const string _browserSuccessUrl = "browser/success/url"; + private const string _browserCancelUrl = "browser/cancel/url"; + private const string _desktopSuccessUrl = "desktop/success/url"; + private const string _desktopCancelUrl = "desktop/cancel/url"; public CreatePremiumCheckoutSessionCommandTests() { var stripeSettings = new GlobalSettings.StripeSettings { PremiumCheckoutSuccessUrl = _successUrl, - PremiumCheckoutCancelUrl = _cancelUrl + PremiumCheckoutCancelUrl = _cancelUrl, + BrowserPremiumCheckoutSuccessUrl = _browserSuccessUrl, + BrowserPremiumCheckoutCancelUrl = _browserCancelUrl, + DesktopPremiumCheckoutSuccessUrl = _desktopSuccessUrl, + DesktopPremiumCheckoutCancelUrl = _desktopCancelUrl }; _globalSettings.Stripe.Returns(stripeSettings); @@ -67,7 +75,7 @@ public async Task Run_UserNotPremium_UserDoesNotHaveExistingStripeCustomer_Retur user.Premium = false; user.GatewayCustomerId = null; const string appVersion = "1.0.0"; - const string platform = "iOS"; + var platform = StripeConstants.CheckoutSession.Platforms.Ios; var newCustomer = new Customer { Id = "cus_123" }; _subscriberService.CreateStripeCustomer(user).Returns(newCustomer); @@ -104,7 +112,7 @@ public async Task Run_UserNotPremium_UserHasExistingStripeCustomer_ReturnsChecko user.Premium = false; user.GatewayCustomerId = "cus_existing"; const string appVersion = "2.0.0"; - const string platform = "Android"; + var platform = StripeConstants.CheckoutSession.Platforms.Android; var existingCustomer = new Customer { Id = "cus_existing" }; _subscriberService.GetCustomerOrThrow(user).Returns(existingCustomer); @@ -140,7 +148,7 @@ public async Task Run_UserIsPremium_ReturnsBadRequest(User user) user.Premium = true; // Act - var result = await _command.Run(user, "1.0.0", "iOS"); + var result = await _command.Run(user, "1.0.0", StripeConstants.CheckoutSession.Platforms.Ios); // Assert Assert.True(result.IsT1); @@ -161,7 +169,7 @@ public async Task Run_CreateStripeCustomerThrows_ReturnsUnhandled(User user) _subscriberService.CreateStripeCustomer(user).ThrowsAsync(new BillingException()); // Act - var result = await _command.Run(user, "1.0.0", "iOS"); + var result = await _command.Run(user, "1.0.0", StripeConstants.CheckoutSession.Platforms.Ios); // Assert Assert.True(result.IsT3); @@ -180,7 +188,7 @@ public async Task Run_GetCustomerOrThrowThrows_ReturnsUnhandled(User user) _subscriberService.GetCustomerOrThrow(user).ThrowsAsync(new BillingException()); // Act - var result = await _command.Run(user, "1.0.0", "iOS"); + var result = await _command.Run(user, "1.0.0", StripeConstants.CheckoutSession.Platforms.Ios); // Assert Assert.True(result.IsT3); @@ -200,7 +208,7 @@ public async Task Run_GetAvailablePremiumPlanThrows_ReturnsUnhandled(User user) _pricingClient.GetAvailablePremiumPlan().ThrowsAsync(); // Act - var result = await _command.Run(user, "1.0.0", "iOS"); + var result = await _command.Run(user, "1.0.0", StripeConstants.CheckoutSession.Platforms.Ios); // Assert Assert.True(result.IsT3); // UnhandledException @@ -208,4 +216,95 @@ public async Task Run_GetAvailablePremiumPlanThrows_ReturnsUnhandled(User user) await _stripeAdapter.DidNotReceive().CreateCheckoutSessionAsync(Arg.Any()); } + [Theory] + [BitAutoData] + public async Task Run_UserNotPremium_BrowserPlatform_UsesCorrectUrls(User user) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + const string appVersion = "1.0.0"; + var platform = StripeConstants.CheckoutSession.Platforms.Browser; + + var newCustomer = new Customer { Id = "cus_123" }; + _subscriberService.CreateStripeCustomer(user).Returns(newCustomer); + + const string checkoutSessionUrl = "https://checkout.stripe.com/session/789"; + _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.Received(1).CreateCheckoutSessionAsync(Arg.Is(options => + options.Customer == "cus_123" + && options.Mode == StripeConstants.CheckoutSession.Modes.Subscription + && options.LineItems[0].Price == StripeConstants.Prices.PremiumAnnually + && options.LineItems[0].Quantity == 1 + && options.AutomaticTax.Enabled == true + && options.SuccessUrl == _browserSuccessUrl + && options.CancelUrl == _browserCancelUrl + && options.PaymentMethodTypes.Contains(StripeConstants.PaymentMethodTypes.Card) + && 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_UserNotPremium_DesktopPlatform_UsesCorrectUrls(User user) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + const string appVersion = "1.0.0"; + var platform = StripeConstants.CheckoutSession.Platforms.Desktop; + + var newCustomer = new Customer { Id = "cus_123" }; + _subscriberService.CreateStripeCustomer(user).Returns(newCustomer); + + const string checkoutSessionUrl = "https://checkout.stripe.com/session/101"; + _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.Received(1).CreateCheckoutSessionAsync(Arg.Is(options => + options.Customer == "cus_123" + && options.Mode == StripeConstants.CheckoutSession.Modes.Subscription + && options.LineItems[0].Price == StripeConstants.Prices.PremiumAnnually + && options.LineItems[0].Quantity == 1 + && options.AutomaticTax.Enabled == true + && options.SuccessUrl == _desktopSuccessUrl + && options.CancelUrl == _desktopCancelUrl + && options.PaymentMethodTypes.Contains(StripeConstants.PaymentMethodTypes.Card) + && 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_UnsupportedPlatform_ReturnsUnhandledException(User user) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + + _subscriberService.CreateStripeCustomer(user).Returns(new Customer { Id = "cus_123" }); + + // Act + var result = await _command.Run(user, "1.0.0", "web"); + + // Assert + Assert.True(result.IsT3); + Assert.IsType(result.AsT3.Exception); + await _stripeAdapter.DidNotReceive().CreateCheckoutSessionAsync(Arg.Any()); + } + } From efa655c7981e43d97c114b42492cc034887097ef Mon Sep 17 00:00:00 2001 From: Cy Okeke Date: Mon, 27 Apr 2026 15:46:19 +0100 Subject: [PATCH 2/2] Fixed the failing test --- .../Models/Requests/CreatePremiumCheckoutSessionRequestTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Api.Test/Billing/Models/Requests/CreatePremiumCheckoutSessionRequestTests.cs b/test/Api.Test/Billing/Models/Requests/CreatePremiumCheckoutSessionRequestTests.cs index bf74b0b1f782..e03bb5e9de6a 100644 --- a/test/Api.Test/Billing/Models/Requests/CreatePremiumCheckoutSessionRequestTests.cs +++ b/test/Api.Test/Billing/Models/Requests/CreatePremiumCheckoutSessionRequestTests.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Api.Billing.Models.Requests.Premium; using Xunit;