Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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
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