Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -42,6 +43,22 @@ public async Task<IResult> GetCreditAsync(
return TypedResults.Ok(credit);
}

[HttpPost("premium/checkout")]
[InjectUser]
public async Task<IResult> 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<IResult> AddCreditViaBitPayAsync(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ValidationResult> 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)]);
}
}
}
25 changes: 25 additions & 0 deletions src/Core/Billing/Constants/StripeConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,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
Expand Down Expand Up @@ -191,5 +193,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";
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ο»Ώ
namespace Bit.Core.Billing.Models.Api.Response.Premium;

public record PremiumCheckoutSessionResponseModel(string CheckoutSessionUrl);
2 changes: 2 additions & 0 deletions src/Core/Billing/Payment/Registrations.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -17,6 +18,7 @@ public static void AddPaymentOperations(this IServiceCollection services)
services.AddTransient<ICreateBitPayInvoiceForCreditCommand, CreateBitPayInvoiceForCreditCommand>();
services.AddTransient<IUpdateBillingAddressCommand, UpdateBillingAddressCommand>();
services.AddTransient<IUpdatePaymentMethodCommand, UpdatePaymentMethodCommand>();
services.AddTransient<ICreatePremiumCheckoutSessionCommand, CreatePremiumCheckoutSessionCommand>();

// Discount services
services.AddScoped<IDiscountAudienceFilter, AllUsersFilter>();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Creates a Stripe Checkout Session for a user to purchase a premium subscription.
/// </summary>
public interface ICreatePremiumCheckoutSessionCommand
{
/// <summary>
/// Creates a Stripe Checkout Session for a user to purchase a premium subscription.
/// </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>
/// <returns> The url of the created Checkout Session. </returns>
Task<BillingCommandResult<PremiumCheckoutSessionResponseModel>> Run(User user, string originatingAppVersion, string originatingPlatform);
}

public class CreatePremiumCheckoutSessionCommand(
IStripeAdapter stripeAdapter,
IPricingClient pricingClient,
ISubscriberService subscriberService,
IUserService userService,
IGlobalSettings globalSettings,
ILogger<CreatePremiumCheckoutSessionCommand> logger
) : BaseBillingCommand<CreatePremiumCheckoutSessionCommand>(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<BillingCommandResult<PremiumCheckoutSessionResponseModel>>
Run(User user, string originatingAppVersion, string originatingPlatform) => HandleAsync<PremiumCheckoutSessionResponseModel>(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);
});

/// <summary>
/// Creates a Stripe customer for the user.
/// </summary>
/// <param name="user"> The user for whom the Stripe customer is being created. </param>
/// <returns> The created Stripe customer for the user. </returns>
private async Task<Customer> CreateCustomerAsync(User user)
{
var customerCreateOptions = new CustomerCreateOptions
{
Description = user.Name,
Email = user.Email,
Metadata = new Dictionary<string, string>()
{
[StripeConstants.MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion,
}
};

var customer = await stripeAdapter.CreateCustomerAsync(customerCreateOptions);

user.GatewayCustomerId = customer.Id;
await userService.SaveUserAsync(user);

return customer;
}

/// <summary>
/// Creates the options for creating a Stripe Checkout Session.
/// </summary>
/// <param name="user"> The user for whom the Checkout Session is being created. </param>
/// <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>
/// <returns> The created SessionCreateOptions for Stripe Checkout Session creation. </returns>
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<string, string>
{
[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],
};
}
}
2 changes: 2 additions & 0 deletions src/Core/Billing/Services/IStripeAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using Bit.Core.Models.BitStripe;
using Stripe;
using Stripe.Checkout;
using Stripe.Tax;

namespace Bit.Core.Billing.Services;
Expand Down Expand Up @@ -52,4 +53,5 @@ Task<CustomerBalanceTransaction> CreateCustomerBalanceTransactionAsync(string cu
Task<Coupon> GetCouponAsync(string couponId, CouponGetOptions options = null);
Task<List<Product>> ListProductsAsync(ProductListOptions options = null);
Task<StripeList<Subscription>> ListSubscriptionsAsync(SubscriptionListOptions options = null);
Task<Session> CreateCheckoutSessionAsync(SessionCreateOptions options);
}
17 changes: 12 additions & 5 deletions src/Core/Billing/Services/Implementations/StripeAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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()
{
Expand All @@ -48,6 +51,7 @@ public StripeAdapter()
_taxRegistrationService = new RegistrationService();
_couponService = new CouponService();
_productService = new ProductService();
_checkoutSessionsService = new SessionService();
}

/**************
Expand Down Expand Up @@ -95,6 +99,9 @@ public Task<Subscription> UpdateSubscriptionAsync(string id,
public Task<Subscription> CancelSubscriptionAsync(string id, SubscriptionCancelOptions options = null) =>
_subscriptionService.CancelAsync(id, options);

public Task<StripeList<Subscription>> ListSubscriptionsAsync(SubscriptionListOptions options = null) =>
_subscriptionService.ListAsync(options);

/*************
** INVOICE **
*************/
Expand Down Expand Up @@ -229,9 +236,9 @@ public Task<Coupon> GetCouponAsync(string couponId, CouponGetOptions options = n
public async Task<List<Product>> ListProductsAsync(ProductListOptions options = null) =>
(await _productService.ListAsync(options)).Data;

/****************
** SUBSCRIPTION **
****************/
public Task<StripeList<Subscription>> ListSubscriptionsAsync(SubscriptionListOptions options = null) =>
_subscriptionService.ListAsync(options);
/***********************
** CHECKOUT SESSION **
***********************/
public Task<Session> CreateCheckoutSessionAsync(SessionCreateOptions options) =>
_checkoutSessionsService.CreateAsync(options);
}
2 changes: 2 additions & 0 deletions src/Core/Settings/GlobalSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/Core/Settings/IGlobalSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading
Loading