diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 49cd81d28f..2d92c68b93 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,8 +46,10 @@ jobs: permissions: security-events: write id-token: write + timeout-minutes: 45 strategy: fail-fast: false + max-parallel: 5 matrix: include: - project_name: Admin @@ -122,7 +124,7 @@ jobs: uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: cache: "npm" cache-dependency-path: "**/package-lock.json" @@ -159,7 +161,7 @@ jobs: ls -atlh ../../../ - name: Upload project artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 if: ${{ matrix.dotnet }} with: name: ${{ matrix.project_name }}.zip @@ -364,7 +366,7 @@ jobs: if: | github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: docker-stub-US.zip path: docker-stub-US.zip @@ -374,7 +376,7 @@ jobs: if: | github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: docker-stub-EU.zip path: docker-stub-EU.zip @@ -386,21 +388,21 @@ jobs: pwsh ./generate_openapi_files.ps1 - name: Upload Public API Swagger artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: swagger.json path: api.public.json if-no-files-found: error - name: Upload Internal API Swagger artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: internal.json path: api.json if-no-files-found: error - name: Upload Identity Swagger artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: identity.json path: identity.json @@ -446,7 +448,7 @@ jobs: - name: Upload project artifact for Windows if: ${{ contains(matrix.target, 'win') == true }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: MsSqlMigratorUtility-${{ matrix.target }} path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe @@ -454,7 +456,7 @@ jobs: - name: Upload project artifact if: ${{ contains(matrix.target, 'win') == false }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: MsSqlMigratorUtility-${{ matrix.target }} path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility diff --git a/.github/workflows/review-code.yml b/.github/workflows/review-code.yml index 46309af38e..0e0597fccf 100644 --- a/.github/workflows/review-code.yml +++ b/.github/workflows/review-code.yml @@ -15,6 +15,7 @@ jobs: AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} permissions: + actions: read contents: read id-token: write pull-requests: write diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index 4a973c0b7c..fb1c18b158 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -197,7 +197,7 @@ jobs: shell: pwsh - name: Upload DACPAC - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: sql.dacpac path: Sql.dacpac @@ -223,7 +223,7 @@ jobs: shell: pwsh - name: Report validation results - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: report.xml path: | diff --git a/Directory.Build.props b/Directory.Build.props index 4511202024..3e55b8a8cc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.11.0 + 2025.11.1 Bit.$(MSBuildProjectName) enable diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index a0842daa34..bc26fb270a 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -651,7 +651,23 @@ public class AccountController : Controller EmailVerified = emailVerified, ApiKey = CoreHelpers.SecureRandomString(30) }; - await _registerUserCommand.RegisterUser(newUser); + + /* + The feature flag is checked here so that we can send the new MJML welcome email templates. + The other organization invites flows have an OrganizationUser allowing the RegisterUserCommand the ability + to fetch the Organization. The old method RegisterUser(User) here does not have that context, so we need + to use a new method RegisterSSOAutoProvisionedUserAsync(User, Organization) to send the correct email. + [PM-28057]: Prefer RegisterSSOAutoProvisionedUserAsync for SSO auto-provisioned users. + TODO: Remove Feature flag: PM-28221 + */ + if (_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)) + { + await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization); + } + else + { + await _registerUserCommand.RegisterUser(newUser); + } // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email var twoFactorPolicy = diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index 0fe37d89fd..c04948e21f 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; +using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; @@ -18,6 +19,7 @@ using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using NSubstitute; @@ -1008,4 +1010,131 @@ public class AccountControllerTest _output.WriteLine($"Scenario={scenario} | OFF: SSO={offCounts.UserGetBySso}, Email={offCounts.UserGetByEmail}, Org={offCounts.OrgGetById}, OrgUserByOrg={offCounts.OrgUserGetByOrg}, OrgUserByEmail={offCounts.OrgUserGetByEmail}"); } } + + [Theory, BitAutoData] + public async Task AutoProvisionUserAsync_WithFeatureFlagEnabled_CallsRegisterSSOAutoProvisionedUser( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-new-user"; + var email = "newuser@example.com"; + var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null }; + + // No existing user (JIT provisioning scenario) + sutProvider.GetDependency().GetByEmailAsync(email).Returns((User?)null); + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); + sutProvider.GetDependency().GetByOrganizationEmailAsync(orgId, email) + .Returns((OrganizationUser?)null); + + // Feature flag enabled + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + // Mock the RegisterSSOAutoProvisionedUserAsync to return success + sutProvider.GetDependency() + .RegisterSSOAutoProvisionedUserAsync(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Success); + + var claims = new[] + { + new Claim(JwtClaimTypes.Email, email), + new Claim(JwtClaimTypes.Name, "New User") + } as IEnumerable; + var config = new SsoConfigurationData(); + + var method = typeof(AccountController).GetMethod( + "CreateUserAndOrgUserConditionallyAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // Act + var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke( + sutProvider.Sut, + new object[] + { + orgId.ToString(), + providerUserId, + claims, + null!, + config + })!; + + var result = await task; + + // Assert + await sutProvider.GetDependency().Received(1) + .RegisterSSOAutoProvisionedUserAsync( + Arg.Is(u => u.Email == email && u.Name == "New User"), + Arg.Is(o => o.Id == orgId && o.Name == "Test Org")); + + Assert.NotNull(result.user); + Assert.Equal(email, result.user.Email); + Assert.Equal(organization.Id, result.organization.Id); + } + + [Theory, BitAutoData] + public async Task AutoProvisionUserAsync_WithFeatureFlagDisabled_CallsRegisterUserInstead( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-legacy-user"; + var email = "legacyuser@example.com"; + var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null }; + + // No existing user (JIT provisioning scenario) + sutProvider.GetDependency().GetByEmailAsync(email).Returns((User?)null); + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); + sutProvider.GetDependency().GetByOrganizationEmailAsync(orgId, email) + .Returns((OrganizationUser?)null); + + // Feature flag disabled + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(false); + + // Mock the RegisterUser to return success + sutProvider.GetDependency() + .RegisterUser(Arg.Any()) + .Returns(IdentityResult.Success); + + var claims = new[] + { + new Claim(JwtClaimTypes.Email, email), + new Claim(JwtClaimTypes.Name, "Legacy User") + } as IEnumerable; + var config = new SsoConfigurationData(); + + var method = typeof(AccountController).GetMethod( + "CreateUserAndOrgUserConditionallyAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // Act + var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke( + sutProvider.Sut, + new object[] + { + orgId.ToString(), + providerUserId, + claims, + null!, + config + })!; + + var result = await task; + + // Assert + await sutProvider.GetDependency().Received(1) + .RegisterUser(Arg.Is(u => u.Email == email && u.Name == "Legacy User")); + + // Verify the new method was NOT called + await sutProvider.GetDependency().DidNotReceive() + .RegisterSSOAutoProvisionedUserAsync(Arg.Any(), Arg.Any()); + + Assert.NotNull(result.user); + Assert.Equal(email, result.user.Email); + } } diff --git a/src/Api/AdminConsole/Public/Controllers/EventsController.cs b/src/Api/AdminConsole/Public/Controllers/EventsController.cs index 19edbdd5a6..b92e576ef9 100644 --- a/src/Api/AdminConsole/Public/Controllers/EventsController.cs +++ b/src/Api/AdminConsole/Public/Controllers/EventsController.cs @@ -1,6 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - + using System.Net; using Bit.Api.Models.Public.Request; using Bit.Api.Models.Public.Response; @@ -8,6 +6,7 @@ using Bit.Api.Utilities.DiagnosticTools; using Bit.Core.Context; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.SecretsManager.Repositories; using Bit.Core.Services; using Bit.Core.Vault.Repositories; using Microsoft.AspNetCore.Authorization; @@ -22,6 +21,9 @@ public class EventsController : Controller private readonly IEventRepository _eventRepository; private readonly ICipherRepository _cipherRepository; private readonly ICurrentContext _currentContext; + private readonly ISecretRepository _secretRepository; + private readonly IProjectRepository _projectRepository; + private readonly IUserService _userService; private readonly ILogger _logger; private readonly IFeatureService _featureService; @@ -29,12 +31,18 @@ public class EventsController : Controller IEventRepository eventRepository, ICipherRepository cipherRepository, ICurrentContext currentContext, + ISecretRepository secretRepository, + IProjectRepository projectRepository, + IUserService userService, ILogger logger, IFeatureService featureService) { _eventRepository = eventRepository; _cipherRepository = cipherRepository; _currentContext = currentContext; + _secretRepository = secretRepository; + _projectRepository = projectRepository; + _userService = userService; _logger = logger; _featureService = featureService; } @@ -50,35 +58,76 @@ public class EventsController : Controller [ProducesResponseType(typeof(PagedListResponseModel), (int)HttpStatusCode.OK)] public async Task List([FromQuery] EventFilterRequestModel request) { + if (!_currentContext.OrganizationId.HasValue) + { + return new JsonResult(new PagedListResponseModel([], "")); + } + + var organizationId = _currentContext.OrganizationId.Value; var dateRange = request.ToDateRange(); var result = new PagedResult(); if (request.ActingUserId.HasValue) { result = await _eventRepository.GetManyByOrganizationActingUserAsync( - _currentContext.OrganizationId.Value, request.ActingUserId.Value, dateRange.Item1, dateRange.Item2, + organizationId, request.ActingUserId.Value, dateRange.Item1, dateRange.Item2, new PageOptions { ContinuationToken = request.ContinuationToken }); } else if (request.ItemId.HasValue) { var cipher = await _cipherRepository.GetByIdAsync(request.ItemId.Value); - if (cipher != null && cipher.OrganizationId == _currentContext.OrganizationId.Value) + if (cipher != null && cipher.OrganizationId == organizationId) { result = await _eventRepository.GetManyByCipherAsync( cipher, dateRange.Item1, dateRange.Item2, new PageOptions { ContinuationToken = request.ContinuationToken }); } } + else if (request.SecretId.HasValue) + { + var secret = await _secretRepository.GetByIdAsync(request.SecretId.Value); + + if (secret == null) + { + secret = new Core.SecretsManager.Entities.Secret { Id = request.SecretId.Value, OrganizationId = organizationId }; + } + + if (secret.OrganizationId == organizationId) + { + result = await _eventRepository.GetManyBySecretAsync( + secret, dateRange.Item1, dateRange.Item2, + new PageOptions { ContinuationToken = request.ContinuationToken }); + } + else + { + return new JsonResult(new PagedListResponseModel([], "")); + } + } + else if (request.ProjectId.HasValue) + { + var project = await _projectRepository.GetByIdAsync(request.ProjectId.Value); + if (project != null && project.OrganizationId == organizationId) + { + result = await _eventRepository.GetManyByProjectAsync( + project, dateRange.Item1, dateRange.Item2, + new PageOptions { ContinuationToken = request.ContinuationToken }); + } + else + { + return new JsonResult(new PagedListResponseModel([], "")); + } + } else { result = await _eventRepository.GetManyByOrganizationAsync( - _currentContext.OrganizationId.Value, dateRange.Item1, dateRange.Item2, + organizationId, dateRange.Item1, dateRange.Item2, new PageOptions { ContinuationToken = request.ContinuationToken }); } var eventResponses = result.Data.Select(e => new EventResponseModel(e)); - var response = new PagedListResponseModel(eventResponses, result.ContinuationToken); + var response = new PagedListResponseModel(eventResponses, result.ContinuationToken ?? ""); + + _logger.LogAggregateData(_featureService, organizationId, response, request); - _logger.LogAggregateData(_featureService, _currentContext.OrganizationId!.Value, response, request); return new JsonResult(response); } } diff --git a/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs index 2d96425d55..a007349f26 100644 --- a/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/EventFilterRequestModel.cs @@ -24,6 +24,14 @@ public class EventFilterRequestModel /// public Guid? ItemId { get; set; } /// + /// The unique identifier of the related secret that the event describes. + /// + public Guid? SecretId { get; set; } + /// + /// The unique identifier of the related project that the event describes. + /// + public Guid? ProjectId { get; set; } + /// /// A cursor for use in pagination. /// public string ContinuationToken { get; set; } diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 9dbe4a5532..075218dd74 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -4,6 +4,7 @@ using Bit.Api.Models.Request; using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Response; using Bit.Api.Utilities; +using Bit.Core; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Business; @@ -24,7 +25,8 @@ namespace Bit.Api.Billing.Controllers; public class AccountsController( IUserService userService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IUserAccountKeysQuery userAccountKeysQuery) : Controller + IUserAccountKeysQuery userAccountKeysQuery, + IFeatureService featureService) : Controller { [HttpPost("premium")] public async Task PostPremiumAsync( @@ -84,16 +86,24 @@ public class AccountsController( throw new UnauthorizedAccessException(); } - if (!globalSettings.SelfHosted && user.Gateway != null) + // Only cloud-hosted users with payment gateways have subscription and discount information + if (!globalSettings.SelfHosted) { - var subscriptionInfo = await paymentService.GetSubscriptionAsync(user); - var license = await userService.GenerateLicenseAsync(user, subscriptionInfo); - return new SubscriptionResponseModel(user, subscriptionInfo, license); - } - else if (!globalSettings.SelfHosted) - { - var license = await userService.GenerateLicenseAsync(user); - return new SubscriptionResponseModel(user, license); + if (user.Gateway != null) + { + // Note: PM23341_Milestone_2 is the feature flag for the overall Milestone 2 initiative (PM-23341). + // This specific implementation (PM-26682) adds discount display functionality as part of that initiative. + // The feature flag controls the broader Milestone 2 feature set, not just this specific task. + var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); + var subscriptionInfo = await paymentService.GetSubscriptionAsync(user); + var license = await userService.GenerateLicenseAsync(user, subscriptionInfo); + return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount); + } + else + { + var license = await userService.GenerateLicenseAsync(user); + return new SubscriptionResponseModel(user, license); + } } else { diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index 7038bee2a7..29a47e160c 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -1,6 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models.Business; using Bit.Core.Entities; using Bit.Core.Models.Api; @@ -11,7 +9,17 @@ namespace Bit.Api.Models.Response; public class SubscriptionResponseModel : ResponseModel { - public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license) + + /// The user entity containing storage and premium subscription information + /// Subscription information retrieved from the payment provider (Stripe/Braintree) + /// The user's license containing expiration and feature entitlements + /// + /// Whether to include discount information in the response. + /// Set to true when the PM23341_Milestone_2 feature flag is enabled AND + /// you want to expose Milestone 2 discount information to the client. + /// The discount will only be included if it matches the specific Milestone 2 coupon ID. + /// + public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license, bool includeMilestone2Discount = false) : base("subscription") { Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null; @@ -22,9 +30,14 @@ public class SubscriptionResponseModel : ResponseModel MaxStorageGb = user.MaxStorageGb; License = license; Expiration = License.Expires; + + // Only display the Milestone 2 subscription discount on the subscription page. + CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription.CustomerDiscount) + ? new BillingCustomerDiscount(subscription.CustomerDiscount!) + : null; } - public SubscriptionResponseModel(User user, UserLicense license = null) + public SubscriptionResponseModel(User user, UserLicense? license = null) : base("subscription") { StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null; @@ -38,21 +51,109 @@ public class SubscriptionResponseModel : ResponseModel } } - public string StorageName { get; set; } + public string? StorageName { get; set; } public double? StorageGb { get; set; } public short? MaxStorageGb { get; set; } - public BillingSubscriptionUpcomingInvoice UpcomingInvoice { get; set; } - public BillingSubscription Subscription { get; set; } - public UserLicense License { get; set; } + public BillingSubscriptionUpcomingInvoice? UpcomingInvoice { get; set; } + public BillingSubscription? Subscription { get; set; } + /// + /// Customer discount information from Stripe for the Milestone 2 subscription discount. + /// Only includes the specific Milestone 2 coupon (cm3nHfO1) when it's a perpetual discount (no expiration). + /// This is for display purposes only and does not affect Stripe's automatic discount application. + /// Other discounts may still apply in Stripe billing but are not included in this response. + /// + /// Null when: + /// - The PM23341_Milestone_2 feature flag is disabled + /// - There is no active discount + /// - The discount coupon ID doesn't match the Milestone 2 coupon (cm3nHfO1) + /// - The instance is self-hosted + /// + /// + public BillingCustomerDiscount? CustomerDiscount { get; set; } + public UserLicense? License { get; set; } public DateTime? Expiration { get; set; } + + /// + /// Determines whether the Milestone 2 discount should be included in the response. + /// + /// Whether the feature flag is enabled and discount should be considered. + /// The customer discount from subscription info, if any. + /// True if the discount should be included; false otherwise. + private static bool ShouldIncludeMilestone2Discount( + bool includeMilestone2Discount, + SubscriptionInfo.BillingCustomerDiscount? customerDiscount) + { + return includeMilestone2Discount && + customerDiscount != null && + customerDiscount.Id == StripeConstants.CouponIDs.Milestone2SubscriptionDiscount && + customerDiscount.Active; + } } -public class BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount) +/// +/// Customer discount information from Stripe billing. +/// +public class BillingCustomerDiscount { - public string Id { get; } = discount.Id; - public bool Active { get; } = discount.Active; - public decimal? PercentOff { get; } = discount.PercentOff; - public List AppliesTo { get; } = discount.AppliesTo; + /// + /// The Stripe coupon ID (e.g., "cm3nHfO1"). + /// + public string? Id { get; } + + /// + /// Whether the discount is a recurring/perpetual discount with no expiration date. + /// + /// This property is true only when the discount has no end date, meaning it applies + /// indefinitely to all future renewals. This is a product decision for Milestone 2 + /// to only display perpetual discounts in the UI. + /// + /// + /// Note: This does NOT indicate whether the discount is "currently active" in the billing sense. + /// A discount with a future end date is functionally active and will be applied by Stripe, + /// but this property will be false because it has an expiration date. + /// + /// + public bool Active { get; } + + /// + /// Percentage discount applied to the subscription (e.g., 20.0 for 20% off). + /// Null if this is an amount-based discount. + /// + public decimal? PercentOff { get; } + + /// + /// Fixed amount discount in USD (e.g., 14.00 for $14 off). + /// Converted from Stripe's cent-based values (1400 cents → $14.00). + /// Null if this is a percentage-based discount. + /// Note: Stripe stores amounts in the smallest currency unit. This value is always in USD. + /// + public decimal? AmountOff { get; } + + /// + /// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]). + /// + /// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe). + /// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe). + /// Non-empty list: discount applies only to the specified product IDs. + /// + /// + public IReadOnlyList? AppliesTo { get; } + + /// + /// Creates a BillingCustomerDiscount from a SubscriptionInfo.BillingCustomerDiscount. + /// + /// The discount to convert. Must not be null. + /// Thrown when discount is null. + public BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount) + { + ArgumentNullException.ThrowIfNull(discount); + + Id = discount.Id; + Active = discount.Active; + PercentOff = discount.PercentOff; + AmountOff = discount.AmountOff; + AppliesTo = discount.AppliesTo; + } } public class BillingSubscription @@ -83,10 +184,10 @@ public class BillingSubscription public DateTime? PeriodEndDate { get; set; } public DateTime? CancelledDate { get; set; } public bool CancelAtEndDate { get; set; } - public string Status { get; set; } + public string? Status { get; set; } public bool Cancelled { get; set; } public IEnumerable Items { get; set; } = new List(); - public string CollectionMethod { get; set; } + public string? CollectionMethod { get; set; } public DateTime? SuspensionDate { get; set; } public DateTime? UnpaidPeriodEndDate { get; set; } public int? GracePeriod { get; set; } @@ -104,11 +205,11 @@ public class BillingSubscription AddonSubscriptionItem = item.AddonSubscriptionItem; } - public string ProductId { get; set; } - public string Name { get; set; } + public string? ProductId { get; set; } + public string? Name { get; set; } public decimal Amount { get; set; } public int Quantity { get; set; } - public string Interval { get; set; } + public string? Interval { get; set; } public bool SponsoredSubscriptionItem { get; set; } public bool AddonSubscriptionItem { get; set; } } diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 46d8332926..0983225f84 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -402,8 +402,9 @@ public class CiphersController : Controller { var org = _currentContext.GetOrganization(organizationId); - // If we're not an "admin" or if we're not a provider user we don't need to check the ciphers - if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId)) + // If we're not an "admin" we don't need to check the ciphers + if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or + { Permissions.EditAnyCollection: true })) { return false; } @@ -416,8 +417,9 @@ public class CiphersController : Controller { var org = _currentContext.GetOrganization(organizationId); - // If we're not an "admin" or if we're a provider user we don't need to check the ciphers - if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId)) + // If we're not an "admin" we don't need to check the ciphers + if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or + { Permissions.EditAnyCollection: true })) { return false; } diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 7a58f84cd4..6db0cb6373 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -1,11 +1,8 @@ -// FIXME: Update this file to be null safe and then delete the line below - -#nullable disable - -using Bit.Core; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Queries; @@ -17,11 +14,13 @@ using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; using Bit.Core.Services; using Stripe; -using static Bit.Core.Billing.Constants.StripeConstants; using Event = Stripe.Event; +using Plan = Bit.Core.Models.StaticStore.Plan; namespace Bit.Billing.Services.Implementations; +using static StripeConstants; + public class UpcomingInvoiceHandler( IGetPaymentMethodQuery getPaymentMethodQuery, ILogger logger, @@ -57,204 +56,88 @@ public class UpcomingInvoiceHandler( if (organizationId.HasValue) { - var organization = await organizationRepository.GetByIdAsync(organizationId.Value); - - if (organization == null) - { - return; - } - - await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id); - - var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); - - if (!plan.IsAnnual) - { - return; - } - - if (stripeEventUtilityService.IsSponsoredSubscription(subscription)) - { - var sponsorshipIsValid = - await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); - - if (!sponsorshipIsValid) - { - /* - * If the sponsorship is invalid, then the subscription was updated to use the regular families plan - * price. Given that this is the case, we need the new invoice amount - */ - invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId); - } - } - - await SendUpcomingInvoiceEmailsAsync(new List { organization.BillingEmail }, invoice); - - /* - * TODO: https://bitwarden.atlassian.net/browse/PM-4862 - * Disabling this as part of a hot fix. It needs to check whether the organization - * belongs to a Reseller provider and only send an email to the organization owners if it does. - * It also requires a new email template as the current one contains too much billing information. - */ - - // var ownerEmails = await _organizationRepository.GetOwnerEmailAddressesById(organization.Id); - - // await SendEmails(ownerEmails); + await HandleOrganizationUpcomingInvoiceAsync( + organizationId.Value, + parsedEvent, + invoice, + customer, + subscription); } else if (userId.HasValue) { - var user = await userRepository.GetByIdAsync(userId.Value); - - if (user == null) - { - return; - } - - if (!subscription.AutomaticTax.Enabled && subscription.Customer.HasRecognizedTaxLocation()) - { - try - { - await stripeFacade.UpdateSubscription(subscription.Id, - new SubscriptionUpdateOptions - { - AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } - }); - } - catch (Exception exception) - { - logger.LogError( - exception, - "Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}", - user.Id, - parsedEvent.Id); - } - } - - var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); - if (milestone2Feature) - { - await UpdateSubscriptionItemPriceIdAsync(parsedEvent, subscription, user); - } - - if (user.Premium) - { - await (milestone2Feature - ? SendUpdatedUpcomingInvoiceEmailsAsync(new List { user.Email }) - : SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice)); - } + await HandlePremiumUsersUpcomingInvoiceAsync( + userId.Value, + parsedEvent, + invoice, + customer, + subscription); } else if (providerId.HasValue) { - var provider = await providerRepository.GetByIdAsync(providerId.Value); - - if (provider == null) - { - return; - } - - await AlignProviderTaxConcernsAsync(provider, subscription, customer, parsedEvent.Id); - - await SendProviderUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice, subscription, providerId.Value); + await HandleProviderUpcomingInvoiceAsync( + providerId.Value, + parsedEvent, + invoice, + customer, + subscription); } } - private async Task UpdateSubscriptionItemPriceIdAsync(Event parsedEvent, Subscription subscription, User user) + #region Organizations + + private async Task HandleOrganizationUpcomingInvoiceAsync( + Guid organizationId, + Event @event, + Invoice invoice, + Customer customer, + Subscription subscription) { - var pricingItem = - subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually); - if (pricingItem != null) + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) { - try + logger.LogWarning("Could not find Organization ({OrganizationID}) for '{EventType}' event ({EventID})", + organizationId, @event.Type, @event.Id); + return; + } + + await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, @event.Id); + + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3); + + await AlignOrganizationSubscriptionConcernsAsync( + organization, + @event, + subscription, + plan, + milestone3); + + // Don't send the upcoming invoice email unless the organization's on an annual plan. + if (!plan.IsAnnual) + { + return; + } + + if (stripeEventUtilityService.IsSponsoredSubscription(subscription)) + { + var sponsorshipIsValid = + await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId); + + if (!sponsorshipIsValid) { - var plan = await pricingClient.GetAvailablePremiumPlan(); - await stripeFacade.UpdateSubscription(subscription.Id, - new SubscriptionUpdateOptions - { - Items = - [ - new SubscriptionItemOptions { Id = pricingItem.Id, Price = plan.Seat.StripePriceId } - ], - Discounts = - [ - new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount } - ], - ProrationBehavior = "none" - }); - } - catch (Exception exception) - { - logger.LogError( - exception, - "Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}", - user.Id, - parsedEvent.Id); + /* + * If the sponsorship is invalid, then the subscription was updated to use the regular families plan + * price. Given that this is the case, we need the new invoice amount + */ + invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId); } } - } - private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice) - { - var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); - - var items = invoice.Lines.Select(i => i.Description).ToList(); - - if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0) - { - await mailService.SendInvoiceUpcoming( - validEmails, - invoice.AmountDue / 100M, - invoice.NextPaymentAttempt.Value, - items, - true); - } - } - - private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable emails) - { - var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); - var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail - { - ToEmails = validEmails, - View = new UpdatedInvoiceUpcomingView() - }; - await mailer.SendEmail(updatedUpcomingEmail); - } - - private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice, - Subscription subscription, Guid providerId) - { - var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); - - var items = invoice.FormatForProvider(subscription); - - if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0) - { - var provider = await providerRepository.GetByIdAsync(providerId); - if (provider == null) - { - logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId); - return; - } - - var collectionMethod = subscription.CollectionMethod; - var paymentMethod = await getPaymentMethodQuery.Run(provider); - - var hasPaymentMethod = paymentMethod != null; - var paymentMethodDescription = paymentMethod?.Match( - bankAccount => $"Bank account ending in {bankAccount.Last4}", - card => $"{card.Brand} ending in {card.Last4}", - payPal => $"PayPal account {payPal.Email}" - ); - - await mailService.SendProviderInvoiceUpcoming( - validEmails, - invoice.AmountDue / 100M, - invoice.NextPaymentAttempt.Value, - items, - collectionMethod, - hasPaymentMethod, - paymentMethodDescription); - } + await (milestone3 + ? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail]) + : SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice)); } private async Task AlignOrganizationTaxConcernsAsync( @@ -305,6 +188,228 @@ public class UpcomingInvoiceHandler( } } + private async Task AlignOrganizationSubscriptionConcernsAsync( + Organization organization, + Event @event, + Subscription subscription, + Plan plan, + bool milestone3) + { + // currently these are the only plans that need aligned and both require the same flag and share most of the logic + if (!milestone3 || plan.Type is not (PlanType.FamiliesAnnually2019 or PlanType.FamiliesAnnually2025)) + { + return; + } + + var passwordManagerItem = + subscription.Items.FirstOrDefault(item => item.Price.Id == plan.PasswordManager.StripePlanId); + + if (passwordManagerItem == null) + { + logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})", + organization.Id, @event.Type, @event.Id); + return; + } + + var families = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually); + + organization.PlanType = families.Type; + organization.Plan = families.Name; + organization.UsersGetPremium = families.UsersGetPremium; + organization.Seats = families.PasswordManager.BaseSeats; + + var options = new SubscriptionUpdateOptions + { + Items = + [ + new SubscriptionItemOptions + { + Id = passwordManagerItem.Id, + Price = families.PasswordManager.StripePlanId + } + ], + ProrationBehavior = ProrationBehavior.None + }; + + if (plan.Type == PlanType.FamiliesAnnually2019) + { + options.Discounts = + [ + new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount } + ]; + + var premiumAccessAddOnItem = subscription.Items.FirstOrDefault(item => + item.Price.Id == plan.PasswordManager.StripePremiumAccessPlanId); + + if (premiumAccessAddOnItem != null) + { + options.Items.Add(new SubscriptionItemOptions + { + Id = premiumAccessAddOnItem.Id, + Deleted = true + }); + } + + var seatAddOnItem = subscription.Items.FirstOrDefault(item => item.Price.Id == "personal-org-seat-annually"); + + if (seatAddOnItem != null) + { + options.Items.Add(new SubscriptionItemOptions + { + Id = seatAddOnItem.Id, + Deleted = true + }); + } + } + + try + { + await organizationRepository.ReplaceAsync(organization); + await stripeFacade.UpdateSubscription(subscription.Id, options); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to align subscription concerns for Organization ({OrganizationID}) while processing '{EventType}' event ({EventID})", + organization.Id, + @event.Type, + @event.Id); + } + } + + #endregion + + #region Premium Users + + private async Task HandlePremiumUsersUpcomingInvoiceAsync( + Guid userId, + Event @event, + Invoice invoice, + Customer customer, + Subscription subscription) + { + var user = await userRepository.GetByIdAsync(userId); + + if (user == null) + { + logger.LogWarning("Could not find User ({UserID}) for '{EventType}' event ({EventID})", + userId, @event.Type, @event.Id); + return; + } + + await AlignPremiumUsersTaxConcernsAsync(user, @event, customer, subscription); + + var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); + if (milestone2Feature) + { + await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription); + } + + if (user.Premium) + { + await (milestone2Feature + ? SendUpdatedUpcomingInvoiceEmailsAsync(new List { user.Email }) + : SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice)); + } + } + + private async Task AlignPremiumUsersTaxConcernsAsync( + User user, + Event @event, + Customer customer, + Subscription subscription) + { + if (!subscription.AutomaticTax.Enabled && customer.HasRecognizedTaxLocation()) + { + try + { + await stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}", + user.Id, + @event.Id); + } + } + } + + private async Task AlignPremiumUsersSubscriptionConcernsAsync( + User user, + Event @event, + Subscription subscription) + { + var premiumItem = subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually); + + if (premiumItem == null) + { + logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})", + user.Id, @event.Type, @event.Id); + return; + } + + try + { + var plan = await pricingClient.GetAvailablePremiumPlan(); + await stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + Items = + [ + new SubscriptionItemOptions { Id = premiumItem.Id, Price = plan.Seat.StripePriceId } + ], + Discounts = + [ + new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount } + ], + ProrationBehavior = ProrationBehavior.None + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}", + user.Id, + @event.Id); + } + } + + #endregion + + #region Providers + + private async Task HandleProviderUpcomingInvoiceAsync( + Guid providerId, + Event @event, + Invoice invoice, + Customer customer, + Subscription subscription) + { + var provider = await providerRepository.GetByIdAsync(providerId); + + if (provider == null) + { + logger.LogWarning("Could not find Provider ({ProviderID}) for '{EventType}' event ({EventID})", + providerId, @event.Type, @event.Id); + return; + } + + await AlignProviderTaxConcernsAsync(provider, subscription, customer, @event.Id); + + if (!string.IsNullOrEmpty(provider.BillingEmail)) + { + await SendProviderUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice, subscription, providerId); + } + } + private async Task AlignProviderTaxConcernsAsync( Provider provider, Subscription subscription, @@ -349,4 +454,75 @@ public class UpcomingInvoiceHandler( } } } + + private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice, + Subscription subscription, Guid providerId) + { + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); + + var items = invoice.FormatForProvider(subscription); + + if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0) + { + var provider = await providerRepository.GetByIdAsync(providerId); + if (provider == null) + { + logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId); + return; + } + + var collectionMethod = subscription.CollectionMethod; + var paymentMethod = await getPaymentMethodQuery.Run(provider); + + var hasPaymentMethod = paymentMethod != null; + var paymentMethodDescription = paymentMethod?.Match( + bankAccount => $"Bank account ending in {bankAccount.Last4}", + card => $"{card.Brand} ending in {card.Last4}", + payPal => $"PayPal account {payPal.Email}" + ); + + await mailService.SendProviderInvoiceUpcoming( + validEmails, + invoice.AmountDue / 100M, + invoice.NextPaymentAttempt.Value, + items, + collectionMethod, + hasPaymentMethod, + paymentMethodDescription); + } + } + + #endregion + + #region Shared + + private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice) + { + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); + + var items = invoice.Lines.Select(i => i.Description).ToList(); + + if (invoice is { NextPaymentAttempt: not null, AmountDue: > 0 }) + { + await mailService.SendInvoiceUpcoming( + validEmails, + invoice.AmountDue / 100M, + invoice.NextPaymentAttempt.Value, + items, + true); + } + } + + private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable emails) + { + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); + var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail + { + ToEmails = validEmails, + View = new UpdatedInvoiceUpcomingView() + }; + await mailer.SendEmail(updatedUpcomingEmail); + } + + #endregion } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs index 8d8ab8cdfc..0cae0fcc81 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/CloudOrganizationSignUpCommand.cs @@ -75,8 +75,7 @@ public class CloudOrganizationSignUpCommand( PlanType = plan!.Type, Seats = (short)(plan.PasswordManager.BaseSeats + signup.AdditionalSeats), MaxCollections = plan.PasswordManager.MaxCollections, - MaxStorageGb = !plan.PasswordManager.BaseStorageGb.HasValue ? - (short?)null : (short)(plan.PasswordManager.BaseStorageGb.Value + signup.AdditionalStorageGb), + MaxStorageGb = (short)(plan.PasswordManager.BaseStorageGb + signup.AdditionalStorageGb), UsePolicies = plan.HasPolicies, UseSso = plan.HasSso, UseGroups = plan.HasGroups, diff --git a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs index 27e70fbe2d..4a8f08a4f7 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Organizations/ProviderClientOrganizationSignUpCommand.cs @@ -73,7 +73,7 @@ public class ProviderClientOrganizationSignUpCommand : IProviderClientOrganizati PlanType = plan!.Type, Seats = signup.AdditionalSeats, MaxCollections = plan.PasswordManager.MaxCollections, - MaxStorageGb = 1, + MaxStorageGb = plan.PasswordManager.BaseStorageGb, UsePolicies = plan.HasPolicies, UseSso = plan.HasSso, UseOrganizationDomains = plan.HasOrganizationDomains, diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs index 6aef9f248b..d3df63b6ac 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs @@ -9,6 +9,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; /// /// Defines behavior and functionality for a given PolicyType. /// +/// +/// All methods defined in this interface are for the PolicyService#SavePolicy method. This needs to be supported until +/// we successfully refactor policy validators over to policy validation handlers +/// public interface IPolicyValidator { /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index f3dbc83706..7c1987865a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -53,6 +53,7 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddPolicyRequirements(this IServiceCollection services) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs index 798417ae7c..0e2bdc3d69 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs @@ -2,6 +2,13 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +/// +/// Represents all policies required to be enabled before the given policy can be enabled. +/// +/// +/// This interface is intended for policy event handlers that mandate the activation of other policies +/// as prerequisites for enabling the associated policy. +/// public interface IEnforceDependentPoliciesEvent : IPolicyUpdateEvent { /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs index 278a17f35e..4167a392e4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs @@ -3,6 +3,12 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +/// +/// Represents all side effects that should be executed before a policy is upserted. +/// +/// +/// This should be added to policy handlers that need to perform side effects before policy upserts. +/// public interface IOnPolicyPreUpdateEvent : IPolicyUpdateEvent { /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs index ded1a14f1a..a568658d4d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs @@ -2,6 +2,12 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +/// +/// Represents the policy to be upserted. +/// +/// +/// This is used for the VNextSavePolicyCommand. All policy handlers should implement this interface. +/// public interface IPolicyUpdateEvent { /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs index 6d486e1fa0..ee401ef813 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs @@ -3,12 +3,17 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +/// +/// Represents all validations that need to be run to enable or disable the given policy. +/// +/// +/// This is used for the VNextSavePolicyCommand. This optional but should be implemented for all policies that have +/// certain requirements for the given organization. +/// public interface IPolicyValidationEvent : IPolicyUpdateEvent { /// - /// Performs side effects after a policy is validated but before it is saved. - /// For example, this can be used to remove non-compliant users from the organization. - /// Implementation is optional; by default, it will not perform any side effects. + /// Performs any validations required to enable or disable the policy. /// /// The policy save request containing the policy update and metadata /// The current policy, if any diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs new file mode 100644 index 0000000000..c0d302df02 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs @@ -0,0 +1,131 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Core.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +/// +/// Represents an event handler for the Automatic User Confirmation policy. +/// +/// This class validates that the following conditions are met: +///
    +///
  • The Single organization policy is enabled
  • +///
  • All organization users are compliant with the Single organization policy
  • +///
  • No provider users exist
  • +///
