Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@ public class CreatePremiumCheckoutSessionRequest : IValidatableObject
public IEnumerable<ValidationResult> 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)]);
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/Core/Billing/Constants/StripeConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,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";
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public interface ICreatePremiumCheckoutSessionCommand
/// </summary>
/// <param name="user"> The user for whom the Checkout Session is being created. </param>
/// <param name="originatingAppVersion"> The version of the application initiating the Checkout Session. </param>
/// <param name="originatingPlatform"> The platform (e.g., ios, android) from which the Checkout Session is initiated. </param>
/// <param name="originatingPlatform"> The platform (e.g., ios, android, browser, desktop) from which the Checkout Session is initiated. </param>
/// <returns> The url of the created Checkout Session. </returns>
Task<BillingCommandResult<PremiumCheckoutSessionResponseModel>> Run(User user, string originatingAppVersion, string originatingPlatform);
}
Expand Down Expand Up @@ -73,7 +73,7 @@ public Task<BillingCommandResult<PremiumCheckoutSessionResponseModel>>
/// <param name="customer"> The Stripe customer associated with the user. </param>
/// <param name="premiumPlan"> The premium plan for which the Checkout Session is being created. </param>
/// <param name="originatingAppVersion"> The version of the application initiating the Checkout Session. </param>
/// <param name="originatingPlatform"> The platform (e.g., ios, android) from which the Checkout Session is initiated. </param>
/// <param name="originatingPlatform"> The platform (e.g., ios, android, browser, desktop) from which the Checkout Session is initiated. </param>
/// <returns> The created SessionCreateOptions for Stripe Checkout Session creation. </returns>
private SessionCreateOptions CreateSessionOptions(
User user,
Expand All @@ -82,6 +82,8 @@ private SessionCreateOptions CreateSessionOptions(
string originatingAppVersion,
string originatingPlatform)
{
var (successUrl, cancelUrl) = GetCheckoutUrls(originatingPlatform);

return new SessionCreateOptions
{
Customer = customer.Id,
Expand All @@ -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}")
};
}
4 changes: 4 additions & 0 deletions src/Core/Settings/GlobalSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -200,12 +208,103 @@ public async Task Run_GetAvailablePremiumPlanThrows_ReturnsUnhandled(User user)
_pricingClient.GetAvailablePremiumPlan().ThrowsAsync<NotFoundException>();

// 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
Assert.IsType<NotFoundException>(result.AsT3.Exception);
await _stripeAdapter.DidNotReceive().CreateCheckoutSessionAsync(Arg.Any<SessionCreateOptions>());
}

[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<SessionCreateOptions>()).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<SessionCreateOptions>(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<SessionCreateOptions>()).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<SessionCreateOptions>(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<InvalidOperationException>(result.AsT3.Exception);
await _stripeAdapter.DidNotReceive().CreateCheckoutSessionAsync(Arg.Any<SessionCreateOptions>());
}

}
Loading