Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
e03233e
feat(stripe): add checkout session constants and settings
sbrown-livefront Mar 18, 2026
6c409e8
feat(billing): integrate Stripe Checkout Session adapter
sbrown-livefront Mar 18, 2026
c5b7ee9
feat(billing): define premium checkout session DTOs
sbrown-livefront Mar 18, 2026
b18e242
feat(billing): implement CreatePremiumCheckoutSessionCommand
sbrown-livefront Mar 18, 2026
2bbe6fc
feat(billing): add premium checkout session API endpoint
sbrown-livefront Mar 18, 2026
0dd6fd6
test(billing): add premium checkout session tests
sbrown-livefront Mar 18, 2026
0986f38
fix(billing): run dotnet format
sbrown-livefront Mar 18, 2026
0692d6c
Merge branch 'main' into billing/pm-32216/create-stripe-checkout-sess…
sbrown-livefront Mar 18, 2026
e5fb512
Merge branch 'main' into billing/pm-32216/create-stripe-checkout-sess…
sbrown-livefront Mar 20, 2026
67d7e3f
fix(billing): run dotnet format
sbrown-livefront Mar 20, 2026
59e3ec5
refactor(billing): clarify Stripe session types in IStripeAdapter
sbrown-livefront Mar 20, 2026
c985187
refactor(billing): clarify Stripe session service and types in Stripe…
sbrown-livefront Mar 20, 2026
6c6211d
Merge branch 'main' into billing/pm-32216/create-stripe-checkout-sess…
sbrown-livefront Mar 23, 2026
068f50d
Merge branch 'main' into billing/pm-32216/create-stripe-checkout-sess…
sbrown-livefront Mar 24, 2026
b9eea98
Merge branch 'main' into billing/pm-32216/create-stripe-checkout-sess…
sbrown-livefront Mar 25, 2026
8fbcfaf
Merge branch 'main' into billing/pm-32216/create-stripe-checkout-sess…
sbrown-livefront Mar 25, 2026
0376871
Merge branch 'main' into billing/pm-32216/create-stripe-checkout-sess…
sbrown-livefront Mar 25, 2026
6b6b5db
refactor(StripeAdapter): remove duplicate billing portal session method
sbrown-livefront Mar 25, 2026
824a455
style(premium): remove trailing comma from payment method types
sbrown-livefront Mar 25, 2026
7a94cfb
refactor(billing): retrieve client version from context
sbrown-livefront Mar 25, 2026
1be170a
refactor(premium): remove IUserService dependency from checkout command
sbrown-livefront Mar 25, 2026
23561ce
refactor(premium): consolidate stripe customer creation logic
sbrown-livefront Mar 25, 2026
d45530f
fix(billing) run dotnet format
sbrown-livefront Mar 25, 2026
17d39a0
feat(billing): add user ID to premium checkout session subscription
sbrown-livefront Mar 25, 2026
87672a6
test(billing): verify user ID is set in premium checkout session meta…
sbrown-livefront Mar 25, 2026
9bad6d5
test(billing): handle billing exception during stripe customer creation
sbrown-livefront Mar 25, 2026
31cdaef
Merge branch 'main' into billing/pm-32216/create-stripe-checkout-sess…
sbrown-livefront Mar 25, 2026
08137a3
Merge branch 'main' into billing/pm-32216/create-stripe-checkout-sess…
sbrown-livefront Mar 25, 2026
580279c
Merge branch 'main' into billing/pm-32216/create-stripe-checkout-sess…
sbrown-livefront Mar 26, 2026
d911eaf
Merge branch 'main' into billing/pm-32216/create-stripe-checkout-sess…
sbrown-livefront Mar 26, 2026
2291fcb
Merge branch 'main' into billing/pm-32216/create-stripe-checkout-sess…
sbrown-livefront Mar 26, 2026
0e58591
[PM-32218] Create Session Complete Handler (#7283)
sbrown-livefront Mar 27, 2026
804c3b6
Merge branch 'main' into billing/pm-32216/create-stripe-checkout-sess…
sbrown-livefront Mar 27, 2026
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 @@ -27,6 +27,7 @@ namespace Bit.Api.Billing.Controllers.VNext;
public class AccountBillingVNextController(
ICreateBillingPortalSessionCommand createBillingPortalSessionCommand,
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
ICreatePremiumCheckoutSessionCommand createPremiumCheckoutSessionCommand,
ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand,
ICurrentContext currentContext,
IGetApplicableDiscountsQuery getApplicableDiscountsQuery,
Expand All @@ -48,6 +49,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)
{
var appVersion = currentContext.ClientVersion?.ToString();
if (string.IsNullOrWhiteSpace(appVersion))
Comment thread
sbrown-livefront marked this conversation as resolved.
{
return Error.BadRequest("Client version 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)]);
}
}
}
1 change: 1 addition & 0 deletions src/Billing/Constants/HandledStripeWebhook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ public static class HandledStripeWebhook
public const string InvoiceFinalized = "invoice.finalized";
public const string SetupIntentSucceeded = "setup_intent.succeeded";
public const string CouponDeleted = "coupon.deleted";
public const string CheckoutSessionCompleted = "checkout.session.completed";
}
12 changes: 12 additions & 0 deletions src/Billing/Services/IStripeEventService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Stripe;
using Stripe.Checkout;