+/// +/// This class also performs side effects when the policy is being enabled or disabled. They are: +///
    +///
  • Sets the UseAutomaticUserConfirmation organization feature to match the policy update
  • +///
+///
+public class AutomaticUserConfirmationPolicyEventHandler( + IOrganizationUserRepository organizationUserRepository, + IProviderUserRepository providerUserRepository, + IPolicyRepository policyRepository, + IOrganizationRepository organizationRepository, + TimeProvider timeProvider) + : IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent +{ + public PolicyType Type => PolicyType.AutomaticUserConfirmation; + public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy) => + await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy); + + private const string _singleOrgPolicyNotEnabledErrorMessage = + "The Single organization policy must be enabled before enabling the Automatically confirm invited users policy."; + + private const string _usersNotCompliantWithSingleOrgErrorMessage = + "All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations."; + + private const string _providerUsersExistErrorMessage = + "The organization has users with the Provider user type. Please remove provider users before enabling the Automatically confirm invited users policy."; + + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; + + public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + var isNotEnablingPolicy = policyUpdate is not { Enabled: true }; + var policyAlreadyEnabled = currentPolicy is { Enabled: true }; + if (isNotEnablingPolicy || policyAlreadyEnabled) + { + return string.Empty; + } + + return await ValidateEnablingPolicyAsync(policyUpdate.OrganizationId); + } + + public async Task ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) => + await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy); + + public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + var organization = await organizationRepository.GetByIdAsync(policyUpdate.OrganizationId); + + if (organization is not null) + { + organization.UseAutomaticUserConfirmation = policyUpdate.Enabled; + organization.RevisionDate = timeProvider.GetUtcNow().UtcDateTime; + await organizationRepository.UpsertAsync(organization); + } + } + + private async Task ValidateEnablingPolicyAsync(Guid organizationId) + { + var singleOrgValidationError = await ValidateSingleOrgPolicyComplianceAsync(organizationId); + if (!string.IsNullOrWhiteSpace(singleOrgValidationError)) + { + return singleOrgValidationError; + } + + var providerValidationError = await ValidateNoProviderUsersAsync(organizationId); + if (!string.IsNullOrWhiteSpace(providerValidationError)) + { + return providerValidationError; + } + + return string.Empty; + } + + private async Task ValidateSingleOrgPolicyComplianceAsync(Guid organizationId) + { + var singleOrgPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.SingleOrg); + if (singleOrgPolicy is not { Enabled: true }) + { + return _singleOrgPolicyNotEnabledErrorMessage; + } + + return await ValidateUserComplianceWithSingleOrgAsync(organizationId); + } + + private async Task ValidateUserComplianceWithSingleOrgAsync(Guid organizationId) + { + var organizationUsers = (await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId)) + .Where(ou => ou.Status != OrganizationUserStatusType.Invited && + ou.Status != OrganizationUserStatusType.Revoked && + ou.UserId.HasValue) + .ToList(); + + if (organizationUsers.Count == 0) + { + return string.Empty; + } + + var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync( + organizationUsers.Select(ou => ou.UserId!.Value))) + .Any(uo => uo.OrganizationId != organizationId && + uo.Status != OrganizationUserStatusType.Invited); + + return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty; + } + + private async Task ValidateNoProviderUsersAsync(Guid organizationId) + { + var providerUsers = await providerUserRepository.GetManyByOrganizationAsync(organizationId); + + return providerUsers.Count > 0 ? _providerUsersExistErrorMessage : string.Empty; + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 1b52ad8cff..f18ecf341b 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -148,7 +148,7 @@ public class OrganizationService : IOrganizationService } var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, organization, storageAdjustmentGb, - plan.PasswordManager.StripeStoragePlanId); + plan.PasswordManager.StripeStoragePlanId, plan.PasswordManager.BaseStorageGb); await ReplaceAndUpdateCacheAsync(organization); return secret; } diff --git a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs index f04a1181c4..5be7ed481f 100644 --- a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; using Bit.Core.Entities; using Bit.Core.Tokens; @@ -26,7 +23,7 @@ public class OrgUserInviteTokenable : ExpiringTokenable public string Identifier { get; set; } = TokenIdentifier; public Guid OrgUserId { get; set; } - public string OrgUserEmail { get; set; } + public string? OrgUserEmail { get; set; } [JsonConstructor] public OrgUserInviteTokenable() diff --git a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs index 62dd9dd293..97c2eabd3c 100644 --- a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs @@ -1,4 +1,5 @@ -using Bit.Core.Entities; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.Registration; @@ -14,6 +15,15 @@ public interface IRegisterUserCommand /// public Task RegisterUser(User user); + /// + /// Creates a new user, sends a welcome email, and raises the signup reference event. + /// This method is used by SSO auto-provisioned organization Users. + /// + /// The to create + /// The associated with the user + /// + Task RegisterSSOAutoProvisionedUserAsync(User user, Organization organization); + /// /// Creates a new user with a given master password hash, sends a welcome email (differs based on initiation path), /// and raises the signup reference event. Optionally accepts an org invite token and org user id to associate diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 991be2b764..4aaa9360a0 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -1,11 +1,10 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -24,6 +23,7 @@ public class RegisterUserCommand : IRegisterUserCommand { private readonly IGlobalSettings _globalSettings; private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; private readonly IPolicyRepository _policyRepository; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; @@ -37,24 +37,27 @@ public class RegisterUserCommand : IRegisterUserCommand private readonly IValidateRedemptionTokenCommand _validateRedemptionTokenCommand; private readonly IDataProtectorTokenFactory _emergencyAccessInviteTokenDataFactory; + private readonly IFeatureService _featureService; private readonly string _disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator."; public RegisterUserCommand( - IGlobalSettings globalSettings, - IOrganizationUserRepository organizationUserRepository, - IPolicyRepository policyRepository, - IDataProtectionProvider dataProtectionProvider, - IDataProtectorTokenFactory orgUserInviteTokenDataFactory, - IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, - IUserService userService, - IMailService mailService, - IValidateRedemptionTokenCommand validateRedemptionTokenCommand, - IDataProtectorTokenFactory emergencyAccessInviteTokenDataFactory - ) + IGlobalSettings globalSettings, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository, + IDataProtectionProvider dataProtectionProvider, + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, + IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, + IUserService userService, + IMailService mailService, + IValidateRedemptionTokenCommand validateRedemptionTokenCommand, + IDataProtectorTokenFactory emergencyAccessInviteTokenDataFactory, + IFeatureService featureService) { _globalSettings = globalSettings; _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; _policyRepository = policyRepository; _organizationServiceDataProtector = dataProtectionProvider.CreateProtector( @@ -69,9 +72,9 @@ public class RegisterUserCommand : IRegisterUserCommand _emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory; _providerServiceDataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); + _featureService = featureService; } - public async Task RegisterUser(User user) { var result = await _userService.CreateUserAsync(user); @@ -83,11 +86,22 @@ public class RegisterUserCommand : IRegisterUserCommand return result; } + public async Task RegisterSSOAutoProvisionedUserAsync(User user, Organization organization) + { + var result = await _userService.CreateUserAsync(user); + if (result == IdentityResult.Success) + { + await SendWelcomeEmailAsync(user, organization); + } + + return result; + } + public async Task RegisterUserViaOrganizationInviteToken(User user, string masterPasswordHash, string orgInviteToken, Guid? orgUserId) { - ValidateOrgInviteToken(orgInviteToken, orgUserId, user); - await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user); + TryValidateOrgInviteToken(orgInviteToken, orgUserId, user); + var orgUser = await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user); user.ApiKey = CoreHelpers.SecureRandomString(30); @@ -97,16 +111,17 @@ public class RegisterUserCommand : IRegisterUserCommand } var result = await _userService.CreateUserAsync(user, masterPasswordHash); + var organization = await GetOrganizationUserOrganization(orgUserId ?? Guid.Empty, orgUser); if (result == IdentityResult.Success) { var sentWelcomeEmail = false; if (!string.IsNullOrEmpty(user.ReferenceData)) { - var referenceData = JsonConvert.DeserializeObject>(user.ReferenceData); + var referenceData = JsonConvert.DeserializeObject>(user.ReferenceData) ?? []; if (referenceData.TryGetValue("initiationPath", out var value)) { - var initiationPath = value.ToString(); - await SendAppropriateWelcomeEmailAsync(user, initiationPath); + var initiationPath = value.ToString() ?? string.Empty; + await SendAppropriateWelcomeEmailAsync(user, initiationPath, organization); sentWelcomeEmail = true; if (!string.IsNullOrEmpty(initiationPath)) { @@ -117,14 +132,22 @@ public class RegisterUserCommand : IRegisterUserCommand if (!sentWelcomeEmail) { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user, organization); } } return result; } - private void ValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, User user) + /// + /// This method attempts to validate the org invite token if provided. If the token is invalid an exception is thrown. + /// If there is no exception it is assumed the token is valid or not provided and open registration is allowed. + /// + /// The organization invite token. + /// The organization user ID. + /// The user being registered. + /// If validation fails then an exception is thrown. + private void TryValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, User user) { var orgInviteTokenProvided = !string.IsNullOrWhiteSpace(orgInviteToken); @@ -137,7 +160,6 @@ public class RegisterUserCommand : IRegisterUserCommand } // Token data is invalid - if (_globalSettings.DisableUserRegistration) { throw new BadRequestException(_disabledUserRegistrationExceptionMsg); @@ -147,7 +169,6 @@ public class RegisterUserCommand : IRegisterUserCommand } // no token data or missing token data - // Throw if open registration is disabled and there isn't an org invite token or an org user id // as you can't register without them. if (_globalSettings.DisableUserRegistration) @@ -171,12 +192,20 @@ public class RegisterUserCommand : IRegisterUserCommand // If both orgInviteToken && orgUserId are missing, then proceed with open registration } + /// + /// Validates the org invite token using the new tokenable logic first, then falls back to the old token validation logic for backwards compatibility. + /// Will set the out parameter organizationWelcomeEmailDetails if the new token is valid. If the token is invalid then no welcome email needs to be sent + /// so the out parameter is set to null. + /// + /// Invite token + /// Inviting Organization UserId + /// User email + /// true if the token is valid false otherwise private bool IsOrgInviteTokenValid(string orgInviteToken, Guid orgUserId, string userEmail) { // TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete var newOrgInviteTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( _orgUserInviteTokenDataFactory, orgInviteToken, orgUserId, userEmail); - return newOrgInviteTokenValid || CoreHelpers.UserInviteTokenIsValid( _organizationServiceDataProtector, orgInviteToken, userEmail, orgUserId, _globalSettings); } @@ -187,11 +216,12 @@ public class RegisterUserCommand : IRegisterUserCommand /// /// The optional org user id /// The newly created user object which could be modified - private async Task SetUserEmail2FaIfOrgPolicyEnabledAsync(Guid? orgUserId, User user) + /// The organization user if one exists for the provided org user id, null otherwise + private async Task SetUserEmail2FaIfOrgPolicyEnabledAsync(Guid? orgUserId, User user) { if (!orgUserId.HasValue) { - return; + return null; } var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value); @@ -213,10 +243,11 @@ public class RegisterUserCommand : IRegisterUserCommand _userService.SetTwoFactorProvider(user, TwoFactorProviderType.Email); } } + return orgUser; } - private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath) + private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath, Organization? organization) { var isFromMarketingWebsite = initiationPath.Contains("Secrets Manager trial"); @@ -226,16 +257,14 @@ public class RegisterUserCommand : IRegisterUserCommand } else { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user, organization); } } public async Task RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash, string emailVerificationToken) { - ValidateOpenRegistrationAllowed(); - var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email); user.EmailVerified = true; @@ -245,7 +274,7 @@ public class RegisterUserCommand : IRegisterUserCommand var result = await _userService.CreateUserAsync(user, masterPasswordHash); if (result == IdentityResult.Success) { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user); } return result; @@ -263,7 +292,7 @@ public class RegisterUserCommand : IRegisterUserCommand var result = await _userService.CreateUserAsync(user, masterPasswordHash); if (result == IdentityResult.Success) { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user); } return result; @@ -283,7 +312,7 @@ public class RegisterUserCommand : IRegisterUserCommand var result = await _userService.CreateUserAsync(user, masterPasswordHash); if (result == IdentityResult.Success) { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user); } return result; @@ -301,7 +330,7 @@ public class RegisterUserCommand : IRegisterUserCommand var result = await _userService.CreateUserAsync(user, masterPasswordHash); if (result == IdentityResult.Success) { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user); } return result; @@ -357,4 +386,59 @@ public class RegisterUserCommand : IRegisterUserCommand return tokenable; } + + /// + /// We send different welcome emails depending on whether the user is joining a free/family or an enterprise organization. If information to populate the + /// email isn't present we send the standard individual welcome email. + /// + /// Target user for the email + /// this value is nullable + /// + private async Task SendWelcomeEmailAsync(User user, Organization? organization = null) + { + // Check if feature is enabled + // TODO: Remove Feature flag: PM-28221 + if (!_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)) + { + await _mailService.SendWelcomeEmailAsync(user); + return; + } + + // Most emails are probably for non organization users so we default to that experience + if (organization == null) + { + await _mailService.SendIndividualUserWelcomeEmailAsync(user); + } + // We need to make sure that the organization email has the correct data to display otherwise we just send the standard welcome email + else if (!string.IsNullOrEmpty(organization.DisplayName())) + { + // If the organization is Free or Families plan, send families welcome email + if (organization.PlanType is PlanType.FamiliesAnnually + or PlanType.FamiliesAnnually2019 + or PlanType.Free) + { + await _mailService.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.DisplayName()); + } + else + { + await _mailService.SendOrganizationUserWelcomeEmailAsync(user, organization.DisplayName()); + } + } + // If the organization data isn't present send the standard welcome email + else + { + await _mailService.SendIndividualUserWelcomeEmailAsync(user); + } + } + + private async Task GetOrganizationUserOrganization(Guid orgUserId, OrganizationUser? orgUser = null) + { + var organizationUser = orgUser ?? await _organizationUserRepository.GetByIdAsync(orgUserId); + if (organizationUser == null) + { + return null; + } + + return await _organizationRepository.GetByIdAsync(organizationUser.OrganizationId); + } } diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 517273db4e..11f043fc69 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -22,7 +22,8 @@ public static class StripeConstants { public const string LegacyMSPDiscount = "msp-discount-35"; public const string SecretsManagerStandalone = "sm-standalone"; - public const string Milestone2SubscriptionDiscount = "cm3nHfO1"; + public const string Milestone2SubscriptionDiscount = "milestone-2c"; + public const string Milestone3SubscriptionDiscount = "milestone-3"; public static class MSPDiscounts { diff --git a/src/Core/Billing/Models/StaticStore/Plan.cs b/src/Core/Billing/Models/StaticStore/Plan.cs index 6d8d00089c..bab64d9879 100644 --- a/src/Core/Billing/Models/StaticStore/Plan.cs +++ b/src/Core/Billing/Models/StaticStore/Plan.cs @@ -97,7 +97,7 @@ public abstract record Plan public decimal PremiumAccessOptionPrice { get; init; } public short? MaxSeats { get; init; } // Storage - public short? BaseStorageGb { get; init; } + public short BaseStorageGb { get; init; } public bool HasAdditionalStorageOption { get; init; } public decimal AdditionalStoragePricePerGb { get; init; } public string StripeStoragePlanId { get; init; } diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index 1f752a007b..472f31ac4b 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -80,6 +80,8 @@ public class CreatePremiumCloudHostedSubscriptionCommand( return new BadRequest("Additional storage must be greater than 0."); } + var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); + Customer? customer; /* @@ -107,7 +109,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( customer = await ReconcileBillingLocationAsync(customer, billingAddress); - var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null); + var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, additionalStorageGb > 0 ? additionalStorageGb : null); paymentMethod.Switch( tokenized => @@ -140,7 +142,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( user.Gateway = GatewayType.Stripe; user.GatewayCustomerId = customer.Id; user.GatewaySubscriptionId = subscription.Id; - user.MaxStorageGb = (short)(1 + additionalStorageGb); + user.MaxStorageGb = (short)(premiumPlan.Storage.Provided + additionalStorageGb); user.LicenseKey = CoreHelpers.SecureRandomString(20); user.RevisionDate = DateTime.UtcNow; @@ -304,9 +306,9 @@ public class CreatePremiumCloudHostedSubscriptionCommand( private async Task CreateSubscriptionAsync( Guid userId, Customer customer, + Pricing.Premium.Plan premiumPlan, int? storage) { - var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); var subscriptionItemOptionsList = new List { diff --git a/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs b/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs index ac60411366..42090a56ca 100644 --- a/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs +++ b/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs @@ -99,11 +99,19 @@ public record PlanAdapter : Core.Models.StaticStore.Plan _ => true); var baseSeats = GetBaseSeats(plan.Seats); var maxSeats = GetMaxSeats(plan.Seats); - var baseStorageGb = (short?)plan.Storage?.Provided; + var baseStorageGb = (short)(plan.Storage?.Provided ?? 0); var hasAdditionalStorageOption = plan.Storage != null; var additionalStoragePricePerGb = plan.Storage?.Price ?? 0; var stripeStoragePlanId = plan.Storage?.StripePriceId; short? maxCollections = plan.AdditionalData.TryGetValue("passwordManager.maxCollections", out var value) ? short.Parse(value) : null; + var stripePremiumAccessPlanId = + plan.AdditionalData.TryGetValue("premiumAccessAddOnPriceId", out var premiumAccessAddOnPriceIdValue) + ? premiumAccessAddOnPriceIdValue + : null; + var premiumAccessOptionPrice = + plan.AdditionalData.TryGetValue("premiumAccessAddOnPriceAmount", out var premiumAccessAddOnPriceAmountValue) + ? decimal.Parse(premiumAccessAddOnPriceAmountValue) + : 0; return new PasswordManagerPlanFeatures { @@ -121,7 +129,9 @@ public record PlanAdapter : Core.Models.StaticStore.Plan HasAdditionalStorageOption = hasAdditionalStorageOption, AdditionalStoragePricePerGb = additionalStoragePricePerGb, StripeStoragePlanId = stripeStoragePlanId, - MaxCollections = maxCollections + MaxCollections = maxCollections, + StripePremiumAccessPlanId = stripePremiumAccessPlanId, + PremiumAccessOptionPrice = premiumAccessOptionPrice }; } diff --git a/src/Core/Billing/Pricing/Premium/Purchasable.cs b/src/Core/Billing/Pricing/Premium/Purchasable.cs index 633eb2e8aa..6bf69d9593 100644 --- a/src/Core/Billing/Pricing/Premium/Purchasable.cs +++ b/src/Core/Billing/Pricing/Premium/Purchasable.cs @@ -4,4 +4,5 @@ public class Purchasable { public string StripePriceId { get; init; } = null!; public decimal Price { get; init; } + public int Provided { get; init; } } diff --git a/src/Core/Billing/Pricing/PricingClient.cs b/src/Core/Billing/Pricing/PricingClient.cs index 1ec44c6496..6fdef73885 100644 --- a/src/Core/Billing/Pricing/PricingClient.cs +++ b/src/Core/Billing/Pricing/PricingClient.cs @@ -186,6 +186,6 @@ public class PricingClient( Available = true, LegacyYear = null, Seat = new Purchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually }, - Storage = new Purchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal } + Storage = new Purchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal, Provided = 1 } }; } diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 3170060de4..daa06b907a 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -101,7 +101,9 @@ public class PremiumUserBillingService( */ customer = await ReconcileBillingLocationAsync(customer, customerSetup.TaxInformation); - var subscription = await CreateSubscriptionAsync(user.Id, customer, storage); + var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); + + var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, storage); switch (customerSetup.TokenizedPaymentSource) { @@ -119,6 +121,7 @@ public class PremiumUserBillingService( user.Gateway = GatewayType.Stripe; user.GatewayCustomerId = customer.Id; user.GatewaySubscriptionId = subscription.Id; + user.MaxStorageGb = (short)(premiumPlan.Storage.Provided + (storage ?? 0)); await userRepository.ReplaceAsync(user); } @@ -301,9 +304,9 @@ public class PremiumUserBillingService( private async Task CreateSubscriptionAsync( Guid userId, Customer customer, + Pricing.Premium.Plan premiumPlan, int? storage) { - var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); var subscriptionItemOptionsList = new List { diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3a48380e87..3d0e7d71c9 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -162,6 +162,8 @@ public static class FeatureFlagKeys "pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; public const string RecoveryCodeSupportForSsoRequiredUsers = "pm-21153-recovery-code-support-for-sso-required"; public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; + public const string MjmlWelcomeEmailTemplates = "mjml-welcome-email-templates"; + public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow"; /* Autofill Team */ public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 4901c5b43c..81370fe173 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -68,6 +68,9 @@ + + + diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs index fad0af840d..f9cc04f73e 100644 --- a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs @@ -29,8 +29,8 @@ .mj-outlook-group-fix { width:100% !important; } - - + + - - - - + + + + - + - + - - + +
- + - - + +
- +
- +
- - + + - - + +
- +
- +
- + - + - + - +
- +
- + - +
- +
- +

