diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index 9facdd9b24de..f357cb13d3c9 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -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; @@ -21,8 +25,11 @@ 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, @@ -30,8 +37,7 @@ public class AccountBillingVNextController( IReinstateSubscriptionCommand reinstateSubscriptionCommand, IUpdatePaymentMethodCommand updatePaymentMethodCommand, IUpdatePremiumStorageCommand updatePremiumStorageCommand, - IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand, - IGetApplicableDiscountsQuery getApplicableDiscountsQuery) : BaseBillingController + IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController { [HttpGet("credit")] [InjectUser] @@ -147,4 +153,19 @@ public async Task GetApplicableDiscountsAsync( return Handle(result); } + [HttpPost("portal-session")] + [InjectUser] + public async Task 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 })); + } + } diff --git a/src/Api/Billing/Models/Responses/Portal/PortalSessionResponse.cs b/src/Api/Billing/Models/Responses/Portal/PortalSessionResponse.cs new file mode 100644 index 000000000000..752a295a4349 --- /dev/null +++ b/src/Api/Billing/Models/Responses/Portal/PortalSessionResponse.cs @@ -0,0 +1,12 @@ +namespace Bit.Api.Billing.Models.Responses.Portal; + +/// +/// Response model containing the Stripe billing portal session URL. +/// +public class PortalSessionResponse +{ + /// + /// The URL to redirect the user to for accessing the Stripe billing portal. + /// + public required string Url { get; init; } +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index d7146455900c..4c929dc4b462 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -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; @@ -44,6 +45,7 @@ public static void AddBillingOperations(this IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); } private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services) diff --git a/src/Core/Billing/Portal/Commands/CreateBillingPortalSessionCommand.cs b/src/Core/Billing/Portal/Commands/CreateBillingPortalSessionCommand.cs new file mode 100644 index 000000000000..5a6b46a6dd27 --- /dev/null +++ b/src/Core/Billing/Portal/Commands/CreateBillingPortalSessionCommand.cs @@ -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> Run(User user, string returnUrl); +} + +public class CreateBillingPortalSessionCommand( + ILogger logger, + IStripeAdapter stripeAdapter) + : BaseBillingCommand(logger), ICreateBillingPortalSessionCommand +{ + private readonly ILogger _logger = logger; + + protected override Conflict DefaultConflict => + new("Unable to create billing portal session. Please contact support for assistance."); + + public Task> Run(User user, string returnUrl) => + HandleAsync(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; + }); +} diff --git a/src/Core/Billing/Services/IStripeAdapter.cs b/src/Core/Billing/Services/IStripeAdapter.cs index d7d14432caf9..4c18837f2980 100644 --- a/src/Core/Billing/Services/IStripeAdapter.cs +++ b/src/Core/Billing/Services/IStripeAdapter.cs @@ -3,6 +3,7 @@ using Bit.Core.Models.BitStripe; using Stripe; +using Stripe.BillingPortal; using Stripe.Tax; namespace Bit.Core.Billing.Services; @@ -52,4 +53,5 @@ Task CreateCustomerBalanceTransactionAsync(string cu Task GetCouponAsync(string couponId, CouponGetOptions options = null); Task> ListProductsAsync(ProductListOptions options = null); Task> ListSubscriptionsAsync(SubscriptionListOptions options = null); + Task CreateBillingPortalSessionAsync(SessionCreateOptions options); } diff --git a/src/Core/Billing/Services/Implementations/StripeAdapter.cs b/src/Core/Billing/Services/Implementations/StripeAdapter.cs index 5672c6ca4d0f..866726b0bad7 100644 --- a/src/Core/Billing/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Billing/Services/Implementations/StripeAdapter.cs @@ -4,6 +4,7 @@ using Bit.Core.Models.BitStripe; using Stripe; +using Stripe.BillingPortal; using Stripe.Tax; using Stripe.TestHelpers; using CustomerService = Stripe.CustomerService; @@ -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() { @@ -48,6 +50,7 @@ public StripeAdapter() _taxRegistrationService = new RegistrationService(); _couponService = new CouponService(); _productService = new ProductService(); + _billingPortalSessionService = new SessionService(); } /************** @@ -234,4 +237,10 @@ public async Task> ListProductsAsync(ProductListOptions options = ****************/ public Task> ListSubscriptionsAsync(SubscriptionListOptions options = null) => _subscriptionService.ListAsync(options); + + /********************** + ** BILLING PORTAL ** + **********************/ + public Task CreateBillingPortalSessionAsync(SessionCreateOptions options) => + _billingPortalSessionService.CreateAsync(options); } diff --git a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs index 706b3ae21967..92ba431d600f 100644 --- a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs @@ -5,17 +5,23 @@ using Bit.Core.Billing.Models.Api.Response; using Bit.Core.Billing.Models.Business; 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.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using NSubstitute; using OneOf.Types; +using Stripe; using Xunit; using BadRequest = Bit.Core.Billing.Commands.BadRequest; +using Conflict = Bit.Core.Billing.Commands.Conflict; +using NotFound = Microsoft.AspNetCore.Http.HttpResults.NotFound; namespace Bit.Api.Test.Billing.Controllers.VNext; @@ -25,6 +31,8 @@ public class AccountBillingVNextControllerTests private readonly IGetUserLicenseQuery _getUserLicenseQuery; private readonly IUpgradePremiumToOrganizationCommand _upgradePremiumToOrganizationCommand; private readonly IGetApplicableDiscountsQuery _getApplicableDiscountsQuery; + private readonly ICreateBillingPortalSessionCommand _createBillingPortalSessionCommand; + private readonly ICurrentContext _currentContext; private readonly AccountBillingVNextController _sut; public AccountBillingVNextControllerTests() @@ -33,10 +41,15 @@ public AccountBillingVNextControllerTests() _getUserLicenseQuery = Substitute.For(); _upgradePremiumToOrganizationCommand = Substitute.For(); _getApplicableDiscountsQuery = Substitute.For(); + _createBillingPortalSessionCommand = Substitute.For(); + _currentContext = Substitute.For(); _sut = new AccountBillingVNextController( + _createBillingPortalSessionCommand, Substitute.For(), Substitute.For(), + _currentContext, + _getApplicableDiscountsQuery, Substitute.For(), Substitute.For(), Substitute.For(), @@ -44,8 +57,7 @@ public AccountBillingVNextControllerTests() Substitute.For(), Substitute.For(), _updatePremiumStorageCommand, - _upgradePremiumToOrganizationCommand, - _getApplicableDiscountsQuery); + _upgradePremiumToOrganizationCommand); } [Theory, BitAutoData] @@ -291,4 +303,164 @@ public async Task GetApplicableDiscountsAsync_EligibleDiscounts_ReturnsOkWithDis Assert.Equal(models, okResult.Value); await _getApplicableDiscountsQuery.Received(1).Run(user); } + + [Theory, BitAutoData] + public async Task CreatePortalSessionAsync_Success_ReturnsPortalUrlAsync(User user) + { + // Arrange + var portalUrl = "https://billing.stripe.com/session/test123"; + var expectedReturnUrl = "bitwarden://premium-upgrade-callback"; + + _currentContext.DeviceType.Returns(DeviceType.Android); + _createBillingPortalSessionCommand.Run(user, expectedReturnUrl) + .Returns(new BillingCommandResult(portalUrl)); + + // Act + var result = await _sut.CreatePortalSessionAsync(user); + + // Assert + Assert.IsAssignableFrom(result); + await _createBillingPortalSessionCommand.Received(1).Run(user, expectedReturnUrl); + } + + [Theory, BitAutoData] + public async Task CreatePortalSessionAsync_NoCustomerId_ReturnsConflictAsync(User user) + { + // Arrange + var expectedReturnUrl = "bitwarden://premium-upgrade-callback"; + + _currentContext.DeviceType.Returns(DeviceType.AndroidAmazon); + _createBillingPortalSessionCommand.Run(user, expectedReturnUrl) + .Returns(new BillingCommandResult(new Conflict("Unable to create billing portal session. Please contact support for assistance."))); + + // Act + var result = await _sut.CreatePortalSessionAsync(user); + + // Assert + Assert.IsAssignableFrom(result); + await _createBillingPortalSessionCommand.Received(1).Run(user, expectedReturnUrl); + } + + [Theory, BitAutoData] + public async Task CreatePortalSessionAsync_NoSubscriptionId_ReturnsConflictAsync(User user) + { + // Arrange + var expectedReturnUrl = "bitwarden://premium-upgrade-callback"; + + _currentContext.DeviceType.Returns(DeviceType.iOS); + _createBillingPortalSessionCommand.Run(user, expectedReturnUrl) + .Returns(new BillingCommandResult(new Conflict("Unable to create billing portal session. Please contact support for assistance."))); + + // Act + var result = await _sut.CreatePortalSessionAsync(user); + + // Assert + Assert.IsAssignableFrom(result); + await _createBillingPortalSessionCommand.Received(1).Run(user, expectedReturnUrl); + } + + [Theory, BitAutoData] + public async Task CreatePortalSessionAsync_InvalidSubscriptionStatus_ReturnsBadRequestAsync(User user) + { + // Arrange + var expectedReturnUrl = "bitwarden://premium-upgrade-callback"; + + _currentContext.DeviceType.Returns(DeviceType.iOS); + _createBillingPortalSessionCommand.Run(user, expectedReturnUrl) + .Returns(new BillingCommandResult(new BadRequest("Your subscription cannot be managed in its current status."))); + + // Act + var result = await _sut.CreatePortalSessionAsync(user); + + // Assert + Assert.IsAssignableFrom(result); + await _createBillingPortalSessionCommand.Received(1).Run(user, expectedReturnUrl); + } + + [Theory, BitAutoData] + public async Task CreatePortalSessionAsync_SubscriptionNotFound_ReturnsConflictAsync(User user) + { + // Arrange + var expectedReturnUrl = "bitwarden://premium-upgrade-callback"; + + _currentContext.DeviceType.Returns(DeviceType.Android); + _createBillingPortalSessionCommand.Run(user, expectedReturnUrl) + .Returns(new BillingCommandResult(new Conflict("Unable to create billing portal session. Please contact support for assistance."))); + + // Act + var result = await _sut.CreatePortalSessionAsync(user); + + // Assert + Assert.IsAssignableFrom(result); + await _createBillingPortalSessionCommand.Received(1).Run(user, expectedReturnUrl); + } + + [Theory, BitAutoData] + public async Task CreatePortalSessionAsync_StripeException_ReturnsServerErrorAsync(User user) + { + // Arrange + var expectedReturnUrl = "bitwarden://premium-upgrade-callback"; + var exception = new StripeException("Stripe API error"); + + _currentContext.DeviceType.Returns(DeviceType.iOS); + _createBillingPortalSessionCommand.Run(user, expectedReturnUrl) + .Returns(new BillingCommandResult(new Unhandled(exception))); + + // Act + var result = await _sut.CreatePortalSessionAsync(user); + + // Assert + Assert.IsAssignableFrom(result); + await _createBillingPortalSessionCommand.Received(1).Run(user, expectedReturnUrl); + } + + [Theory, BitAutoData] + public async Task CreatePortalSessionAsync_SessionWithNullUrl_ReturnsServerErrorAsync(User user) + { + // Arrange + var expectedReturnUrl = "bitwarden://premium-upgrade-callback"; + + _currentContext.DeviceType.Returns(DeviceType.Android); + _createBillingPortalSessionCommand.Run(user, expectedReturnUrl) + .Returns(new BillingCommandResult(new Conflict("Unable to create billing portal session. Please contact support for assistance."))); + + // Act + var result = await _sut.CreatePortalSessionAsync(user); + + // Assert + Assert.IsAssignableFrom(result); + await _createBillingPortalSessionCommand.Received(1).Run(user, expectedReturnUrl); + } + + [Theory, BitAutoData] + public async Task CreatePortalSessionAsync_NullSession_ReturnsServerErrorAsync(User user) + { + // Arrange + var expectedReturnUrl = "bitwarden://premium-upgrade-callback"; + + _currentContext.DeviceType.Returns(DeviceType.iOS); + _createBillingPortalSessionCommand.Run(user, expectedReturnUrl) + .Returns(new BillingCommandResult(new Conflict("Unable to create billing portal session. Please contact support for assistance."))); + + // Act + var result = await _sut.CreatePortalSessionAsync(user); + + // Assert + Assert.IsAssignableFrom(result); + await _createBillingPortalSessionCommand.Received(1).Run(user, expectedReturnUrl); + } + + [Theory, BitAutoData] + public async Task CreatePortalSessionAsync_NonMobileDevice_ReturnsNotFoundAsync(User user) + { + // Arrange + _currentContext.DeviceType.Returns(DeviceType.ChromeBrowser); + + // Act + var result = await _sut.CreatePortalSessionAsync(user); + + // Assert + Assert.IsType(result); + await _createBillingPortalSessionCommand.DidNotReceiveWithAnyArgs().Run(Arg.Any(), Arg.Any()); + } } diff --git a/test/Core.Test/Billing/Portal/Commands/CreateBillingPortalSessionCommandTests.cs b/test/Core.Test/Billing/Portal/Commands/CreateBillingPortalSessionCommandTests.cs new file mode 100644 index 000000000000..b16dc0e07865 --- /dev/null +++ b/test/Core.Test/Billing/Portal/Commands/CreateBillingPortalSessionCommandTests.cs @@ -0,0 +1,333 @@ +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Portal.Commands; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Stripe; +using Stripe.BillingPortal; +using Xunit; + +namespace Bit.Core.Test.Billing.Portal.Commands; + +using static StripeConstants; + +public class CreateBillingPortalSessionCommandTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly CreateBillingPortalSessionCommand _command; + private readonly User _user; + + public CreateBillingPortalSessionCommandTests() + { + _command = new CreateBillingPortalSessionCommand(_logger, _stripeAdapter); + _user = new User + { + Id = Guid.NewGuid(), + Email = "test@example.com", + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + } + + [Fact] + public async Task Run_WithValidUser_ReturnsPortalUrl() + { + // Arrange + var returnUrl = "https://example.com/billing"; + var expectedUrl = "https://billing.stripe.com/session/test123"; + var session = new Session { Url = expectedUrl }; + var subscription = new Subscription { Id = _user.GatewaySubscriptionId, Status = SubscriptionStatus.Active }; + + _stripeAdapter.GetSubscriptionAsync(_user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _stripeAdapter.CreateBillingPortalSessionAsync(Arg.Any()) + .Returns(session); + + // Act + var result = await _command.Run(_user, returnUrl); + + // Assert + Assert.True(result.IsT0); + Assert.Equal(expectedUrl, result.AsT0); + + await _stripeAdapter.Received(1).GetSubscriptionAsync(_user.GatewaySubscriptionId, Arg.Any()); + await _stripeAdapter.Received(1).CreateBillingPortalSessionAsync( + Arg.Is(o => + o.Customer == _user.GatewayCustomerId && + o.ReturnUrl == returnUrl)); + } + + [Fact] + public async Task Run_WithoutGatewayCustomerId_ReturnsConflict() + { + // Arrange + var userWithoutCustomerId = new User + { + Id = Guid.NewGuid(), + Email = "test@example.com", + GatewayCustomerId = null + }; + var returnUrl = "https://example.com/billing"; + + // Act + var result = await _command.Run(userWithoutCustomerId, returnUrl); + + // Assert + Assert.True(result.IsT2); + var conflict = result.AsT2; + Assert.Equal("Unable to create billing portal session. Please contact support for assistance.", conflict.Response); + + await _stripeAdapter.DidNotReceive().CreateBillingPortalSessionAsync(Arg.Any()); + + _logger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("does not have a Stripe customer ID") && o.ToString()!.Contains(userWithoutCustomerId.Id.ToString())), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task Run_WithEmptyGatewayCustomerId_ReturnsConflict() + { + // Arrange + var userWithEmptyCustomerId = new User + { + Id = Guid.NewGuid(), + Email = "test@example.com", + GatewayCustomerId = string.Empty + }; + var returnUrl = "https://example.com/billing"; + + // Act + var result = await _command.Run(userWithEmptyCustomerId, returnUrl); + + // Assert + Assert.True(result.IsT2); + var conflict = result.AsT2; + Assert.Equal("Unable to create billing portal session. Please contact support for assistance.", conflict.Response); + + await _stripeAdapter.DidNotReceive().CreateBillingPortalSessionAsync(Arg.Any()); + + _logger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("does not have a Stripe customer ID") && o.ToString()!.Contains(userWithEmptyCustomerId.Id.ToString())), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task Run_WhenStripeThrowsException_ReturnsUnhandled() + { + // Arrange + var returnUrl = "https://example.com/billing"; + var subscription = new Subscription { Id = _user.GatewaySubscriptionId, Status = SubscriptionStatus.Active }; + var stripeException = new StripeException { StripeError = new StripeError { Code = "api_error" } }; + + _stripeAdapter.GetSubscriptionAsync(_user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _stripeAdapter.CreateBillingPortalSessionAsync(Arg.Any()) + .Throws(stripeException); + + // Act + var result = await _command.Run(_user, returnUrl); + + // Assert + Assert.True(result.IsT3); + var unhandled = result.AsT3; + Assert.Equal(stripeException, unhandled.Exception); + + await _stripeAdapter.Received(1).GetSubscriptionAsync(_user.GatewaySubscriptionId, Arg.Any()); + await _stripeAdapter.Received(1).CreateBillingPortalSessionAsync(Arg.Any()); + } + + [Fact] + public async Task Run_WithDifferentReturnUrls_UsesCorrectUrl() + { + // Arrange + var returnUrl1 = "https://example.com/billing"; + var returnUrl2 = "https://different.com/account"; + var session = new Session { Url = "https://billing.stripe.com/session/test123" }; + var subscription = new Subscription { Id = _user.GatewaySubscriptionId, Status = SubscriptionStatus.Active }; + + _stripeAdapter.GetSubscriptionAsync(_user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _stripeAdapter.CreateBillingPortalSessionAsync(Arg.Any()) + .Returns(session); + + // Act + var result1 = await _command.Run(_user, returnUrl1); + var result2 = await _command.Run(_user, returnUrl2); + + // Assert + Assert.True(result1.IsT0); + Assert.True(result2.IsT0); + + await _stripeAdapter.Received(1).CreateBillingPortalSessionAsync( + Arg.Is(o => o.ReturnUrl == returnUrl1)); + await _stripeAdapter.Received(1).CreateBillingPortalSessionAsync( + Arg.Is(o => o.ReturnUrl == returnUrl2)); + } + + [Fact] + public async Task Run_WithoutGatewaySubscriptionId_ReturnsConflict() + { + // Arrange + var userWithoutSubscriptionId = new User + { + Id = Guid.NewGuid(), + Email = "test@example.com", + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = null + }; + var returnUrl = "https://example.com/billing"; + + // Act + var result = await _command.Run(userWithoutSubscriptionId, returnUrl); + + // Assert + Assert.True(result.IsT2); + var conflict = result.AsT2; + Assert.Equal("Unable to create billing portal session. Please contact support for assistance.", conflict.Response); + + await _stripeAdapter.DidNotReceive().GetSubscriptionAsync(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive().CreateBillingPortalSessionAsync(Arg.Any()); + + _logger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("does not have a subscription") && o.ToString()!.Contains(userWithoutSubscriptionId.Id.ToString())), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task Run_WithActiveSubscription_ReturnsPortalUrl() + { + // Arrange + var returnUrl = "https://example.com/billing"; + var expectedUrl = "https://billing.stripe.com/session/test123"; + var session = new Session { Url = expectedUrl }; + var subscription = new Subscription { Id = _user.GatewaySubscriptionId, Status = SubscriptionStatus.Active }; + + _stripeAdapter.GetSubscriptionAsync(_user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _stripeAdapter.CreateBillingPortalSessionAsync(Arg.Any()) + .Returns(session); + + // Act + var result = await _command.Run(_user, returnUrl); + + // Assert + Assert.True(result.IsT0); + Assert.Equal(expectedUrl, result.AsT0); + + await _stripeAdapter.Received(1).GetSubscriptionAsync(_user.GatewaySubscriptionId, Arg.Any()); + } + + [Fact] + public async Task Run_WithPastDueSubscription_ReturnsPortalUrl() + { + // Arrange + var returnUrl = "https://example.com/billing"; + var expectedUrl = "https://billing.stripe.com/session/test456"; + var session = new Session { Url = expectedUrl }; + var subscription = new Subscription { Id = _user.GatewaySubscriptionId, Status = SubscriptionStatus.PastDue }; + + _stripeAdapter.GetSubscriptionAsync(_user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + _stripeAdapter.CreateBillingPortalSessionAsync(Arg.Any()) + .Returns(session); + + // Act + var result = await _command.Run(_user, returnUrl); + + // Assert + Assert.True(result.IsT0); + Assert.Equal(expectedUrl, result.AsT0); + + await _stripeAdapter.Received(1).GetSubscriptionAsync(_user.GatewaySubscriptionId, Arg.Any()); + } + + [Fact] + public async Task Run_WithCanceledSubscription_ReturnsBadRequest() + { + // Arrange + var returnUrl = "https://example.com/billing"; + var subscription = new Subscription { Id = _user.GatewaySubscriptionId, Status = SubscriptionStatus.Canceled }; + + _stripeAdapter.GetSubscriptionAsync(_user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + + // Act + var result = await _command.Run(_user, returnUrl); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Your subscription cannot be managed in its current status.", badRequest.Response); + + await _stripeAdapter.DidNotReceive().CreateBillingPortalSessionAsync(Arg.Any()); + + _logger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("not eligible for portal access") && o.ToString()!.Contains(_user.Id.ToString())), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task Run_WithIncompleteSubscription_ReturnsBadRequest() + { + // Arrange + var returnUrl = "https://example.com/billing"; + var subscription = new Subscription { Id = _user.GatewaySubscriptionId, Status = SubscriptionStatus.Incomplete }; + + _stripeAdapter.GetSubscriptionAsync(_user.GatewaySubscriptionId, Arg.Any()) + .Returns(subscription); + + // Act + var result = await _command.Run(_user, returnUrl); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Your subscription cannot be managed in its current status.", badRequest.Response); + + await _stripeAdapter.DidNotReceive().CreateBillingPortalSessionAsync(Arg.Any()); + } + + [Fact] + public async Task Run_WhenSubscriptionFetchFails_ReturnsConflict() + { + // Arrange + var returnUrl = "https://example.com/billing"; + var stripeException = new StripeException { StripeError = new StripeError { Code = "resource_missing" } }; + + _stripeAdapter.GetSubscriptionAsync(_user.GatewaySubscriptionId, Arg.Any()) + .Throws(stripeException); + + // Act + var result = await _command.Run(_user, returnUrl); + + // Assert + Assert.True(result.IsT2); + var conflict = result.AsT2; + Assert.Equal("Unable to create billing portal session. Please contact support for assistance.", conflict.Response); + + await _stripeAdapter.DidNotReceive().CreateBillingPortalSessionAsync(Arg.Any()); + + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Failed to fetch subscription") && o.ToString()!.Contains(_user.Id.ToString())), + stripeException, + Arg.Any>()); + } + +}