namespace Bit.Billing.Services;

Expand Down Expand Up @@ -70,6 +71,17 @@ public interface IStripeEventService
/// <returns>A Stripe <see cref="Subscription"/>.</returns>
Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string>? expand = null);

/// <summary>
/// Extracts the <see cref="Session"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
/// uses the session ID extracted from the event to retrieve the most up-to-date session from Stripe's API'
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether to retrieve a fresh copy of the session object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh session object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Session"/>.</returns>
Task<Session> GetCheckoutSession(Event stripeEvent, bool fresh = false, List<string>? expand = null);

/// <summary>
/// Ensures that the customer associated with the Stripe <see cref="Event"/> is in the correct region for this server.
/// We use the customer instead of the subscription given that all subscriptions have customers, but not all
Expand Down
5 changes: 5 additions & 0 deletions src/Billing/Services/IStripeWebhookHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,8 @@ public interface ISetupIntentSucceededHandler : IStripeWebhookHandler;
/// Defines the contract for handling Stripe coupon deleted events.
/// </summary>
public interface ICouponDeletedHandler : IStripeWebhookHandler;

/// <summary>
/// Defines the contract for handling Stripe checkout session completed events.
/// </summary>
public interface ICheckoutSessionCompletedHandler : IStripeWebhookHandler;
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Stripe;

namespace Bit.Billing.Services.Implementations;

public class CheckoutSessionCompletedHandler(
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService,
IStripeAdapter stripeAdapter,
IUserRepository userRepository,
IPricingClient pricingClient,
IPushNotificationAdapter pushNotificationAdapter,
ILogger<CheckoutSessionCompletedHandler> logger)
: ICheckoutSessionCompletedHandler
{
public async Task HandleAsync(Event parsedEvent)
{
var session = await stripeEventService.GetCheckoutSession(parsedEvent, true, ["subscription"]);
var subscription = session.Subscription;

if (subscription is null)
{
logger.LogError("Checkout Session {SessionId} has no subscription ID", session.Id);
return;
}

var (_, userId, _) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
if (!userId.HasValue)
{
logger.LogError("No userId found in metadata for subscription {SubscriptionId}", subscription.Id);
return;
}

var user = await userRepository.GetByIdAsync(userId.Value);
if (user is null)
{
logger.LogError("User {UserId} not found for subscription {SubscriptionId}", userId.Value, subscription.Id);
return;
}

if (user.Premium)
{
logger.LogError("User {UserId} is already premium for subscription {SubscriptionId}", user.Id, subscription.Id);
return;
}

var premiumPlan = await pricingClient.GetAvailablePremiumPlan();

// if the subscription does not contain the premium seat, this is not a premium subscription upgrade
if (subscription.Items.All(i => i.Price.Id != premiumPlan.Seat.StripePriceId))
{
logger.LogError("Subscription {SubscriptionId} does not contain premium seat", subscription.Id);
return;
}

user.Premium = true;
user.GatewaySubscriptionId = subscription.Id;
user.Gateway = GatewayType.Stripe;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
user.MaxStorageGb = (short)premiumPlan.Storage.Provided;
user.LicenseKey = string.IsNullOrWhiteSpace(user.LicenseKey) ? CoreHelpers.SecureRandomString(20) : user.LicenseKey;
user.RevisionDate = DateTime.UtcNow;

await userRepository.ReplaceAsync(user);
await pushNotificationAdapter.NotifyPremiumStatusChangedAsync(user);
await UpdateDefaultPaymentMethodAsync(subscription.DefaultPaymentMethodId, session.CustomerId, subscription.Id);
}

private async Task UpdateDefaultPaymentMethodAsync(string? defaultPaymentMethodId, string customerId, string subscriptionId)
{
if (string.IsNullOrWhiteSpace(defaultPaymentMethodId))
{
logger.LogWarning("No default payment method found for customer {CustomerId}", customerId);
return;
}

await stripeAdapter.UpdateCustomerAsync(customerId, new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = defaultPaymentMethodId
}
});