Verify your email to access this Bitwarden Send

- +
- +
- + - +
- + - + - - +
- + +
- + - +
- +
- +
- +
- +
- - + + - - + +
- +
- +
- - + + - + - + - - + +
- +
- - + +
- +
- +
- +
- + - + - + - + - + - +
- +
Your verification code is:
- +
- +
{{Token}}
- +
- +
- +
- -
This code expires in {{Expiry}} minutes. After that, you'll need to - verify your email again.
- + +
This code expires in {{Expiry}} minutes. After that, you'll need + to verify your email again.
+
- +
- +
- +
- +
- - + + - - + +
- +
- +
- +
- + - + - +
- +

Bitwarden Send transmits sensitive, temporary information to others easily and securely. Learn more about @@ -325,160 +333,160 @@ sign up to try it today.

- +
- +
- +
- +
- +
- - + +
- +
- - + + - + - + - - + +
- +
- - + +
- +
- +
- + - + - +
- +

- Learn more about Bitwarden -

- Find user guides, product documentation, and videos on the - Bitwarden Help Center.
- + Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center. +
- +
- + - +
- + - + - - +
- +
- +
- +
- +
- - + +
- +
- - + + - + - + - - + +
- +
- +
- + - + - + - +
- - + + - + - + - +
@@ -493,15 +501,15 @@
- + - + - +
@@ -516,15 +524,15 @@
- + - + - +
@@ -539,15 +547,15 @@
- + - + - +
@@ -562,15 +570,15 @@
- + - + - +
@@ -585,15 +593,15 @@
- + - + - +
@@ -608,15 +616,15 @@
- + - + - +
@@ -631,20 +639,20 @@
- - + +
- +

© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA @@ -655,28 +663,29 @@ bitwarden.com | Learn why we include this

- +
- +
- +
- +
- - + + - - + +
- + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs new file mode 100644 index 0000000000..3cbc9446c8 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs @@ -0,0 +1,915 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ Welcome to Bitwarden! +

+ +

+ Let's get set up to autofill. +

+
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
A {{OrganizationName}} administrator will approve you + before you can share passwords. While you wait for approval, get + started with Bitwarden Password Manager:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Browser Extension Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
With the Bitwarden extension, you can fill passwords with one click.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Install Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Quickly transfer existing passwords to Bitwarden using the importer.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Devices Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Take your passwords with you anywhere.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.text.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.text.hbs new file mode 100644 index 0000000000..38f53e7755 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.text.hbs @@ -0,0 +1,19 @@ +{{#>FullTextLayout}} +Welcome to Bitwarden! +Let's get you set up with autofill. + +A {{OrganizationName}} administrator will approve you before you can share passwords. +While you wait for approval, get started with Bitwarden Password Manager: + +Get the browser extension: +With the Bitwarden extension, you can fill passwords with one click. (https://www.bitwarden.com/download) + +Add passwords to your vault: +Quickly transfer existing passwords to Bitwarden using the importer. (https://bitwarden.com/help/import-data/) + +Download Bitwarden on all devices: +Take your passwords with you anywhere. (https://www.bitwarden.com/download) + +Learn more about Bitwarden +Find user guides, product documentation, and videos on the Bitwarden Help Center. (https://bitwarden.com/help/) +{{/FullTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.html.hbs new file mode 100644 index 0000000000..d77542bfb6 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.html.hbs @@ -0,0 +1,914 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ Welcome to Bitwarden! +

+ +

+ Let's get set up to autofill. +

+
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
Follow these simple steps to get up and running with Bitwarden + Password Manager:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Browser Extension Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
With the Bitwarden extension, you can fill passwords with one click.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Install Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Quickly transfer existing passwords to Bitwarden using the importer.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Devices Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Take your passwords with you anywhere.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.text.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.text.hbs new file mode 100644 index 0000000000..f698e79ca7 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.text.hbs @@ -0,0 +1,18 @@ +{{#>FullTextLayout}} +Welcome to Bitwarden! +Let's get you set up with autofill. + +Follow these simple steps to get up and running with Bitwarden Password Manager: + +Get the browser extension: +With the Bitwarden extension, you can fill passwords with one click. (https://www.bitwarden.com/download) + +Add passwords to your vault: +Quickly transfer existing passwords to Bitwarden using the importer. (https://bitwarden.com/help/import-data/) + +Download Bitwarden on all devices: +Take your passwords with you anywhere. (https://bitwarden.com/help/auto-fill-browser/) + +Learn more about Bitwarden +Find user guides, product documentation, and videos on the Bitwarden Help Center. (https://bitwarden.com/help/) +{{/FullTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.html.hbs new file mode 100644 index 0000000000..2b1141caad --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.html.hbs @@ -0,0 +1,915 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ Welcome to Bitwarden! +

+ +

+ Let's get set up to autofill. +

+
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
A {{OrganizationName}} administrator will need to confirm + you before you can share passwords. Get started with Bitwarden + Password Manager:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Browser Extension Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
With the Bitwarden extension, you can fill passwords with one click.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Install Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Quickly transfer existing passwords to Bitwarden using the importer.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Autofill Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Fill your passwords securely with one click.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.text.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.text.hbs new file mode 100644 index 0000000000..3808cc818d --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.text.hbs @@ -0,0 +1,20 @@ +{{#>FullTextLayout}} +Welcome to Bitwarden! +Let's get you set up with autofill. + +A {{OrganizationName}} administrator will approve you before you can share passwords. +Get started with Bitwarden Password Manager: + +Get the browser extension: +With the Bitwarden extension, you can fill passwords with one click. (https://www.bitwarden.com/download) + +Add passwords to your vault: +Quickly transfer existing passwords to Bitwarden using the importer. (https://bitwarden.com/help/import-data/) + +Try Bitwarden autofill: +Fill your passwords securely with one click. (https://bitwarden.com/help/auto-fill-browser/) + + +Learn more about Bitwarden +Find user guides, product documentation, and videos on the Bitwarden Help Center. (https://bitwarden.com/help/) +{{/FullTextLayout}} diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-free-user.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-individual-user.mjml similarity index 100% rename from src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-free-user.mjml rename to src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-individual-user.mjml diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml index d3d4eb9891..660bbf0b45 100644 --- a/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml @@ -1,7 +1,13 @@ - + + .send-bubble { + padding-left: 20px; + padding-right: 20px; + width: 90% !important; + } + @@ -18,18 +24,17 @@ Your verification code is: - {{Token}} + + {{Token}} + - This code expires in {{Expiry}} minutes. After that, you'll need to - verify your email again. + This code expires in {{Expiry}} minutes. After that, you'll need + to verify your email again. - + + /// Converts Stripe's minor currency units (cents) to major currency units (dollars). + /// IMPORTANT: Only supports USD. All Bitwarden subscriptions are USD-only. + ///
+ private const decimal StripeMinorUnitDivisor = 100M; + /// + /// Converts Stripe's minor currency units (cents) to major currency units (dollars). + /// Preserves null semantics to distinguish between "no amount" (null) and "zero amount" (0.00m). + /// + /// The amount in Stripe's minor currency units (e.g., cents for USD). + /// The amount in major currency units (e.g., dollars for USD), or null if the input is null. + private static decimal? ConvertFromStripeMinorUnits(long? amountInCents) + { + return amountInCents.HasValue ? amountInCents.Value / StripeMinorUnitDivisor : null; + } + + public BillingCustomerDiscount? CustomerDiscount { get; set; } + public BillingSubscription? Subscription { get; set; } + public BillingUpcomingInvoice? UpcomingInvoice { get; set; } + + /// + /// Represents customer discount information from Stripe billing. + /// public class BillingCustomerDiscount { public BillingCustomerDiscount() { } + /// + /// Creates a BillingCustomerDiscount from a Stripe Discount object. + /// + /// The Stripe discount containing coupon and expiration information. public BillingCustomerDiscount(Discount discount) { Id = discount.Coupon?.Id; + // Active = true only for perpetual/recurring discounts (no end date) + // This is intentional for Milestone 2 - only perpetual discounts are shown in UI Active = discount.End == null; PercentOff = discount.Coupon?.PercentOff; - AppliesTo = discount.Coupon?.AppliesTo?.Products ?? []; + AmountOff = ConvertFromStripeMinorUnits(discount.Coupon?.AmountOff); + // Stripe's CouponAppliesTo.Products is already IReadOnlyList, so no conversion needed + AppliesTo = discount.Coupon?.AppliesTo?.Products; } - public string Id { get; set; } + /// + /// The Stripe coupon ID (e.g., "cm3nHfO1"). + /// Note: Only specific coupon IDs are displayed in the UI based on feature flag configuration, + /// though Stripe may apply additional discounts that are not shown. + /// + public string? Id { get; set; } + + /// + /// True only for perpetual/recurring discounts (End == null). + /// False for any discount with an expiration date, even if not yet expired. + /// Product decision for Milestone 2: only show perpetual discounts in UI. + /// public bool Active { get; set; } + + /// + /// Percentage discount applied to the subscription (e.g., 20.0 for 20% off). + /// Null if this is an amount-based discount. + /// public decimal? PercentOff { get; set; } - public List AppliesTo { get; set; } + + /// + /// Fixed amount discount in USD (e.g., 14.00 for $14 off). + /// Converted from Stripe's cent-based values (1400 cents → $14.00). + /// Null if this is a percentage-based discount. + /// + public decimal? AmountOff { get; set; } + + /// + /// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]). + /// + /// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe). + /// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe). + /// Non-empty list: discount applies only to the specified product IDs. + /// + /// + public IReadOnlyList? AppliesTo { get; set; } } public class BillingSubscription { public BillingSubscription(Subscription sub) { - Status = sub.Status; - TrialStartDate = sub.TrialStart; - TrialEndDate = sub.TrialEnd; - var currentPeriod = sub.GetCurrentPeriod(); + Status = sub?.Status; + TrialStartDate = sub?.TrialStart; + TrialEndDate = sub?.TrialEnd; + var currentPeriod = sub?.GetCurrentPeriod(); if (currentPeriod != null) { var (start, end) = currentPeriod.Value; PeriodStartDate = start; PeriodEndDate = end; } - CancelledDate = sub.CanceledAt; - CancelAtEndDate = sub.CancelAtPeriodEnd; - Cancelled = sub.Status == "canceled" || sub.Status == "unpaid" || sub.Status == "incomplete_expired"; - if (sub.Items?.Data != null) + CancelledDate = sub?.CanceledAt; + CancelAtEndDate = sub?.CancelAtPeriodEnd ?? false; + var status = sub?.Status; + Cancelled = status == "canceled" || status == "unpaid" || status == "incomplete_expired"; + if (sub?.Items?.Data != null) { Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i)); } - CollectionMethod = sub.CollectionMethod; - GracePeriod = sub.CollectionMethod == "charge_automatically" + CollectionMethod = sub?.CollectionMethod; + GracePeriod = sub?.CollectionMethod == "charge_automatically" ? 14 : 30; } @@ -64,10 +124,10 @@ public class SubscriptionInfo public TimeSpan? PeriodDuration => PeriodEndDate - PeriodStartDate; public DateTime? CancelledDate { get; set; } public bool CancelAtEndDate { get; set; } - public string Status { get; set; } + public string? Status { get; set; } public bool Cancelled { get; set; } public IEnumerable Items { get; set; } = new List(); - public string CollectionMethod { get; set; } + public string? CollectionMethod { get; set; } public DateTime? SuspensionDate { get; set; } public DateTime? UnpaidPeriodEndDate { get; set; } public int GracePeriod { get; set; } @@ -80,7 +140,7 @@ public class SubscriptionInfo { ProductId = item.Plan.ProductId; Name = item.Plan.Nickname; - Amount = item.Plan.Amount.GetValueOrDefault() / 100M; + Amount = ConvertFromStripeMinorUnits(item.Plan.Amount) ?? 0; Interval = item.Plan.Interval; if (item.Metadata != null) @@ -90,15 +150,15 @@ public class SubscriptionInfo } Quantity = (int)item.Quantity; - SponsoredSubscriptionItem = Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id); + SponsoredSubscriptionItem = item.Plan != null && Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id); } public bool AddonSubscriptionItem { get; set; } - public string ProductId { get; set; } - public string Name { get; set; } + public string? ProductId { get; set; } + public string? Name { get; set; } public decimal Amount { get; set; } public int Quantity { get; set; } - public string Interval { get; set; } + public string? Interval { get; set; } public bool SponsoredSubscriptionItem { get; set; } } } @@ -109,7 +169,7 @@ public class SubscriptionInfo public BillingUpcomingInvoice(Invoice inv) { - Amount = inv.AmountDue / 100M; + Amount = ConvertFromStripeMinorUnits(inv.AmountDue) ?? 0; Date = inv.Created; } diff --git a/src/Core/Models/Mail/Auth/OrganizationWelcomeEmailViewModel.cs b/src/Core/Models/Mail/Auth/OrganizationWelcomeEmailViewModel.cs new file mode 100644 index 0000000000..b852d24ec9 --- /dev/null +++ b/src/Core/Models/Mail/Auth/OrganizationWelcomeEmailViewModel.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Models.Mail.Auth; + +public class OrganizationWelcomeEmailViewModel : BaseMailModel +{ + public required string OrganizationName { get; set; } +} diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index 2b39e6cca6..b704cb0460 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -254,9 +254,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand organization.UseApi = newPlan.HasApi; organization.SelfHost = newPlan.HasSelfHost; organization.UsePolicies = newPlan.HasPolicies; - organization.MaxStorageGb = !newPlan.PasswordManager.BaseStorageGb.HasValue - ? (short?)null - : (short)(newPlan.PasswordManager.BaseStorageGb.Value + upgrade.AdditionalStorageGb); + organization.MaxStorageGb = (short)(newPlan.PasswordManager.BaseStorageGb + upgrade.AdditionalStorageGb); organization.UseGroups = newPlan.HasGroups; organization.UseDirectory = newPlan.HasDirectory; organization.UseEvents = newPlan.HasEvents; diff --git a/src/Core/Platform/Mail/HandlebarsMailService.cs b/src/Core/Platform/Mail/HandlebarsMailService.cs index 072fe79e71..a602129886 100644 --- a/src/Core/Platform/Mail/HandlebarsMailService.cs +++ b/src/Core/Platform/Mail/HandlebarsMailService.cs @@ -424,6 +424,8 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + // TODO: DO NOT move to IMailer implementation: PM-27852 + [Obsolete("Use SendIndividualUserWelcomeEmailAsync instead")] public async Task SendWelcomeEmailAsync(User user) { var message = CreateDefaultMessage("Welcome to Bitwarden!", user.Email); @@ -437,6 +439,50 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + // TODO: Move to IMailer implementation: PM-27852 + public async Task SendIndividualUserWelcomeEmailAsync(User user) + { + var message = CreateDefaultMessage("Welcome to Bitwarden!", user.Email); + var model = new BaseMailModel + { + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName + }; + await AddMessageContentAsync(message, "MJML.Auth.Onboarding.welcome-individual-user", model); + message.Category = "Welcome"; + await _mailDeliveryService.SendEmailAsync(message); + } + + // TODO: Move to IMailer implementation: PM-27852 + public async Task SendOrganizationUserWelcomeEmailAsync(User user, string organizationName) + { + var message = CreateDefaultMessage("Welcome to Bitwarden!", user.Email); + var model = new OrganizationWelcomeEmailViewModel + { + OrganizationName = CoreHelpers.SanitizeForEmail(organizationName, false), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName + }; + await AddMessageContentAsync(message, "MJML.Auth.Onboarding.welcome-org-user", model); + message.Category = "Welcome"; + await _mailDeliveryService.SendEmailAsync(message); + } + + // TODO: Move to IMailer implementation: PM-27852 + public async Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(User user, string familyOrganizationName) + { + var message = CreateDefaultMessage("Welcome to Bitwarden!", user.Email); + var model = new OrganizationWelcomeEmailViewModel + { + OrganizationName = CoreHelpers.SanitizeForEmail(familyOrganizationName, false), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName + }; + await AddMessageContentAsync(message, "MJML.Auth.Onboarding.welcome-family-user", model); + message.Category = "Welcome"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendTrialInitiationEmailAsync(string userEmail) { var message = CreateDefaultMessage("Welcome to Bitwarden; 3 steps to get started!", userEmail); diff --git a/src/Core/Platform/Mail/IMailService.cs b/src/Core/Platform/Mail/IMailService.cs index 52fbdb9b6d..16c5c312fe 100644 --- a/src/Core/Platform/Mail/IMailService.cs +++ b/src/Core/Platform/Mail/IMailService.cs @@ -15,7 +15,28 @@ namespace Bit.Core.Services; [Obsolete("The IMailService has been deprecated in favor of the IMailer. All new emails should be sent with an IMailer implementation.")] public interface IMailService { + [Obsolete("Use SendIndividualUserWelcomeEmailAsync instead")] Task SendWelcomeEmailAsync(User user); + /// + /// Email sent to users who have created a new account as an individual user. + /// + /// The new User + /// Task + Task SendIndividualUserWelcomeEmailAsync(User user); + /// + /// Email sent to users who have been confirmed to an organization. + /// + /// The User + /// The Organization user is being added to + /// Task + Task SendOrganizationUserWelcomeEmailAsync(User user, string organizationName); + /// + /// Email sent to users who have been confirmed to a free or families organization. + /// + /// The User + /// The Families Organization user is being added to + /// Task + Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(User user, string familyOrganizationName); Task SendVerifyEmailEmailAsync(string email, Guid userId, string token); Task SendRegistrationVerificationEmailAsync(string email, string token); Task SendTrialInitiationSignupEmailAsync( diff --git a/src/Core/Platform/Mail/NoopMailService.cs b/src/Core/Platform/Mail/NoopMailService.cs index 45a860a155..da55470db3 100644 --- a/src/Core/Platform/Mail/NoopMailService.cs +++ b/src/Core/Platform/Mail/NoopMailService.cs @@ -114,6 +114,20 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendIndividualUserWelcomeEmailAsync(User user) + { + return Task.FromResult(0); + } + + public Task SendOrganizationUserWelcomeEmailAsync(User user, string organizationName) + { + return Task.FromResult(0); + } + + public Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(User user, string familyOrganizationName) + { + return Task.FromResult(0); + } public Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token) { return Task.FromResult(0); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index ff99393955..5dd1ff50e7 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -643,9 +643,21 @@ public class StripePaymentService : IPaymentService var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = ["customer.discount.coupon.applies_to", "discounts.coupon.applies_to", "test_clock"] }); + if (subscription == null) + { + return subscriptionInfo; + } + subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(subscription); - var discount = subscription.Customer.Discount ?? subscription.Discounts.FirstOrDefault(); + // Discount selection priority: + // 1. Customer-level discount (applies to all subscriptions for the customer) + // 2. First subscription-level discount (if multiple exist, FirstOrDefault() selects the first one) + // Note: When multiple subscription-level discounts exist, only the first one is used. + // This matches Stripe's behavior where the first discount in the list is applied. + // Defensive null checks: Even though we expand "customer" and "discounts", external APIs + // may not always return the expected data structure, so we use null-safe operators. + var discount = subscription.Customer?.Discount ?? subscription.Discounts?.FirstOrDefault(); if (discount != null) { diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index daf1b2078d..57b69deb71 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -904,7 +904,6 @@ public class UserService : UserManager, IUserService } else { - user.MaxStorageGb = (short)(1 + additionalStorageGb); user.LicenseKey = CoreHelpers.SecureRandomString(20); } @@ -977,7 +976,8 @@ public class UserService : UserManager, IUserService var premiumPlan = await _pricingClient.GetAvailablePremiumPlan(); - var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb, premiumPlan.Storage.StripePriceId); + var baseStorageGb = (short)premiumPlan.Storage.Provided; + var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb, premiumPlan.Storage.StripePriceId, baseStorageGb); await SaveUserAsync(user); return secret; } diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index c467d1e652..e2c2168656 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -783,6 +783,19 @@ public class GlobalSettings : IGlobalSettings { public virtual IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings(); public virtual IConnectionStringSettings Cosmos { get; set; } = new ConnectionStringSettings(); + + public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(30); + public bool IsFailSafeEnabled { get; set; } = true; + public TimeSpan FailSafeMaxDuration { get; set; } = TimeSpan.FromHours(2); + public TimeSpan FailSafeThrottleDuration { get; set; } = TimeSpan.FromSeconds(30); + public float? EagerRefreshThreshold { get; set; } = 0.9f; + public TimeSpan FactorySoftTimeout { get; set; } = TimeSpan.FromMilliseconds(100); + public TimeSpan FactoryHardTimeout { get; set; } = TimeSpan.FromMilliseconds(1500); + public TimeSpan DistributedCacheSoftTimeout { get; set; } = TimeSpan.FromSeconds(1); + public TimeSpan DistributedCacheHardTimeout { get; set; } = TimeSpan.FromSeconds(2); + public bool AllowBackgroundDistributedCacheOperations { get; set; } = true; + public TimeSpan JitterMaxDuration { get; set; } = TimeSpan.FromSeconds(2); + public TimeSpan DistributedCacheCircuitBreakerDuration { get; set; } = TimeSpan.FromSeconds(30); } public class WebPushSettings : IWebPushSettings diff --git a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs index c7f7e3aff7..fa558f5963 100644 --- a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs +++ b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs @@ -150,17 +150,34 @@ public class ImportCiphersCommand : IImportCiphersCommand foreach (var collection in collections) { - if (!organizationCollectionsIds.Contains(collection.Id)) + // If the collection already exists, skip it + if (organizationCollectionsIds.Contains(collection.Id)) { - collection.SetNewId(); - newCollections.Add(collection); - newCollectionUsers.Add(new CollectionUser - { - CollectionId = collection.Id, - OrganizationUserId = importingOrgUser.Id, - Manage = true - }); + continue; } + + // Create new collections if not already present + collection.SetNewId(); + newCollections.Add(collection); + + /* + * If the organization was created by a Provider, the organization may have zero members (users) + * In this situation importingOrgUser will be null, and accessing importingOrgUser.Id will + * result in a null reference exception. + * + * Avoid user assignment, but proceed with adding the collection. + */ + if (importingOrgUser == null) + { + continue; + } + + newCollectionUsers.Add(new CollectionUser + { + CollectionId = collection.Id, + OrganizationUserId = importingOrgUser.Id, + Manage = true + }); } // Create associations based on the newly assigned ids diff --git a/src/Core/Utilities/BillingHelpers.cs b/src/Core/Utilities/BillingHelpers.cs index e7ccfc3547..2c1dfcbbbd 100644 --- a/src/Core/Utilities/BillingHelpers.cs +++ b/src/Core/Utilities/BillingHelpers.cs @@ -7,7 +7,7 @@ namespace Bit.Core.Utilities; public static class BillingHelpers { internal static async Task AdjustStorageAsync(IPaymentService paymentService, IStorableSubscriber storableSubscriber, - short storageAdjustmentGb, string storagePlanId) + short storageAdjustmentGb, string storagePlanId, short baseStorageGb) { if (storableSubscriber == null) { @@ -30,9 +30,9 @@ public static class BillingHelpers } var newStorageGb = (short)(storableSubscriber.MaxStorageGb.Value + storageAdjustmentGb); - if (newStorageGb < 1) + if (newStorageGb < baseStorageGb) { - newStorageGb = 1; + newStorageGb = baseStorageGb; } if (newStorageGb > 100) @@ -48,7 +48,7 @@ public static class BillingHelpers "Delete some stored data first."); } - var additionalStorage = newStorageGb - 1; + var additionalStorage = newStorageGb - baseStorageGb; var paymentIntentClientSecret = await paymentService.AdjustStorageAsync(storableSubscriber, additionalStorage, storagePlanId); storableSubscriber.MaxStorageGb = newStorageGb; diff --git a/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs b/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs new file mode 100644 index 0000000000..3f926fd468 --- /dev/null +++ b/src/Core/Utilities/ExtendedCacheServiceCollectionExtensions.cs @@ -0,0 +1,90 @@ +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.StackExchangeRedis; +using Microsoft.Extensions.DependencyInjection.Extensions; +using StackExchange.Redis; +using ZiggyCreatures.Caching.Fusion; +using ZiggyCreatures.Caching.Fusion.Backplane; +using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; +using ZiggyCreatures.Caching.Fusion.Serialization.SystemTextJson; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class ExtendedCacheServiceCollectionExtensions +{ + /// + /// Add Fusion Cache to the service + /// collection.
+ ///
+ /// If Redis is configured, it uses Redis for an L2 cache and backplane. If not, it simply uses in-memory caching. + ///
+ public static IServiceCollection TryAddExtendedCacheServices(this IServiceCollection services, GlobalSettings globalSettings) + { + if (services.Any(s => s.ServiceType == typeof(IFusionCache))) + { + return services; + } + + var fusionCacheBuilder = services.AddFusionCache() + .WithOptions(options => + { + options.DistributedCacheCircuitBreakerDuration = globalSettings.DistributedCache.DistributedCacheCircuitBreakerDuration; + }) + .WithDefaultEntryOptions(new FusionCacheEntryOptions + { + Duration = globalSettings.DistributedCache.Duration, + IsFailSafeEnabled = globalSettings.DistributedCache.IsFailSafeEnabled, + FailSafeMaxDuration = globalSettings.DistributedCache.FailSafeMaxDuration, + FailSafeThrottleDuration = globalSettings.DistributedCache.FailSafeThrottleDuration, + EagerRefreshThreshold = globalSettings.DistributedCache.EagerRefreshThreshold, + FactorySoftTimeout = globalSettings.DistributedCache.FactorySoftTimeout, + FactoryHardTimeout = globalSettings.DistributedCache.FactoryHardTimeout, + DistributedCacheSoftTimeout = globalSettings.DistributedCache.DistributedCacheSoftTimeout, + DistributedCacheHardTimeout = globalSettings.DistributedCache.DistributedCacheHardTimeout, + AllowBackgroundDistributedCacheOperations = globalSettings.DistributedCache.AllowBackgroundDistributedCacheOperations, + JitterMaxDuration = globalSettings.DistributedCache.JitterMaxDuration + }) + .WithSerializer( + new FusionCacheSystemTextJsonSerializer() + ); + + if (!CoreHelpers.SettingHasValue(globalSettings.DistributedCache.Redis.ConnectionString)) + { + return services; + } + + services.TryAddSingleton(sp => + ConnectionMultiplexer.Connect(globalSettings.DistributedCache.Redis.ConnectionString)); + + fusionCacheBuilder + .WithDistributedCache(sp => + { + var cache = sp.GetService(); + if (cache is not null) + { + return cache; + } + var mux = sp.GetRequiredService(); + return new RedisCache(new RedisCacheOptions + { + ConnectionMultiplexerFactory = () => Task.FromResult(mux) + }); + }) + .WithBackplane(sp => + { + var backplane = sp.GetService(); + if (backplane is not null) + { + return backplane; + } + var mux = sp.GetRequiredService(); + return new RedisBackplane(new RedisBackplaneOptions + { + ConnectionMultiplexerFactory = () => Task.FromResult(mux) + }); + }); + + return services; + } +} diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql index ac7be1bbae..c6816a1226 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql @@ -23,4 +23,10 @@ BEGIN DECLARE @UpdateCollectionsSuccess INT EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds + + -- Bump the account revision date AFTER collections are assigned. + IF @UpdateCollectionsSuccess = 0 + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END END diff --git a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs new file mode 100644 index 0000000000..d84fddd282 --- /dev/null +++ b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs @@ -0,0 +1,800 @@ +using System.Security.Claims; +using Bit.Api.Billing.Controllers; +using Bit.Core; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Queries.Interfaces; +using Bit.Core.Models.Business; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Test.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Api.Test.Billing.Controllers; + +[SubscriptionInfoCustomize] +public class AccountsControllerTests : IDisposable +{ + private const string TestMilestone2CouponId = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount; + + private readonly IUserService _userService; + private readonly IFeatureService _featureService; + private readonly IPaymentService _paymentService; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IUserAccountKeysQuery _userAccountKeysQuery; + private readonly GlobalSettings _globalSettings; + private readonly AccountsController _sut; + + public AccountsControllerTests() + { + _userService = Substitute.For(); + _featureService = Substitute.For(); + _paymentService = Substitute.For(); + _twoFactorIsEnabledQuery = Substitute.For(); + _userAccountKeysQuery = Substitute.For(); + _globalSettings = new GlobalSettings { SelfHosted = false }; + + _sut = new AccountsController( + _userService, + _twoFactorIsEnabledQuery, + _userAccountKeysQuery, + _featureService + ); + } + + public void Dispose() + { + _sut?.Dispose(); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenFeatureFlagEnabled_IncludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenFeatureFlagDisabled_ExcludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when feature flag is disabled + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithNonMatchingCouponId_ExcludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = "different-coupon-id", // Non-matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when coupon ID doesn't match + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenSelfHosted_ReturnsBasicResponse(User user) + { + // Arrange + var selfHostedSettings = new GlobalSettings { SelfHosted = true }; + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + + // Act + var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenNoGateway_ExcludesDiscount(User user, UserLicense license) + { + // Arrange + user.Gateway = null; // No gateway configured + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _userService.GenerateLicenseAsync(user).Returns(license); + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when no gateway + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithInactiveDiscount_ExcludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = false, // Inactive discount + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when discount is inactive + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_FullPipeline_ConvertsStripeDiscountToApiResponse( + User user, + UserLicense license) + { + // Arrange - Create a Stripe Discount object with real structure + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 25m, + AmountOff = 1400, // 1400 cents = $14.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_families" } + } + }, + End = null // Active discount + }; + + // Convert Stripe Discount to BillingCustomerDiscount (simulating what StripePaymentService does) + var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify full pipeline conversion + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + + // Verify Stripe data correctly converted to API response + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(25m, result.CustomerDiscount.PercentOff); + + // Verify cents-to-dollars conversion (1400 cents -> $14.00) + Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); + + // Verify AppliesTo products are preserved + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count()); + Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo); + Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_FullPipeline_WithFeatureFlagToggle_ControlsVisibility( + User user, + UserLicense license) + { + // Arrange - Create Stripe Discount + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 20m + }, + End = null + }; + + var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act & Assert - Feature flag ENABLED + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + var resultWithFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + Assert.NotNull(resultWithFlag.CustomerDiscount); + + // Act & Assert - Feature flag DISABLED + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false); + var resultWithoutFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + Assert.Null(resultWithoutFlag.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineFromStripeToApiResponse( + User user, + UserLicense license) + { + // Arrange - Create a real Stripe Discount object as it would come from Stripe API + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 30m, + AmountOff = 2000, // 2000 cents = $20.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_families", "prod_teams" } + } + }, + End = null // Active discount (no end date) + }; + + // Step 1: Map Stripe Discount through SubscriptionInfo.BillingCustomerDiscount + // This simulates what StripePaymentService.GetSubscriptionAsync does + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + + // Verify the mapping worked correctly + Assert.Equal(TestMilestone2CouponId, billingCustomerDiscount.Id); + Assert.True(billingCustomerDiscount.Active); + Assert.Equal(30m, billingCustomerDiscount.PercentOff); + Assert.Equal(20.00m, billingCustomerDiscount.AmountOff); // Converted from cents + Assert.NotNull(billingCustomerDiscount.AppliesTo); + Assert.Equal(3, billingCustomerDiscount.AppliesTo.Count); + + // Step 2: Create SubscriptionInfo with the mapped discount + // This simulates what StripePaymentService returns + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount + }; + + // Step 3: Set up controller dependencies + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act - Step 4: Call AccountsController.GetSubscriptionAsync + // This exercises the complete pipeline: + // - Retrieves subscriptionInfo from paymentService (with discount from Stripe) + // - Maps through SubscriptionInfo.BillingCustomerDiscount (already done above) + // - Filters in SubscriptionResponseModel constructor (based on feature flag, coupon ID, active status) + // - Returns via AccountsController + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify the complete pipeline worked end-to-end + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + + // Verify Stripe Discount → SubscriptionInfo.BillingCustomerDiscount mapping + // (verified above, but confirming it made it through) + + // Verify SubscriptionInfo.BillingCustomerDiscount → SubscriptionResponseModel.BillingCustomerDiscount filtering + // The filter should pass because: + // - includeMilestone2Discount = true (feature flag enabled) + // - subscription.CustomerDiscount != null + // - subscription.CustomerDiscount.Id == Milestone2SubscriptionDiscount + // - subscription.CustomerDiscount.Active = true + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(30m, result.CustomerDiscount.PercentOff); + Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Verify cents-to-dollars conversion + + // Verify AppliesTo products are preserved through the entire pipeline + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Equal(3, result.CustomerDiscount.AppliesTo.Count()); + Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo); + Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo); + Assert.Contains("prod_teams", result.CustomerDiscount.AppliesTo); + + // Verify the payment service was called correctly + await _paymentService.Received(1).GetSubscriptionAsync(user); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_MultipleDiscountsInSubscription_PrefersCustomerDiscount( + User user, + UserLicense license) + { + // Arrange - Create Stripe subscription with multiple discounts + // Customer discount should be preferred over subscription discounts + var customerDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 30m, + AmountOff = null + }, + End = null + }; + + var subscriptionDiscount1 = new Discount + { + Coupon = new Coupon + { + Id = "other-coupon-1", + PercentOff = 10m + }, + End = null + }; + + var subscriptionDiscount2 = new Discount + { + Coupon = new Coupon + { + Id = "other-coupon-2", + PercentOff = 15m + }, + End = null + }; + + // Map through SubscriptionInfo.BillingCustomerDiscount + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customerDiscount); + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Should use customer discount, not subscription discounts + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.Equal(30m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_BothPercentOffAndAmountOffPresent_HandlesEdgeCase( + User user, + UserLicense license) + { + // Arrange - Edge case: Stripe coupon with both PercentOff and AmountOff + // This tests the scenario mentioned in BillingCustomerDiscountTests.cs line 212-232 + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 25m, + AmountOff = 2000, // 2000 cents = $20.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium" } + } + }, + End = null + }; + + // Map through SubscriptionInfo.BillingCustomerDiscount + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Both values should be preserved through the pipeline + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.Equal(25m, result.CustomerDiscount.PercentOff); + Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Converted from cents + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_BillingSubscriptionMapsThroughPipeline( + User user, + UserLicense license) + { + // Arrange - Create Stripe subscription with subscription details + var stripeSubscription = new Subscription + { + Id = "sub_test123", + Status = "active", + TrialStart = DateTime.UtcNow.AddDays(-30), + TrialEnd = DateTime.UtcNow.AddDays(-20), + CanceledAt = null, + CancelAtPeriodEnd = false, + CollectionMethod = "charge_automatically" + }; + + // Map through SubscriptionInfo.BillingSubscription + var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription); + var subscriptionInfo = new SubscriptionInfo + { + Subscription = billingSubscription, + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m + } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify BillingSubscription mapped through pipeline + Assert.NotNull(result); + Assert.NotNull(result.Subscription); + Assert.Equal("active", result.Subscription.Status); + Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_BillingUpcomingInvoiceMapsThroughPipeline( + User user, + UserLicense license) + { + // Arrange - Create Stripe invoice for upcoming invoice + var stripeInvoice = new Invoice + { + AmountDue = 2000, // 2000 cents = $20.00 + Created = DateTime.UtcNow.AddDays(1) + }; + + // Map through SubscriptionInfo.BillingUpcomingInvoice + var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice); + var subscriptionInfo = new SubscriptionInfo + { + UpcomingInvoice = billingUpcomingInvoice, + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m + } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify BillingUpcomingInvoice mapped through pipeline + Assert.NotNull(result); + Assert.NotNull(result.UpcomingInvoice); + Assert.Equal(20.00m, result.UpcomingInvoice.Amount); // Converted from cents + Assert.NotNull(result.UpcomingInvoice.Date); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineWithAllComponents( + User user, + UserLicense license) + { + // Arrange - Complete Stripe objects for full pipeline test + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 20m, + AmountOff = 1000, // $10.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_families" } + } + }, + End = null + }; + + var stripeSubscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically" + }; + + var stripeInvoice = new Invoice + { + AmountDue = 1500, // $15.00 + Created = DateTime.UtcNow.AddDays(7) + }; + + // Map through SubscriptionInfo (simulating StripePaymentService) + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription); + var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice); + + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount, + Subscription = billingSubscription, + UpcomingInvoice = billingUpcomingInvoice + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act - Full pipeline: Stripe → SubscriptionInfo → SubscriptionResponseModel → API response + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify all components mapped correctly through the pipeline + Assert.NotNull(result); + + // Verify discount + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + Assert.Equal(10.00m, result.CustomerDiscount.AmountOff); + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count()); + + // Verify subscription + Assert.NotNull(result.Subscription); + Assert.Equal("active", result.Subscription.Status); + Assert.Equal(14, result.Subscription.GracePeriod); + + // Verify upcoming invoice + Assert.NotNull(result.UpcomingInvoice); + Assert.Equal(15.00m, result.UpcomingInvoice.Amount); + Assert.NotNull(result.UpcomingInvoice.Date); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_SelfHosted_WithDiscountFlagEnabled_NeverIncludesDiscount(User user) + { + // Arrange - Self-hosted user with discount flag enabled (should still return null) + var selfHostedSettings = new GlobalSettings { SelfHosted = true }; + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled + + // Act + var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService); + + // Assert - Should never include discount for self-hosted, even with flag enabled + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_NullGateway_WithDiscountFlagEnabled_NeverIncludesDiscount( + User user, + UserLicense license) + { + // Arrange - User with null gateway and discount flag enabled (should still return null) + user.Gateway = null; // No gateway configured + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _userService.GenerateLicenseAsync(user).Returns(license); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Should never include discount when no gateway, even with flag enabled + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } +} diff --git a/test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs b/test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs new file mode 100644 index 0000000000..051a66bbd3 --- /dev/null +++ b/test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs @@ -0,0 +1,400 @@ +using Bit.Api.Models.Response; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Entities; +using Bit.Core.Models.Business; +using Bit.Test.Common.AutoFixture.Attributes; +using Stripe; +using Xunit; + +namespace Bit.Api.Test.Models.Response; + +public class SubscriptionResponseModelTests +{ + [Theory] + [BitAutoData] + public void Constructor_IncludeMilestone2DiscountTrueMatchingCouponId_ReturnsDiscount( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + Assert.Null(result.CustomerDiscount.AmountOff); + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Single(result.CustomerDiscount.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_IncludeMilestone2DiscountTrueNonMatchingCouponId_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = "different-coupon-id", // Non-matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_IncludeMilestone2DiscountFalseMatchingCouponId_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: false); + + // Assert - Should be null because includeMilestone2Discount is false + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullCustomerDiscount_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = null + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_AmountOffDiscountMatchingCouponId_ReturnsDiscount( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = null, + AmountOff = 14.00m, // Already converted from cents in BillingCustomerDiscount + AppliesTo = new List() + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Null(result.CustomerDiscount.PercentOff); + Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_DefaultIncludeMilestone2DiscountParameter_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 20m + } + }; + + // Act - Using default parameter (includeMilestone2Discount defaults to false) + var result = new SubscriptionResponseModel(user, subscriptionInfo, license); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullDiscountIdIncludeMilestone2DiscountTrue_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = null, // Null discount ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_MatchingCouponIdInactiveDiscount_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID + Active = false, // Inactive discount + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_UserOnly_SetsBasicProperties(User user) + { + // Arrange + user.Storage = 5368709120; // 5 GB in bytes + user.MaxStorageGb = (short)10; + user.PremiumExpirationDate = DateTime.UtcNow.AddMonths(12); + + // Act + var result = new SubscriptionResponseModel(user); + + // Assert + Assert.NotNull(result.StorageName); + Assert.Equal(5.0, result.StorageGb); + Assert.Equal((short)10, result.MaxStorageGb); + Assert.Equal(user.PremiumExpirationDate, result.Expiration); + Assert.Null(result.License); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_UserAndLicense_IncludesLicense(User user, UserLicense license) + { + // Arrange + user.Storage = 1073741824; // 1 GB in bytes + user.MaxStorageGb = (short)5; + + // Act + var result = new SubscriptionResponseModel(user, license); + + // Assert + Assert.NotNull(result.License); + Assert.Equal(license, result.License); + Assert.Equal(1.0, result.StorageGb); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullStorage_SetsStorageToZero(User user) + { + // Arrange + user.Storage = null; + + // Act + var result = new SubscriptionResponseModel(user); + + // Assert + Assert.Null(result.StorageName); + Assert.Equal(0, result.StorageGb); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullLicense_ExcludesLicense(User user) + { + // Act + var result = new SubscriptionResponseModel(user, null); + + // Assert + Assert.Null(result.License); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_BothPercentOffAndAmountOffPresent_HandlesEdgeCase( + User user, + UserLicense license) + { + // Arrange - Edge case: Both PercentOff and AmountOff present + // This tests the scenario where Stripe coupon has both discount types + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 25m, + AmountOff = 20.00m, // Already converted from cents + AppliesTo = new List { "prod_premium" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert - Both values should be preserved + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(25m, result.CustomerDiscount.PercentOff); + Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Single(result.CustomerDiscount.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithSubscriptionAndInvoice_MapsAllProperties( + User user, + UserLicense license) + { + // Arrange - Test with Subscription, UpcomingInvoice, and CustomerDiscount + var stripeSubscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically" + }; + + var stripeInvoice = new Invoice + { + AmountDue = 1500, // 1500 cents = $15.00 + Created = DateTime.UtcNow.AddDays(7) + }; + + var subscriptionInfo = new SubscriptionInfo + { + Subscription = new SubscriptionInfo.BillingSubscription(stripeSubscription), + UpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice), + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "prod_premium" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert - Verify all properties are mapped correctly + Assert.NotNull(result.Subscription); + Assert.Equal("active", result.Subscription.Status); + Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days + + Assert.NotNull(result.UpcomingInvoice); + Assert.Equal(15.00m, result.UpcomingInvoice.Amount); + Assert.NotNull(result.UpcomingInvoice.Date); + + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullSubscriptionAndInvoice_HandlesNullsGracefully( + User user, + UserLicense license) + { + // Arrange - Test with null Subscription and UpcomingInvoice + var subscriptionInfo = new SubscriptionInfo + { + Subscription = null, + UpcomingInvoice = null, + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 20m + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert - Null Subscription and UpcomingInvoice should be handled gracefully + Assert.Null(result.Subscription); + Assert.Null(result.UpcomingInvoice); + Assert.NotNull(result.CustomerDiscount); + } +} diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 913355f2db..82fa4bb63a 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -269,6 +269,7 @@ public class UpcomingInvoiceHandlerTests Arg.Is(o => o.Items[0].Id == priceSubscriptionId && o.Items[0].Price == priceId && + o.Discounts[0].Coupon == CouponIDs.Milestone2SubscriptionDiscount && o.ProrationBehavior == "none")); // Verify the updated invoice email was sent @@ -945,4 +946,1013 @@ public class UpcomingInvoiceHandlerTests Arg.Any(), Arg.Any()); } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesSubscriptionAndOrganization() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + var premiumAccessItemId = "si_premium_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + }, + new() + { + Id = premiumAccessItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is(subscriptionId), + Arg.Is(o => + o.Items.Count == 2 && + o.Items[0].Id == passwordManagerItemId && + o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && + o.Items[1].Id == premiumAccessItemId && + o.Items[1].Deleted == true && + o.Discounts.Count == 1 && + o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && + o.ProrationBehavior == ProrationBehavior.None)); + + await _organizationRepository.Received(1).ReplaceAsync( + Arg.Is(org => + org.Id == _organizationId && + org.PlanType == PlanType.FamiliesAnnually && + org.Plan == familiesPlan.Name && + org.UsersGetPremium == familiesPlan.UsersGetPremium && + org.Seats == familiesPlan.PasswordManager.BaseSeats)); + + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("org@example.com") && + email.Subject == "Your Subscription Will Renew Soon")); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_WithoutPremiumAccess_UpdatesSubscriptionAndOrganization() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is(subscriptionId), + Arg.Is(o => + o.Items.Count == 1 && + o.Items[0].Id == passwordManagerItemId && + o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && + o.Discounts.Count == 1 && + o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && + o.ProrationBehavior == ProrationBehavior.None)); + + await _organizationRepository.Received(1).ReplaceAsync( + Arg.Is(org => + org.Id == _organizationId && + org.PlanType == PlanType.FamiliesAnnually && + org.Plan == familiesPlan.Name && + org.UsersGetPremium == familiesPlan.UsersGetPremium && + org.Seats == familiesPlan.PasswordManager.BaseSeats)); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Disabled_AndFamilies2019Plan_DoesNotUpdateSubscription() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert - should not update subscription or organization when feature flag is disabled + await _stripeFacade.DidNotReceive().UpdateSubscription( + Arg.Any(), + Arg.Is(o => o.Discounts != null)); + + await _organizationRepository.DidNotReceive().ReplaceAsync( + Arg.Is(org => org.PlanType == PlanType.FamiliesAnnually)); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_ButNotFamilies2019Plan_DoesNotUpdateSubscription() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = "si_pm_123", + Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually // Already on the new plan + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert - should not update subscription when not on FamiliesAnnually2019 plan + await _stripeFacade.DidNotReceive().UpdateSubscription( + Arg.Any(), + Arg.Is(o => o.Discounts != null)); + + await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndPasswordManagerItemNotFound_LogsWarning() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = "si_different_item", + Price = new Price { Id = "different-price-id" } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + _logger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => + o.ToString().Contains($"Could not find Organization's ({_organizationId}) password manager item") && + o.ToString().Contains(parsedEvent.Id)), + Arg.Any(), + Arg.Any>()); + + // Should not update subscription or organization when password manager item not found + await _stripeFacade.DidNotReceive().UpdateSubscription( + Arg.Any(), + Arg.Is(o => o.Discounts != null)); + + await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsError() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Simulate update failure + _stripeFacade + .UpdateSubscription(Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception("Stripe API error")); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => + o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") && + o.ToString().Contains(parsedEvent.Type) && + o.ToString().Contains(parsedEvent.Id)), + Arg.Any(), + Arg.Any>()); + + // Should still attempt to send email despite the failure + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("org@example.com") && + email.Subject == "Your Subscription Will Renew Soon")); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesItem() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + var seatAddOnItemId = "si_seat_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + }, + new() + { + Id = seatAddOnItemId, + Price = new Price { Id = "personal-org-seat-annually" }, + Quantity = 3 + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is(subscriptionId), + Arg.Is(o => + o.Items.Count == 2 && + o.Items[0].Id == passwordManagerItemId && + o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && + o.Items[1].Id == seatAddOnItemId && + o.Items[1].Deleted == true && + o.Discounts.Count == 1 && + o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && + o.ProrationBehavior == ProrationBehavior.None)); + + await _organizationRepository.Received(1).ReplaceAsync( + Arg.Is(org => + org.Id == _organizationId && + org.PlanType == PlanType.FamiliesAnnually && + org.Plan == familiesPlan.Name && + org.UsersGetPremium == familiesPlan.UsersGetPremium && + org.Seats == familiesPlan.PasswordManager.BaseSeats)); + + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("org@example.com") && + email.Subject == "Your Subscription Will Renew Soon")); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnWithQuantityOne_DeletesItem() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + var seatAddOnItemId = "si_seat_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + }, + new() + { + Id = seatAddOnItemId, + Price = new Price { Id = "personal-org-seat-annually" }, + Quantity = 1 + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is(subscriptionId), + Arg.Is(o => + o.Items.Count == 2 && + o.Items[0].Id == passwordManagerItemId && + o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && + o.Items[1].Id == seatAddOnItemId && + o.Items[1].Deleted == true && + o.Discounts.Count == 1 && + o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && + o.ProrationBehavior == ProrationBehavior.None)); + + await _organizationRepository.Received(1).ReplaceAsync( + Arg.Is(org => + org.Id == _organizationId && + org.PlanType == PlanType.FamiliesAnnually && + org.Plan == familiesPlan.Name && + org.UsersGetPremium == familiesPlan.UsersGetPremium && + org.Seats == familiesPlan.PasswordManager.BaseSeats)); + + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("org@example.com") && + email.Subject == "Your Subscription Will Renew Soon")); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_WithPremiumAccessAndSeatAddOn_UpdatesBothItems() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + var premiumAccessItemId = "si_premium_123"; + var seatAddOnItemId = "si_seat_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + }, + new() + { + Id = premiumAccessItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId } + }, + new() + { + Id = seatAddOnItemId, + Price = new Price { Id = "personal-org-seat-annually" }, + Quantity = 2 + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is(subscriptionId), + Arg.Is(o => + o.Items.Count == 3 && + o.Items[0].Id == passwordManagerItemId && + o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && + o.Items[1].Id == premiumAccessItemId && + o.Items[1].Deleted == true && + o.Items[2].Id == seatAddOnItemId && + o.Items[2].Deleted == true && + o.Discounts.Count == 1 && + o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && + o.ProrationBehavior == ProrationBehavior.None)); + + await _organizationRepository.Received(1).ReplaceAsync( + Arg.Is(org => + org.Id == _organizationId && + org.PlanType == PlanType.FamiliesAnnually && + org.Plan == familiesPlan.Name && + org.UsersGetPremium == familiesPlan.UsersGetPremium && + org.Seats == familiesPlan.PasswordManager.BaseSeats)); + + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("org@example.com") && + email.Subject == "Your Subscription Will Renew Soon")); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2025Plan_UpdatesSubscriptionOnlyNoAddons() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2025Plan = new Families2025Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2025 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is(subscriptionId), + Arg.Is(o => + o.Items.Count == 1 && + o.Items[0].Id == passwordManagerItemId && + o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && + o.Discounts == null && + o.ProrationBehavior == ProrationBehavior.None)); + + await _organizationRepository.Received(1).ReplaceAsync( + Arg.Is(org => + org.Id == _organizationId && + org.PlanType == PlanType.FamiliesAnnually && + org.Plan == familiesPlan.Name && + org.UsersGetPremium == familiesPlan.UsersGetPremium && + org.Seats == familiesPlan.PasswordManager.BaseSeats)); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Disabled_AndFamilies2025Plan_DoesNotUpdateSubscription() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2025Plan = new Families2025Plan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2025 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2025).Returns(families2025Plan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert - should not update subscription or organization when feature flag is disabled + await _stripeFacade.DidNotReceive().UpdateSubscription( + Arg.Any(), + Arg.Any()); + + await _organizationRepository.DidNotReceive().ReplaceAsync( + Arg.Is(org => org.PlanType == PlanType.FamiliesAnnually)); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs new file mode 100644 index 0000000000..4781127a3d --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs @@ -0,0 +1,628 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +[SutProviderCustomize] +public class AutomaticUserConfirmationPolicyEventHandlerTests +{ + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_SingleOrgNotEnabled_ReturnsError( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns((Policy?)null); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_SingleOrgPolicyDisabled_ReturnsError( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_UsersNotCompliantWithSingleOrg_ReturnsError( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + Guid nonCompliantUserId, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = nonCompliantUserId, + Email = "user@example.com" + }; + + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = nonCompliantUserId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_UserWithInvitedStatusInOtherOrg_ValidationPasses( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + Guid userId, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = userId, + Email = "test@email.com" + }; + + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = null, // invited users do not have a user id + Status = OrganizationUserStatusType.Invited, + Email = orgUser.Email + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_ProviderUsersExist_ReturnsError( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var providerUser = new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = Guid.NewGuid(), + UserId = Guid.NewGuid(), + Status = ProviderUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([providerUser]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("Provider user type", result, StringComparison.OrdinalIgnoreCase); + } + + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_AllValidationsPassed_ReturnsEmptyString( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = Guid.NewGuid(), + Email = "user@example.com" + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_PolicyAlreadyEnabled_ReturnsEmptyString( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, currentPolicy); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + await sutProvider.GetDependency() + .DidNotReceive() + .GetByOrganizationIdTypeAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_ReturnsEmptyString( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, currentPolicy); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + await sutProvider.GetDependency() + .DidNotReceive() + .GetByOrganizationIdTypeAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + Guid nonCompliantOwnerId, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var ownerUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.Owner, + Status = OrganizationUserStatusType.Confirmed, + UserId = nonCompliantOwnerId, + Email = "owner@example.com" + }; + + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = nonCompliantOwnerId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([ownerUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_InvitedUsersExcluded_FromComplianceCheck( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var invitedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Invited, + UserId = Guid.NewGuid(), + Email = "invited@example.com" + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([invitedUser]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_RevokedUsersExcluded_FromComplianceCheck( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var revokedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Revoked, + UserId = Guid.NewGuid(), + Email = "revoked@example.com" + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([revokedUser]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_AcceptedUsersIncluded_InComplianceCheck( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + Guid nonCompliantUserId, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var acceptedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Accepted, + UserId = nonCompliantUserId, + Email = "accepted@example.com" + }; + + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = nonCompliantUserId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([acceptedUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_EmptyOrganization_ReturnsEmptyString( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_WithSavePolicyModel_CallsValidateWithPolicyUpdate( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var savePolicyModel = new SavePolicyModel(policyUpdate); + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_EnablingPolicy_SetsUseAutomaticUserConfirmationToTrue( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Id = policyUpdate.OrganizationId; + organization.UseAutomaticUserConfirmation = false; + + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns(organization); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(o => + o.Id == organization.Id && + o.UseAutomaticUserConfirmation == true && + o.RevisionDate > DateTime.MinValue)); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_DisablingPolicy_SetsUseAutomaticUserConfirmationToFalse( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Id = policyUpdate.OrganizationId; + organization.UseAutomaticUserConfirmation = true; + + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns(organization); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(o => + o.Id == organization.Id && + o.UseAutomaticUserConfirmation == false && + o.RevisionDate > DateTime.MinValue)); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_OrganizationNotFound_DoesNotThrowException( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns((Organization?)null); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ExecutePreUpsertSideEffectAsync_CallsOnSaveSideEffectsAsync( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Id = policyUpdate.OrganizationId; + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + + var savePolicyModel = new SavePolicyModel(policyUpdate); + + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns(organization); + + // Act + await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, currentPolicy); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(o => + o.Id == organization.Id && + o.UseAutomaticUserConfirmation == policyUpdate.Enabled)); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_UpdatesRevisionDate( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Id = policyUpdate.OrganizationId; + var originalRevisionDate = DateTime.UtcNow.AddDays(-1); + organization.RevisionDate = originalRevisionDate; + + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns(organization); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(o => + o.Id == organization.Id && + o.RevisionDate > originalRevisionDate)); + } +} diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index b19ae47cfc..16a48b12e3 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -7,6 +7,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.UserFeatures.Registration.Implementations; +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -80,6 +81,120 @@ public class RegisterUserCommandTests .SendWelcomeEmailAsync(Arg.Any()); } + // ----------------------------------------------------------------------------------------------- + // RegisterSSOAutoProvisionedUserAsync tests + // ----------------------------------------------------------------------------------------------- + [Theory, BitAutoData] + public async Task RegisterSSOAutoProvisionedUserAsync_Success( + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + user.Id = Guid.NewGuid(); + organization.Id = Guid.NewGuid(); + organization.Name = "Test Organization"; + + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + // Act + var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + Assert.True(result.Succeeded); + await sutProvider.GetDependency() + .Received(1) + .CreateUserAsync(user); + } + + [Theory, BitAutoData] + public async Task RegisterSSOAutoProvisionedUserAsync_UserRegistrationFails_ReturnsFailedResult( + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var expectedError = new IdentityError(); + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Failed(expectedError)); + + // Act + var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + Assert.False(result.Succeeded); + Assert.Contains(expectedError, result.Errors); + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationUserWelcomeEmailAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.TeamsAnnually)] + public async Task RegisterSSOAutoProvisionedUserAsync_EnterpriseOrg_SendsOrganizationWelcomeEmail( + PlanType planType, + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = planType; + organization.Name = "Enterprise Org"; + + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns((OrganizationUser)null); + + // Act + await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationUserWelcomeEmailAsync(user, organization.Name); + } + + [Theory, BitAutoData] + public async Task RegisterSSOAutoProvisionedUserAsync_FeatureFlagDisabled_SendsLegacyWelcomeEmail( + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(false); + + // Act + await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendWelcomeEmailAsync(user); + } + // ----------------------------------------------------------------------------------------------- // RegisterUserWithOrganizationInviteToken tests // ----------------------------------------------------------------------------------------------- @@ -646,5 +761,186 @@ public class RegisterUserCommandTests Assert.Equal("Open registration has been disabled by the system administrator.", result.Message); } + // ----------------------------------------------------------------------------------------------- + // SendWelcomeEmail tests + // ----------------------------------------------------------------------------------------------- + [Theory] + [BitAutoData(PlanType.FamiliesAnnually)] + [BitAutoData(PlanType.FamiliesAnnually2019)] + [BitAutoData(PlanType.Free)] + public async Task SendWelcomeEmail_FamilyOrg_SendsFamilyWelcomeEmail( + PlanType planType, + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = planType; + organization.Name = "Family Org"; + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns((OrganizationUser)null); + + // Act + await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.Name); + } + + [Theory] + [BitAutoData] + public async Task SendWelcomeEmail_OrganizationNull_SendsIndividualWelcomeEmail( + User user, + OrganizationUser orgUser, + string orgInviteToken, + string masterPasswordHash, + SutProvider sutProvider) + { + // Arrange + user.ReferenceData = null; + orgUser.Email = user.Email; + + sutProvider.GetDependency() + .CreateUserAsync(user, masterPasswordHash) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.Id) + .Returns(orgUser); + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) + .Returns((Policy)null); + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.OrganizationId) + .Returns((Organization)null); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + var orgInviteTokenable = new OrgUserInviteTokenable(orgUser); + + sutProvider.GetDependency>() + .TryUnprotect(orgInviteToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = orgInviteTokenable; + return true; + }); + + // Act + var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUser.Id); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendIndividualUserWelcomeEmailAsync(user); + } + + [Theory] + [BitAutoData] + public async Task SendWelcomeEmail_OrganizationDisplayNameNull_SendsIndividualWelcomeEmail( + User user, + SutProvider sutProvider) + { + // Arrange + Organization organization = new Organization + { + Name = null + }; + + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns((OrganizationUser)null); + + // Act + await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendIndividualUserWelcomeEmailAsync(user); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationWelcomeEmailDetailsAsync_HappyPath_ReturnsOrganizationWelcomeEmailDetails( + Organization organization, + User user, + OrganizationUser orgUser, + string masterPasswordHash, + string orgInviteToken, + SutProvider sutProvider) + { + // Arrange + user.ReferenceData = null; + orgUser.Email = user.Email; + organization.PlanType = PlanType.EnterpriseAnnually; + + sutProvider.GetDependency() + .CreateUserAsync(user, masterPasswordHash) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.Id) + .Returns(orgUser); + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) + .Returns((Policy)null); + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.OrganizationId) + .Returns(organization); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + var orgInviteTokenable = new OrgUserInviteTokenable(orgUser); + + sutProvider.GetDependency>() + .TryUnprotect(orgInviteToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = orgInviteTokenable; + return true; + }); + + // Act + var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUser.Id); + + // Assert + Assert.True(result.Succeeded); + + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(orgUser.OrganizationId); + + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationUserWelcomeEmailAsync(user, organization.DisplayName()); + } } diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index 493246c578..cc9c409b4a 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -53,7 +53,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests Available = true, LegacyYear = null, Seat = new PremiumPurchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually }, - Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal } + Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal, Provided = 1 } }; _pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan); @@ -720,4 +720,63 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests await _stripeAdapter.DidNotReceive().SubscriptionCreateAsync(Arg.Any()); await _userService.DidNotReceive().SaveUserAsync(Arg.Any()); } + + [Theory, BitAutoData] + public async Task Run_WithAdditionalStorage_SetsCorrectMaxStorageGb( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + const short additionalStorage = 2; + + // Setup premium plan with 5GB provided storage + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new PremiumPurchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually }, + Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal, Provided = 1 } + }; + _pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan); + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + mockSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, additionalStorage); + + // Assert + Assert.True(result.IsT0); + Assert.Equal((short)3, user.MaxStorageGb); // 1 (provided) + 2 (additional) = 3 + await _userService.Received(1).SaveUserAsync(user); + } + } diff --git a/test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs b/test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs new file mode 100644 index 0000000000..6dbe829da5 --- /dev/null +++ b/test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs @@ -0,0 +1,497 @@ +using Bit.Core.Models.Business; +using Bit.Test.Common.AutoFixture.Attributes; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Models.Business; + +public class BillingCustomerDiscountTests +{ + [Theory] + [BitAutoData] + public void Constructor_PercentageDiscount_SetsIdActivePercentOffAndAppliesTo(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 25.5m, + AmountOff = null, + AppliesTo = new CouponAppliesTo + { + Products = new List { "product1", "product2" } + } + }, + End = null // Active discount + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(couponId, result.Id); + Assert.True(result.Active); + Assert.Equal(25.5m, result.PercentOff); + Assert.Null(result.AmountOff); + Assert.NotNull(result.AppliesTo); + Assert.Equal(2, result.AppliesTo.Count); + Assert.Contains("product1", result.AppliesTo); + Assert.Contains("product2", result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_AmountDiscount_ConvertsFromCentsToDollars(string couponId) + { + // Arrange - Stripe sends 1400 cents for $14.00 + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = null, + AmountOff = 1400, // 1400 cents + AppliesTo = new CouponAppliesTo + { + Products = new List() + } + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(couponId, result.Id); + Assert.True(result.Active); + Assert.Null(result.PercentOff); + Assert.Equal(14.00m, result.AmountOff); // Converted to dollars + Assert.NotNull(result.AppliesTo); + Assert.Empty(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_InactiveDiscount_SetsActiveToFalse(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 15m + }, + End = DateTime.UtcNow.AddDays(-1) // Expired discount + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(couponId, result.Id); + Assert.False(result.Active); + Assert.Equal(15m, result.PercentOff); + } + + [Fact] + public void Constructor_NullCoupon_SetsDiscountPropertiesToNull() + { + // Arrange + var discount = new Discount + { + Coupon = null, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.Id); + Assert.True(result.Active); + Assert.Null(result.PercentOff); + Assert.Null(result.AmountOff); + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_NullAmountOff_SetsAmountOffToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AmountOff = null + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_ZeroAmountOff_ConvertsCorrectly(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 0 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(0m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_LargeAmountOff_ConvertsCorrectly(string couponId) + { + // Arrange - $100.00 discount + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 10000 // 10000 cents = $100.00 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(100.00m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_SmallAmountOff_ConvertsCorrectly(string couponId) + { + // Arrange - $0.50 discount + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 50 // 50 cents = $0.50 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(0.50m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_BothDiscountTypes_SetsPercentOffAndAmountOff(string couponId) + { + // Arrange - Coupon with both percentage and amount (edge case) + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 20m, + AmountOff = 500 // $5.00 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(20m, result.PercentOff); + Assert.Equal(5.00m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullAppliesTo_SetsAppliesToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AppliesTo = null + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullProductsList_SetsAppliesToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AppliesTo = new CouponAppliesTo + { + Products = null + } + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithDecimalAmountOff_RoundsCorrectly(string couponId) + { + // Arrange - 1425 cents = $14.25 + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 1425 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(14.25m, result.AmountOff); + } + + [Fact] + public void Constructor_DefaultConstructor_InitializesAllPropertiesToNullOrFalse() + { + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(); + + // Assert + Assert.Null(result.Id); + Assert.False(result.Active); + Assert.Null(result.PercentOff); + Assert.Null(result.AmountOff); + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithFutureEndDate_SetsActiveToFalse(string couponId) + { + // Arrange - Discount expires in the future + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 20m + }, + End = DateTime.UtcNow.AddDays(30) // Expires in 30 days + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.False(result.Active); // Should be inactive because End is not null + } + + [Theory] + [BitAutoData] + public void Constructor_WithPastEndDate_SetsActiveToFalse(string couponId) + { + // Arrange - Discount already expired + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 20m + }, + End = DateTime.UtcNow.AddDays(-30) // Expired 30 days ago + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.False(result.Active); // Should be inactive because End is not null + } + + [Fact] + public void Constructor_WithNullCouponId_SetsIdToNull() + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = null, + PercentOff = 20m + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.Id); + Assert.True(result.Active); + Assert.Equal(20m, result.PercentOff); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullPercentOff_SetsPercentOffToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = null, + AmountOff = 1000 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.PercentOff); + Assert.Equal(10.00m, result.AmountOff); + } + + [Fact] + public void Constructor_WithCompleteStripeDiscount_MapsAllProperties() + { + // Arrange - Comprehensive test with all Stripe Discount properties set + var discount = new Discount + { + Coupon = new Coupon + { + Id = "premium_discount_2024", + PercentOff = 25m, + AmountOff = 1500, // $15.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_family", "prod_teams" } + } + }, + End = null // Active + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert - Verify all properties mapped correctly + Assert.Equal("premium_discount_2024", result.Id); + Assert.True(result.Active); + Assert.Equal(25m, result.PercentOff); + Assert.Equal(15.00m, result.AmountOff); + Assert.NotNull(result.AppliesTo); + Assert.Equal(3, result.AppliesTo.Count); + Assert.Contains("prod_premium", result.AppliesTo); + Assert.Contains("prod_family", result.AppliesTo); + Assert.Contains("prod_teams", result.AppliesTo); + } + + [Fact] + public void Constructor_WithMinimalStripeDiscount_HandlesNullsGracefully() + { + // Arrange - Minimal Stripe Discount with most properties null + var discount = new Discount + { + Coupon = new Coupon + { + Id = null, + PercentOff = null, + AmountOff = null, + AppliesTo = null + }, + End = DateTime.UtcNow.AddDays(10) // Has end date + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert - Should handle all nulls gracefully + Assert.Null(result.Id); + Assert.False(result.Active); + Assert.Null(result.PercentOff); + Assert.Null(result.AmountOff); + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithEmptyProductsList_PreservesEmptyList(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AppliesTo = new CouponAppliesTo + { + Products = new List() // Empty but not null + } + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.NotNull(result.AppliesTo); + Assert.Empty(result.AppliesTo); + } +} diff --git a/test/Core.Test/Models/Business/SubscriptionInfoTests.cs b/test/Core.Test/Models/Business/SubscriptionInfoTests.cs new file mode 100644 index 0000000000..ef6a61ad5d --- /dev/null +++ b/test/Core.Test/Models/Business/SubscriptionInfoTests.cs @@ -0,0 +1,125 @@ +using Bit.Core.Models.Business; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Models.Business; + +public class SubscriptionInfoTests +{ + [Fact] + public void BillingSubscriptionItem_NullPlan_HandlesGracefully() + { + // Arrange - SubscriptionItem with null Plan + var subscriptionItem = new SubscriptionItem + { + Plan = null, + Quantity = 1 + }; + + // Act + var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem); + + // Assert - Should handle null Plan gracefully + Assert.Null(result.ProductId); + Assert.Null(result.Name); + Assert.Equal(0m, result.Amount); // Defaults to 0 when Plan is null + Assert.Null(result.Interval); + Assert.Equal(1, result.Quantity); + Assert.False(result.SponsoredSubscriptionItem); + Assert.False(result.AddonSubscriptionItem); + } + + [Fact] + public void BillingSubscriptionItem_NullAmount_SetsToZero() + { + // Arrange - SubscriptionItem with Plan but null Amount + var subscriptionItem = new SubscriptionItem + { + Plan = new Plan + { + ProductId = "prod_test", + Nickname = "Test Plan", + Amount = null, // Null amount + Interval = "month" + }, + Quantity = 1 + }; + + // Act + var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem); + + // Assert - Should default to 0 when Amount is null + Assert.Equal("prod_test", result.ProductId); + Assert.Equal("Test Plan", result.Name); + Assert.Equal(0m, result.Amount); // Business rule: defaults to 0 when null + Assert.Equal("month", result.Interval); + Assert.Equal(1, result.Quantity); + } + + [Fact] + public void BillingSubscriptionItem_ZeroAmount_PreservesZero() + { + // Arrange - SubscriptionItem with Plan and zero Amount + var subscriptionItem = new SubscriptionItem + { + Plan = new Plan + { + ProductId = "prod_test", + Nickname = "Test Plan", + Amount = 0, // Zero amount (0 cents) + Interval = "month" + }, + Quantity = 1 + }; + + // Act + var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem); + + // Assert - Should preserve zero amount + Assert.Equal("prod_test", result.ProductId); + Assert.Equal("Test Plan", result.Name); + Assert.Equal(0m, result.Amount); // Zero amount preserved + Assert.Equal("month", result.Interval); + } + + [Fact] + public void BillingUpcomingInvoice_ZeroAmountDue_ConvertsToZero() + { + // Arrange - Invoice with zero AmountDue + // Note: Stripe's Invoice.AmountDue is non-nullable long, so we test with 0 + // The null-coalescing operator (?? 0) in the constructor handles the case where + // ConvertFromStripeMinorUnits returns null, but since AmountDue is non-nullable, + // this test verifies the conversion path works correctly for zero values + var invoice = new Invoice + { + AmountDue = 0, // Zero amount due (0 cents) + Created = DateTime.UtcNow + }; + + // Act + var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice); + + // Assert - Should convert zero correctly + Assert.Equal(0m, result.Amount); + Assert.NotNull(result.Date); + } + + [Fact] + public void BillingUpcomingInvoice_ValidAmountDue_ConvertsCorrectly() + { + // Arrange - Invoice with valid AmountDue + var invoice = new Invoice + { + AmountDue = 2500, // 2500 cents = $25.00 + Created = DateTime.UtcNow + }; + + // Act + var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice); + + // Assert - Should convert correctly + Assert.Equal(25.00m, result.Amount); // Converted from cents + Assert.NotNull(result.Date); + } +} + diff --git a/test/Core.Test/Services/HandlebarsMailServiceTests.cs b/test/Core.Test/Services/HandlebarsMailServiceTests.cs index d624bebf51..b98c4580f5 100644 --- a/test/Core.Test/Services/HandlebarsMailServiceTests.cs +++ b/test/Core.Test/Services/HandlebarsMailServiceTests.cs @@ -268,4 +268,115 @@ public class HandlebarsMailServiceTests // Assert await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any()); } + + [Fact] + public async Task SendIndividualUserWelcomeEmailAsync_SendsCorrectEmail() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Email = "test@example.com" + }; + + // Act + await _sut.SendIndividualUserWelcomeEmailAsync(user); + + // Assert + await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is(m => + m.MetaData != null && + m.ToEmails.Contains("test@example.com") && + m.Subject == "Welcome to Bitwarden!" && + m.Category == "Welcome")); + } + + [Fact] + public async Task SendOrganizationUserWelcomeEmailAsync_SendsCorrectEmailWithOrganizationName() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Email = "user@company.com" + }; + var organizationName = "Bitwarden Corp"; + + // Act + await _sut.SendOrganizationUserWelcomeEmailAsync(user, organizationName); + + // Assert + await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is(m => + m.MetaData != null && + m.ToEmails.Contains("user@company.com") && + m.Subject == "Welcome to Bitwarden!" && + m.HtmlContent.Contains("Bitwarden Corp") && + m.Category == "Welcome")); + } + + [Fact] + public async Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync_SendsCorrectEmailWithFamilyTemplate() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Email = "family@example.com" + }; + var familyOrganizationName = "Smith Family"; + + // Act + await _sut.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, familyOrganizationName); + + // Assert + await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is(m => + m.MetaData != null && + m.ToEmails.Contains("family@example.com") && + m.Subject == "Welcome to Bitwarden!" && + m.HtmlContent.Contains("Smith Family") && + m.Category == "Welcome")); + } + + [Theory] + [InlineData("Acme Corp", "Acme Corp")] + [InlineData("Company & Associates", "Company & Associates")] + [InlineData("Test \"Quoted\" Org", "Test "Quoted" Org")] + public async Task SendOrganizationUserWelcomeEmailAsync_SanitizesOrganizationNameForEmail(string inputOrgName, string expectedSanitized) + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Email = "test@example.com" + }; + + // Act + await _sut.SendOrganizationUserWelcomeEmailAsync(user, inputOrgName); + + // Assert + await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is(m => + m.HtmlContent.Contains(expectedSanitized) && + !m.HtmlContent.Contains("