Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0ca5017
Implement the portal session url
cyprain-okeke Mar 16, 2026
618d79f
Remove comment
cyprain-okeke Mar 16, 2026
29034b6
Merge branch 'main' into billing/pm-32480/Endpoint-for-returning-stri…
cyprain-okeke Mar 16, 2026
e724b2b
formatting issues have been resolved
cyprain-okeke Mar 16, 2026
b085202
Merge branch 'main' into billing/pm-32480/Endpoint-for-returning-stri…
cyprain-okeke Mar 16, 2026
45dc273
Allow deep linking url
cyprain-okeke Mar 16, 2026
f56408b
Merge branch 'main' into billing/pm-32480/Endpoint-for-returning-stri…
cyprain-okeke Mar 16, 2026
76d6045
Merge branch 'main' into billing/pm-32480/Endpoint-for-returning-stri…
cyprain-okeke Mar 16, 2026
b9c9bed
remove thr return url request
cyprain-okeke Mar 17, 2026
3ed84b8
Merge branch 'main' into billing/pm-32480/Endpoint-for-returning-stri…
cyprain-okeke Mar 17, 2026
4222227
Merge branch 'main' into billing/pm-32480/Endpoint-for-returning-stri…
cyprain-okeke Mar 18, 2026
5e7bf78
Resolve review comments around comments
cyprain-okeke Mar 18, 2026
1b76d59
Fix the failing test after removing _globalSettings
cyprain-okeke Mar 18, 2026
81fbff3
Fix the failing unit test
cyprain-okeke Mar 18, 2026
2a37115
Merge branch 'main' into billing/pm-32480/Endpoint-for-returning-stri…
cyprain-okeke Mar 18, 2026
aa49b12
Merge branch 'main' into billing/pm-32480/Endpoint-for-returning-stri…
cyprain-okeke Mar 18, 2026
1f9b600
Merge branch 'main' into billing/pm-32480/Endpoint-for-returning-stri…
cyprain-okeke Mar 19, 2026
6993e89
Merge branch 'main' into billing/pm-32480/Endpoint-for-returning-stri…
cyprain-okeke Mar 19, 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 @@ -2,14 +2,18 @@
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Api.Billing.Models.Requests.Premium;
using Bit.Api.Billing.Models.Requests.Storage;
using Bit.Api.Billing.Models.Responses.Portal;
using Bit.Core;
using Bit.Core.Billing.Licenses.Queries;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Portal.Commands;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Subscriptions.Commands;
using Bit.Core.Billing.Subscriptions.Queries;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -21,17 +25,19 @@ namespace Bit.Api.Billing.Controllers.VNext;
[Route("account/billing/vnext")]
[SelfHosted(NotSelfHostedOnly = true)]
public class AccountBillingVNextController(
ICreateBillingPortalSessionCommand createBillingPortalSessionCommand,
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand,
ICurrentContext currentContext,
IGetApplicableDiscountsQuery getApplicableDiscountsQuery,
IGetBitwardenSubscriptionQuery getBitwardenSubscriptionQuery,
IGetCreditQuery getCreditQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IGetUserLicenseQuery getUserLicenseQuery,
IReinstateSubscriptionCommand reinstateSubscriptionCommand,
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
IUpdatePremiumStorageCommand updatePremiumStorageCommand,
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand,
IGetApplicableDiscountsQuery getApplicableDiscountsQuery) : BaseBillingController
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController
{
[HttpGet("credit")]
[InjectUser]
Expand Down Expand Up @@ -147,4 +153,19 @@ public async Task<IResult> GetApplicableDiscountsAsync(
return Handle(result);
}

[HttpPost("portal-session")]
[InjectUser]
public async Task<IResult> CreatePortalSessionAsync([BindNever] User user)
{
if (DeviceTypes.ToClientType(currentContext.DeviceType) != ClientType.Mobile)
{
return TypedResults.NotFound();
}

var returnUrl = "bitwarden://premium-upgrade-callback";

var result = await createBillingPortalSessionCommand.Run(user, returnUrl);
return Handle(result.Map(url => new PortalSessionResponse { Url = url }));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ο»Ώnamespace Bit.Api.Billing.Models.Responses.Portal;

/// <summary>
/// Response model containing the Stripe billing portal session URL.
/// </summary>
public class PortalSessionResponse
{
/// <summary>
/// The URL to redirect the user to for accessing the Stripe billing portal.
/// </summary>
public required string Url { get; init; }
}
2 changes: 2 additions & 0 deletions src/Core/Billing/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Billing.Organizations.Services;
using Bit.Core.Billing.Payment;
using Bit.Core.Billing.Portal.Commands;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Premium.Queries;
using Bit.Core.Billing.Pricing;
Expand Down Expand Up @@ -44,6 +45,7 @@ public static void AddBillingOperations(this IServiceCollection services)
services.AddTransient<IBraintreeService, BraintreeService>();
services.AddTransient<IUpdateOrganizationSubscriptionCommand, UpdateOrganizationSubscriptionCommand>();
services.AddTransient<IUpgradeOrganizationPlanVNextCommand, UpgradeOrganizationPlanVNextCommand>();
services.AddTransient<ICreateBillingPortalSessionCommand, CreateBillingPortalSessionCommand>();
}

private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
ο»Ώusing Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Microsoft.Extensions.Logging;
using Stripe;
using Stripe.BillingPortal;

namespace Bit.Core.Billing.Portal.Commands;

using static StripeConstants;

public interface ICreateBillingPortalSessionCommand
{
Task<BillingCommandResult<string>> Run(User user, string returnUrl);
}

public class CreateBillingPortalSessionCommand(
ILogger<CreateBillingPortalSessionCommand> logger,
IStripeAdapter stripeAdapter)
: BaseBillingCommand<CreateBillingPortalSessionCommand>(logger), ICreateBillingPortalSessionCommand
{
private readonly ILogger<CreateBillingPortalSessionCommand> _logger = logger;

protected override Conflict DefaultConflict =>
new("Unable to create billing portal session. Please contact support for assistance.");

public Task<BillingCommandResult<string>> Run(User user, string returnUrl) =>
HandleAsync<string>(async () =>
{
if (string.IsNullOrEmpty(user.GatewayCustomerId))
{
_logger.LogWarning("{Command}: User ({UserId}) does not have a Stripe customer ID",
CommandName, user.Id);
return DefaultConflict;
}

if (string.IsNullOrEmpty(user.GatewaySubscriptionId))
{
_logger.LogWarning("{Command}: User ({UserId}) does not have a subscription",
CommandName, user.Id);
return DefaultConflict;
}

// Fetch the subscription to validate its status
Subscription subscription;
try
{
subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId);
}
catch (StripeException stripeException)
{
_logger.LogError(stripeException,
"{Command}: Failed to fetch subscription ({SubscriptionId}) for user ({UserId})",
CommandName, user.GatewaySubscriptionId, user.Id);
return DefaultConflict;
}

// Only allow portal access for active or past_due subscriptions
if (subscription.Status != SubscriptionStatus.Active && subscription.Status != SubscriptionStatus.PastDue)
{
_logger.LogWarning(
"{Command}: User ({UserId}) subscription ({SubscriptionId}) has status '{Status}' which is not eligible for portal access",
CommandName, user.Id, user.GatewaySubscriptionId, subscription.Status);
return new BadRequest("Your subscription cannot be managed in its current status.");
}

var options = new SessionCreateOptions
{
Customer = user.GatewayCustomerId,
ReturnUrl = returnUrl
};

var session = await stripeAdapter.CreateBillingPortalSessionAsync(options);

return session.Url;
});
}
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.BillingPortal;
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> CreateBillingPortalSessionAsync(SessionCreateOptions options);
}
9 changes: 9 additions & 0 deletions src/Core/Billing/Services/Implementations/StripeAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

using Bit.Core.Models.BitStripe;
using Stripe;
using Stripe.BillingPortal;
using Stripe.Tax;
using Stripe.TestHelpers;
using CustomerService = Stripe.CustomerService;
Expand All @@ -29,6 +30,7 @@ public class StripeAdapter : IStripeAdapter
private readonly RegistrationService _taxRegistrationService;
private readonly CouponService _couponService;
private readonly ProductService _productService;
private readonly SessionService _billingPortalSessionService;

public StripeAdapter()
{
Expand All @@ -48,6 +50,7 @@ public StripeAdapter()
_taxRegistrationService = new RegistrationService();
_couponService = new CouponService();
_productService = new ProductService();
_billingPortalSessionService = new SessionService();
}

/**************
Expand Down Expand Up @@ -234,4 +237,10 @@ public async Task<List<Product>> ListProductsAsync(ProductListOptions options =
****************/
public Task<StripeList<Subscription>> ListSubscriptionsAsync(SubscriptionListOptions options = null) =>
_subscriptionService.ListAsync(options);

/**********************
** BILLING PORTAL **
**********************/
public Task<Session> CreateBillingPortalSessionAsync(SessionCreateOptions options) =>
_billingPortalSessionService.CreateAsync(options);
}
Loading
Loading