await stripeAdapter.UpdateSubscriptionAsync(subscriptionId, new SubscriptionUpdateOptions
{
DefaultPaymentMethod = string.Empty
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ public class StripeEventProcessor(
ICustomerUpdatedHandler customerUpdatedHandler,
IInvoiceFinalizedHandler invoiceFinalizedHandler,
ISetupIntentSucceededHandler setupIntentSucceededHandler,
ICouponDeletedHandler couponDeletedHandler)
ICouponDeletedHandler couponDeletedHandler,
ICheckoutSessionCompletedHandler checkoutSessionCompletedHandler)
: IStripeEventProcessor
{
public async Task ProcessEventAsync(Event parsedEvent)
Expand Down Expand Up @@ -63,6 +64,9 @@ public async Task ProcessEventAsync(Event parsedEvent)
case HandledStripeWebhook.CouponDeleted:
await couponDeletedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.CheckoutSessionCompleted:
await checkoutSessionCompletedHandler.HandleAsync(parsedEvent);
break;
default:
logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
break;
Expand Down
19 changes: 18 additions & 1 deletion src/Billing/Services/Implementations/StripeEventService.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
using Bit.Billing.Constants;
using Bit.Core.Billing.Services;
using Bit.Core.Settings;
using Stripe;
using Stripe.Checkout;

namespace Bit.Billing.Services.Implementations;

public class StripeEventService(
GlobalSettings globalSettings,
IStripeFacade stripeFacade)
IStripeFacade stripeFacade,
IStripeAdapter stripeAdapter)
: IStripeEventService
{
public async Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string>? expand = null)
Expand Down Expand Up @@ -82,6 +85,17 @@ public async Task<Subscription> GetSubscription(Event stripeEvent, bool fresh =
return await stripeFacade.GetSubscription(subscription.Id, new SubscriptionGetOptions { Expand = expand });
}

public async Task<Session> GetCheckoutSession(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
var checkoutSession = Extract<Session>(stripeEvent);
if (!fresh)
{
return checkoutSession;
}

return await stripeAdapter.GetCheckoutSessionAsync(checkoutSession.Id, new SessionGetOptions { Expand = expand });
}

public async Task<bool> ValidateCloudRegion(Event stripeEvent)
{
if (EventTypeAppliesToAllRegions(stripeEvent.Type))
Expand Down Expand Up @@ -117,6 +131,9 @@ HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed
HandledStripeWebhook.SetupIntentSucceeded =>
(await GetSetupIntent(stripeEvent, true, customerExpansion)).Customer?.Metadata,

HandledStripeWebhook.CheckoutSessionCompleted =>
(await GetCheckoutSession(stripeEvent, true, customerExpansion)).Customer?.Metadata,

_ => null
};

Expand Down
1 change: 1 addition & 0 deletions src/Billing/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public void ConfigureServices(IServiceCollection services)
services.AddScoped<IInvoiceFinalizedHandler, InvoiceFinalizedHandler>();
services.AddScoped<ISetupIntentSucceededHandler, SetupIntentSucceededHandler>();
services.AddScoped<ICouponDeletedHandler, CouponDeletedHandler>();
services.AddScoped<ICheckoutSessionCompletedHandler, CheckoutSessionCompletedHandler>();
services.AddScoped<IStripeEventProcessor, StripeEventProcessor>();

// Identity
Expand Down
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 @@ -208,5 +210,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
Loading
Loading