From f9fc43dbb1be8cf1b9e330f858349f6fd3450528 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Wed, 1 Nov 2023 08:43:35 -0400 Subject: [PATCH 1/8] [AC-1650] [AC-1578] (#3320) * Upgraded old 2019 plans to have the same features as 2020 and beyond * Removed redundant test and moved additional test cases to GetByOrgIdAsync_SmNoneFreePlans_ReturnsNull * Fixed issue where feature flag wasn't returning correct plans * Resolved issue where getting plans would return a value that LINQ previously cached when feature flag was in a different state --------- Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> --- .../Queries/Projects/MaxProjectsQueryTests.cs | 19 ++-- .../Shared/_OrganizationFormScripts.cshtml | 8 ++ .../ProfileOrganizationResponseModel.cs | 3 +- ...rofileProviderOrganizationResponseModel.cs | 3 +- src/Api/Controllers/PlansController.cs | 30 +++++- .../Controllers/FreshsalesController.cs | 4 + src/Billing/Controllers/StripeController.cs | 13 +-- src/Core/Constants.cs | 1 + src/Core/Enums/PlanType.cs | 16 ++- src/Core/Models/StaticStore/Plan.cs | 2 +- .../StaticStore/Plans/Enterprise2019Plan.cs | 36 +++++++ .../StaticStore/Plans/Enterprise2020Plan.cs | 101 ++++++++++++++++++ .../StaticStore/Plans/EnterprisePlan.cs | 8 +- .../StaticStore/Plans/Families2019Plan.cs | 1 + .../Models/StaticStore/Plans/Teams2019Plan.cs | 38 +++++++ .../Models/StaticStore/Plans/Teams2020Plan.cs | 95 ++++++++++++++++ .../Models/StaticStore/Plans/TeamsPlan.cs | 8 +- .../Services/Implementations/PolicyService.cs | 16 --- src/Core/Utilities/StaticStore.cs | 10 +- .../Repositories/OrganizationRepository.cs | 69 ++++++------ .../AutoFixture/OrganizationFixtures.cs | 5 +- .../SecretsManagerSubscriptionUpdateTests.cs | 38 ++++++- test/Core.Test/Utilities/StaticStoreTests.cs | 4 +- ...3-10-13_00_2019TeamsPlanFeatureUpgrade.sql | 21 ++++ ...13_01_2019EnterprisePlanFeatureUpgrade.sql | 19 ++++ ...-10-13_02_2019FamilyPlanFeatureUpgrade.sql | 16 +++ 26 files changed, 485 insertions(+), 99 deletions(-) create mode 100644 src/Core/Models/StaticStore/Plans/Enterprise2020Plan.cs create mode 100644 src/Core/Models/StaticStore/Plans/Teams2020Plan.cs create mode 100644 util/Migrator/DbScripts/2023-10-13_00_2019TeamsPlanFeatureUpgrade.sql create mode 100644 util/Migrator/DbScripts/2023-10-13_01_2019EnterprisePlanFeatureUpgrade.sql create mode 100644 util/Migrator/DbScripts/2023-10-13_02_2019FamilyPlanFeatureUpgrade.sql diff --git a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs index 79ffb421e1..58c944e8eb 100644 --- a/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/SecretsManager/Queries/Projects/MaxProjectsQueryTests.cs @@ -30,29 +30,36 @@ public class MaxProjectsQueryTests [Theory] [BitAutoData(PlanType.FamiliesAnnually2019)] - [BitAutoData(PlanType.TeamsMonthly2019)] - [BitAutoData(PlanType.TeamsAnnually2019)] - [BitAutoData(PlanType.EnterpriseMonthly2019)] - [BitAutoData(PlanType.EnterpriseAnnually2019)] [BitAutoData(PlanType.Custom)] [BitAutoData(PlanType.FamiliesAnnually)] public async Task GetByOrgIdAsync_SmPlanIsNull_ThrowsBadRequest(PlanType planType, SutProvider sutProvider, Organization organization) { organization.PlanType = planType; - sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); await Assert.ThrowsAsync( async () => await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1)); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() .GetProjectCountByOrganizationIdAsync(organization.Id); } [Theory] + [BitAutoData(PlanType.TeamsMonthly2019)] + [BitAutoData(PlanType.TeamsMonthly2020)] [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually2019)] + [BitAutoData(PlanType.TeamsAnnually2020)] [BitAutoData(PlanType.TeamsAnnually)] + [BitAutoData(PlanType.EnterpriseMonthly2019)] + [BitAutoData(PlanType.EnterpriseMonthly2020)] [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually2019)] + [BitAutoData(PlanType.EnterpriseAnnually2020)] [BitAutoData(PlanType.EnterpriseAnnually)] public async Task GetByOrgIdAsync_SmNoneFreePlans_ReturnsNull(PlanType planType, SutProvider sutProvider, Organization organization) diff --git a/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml b/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml index 69fd55dc21..5871977fea 100644 --- a/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml +++ b/src/Admin/Views/Shared/_OrganizationFormScripts.cshtml @@ -68,6 +68,10 @@ function togglePlanFeatures(planType) { switch(planType) { + case '@((byte)PlanType.TeamsMonthly2019)': + case '@((byte)PlanType.TeamsAnnually2019)': + case '@((byte)PlanType.TeamsMonthly2020)': + case '@((byte)PlanType.TeamsAnnually2020)': case '@((byte)PlanType.TeamsMonthly)': case '@((byte)PlanType.TeamsAnnually)': document.getElementById('@(nameof(Model.UsePolicies))').checked = false; @@ -85,6 +89,10 @@ document.getElementById('@(nameof(Model.UseScim))').checked = false; break; + case '@((byte)PlanType.EnterpriseMonthly2019)': + case '@((byte)PlanType.EnterpriseAnnually2019)': + case '@((byte)PlanType.EnterpriseMonthly2020)': + case '@((byte)PlanType.EnterpriseAnnually2020)': case '@((byte)PlanType.EnterpriseMonthly)': case '@((byte)PlanType.EnterpriseAnnually)': document.getElementById('@(nameof(Model.UsePolicies))').checked = true; diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index 1407f8e215..e0d1433c15 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -32,8 +32,7 @@ public class ProfileOrganizationResponseModel : ResponseModel UsePasswordManager = organization.UsePasswordManager; UsersGetPremium = organization.UsersGetPremium; UseCustomPermissions = organization.UseCustomPermissions; - UseActivateAutofillPolicy = organization.PlanType == PlanType.EnterpriseAnnually || - organization.PlanType == PlanType.EnterpriseMonthly; + UseActivateAutofillPolicy = StaticStore.GetPlan(organization.PlanType).Product == ProductType.Enterprise; SelfHost = organization.SelfHost; Seats = organization.Seats; MaxCollections = organization.MaxCollections; diff --git a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs index 7ff9805b58..d6b7656e4b 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs @@ -25,8 +25,7 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo UseResetPassword = organization.UseResetPassword; UsersGetPremium = organization.UsersGetPremium; UseCustomPermissions = organization.UseCustomPermissions; - UseActivateAutofillPolicy = organization.PlanType == PlanType.EnterpriseAnnually || - organization.PlanType == PlanType.EnterpriseMonthly; + UseActivateAutofillPolicy = StaticStore.GetPlan(organization.PlanType).Product == ProductType.Enterprise; SelfHost = organization.SelfHost; Seats = organization.Seats; MaxCollections = organization.MaxCollections; diff --git a/src/Api/Controllers/PlansController.cs b/src/Api/Controllers/PlansController.cs index d738e60cfb..14992bd97b 100644 --- a/src/Api/Controllers/PlansController.cs +++ b/src/Api/Controllers/PlansController.cs @@ -1,5 +1,9 @@ using Bit.Api.Models.Response; +using Bit.Core; +using Bit.Core.Context; +using Bit.Core.Enums; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -11,17 +15,39 @@ namespace Bit.Api.Controllers; public class PlansController : Controller { private readonly ITaxRateRepository _taxRateRepository; - public PlansController(ITaxRateRepository taxRateRepository) + private readonly IFeatureService _featureService; + private readonly ICurrentContext _currentContext; + + public PlansController( + ITaxRateRepository taxRateRepository, + IFeatureService featureService, + ICurrentContext currentContext) { _taxRateRepository = taxRateRepository; + _featureService = featureService; + _currentContext = currentContext; } [HttpGet("")] [AllowAnonymous] public ListResponseModel Get() { + var plansUpgradeIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.BillingPlansUpgrade, _currentContext); var data = StaticStore.Plans; - var responses = data.Select(plan => new PlanResponseModel(plan)); + var responses = data + .Where(plan => plansUpgradeIsEnabled || plan.Type <= PlanType.EnterpriseAnnually2020) + .Select(plan => + { + if (!plansUpgradeIsEnabled && plan.Type is <= PlanType.EnterpriseAnnually2020 and >= PlanType.TeamsMonthly2020) + { + plan.LegacyYear = null; + } + else if (plansUpgradeIsEnabled && plan.Type is <= PlanType.EnterpriseAnnually2020 and >= PlanType.TeamsMonthly2020) + { + plan.LegacyYear = 2023; + } + return new PlanResponseModel(plan); + }); return new ListResponseModel(responses); } diff --git a/src/Billing/Controllers/FreshsalesController.cs b/src/Billing/Controllers/FreshsalesController.cs index 95b9e25065..a66edd6ca6 100644 --- a/src/Billing/Controllers/FreshsalesController.cs +++ b/src/Billing/Controllers/FreshsalesController.cs @@ -159,14 +159,18 @@ public class FreshsalesController : Controller planName = "Families"; return true; case PlanType.TeamsAnnually: + case PlanType.TeamsAnnually2020: case PlanType.TeamsAnnually2019: case PlanType.TeamsMonthly: + case PlanType.TeamsMonthly2020: case PlanType.TeamsMonthly2019: planName = "Teams"; return true; case PlanType.EnterpriseAnnually: + case PlanType.EnterpriseAnnually2020: case PlanType.EnterpriseAnnually2019: case PlanType.EnterpriseMonthly: + case PlanType.EnterpriseMonthly2020: case PlanType.EnterpriseMonthly2019: planName = "Enterprise"; return true; diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index e71e025dff..64053b31f2 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -668,18 +668,7 @@ public class StripeController : Controller return new Tuple(orgId, userId); } - private bool OrgPlanForInvoiceNotifications(Organization org) - { - switch (org.PlanType) - { - case PlanType.FamiliesAnnually: - case PlanType.TeamsAnnually: - case PlanType.EnterpriseAnnually: - return true; - default: - return false; - } - } + private static bool OrgPlanForInvoiceNotifications(Organization org) => StaticStore.GetPlan(org.PlanType).IsAnnual; private async Task AttemptToPayInvoiceAsync(Invoice invoice, bool attemptToPayWithStripe = false) { diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8ce379646c..908d0a7861 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -49,6 +49,7 @@ public static class FeatureFlagKeys public const string BulkCollectionAccess = "bulk-collection-access"; public const string AutofillOverlay = "autofill-overlay"; public const string ItemShare = "item-share"; + public const string BillingPlansUpgrade = "billing-plans-upgrade"; public static List GetAllKeys() { diff --git a/src/Core/Enums/PlanType.cs b/src/Core/Enums/PlanType.cs index ac32f217e4..cb88e83601 100644 --- a/src/Core/Enums/PlanType.cs +++ b/src/Core/Enums/PlanType.cs @@ -20,12 +20,20 @@ public enum PlanType : byte Custom = 6, [Display(Name = "Families")] FamiliesAnnually = 7, + [Display(Name = "Teams (Monthly) 2020")] + TeamsMonthly2020 = 8, + [Display(Name = "Teams (Annually) 2020")] + TeamsAnnually2020 = 9, + [Display(Name = "Enterprise (Monthly) 2020")] + EnterpriseMonthly2020 = 10, + [Display(Name = "Enterprise (Annually) 2020")] + EnterpriseAnnually2020 = 11, [Display(Name = "Teams (Monthly)")] - TeamsMonthly = 8, + TeamsMonthly = 12, [Display(Name = "Teams (Annually)")] - TeamsAnnually = 9, + TeamsAnnually = 13, [Display(Name = "Enterprise (Monthly)")] - EnterpriseMonthly = 10, + EnterpriseMonthly = 14, [Display(Name = "Enterprise (Annually)")] - EnterpriseAnnually = 11, + EnterpriseAnnually = 15, } diff --git a/src/Core/Models/StaticStore/Plan.cs b/src/Core/Models/StaticStore/Plan.cs index 4f8b0435ff..c381215f38 100644 --- a/src/Core/Models/StaticStore/Plan.cs +++ b/src/Core/Models/StaticStore/Plan.cs @@ -28,7 +28,7 @@ public abstract record Plan public bool HasCustomPermissions { get; protected init; } public int UpgradeSortOrder { get; protected init; } public int DisplaySortOrder { get; protected init; } - public int? LegacyYear { get; protected init; } + public int? LegacyYear { get; set; } public bool Disabled { get; protected init; } public PasswordManagerPlanFeatures PasswordManager { get; protected init; } public SecretsManagerPlanFeatures SecretsManager { get; protected init; } diff --git a/src/Core/Models/StaticStore/Plans/Enterprise2019Plan.cs b/src/Core/Models/StaticStore/Plans/Enterprise2019Plan.cs index 8cbb579ea2..7684b0897c 100644 --- a/src/Core/Models/StaticStore/Plans/Enterprise2019Plan.cs +++ b/src/Core/Models/StaticStore/Plans/Enterprise2019Plan.cs @@ -24,6 +24,10 @@ public record Enterprise2019Plan : Models.StaticStore.Plan HasTotp = true; Has2fa = true; HasApi = true; + HasSso = true; + HasKeyConnector = true; + HasScim = true; + HasResetPassword = true; UsersGetPremium = true; HasCustomPermissions = true; @@ -31,9 +35,41 @@ public record Enterprise2019Plan : Models.StaticStore.Plan DisplaySortOrder = 3; LegacyYear = 2020; + SecretsManager = new Enterprise2019SecretsManagerFeatures(isAnnual); PasswordManager = new Enterprise2019PasswordManagerFeatures(isAnnual); } + private record Enterprise2019SecretsManagerFeatures : SecretsManagerPlanFeatures + { + public Enterprise2019SecretsManagerFeatures(bool isAnnual) + { + BaseSeats = 0; + BasePrice = 0; + BaseServiceAccount = 200; + + HasAdditionalSeatsOption = true; + HasAdditionalServiceAccountOption = true; + + AllowSeatAutoscale = true; + AllowServiceAccountsAutoscale = true; + + if (isAnnual) + { + StripeSeatPlanId = "secrets-manager-enterprise-seat-annually"; + StripeServiceAccountPlanId = "secrets-manager-service-account-annually"; + SeatPrice = 144; + AdditionalPricePerServiceAccount = 6; + } + else + { + StripeSeatPlanId = "secrets-manager-enterprise-seat-monthly"; + StripeServiceAccountPlanId = "secrets-manager-service-account-monthly"; + SeatPrice = 13; + AdditionalPricePerServiceAccount = 0.5M; + } + } + } + private record Enterprise2019PasswordManagerFeatures : PasswordManagerPlanFeatures { public Enterprise2019PasswordManagerFeatures(bool isAnnual) diff --git a/src/Core/Models/StaticStore/Plans/Enterprise2020Plan.cs b/src/Core/Models/StaticStore/Plans/Enterprise2020Plan.cs new file mode 100644 index 0000000000..4fa7eee972 --- /dev/null +++ b/src/Core/Models/StaticStore/Plans/Enterprise2020Plan.cs @@ -0,0 +1,101 @@ +using Bit.Core.Enums; + +namespace Bit.Core.Models.StaticStore.Plans; + +public record Enterprise2020Plan : Models.StaticStore.Plan +{ + public Enterprise2020Plan(bool isAnnual) + { + Type = isAnnual ? PlanType.EnterpriseAnnually2020 : PlanType.EnterpriseMonthly2020; + Product = ProductType.Enterprise; + Name = isAnnual ? "Enterprise (Annually) 2020" : "Enterprise (Monthly) 2020"; + IsAnnual = isAnnual; + NameLocalizationKey = "planNameEnterprise"; + DescriptionLocalizationKey = "planDescEnterprise"; + CanBeUsedByBusiness = true; + + TrialPeriodDays = 7; + + HasPolicies = true; + HasSelfHost = true; + HasGroups = true; + HasDirectory = true; + HasEvents = true; + HasTotp = true; + Has2fa = true; + HasApi = true; + HasSso = true; + HasKeyConnector = true; + HasScim = true; + HasResetPassword = true; + UsersGetPremium = true; + HasCustomPermissions = true; + + UpgradeSortOrder = 3; + DisplaySortOrder = 3; + LegacyYear = 2023; + + PasswordManager = new Enterprise2020PasswordManagerFeatures(isAnnual); + SecretsManager = new Enterprise2020SecretsManagerFeatures(isAnnual); + } + + private record Enterprise2020SecretsManagerFeatures : SecretsManagerPlanFeatures + { + public Enterprise2020SecretsManagerFeatures(bool isAnnual) + { + BaseSeats = 0; + BasePrice = 0; + BaseServiceAccount = 200; + + HasAdditionalSeatsOption = true; + HasAdditionalServiceAccountOption = true; + + AllowSeatAutoscale = true; + AllowServiceAccountsAutoscale = true; + + if (isAnnual) + { + StripeSeatPlanId = "secrets-manager-enterprise-seat-annually"; + StripeServiceAccountPlanId = "secrets-manager-service-account-annually"; + SeatPrice = 144; + AdditionalPricePerServiceAccount = 6; + } + else + { + StripeSeatPlanId = "secrets-manager-enterprise-seat-monthly"; + StripeServiceAccountPlanId = "secrets-manager-service-account-monthly"; + SeatPrice = 13; + AdditionalPricePerServiceAccount = 0.5M; + } + } + } + + private record Enterprise2020PasswordManagerFeatures : PasswordManagerPlanFeatures + { + public Enterprise2020PasswordManagerFeatures(bool isAnnual) + { + BaseSeats = 0; + BaseStorageGb = 1; + + HasAdditionalStorageOption = true; + HasAdditionalSeatsOption = true; + + AllowSeatAutoscale = true; + + if (isAnnual) + { + AdditionalStoragePricePerGb = 4; + StripeStoragePlanId = "storage-gb-annually"; + StripeSeatPlanId = "2020-enterprise-org-seat-annually"; + SeatPrice = 60; + } + else + { + StripeSeatPlanId = "2020-enterprise-seat-monthly"; + StripeStoragePlanId = "storage-gb-monthly"; + SeatPrice = 6; + AdditionalStoragePricePerGb = 0.5M; + } + } + } +} diff --git a/src/Core/Models/StaticStore/Plans/EnterprisePlan.cs b/src/Core/Models/StaticStore/Plans/EnterprisePlan.cs index fbb3198839..61eabc6436 100644 --- a/src/Core/Models/StaticStore/Plans/EnterprisePlan.cs +++ b/src/Core/Models/StaticStore/Plans/EnterprisePlan.cs @@ -85,14 +85,14 @@ public record EnterprisePlan : Models.StaticStore.Plan { AdditionalStoragePricePerGb = 4; StripeStoragePlanId = "storage-gb-annually"; - StripeSeatPlanId = "2020-enterprise-org-seat-annually"; - SeatPrice = 60; + StripeSeatPlanId = "2023-enterprise-org-seat-annually"; + SeatPrice = 72; } else { - StripeSeatPlanId = "2020-enterprise-seat-monthly"; + StripeSeatPlanId = "2023-enterprise-seat-monthly"; StripeStoragePlanId = "storage-gb-monthly"; - SeatPrice = 6; + SeatPrice = 7; AdditionalStoragePricePerGb = 0.5M; } } diff --git a/src/Core/Models/StaticStore/Plans/Families2019Plan.cs b/src/Core/Models/StaticStore/Plans/Families2019Plan.cs index 14ddb3405b..4bc9abc1f3 100644 --- a/src/Core/Models/StaticStore/Plans/Families2019Plan.cs +++ b/src/Core/Models/StaticStore/Plans/Families2019Plan.cs @@ -17,6 +17,7 @@ public record Families2019Plan : Models.StaticStore.Plan HasSelfHost = true; HasTotp = true; + UsersGetPremium = true; UpgradeSortOrder = 1; DisplaySortOrder = 1; diff --git a/src/Core/Models/StaticStore/Plans/Teams2019Plan.cs b/src/Core/Models/StaticStore/Plans/Teams2019Plan.cs index f806f37735..d81a015de8 100644 --- a/src/Core/Models/StaticStore/Plans/Teams2019Plan.cs +++ b/src/Core/Models/StaticStore/Plans/Teams2019Plan.cs @@ -16,15 +16,53 @@ public record Teams2019Plan : Models.StaticStore.Plan TrialPeriodDays = 7; + HasGroups = true; + HasDirectory = true; + HasEvents = true; HasTotp = true; + Has2fa = true; + HasApi = true; + UsersGetPremium = true; UpgradeSortOrder = 2; DisplaySortOrder = 2; LegacyYear = 2020; + SecretsManager = new Teams2019SecretsManagerFeatures(isAnnual); PasswordManager = new Teams2019PasswordManagerFeatures(isAnnual); } + private record Teams2019SecretsManagerFeatures : SecretsManagerPlanFeatures + { + public Teams2019SecretsManagerFeatures(bool isAnnual) + { + BaseSeats = 0; + BasePrice = 0; + BaseServiceAccount = 50; + + HasAdditionalSeatsOption = true; + HasAdditionalServiceAccountOption = true; + + AllowSeatAutoscale = true; + AllowServiceAccountsAutoscale = true; + + if (isAnnual) + { + StripeSeatPlanId = "secrets-manager-teams-seat-annually"; + StripeServiceAccountPlanId = "secrets-manager-service-account-annually"; + SeatPrice = 72; + AdditionalPricePerServiceAccount = 6; + } + else + { + StripeSeatPlanId = "secrets-manager-teams-seat-monthly"; + StripeServiceAccountPlanId = "secrets-manager-service-account-monthly"; + SeatPrice = 7; + AdditionalPricePerServiceAccount = 0.5M; + } + } + } + private record Teams2019PasswordManagerFeatures : PasswordManagerPlanFeatures { public Teams2019PasswordManagerFeatures(bool isAnnual) diff --git a/src/Core/Models/StaticStore/Plans/Teams2020Plan.cs b/src/Core/Models/StaticStore/Plans/Teams2020Plan.cs new file mode 100644 index 0000000000..680a5deece --- /dev/null +++ b/src/Core/Models/StaticStore/Plans/Teams2020Plan.cs @@ -0,0 +1,95 @@ +using Bit.Core.Enums; + +namespace Bit.Core.Models.StaticStore.Plans; + +public record Teams2020Plan : Models.StaticStore.Plan +{ + public Teams2020Plan(bool isAnnual) + { + Type = isAnnual ? PlanType.TeamsAnnually2020 : PlanType.TeamsMonthly2020; + Product = ProductType.Teams; + Name = isAnnual ? "Teams (Annually) 2020" : "Teams (Monthly) 2020"; + IsAnnual = isAnnual; + NameLocalizationKey = "planNameTeams"; + DescriptionLocalizationKey = "planDescTeams"; + CanBeUsedByBusiness = true; + + TrialPeriodDays = 7; + + HasGroups = true; + HasDirectory = true; + HasEvents = true; + HasTotp = true; + Has2fa = true; + HasApi = true; + UsersGetPremium = true; + + UpgradeSortOrder = 2; + DisplaySortOrder = 2; + LegacyYear = 2023; + + PasswordManager = new Teams2020PasswordManagerFeatures(isAnnual); + SecretsManager = new Teams2020SecretsManagerFeatures(isAnnual); + } + + private record Teams2020SecretsManagerFeatures : SecretsManagerPlanFeatures + { + public Teams2020SecretsManagerFeatures(bool isAnnual) + { + BaseSeats = 0; + BasePrice = 0; + BaseServiceAccount = 50; + + HasAdditionalSeatsOption = true; + HasAdditionalServiceAccountOption = true; + + AllowSeatAutoscale = true; + AllowServiceAccountsAutoscale = true; + + if (isAnnual) + { + StripeSeatPlanId = "secrets-manager-teams-seat-annually"; + StripeServiceAccountPlanId = "secrets-manager-service-account-annually"; + SeatPrice = 72; + AdditionalPricePerServiceAccount = 6; + } + else + { + StripeSeatPlanId = "secrets-manager-teams-seat-monthly"; + StripeServiceAccountPlanId = "secrets-manager-service-account-monthly"; + SeatPrice = 7; + AdditionalPricePerServiceAccount = 0.5M; + } + } + } + + private record Teams2020PasswordManagerFeatures : PasswordManagerPlanFeatures + { + public Teams2020PasswordManagerFeatures(bool isAnnual) + { + BaseSeats = 0; + BaseStorageGb = 1; + BasePrice = 0; + + HasAdditionalStorageOption = true; + HasAdditionalSeatsOption = true; + + AllowSeatAutoscale = true; + + if (isAnnual) + { + StripeStoragePlanId = "storage-gb-annually"; + StripeSeatPlanId = "2020-teams-org-seat-annually"; + SeatPrice = 36; + AdditionalStoragePricePerGb = 4; + } + else + { + StripeSeatPlanId = "2020-teams-org-seat-monthly"; + StripeStoragePlanId = "storage-gb-monthly"; + SeatPrice = 4; + AdditionalStoragePricePerGb = 0.5M; + } + } + } +} diff --git a/src/Core/Models/StaticStore/Plans/TeamsPlan.cs b/src/Core/Models/StaticStore/Plans/TeamsPlan.cs index 0e325242e3..77482d10fb 100644 --- a/src/Core/Models/StaticStore/Plans/TeamsPlan.cs +++ b/src/Core/Models/StaticStore/Plans/TeamsPlan.cs @@ -78,15 +78,15 @@ public record TeamsPlan : Models.StaticStore.Plan if (isAnnual) { StripeStoragePlanId = "storage-gb-annually"; - StripeSeatPlanId = "2020-teams-org-seat-annually"; - SeatPrice = 36; + StripeSeatPlanId = "2023-teams-org-seat-annually"; + SeatPrice = 48; AdditionalStoragePricePerGb = 4; } else { - StripeSeatPlanId = "2020-teams-org-seat-monthly"; + StripeSeatPlanId = "2023-teams-org-seat-monthly"; StripeStoragePlanId = "storage-gb-monthly"; - SeatPrice = 4; + SeatPrice = 5; AdditionalStoragePricePerGb = 0.5M; } } diff --git a/src/Core/Services/Implementations/PolicyService.cs b/src/Core/Services/Implementations/PolicyService.cs index 5f4e680df7..2c9b6c73e1 100644 --- a/src/Core/Services/Implementations/PolicyService.cs +++ b/src/Core/Services/Implementations/PolicyService.cs @@ -98,14 +98,6 @@ public class PolicyService : IPolicyService await DependsOnSingleOrgAsync(org); } break; - - // Activate Autofill is only available to Enterprise 2020-current plans - case PolicyType.ActivateAutofill: - if (policy.Enabled) - { - LockedTo2020Plan(org); - } - break; } var now = DateTime.UtcNow; @@ -274,14 +266,6 @@ public class PolicyService : IPolicyService } } - private void LockedTo2020Plan(Organization org) - { - if (org.PlanType != PlanType.EnterpriseAnnually && org.PlanType != PlanType.EnterpriseMonthly) - { - throw new BadRequestException("This policy is only available to 2020 Enterprise plans."); - } - } - private async Task RequiredBySsoTrustedDeviceEncryptionAsync(Organization org) { var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(org.Id); diff --git a/src/Core/Utilities/StaticStore.cs b/src/Core/Utilities/StaticStore.cs index 6726d9cd31..11052a4468 100644 --- a/src/Core/Utilities/StaticStore.cs +++ b/src/Core/Utilities/StaticStore.cs @@ -6,7 +6,7 @@ using Bit.Core.Models.StaticStore.Plans; namespace Bit.Core.Utilities; -public class StaticStore +public static class StaticStore { static StaticStore() { @@ -112,6 +112,11 @@ public class StaticStore new EnterprisePlan(false), new TeamsPlan(true), new TeamsPlan(false), + + new Enterprise2020Plan(true), + new Enterprise2020Plan(false), + new Teams2020Plan(true), + new Teams2020Plan(false), new FamiliesPlan(), new FreePlan(), new CustomPlan(), @@ -139,8 +144,7 @@ public class StaticStore } }; - public static Models.StaticStore.Plan GetPlan(PlanType planType) => - Plans.SingleOrDefault(p => p.Type == planType); + public static Plan GetPlan(PlanType planType) => Plans.SingleOrDefault(p => p.Type == planType); public static SponsoredPlan GetSponsoredPlan(PlanSponsorshipType planSponsorshipType) => diff --git a/src/Infrastructure.EntityFramework/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/Repositories/OrganizationRepository.cs index 62f4df63e3..b20026a6b7 100644 --- a/src/Infrastructure.EntityFramework/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/OrganizationRepository.cs @@ -95,41 +95,44 @@ public class OrganizationRepository : Repository> SearchUnassignedToProviderAsync(string name, string ownerEmail, int skip, int take) { - using (var scope = ServiceScopeFactory.CreateScope()) + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + var query = from o in dbContext.Organizations + where o.PlanType >= PlanType.TeamsMonthly2020 && o.PlanType <= PlanType.EnterpriseAnnually && + !dbContext.ProviderOrganizations.Any(po => po.OrganizationId == o.Id) && + (string.IsNullOrWhiteSpace(name) || EF.Functions.Like(o.Name, $"%{name}%")) + select o; + + if (string.IsNullOrWhiteSpace(ownerEmail)) { - var dbContext = GetDatabaseContext(scope); - var query = from o in dbContext.Organizations - where o.PlanType >= PlanType.TeamsMonthly && o.PlanType <= PlanType.EnterpriseAnnually && - !dbContext.ProviderOrganizations.Any(po => po.OrganizationId == o.Id) && - (string.IsNullOrWhiteSpace(name) || EF.Functions.Like(o.Name, $"%{name}%")) - select o; - - if (!string.IsNullOrWhiteSpace(ownerEmail)) - { - if (dbContext.Database.IsNpgsql()) - { - query = from o in query - join ou in dbContext.OrganizationUsers - on o.Id equals ou.OrganizationId - join u in dbContext.Users - on ou.UserId equals u.Id - where ou.Type == OrganizationUserType.Owner && EF.Functions.ILike(EF.Functions.Collate(u.Email, "default"), $"{ownerEmail}%") - select o; - } - else - { - query = from o in query - join ou in dbContext.OrganizationUsers - on o.Id equals ou.OrganizationId - join u in dbContext.Users - on ou.UserId equals u.Id - where ou.Type == OrganizationUserType.Owner && EF.Functions.Like(u.Email, $"{ownerEmail}%") - select o; - } - } - - return await query.OrderByDescending(o => o.CreationDate).Skip(skip).Take(take).ToArrayAsync(); + return await query.OrderByDescending(o => o.CreationDate) + .Skip(skip) + .Take(take) + .ToArrayAsync(); } + + if (dbContext.Database.IsNpgsql()) + { + query = from o in query + join ou in dbContext.OrganizationUsers + on o.Id equals ou.OrganizationId + join u in dbContext.Users + on ou.UserId equals u.Id + where ou.Type == OrganizationUserType.Owner && EF.Functions.ILike(EF.Functions.Collate(u.Email, "default"), $"{ownerEmail}%") + select o; + } + else + { + query = from o in query + join ou in dbContext.OrganizationUsers + on o.Id equals ou.OrganizationId + join u in dbContext.Users + on ou.UserId equals u.Id + where ou.Type == OrganizationUserType.Owner && EF.Functions.Like(u.Email, $"{ownerEmail}%") + select o; + } + + return await query.OrderByDescending(o => o.CreationDate).Skip(skip).Take(take).ToArrayAsync(); } public async Task UpdateStorageAsync(Guid id) diff --git a/test/Core.Test/AutoFixture/OrganizationFixtures.cs b/test/Core.Test/AutoFixture/OrganizationFixtures.cs index 658964b73d..297dbe8933 100644 --- a/test/Core.Test/AutoFixture/OrganizationFixtures.cs +++ b/test/Core.Test/AutoFixture/OrganizationFixtures.cs @@ -133,8 +133,8 @@ public class SecretsManagerOrganizationCustomization : ICustomization { public void Customize(IFixture fixture) { + const PlanType planType = PlanType.EnterpriseAnnually; var organizationId = Guid.NewGuid(); - var planType = PlanType.EnterpriseAnnually; fixture.Customize(composer => composer .With(o => o.Id, organizationId) @@ -143,8 +143,7 @@ public class SecretsManagerOrganizationCustomization : ICustomization .With(o => o.PlanType, planType) .With(o => o.Plan, StaticStore.GetPlan(planType).Name) .With(o => o.MaxAutoscaleSmSeats, (int?)null) - .With(o => o.MaxAutoscaleSmServiceAccounts, (int?)null) - ); + .With(o => o.MaxAutoscaleSmServiceAccounts, (int?)null)); } } diff --git a/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs b/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs index 0adef6b473..88e21862b5 100644 --- a/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs +++ b/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs @@ -15,17 +15,45 @@ public class SecretsManagerSubscriptionUpdateTests [BitAutoData(PlanType.Custom)] [BitAutoData(PlanType.FamiliesAnnually)] [BitAutoData(PlanType.FamiliesAnnually2019)] - [BitAutoData(PlanType.EnterpriseMonthly2019)] - [BitAutoData(PlanType.EnterpriseAnnually2019)] - [BitAutoData(PlanType.TeamsMonthly2019)] - [BitAutoData(PlanType.TeamsAnnually2019)] - public async Task UpdateSubscriptionAsync_WithNonSecretsManagerPlanType_ThrowsBadRequestException( + public Task UpdateSubscriptionAsync_WithNonSecretsManagerPlanType_ThrowsBadRequestException( PlanType planType, Organization organization) { + // Arrange organization.PlanType = planType; + // Act var exception = Assert.Throws(() => new SecretsManagerSubscriptionUpdate(organization, false)); + + // Assert Assert.Contains("Invalid Secrets Manager plan", exception.Message, StringComparison.InvariantCultureIgnoreCase); + return Task.CompletedTask; + } + + [Theory] + [BitAutoData(PlanType.EnterpriseMonthly2019)] + [BitAutoData(PlanType.EnterpriseMonthly2020)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.EnterpriseAnnually2019)] + [BitAutoData(PlanType.EnterpriseAnnually2020)] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.TeamsMonthly2019)] + [BitAutoData(PlanType.TeamsMonthly2020)] + [BitAutoData(PlanType.TeamsMonthly)] + [BitAutoData(PlanType.TeamsAnnually2019)] + [BitAutoData(PlanType.TeamsAnnually2020)] + [BitAutoData(PlanType.TeamsAnnually)] + public void UpdateSubscription_WithNonSecretsManagerPlanType_DoesNotThrowException( + PlanType planType, + Organization organization) + { + // Arrange + organization.PlanType = planType; + + // Act + var ex = Record.Exception(() => new SecretsManagerSubscriptionUpdate(organization, false)); + + // Assert + Assert.Null(ex); } } diff --git a/test/Core.Test/Utilities/StaticStoreTests.cs b/test/Core.Test/Utilities/StaticStoreTests.cs index 4e85928cc7..9abd31c8f8 100644 --- a/test/Core.Test/Utilities/StaticStoreTests.cs +++ b/test/Core.Test/Utilities/StaticStoreTests.cs @@ -10,10 +10,10 @@ public class StaticStoreTests [Fact] public void StaticStore_Initialization_Success() { - var plans = StaticStore.Plans; + var plans = StaticStore.Plans.ToList(); Assert.NotNull(plans); Assert.NotEmpty(plans); - Assert.Equal(12, plans.Count()); + Assert.Equal(16, plans.Count); } [Theory] diff --git a/util/Migrator/DbScripts/2023-10-13_00_2019TeamsPlanFeatureUpgrade.sql b/util/Migrator/DbScripts/2023-10-13_00_2019TeamsPlanFeatureUpgrade.sql new file mode 100644 index 0000000000..683e7be531 --- /dev/null +++ b/util/Migrator/DbScripts/2023-10-13_00_2019TeamsPlanFeatureUpgrade.sql @@ -0,0 +1,21 @@ +BEGIN TRY + BEGIN TRANSACTION; + + UPDATE + [dbo].[Organization] + SET + [Use2fa] = 1, + [UseApi] = 1, + [UseDirectory] = 1, + [UseEvents] = 1, + [UseGroups] = 1, + [UsersGetPremium] = 1 + WHERE + [PlanType] IN (2, 3); -- Teams 2019 + + COMMIT TRANSACTION; +END TRY +BEGIN CATCH + ROLLBACK TRANSACTION; + THROW; +END CATCH diff --git a/util/Migrator/DbScripts/2023-10-13_01_2019EnterprisePlanFeatureUpgrade.sql b/util/Migrator/DbScripts/2023-10-13_01_2019EnterprisePlanFeatureUpgrade.sql new file mode 100644 index 0000000000..94e58e89fa --- /dev/null +++ b/util/Migrator/DbScripts/2023-10-13_01_2019EnterprisePlanFeatureUpgrade.sql @@ -0,0 +1,19 @@ +BEGIN TRY + BEGIN TRANSACTION; + + UPDATE + [dbo].[Organization] + SET + [UseSso] = 1, + [UseKeyConnector] = 1, + [UseScim] = 1, + [UseResetPassword] = 1 + WHERE + [PlanType] IN (4, 5) -- Enterprise 2019 + + COMMIT TRANSACTION; +END TRY +BEGIN CATCH + ROLLBACK TRANSACTION; + THROW; +END CATCH diff --git a/util/Migrator/DbScripts/2023-10-13_02_2019FamilyPlanFeatureUpgrade.sql b/util/Migrator/DbScripts/2023-10-13_02_2019FamilyPlanFeatureUpgrade.sql new file mode 100644 index 0000000000..357486acff --- /dev/null +++ b/util/Migrator/DbScripts/2023-10-13_02_2019FamilyPlanFeatureUpgrade.sql @@ -0,0 +1,16 @@ +BEGIN TRY + BEGIN TRANSACTION; + + UPDATE + [dbo].[Organization] + SET + [UsersGetPremium] = 1 + WHERE + [PlanType] = 1 -- Families 2019 Annual + + COMMIT TRANSACTION; +END TRY +BEGIN CATCH + ROLLBACK TRANSACTION; + THROW; +END CATCH From d9faa9a6dfd7d68b6f19c1d1ba9bb40b6d54496f Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 1 Nov 2023 15:19:28 +0100 Subject: [PATCH 2/8] [PM-3892] Implement dollar threshold for all subscriptions (#3283) * Initial commit * Fix the failing text * Fix the unpaid invoice issue * fix the unpaid invoice issue * Changes for the threshold amount * remove the billing threshold * Add some comments to the old method * Fixing issues on secret manager test * import missing package * Resolve pr comments * Refactor PreviewUpcomingInvoiceAndPayAsync method * Resolve some pr comments * Resolving the comment around constant * Resolve pr comment * Add new class * Resolve pr comments * Change the prorateThreshold from 5 to 500 dollars * Fix the failing test * Fix the server returns a 500 error with the banner --- src/Core/Constants.cs | 14 + .../Models/Business/InvoicePreviewResult.cs | 7 + .../Models/Business/PendingInoviceItems.cs | 9 + .../Business/SecretsManagerSubscribeUpdate.cs | 8 +- src/Core/Services/IStripeAdapter.cs | 4 + .../Services/Implementations/StripeAdapter.cs | 18 + .../Implementations/StripePaymentService.cs | 392 +++++++++++++++--- .../Services/StripePaymentServiceTests.cs | 296 +++++++++++++ 8 files changed, 692 insertions(+), 56 deletions(-) create mode 100644 src/Core/Models/Business/InvoicePreviewResult.cs create mode 100644 src/Core/Models/Business/PendingInoviceItems.cs diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 908d0a7861..e32b0f4ce4 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -24,6 +24,20 @@ public static class Constants public const string Fido2KeyCipherMinimumVersion = "2023.10.0"; public const string CipherKeyEncryptionMinimumVersion = "2023.9.2"; + + /// + /// When you set the ProrationBehavior to create_prorations, + /// Stripe will automatically create prorations for any changes made to the subscription, + /// such as changing the plan, adding or removing quantities, or applying discounts. + /// + public const string CreateProrations = "create_prorations"; + + /// + /// When you set the ProrationBehavior to always_invoice, + /// Stripe will always generate an invoice when a subscription update occurs, + /// regardless of whether there is a proration or not. + /// + public const string AlwaysInvoice = "always_invoice"; } public static class TokenPurposes diff --git a/src/Core/Models/Business/InvoicePreviewResult.cs b/src/Core/Models/Business/InvoicePreviewResult.cs new file mode 100644 index 0000000000..d9e211cfb2 --- /dev/null +++ b/src/Core/Models/Business/InvoicePreviewResult.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Models.Business; + +public class InvoicePreviewResult +{ + public bool IsInvoicedNow { get; set; } + public string PaymentIntentClientSecret { get; set; } +} diff --git a/src/Core/Models/Business/PendingInoviceItems.cs b/src/Core/Models/Business/PendingInoviceItems.cs new file mode 100644 index 0000000000..1aee15a3aa --- /dev/null +++ b/src/Core/Models/Business/PendingInoviceItems.cs @@ -0,0 +1,9 @@ +using Stripe; + +namespace Bit.Core.Models.Business; + +public class PendingInoviceItems +{ + public IEnumerable PendingInvoiceItems { get; set; } + public IDictionary PendingInvoiceItemsDict { get; set; } +} diff --git a/src/Core/Models/Business/SecretsManagerSubscribeUpdate.cs b/src/Core/Models/Business/SecretsManagerSubscribeUpdate.cs index 8f3fb89349..54bc8cb95e 100644 --- a/src/Core/Models/Business/SecretsManagerSubscribeUpdate.cs +++ b/src/Core/Models/Business/SecretsManagerSubscribeUpdate.cs @@ -44,7 +44,7 @@ public class SecretsManagerSubscribeUpdate : SubscriptionUpdate { updatedItems.Add(new SubscriptionItemOptions { - Price = _plan.SecretsManager.StripeSeatPlanId, + Plan = _plan.SecretsManager.StripeSeatPlanId, Quantity = _additionalSeats }); } @@ -53,7 +53,7 @@ public class SecretsManagerSubscribeUpdate : SubscriptionUpdate { updatedItems.Add(new SubscriptionItemOptions { - Price = _plan.SecretsManager.StripeServiceAccountPlanId, + Plan = _plan.SecretsManager.StripeServiceAccountPlanId, Quantity = _additionalServiceAccounts }); } @@ -63,14 +63,14 @@ public class SecretsManagerSubscribeUpdate : SubscriptionUpdate { updatedItems.Add(new SubscriptionItemOptions { - Price = _plan.SecretsManager.StripeSeatPlanId, + Plan = _plan.SecretsManager.StripeSeatPlanId, Quantity = _previousSeats, Deleted = _previousSeats == 0 ? true : (bool?)null, }); updatedItems.Add(new SubscriptionItemOptions { - Price = _plan.SecretsManager.StripeServiceAccountPlanId, + Plan = _plan.SecretsManager.StripeServiceAccountPlanId, Quantity = _previousServiceAccounts, Deleted = _previousServiceAccounts == 0 ? true : (bool?)null, }); diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index ff922161cc..4f1cdd37eb 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -1,4 +1,5 @@ using Bit.Core.Models.BitStripe; +using Stripe; namespace Bit.Core.Services; @@ -14,8 +15,11 @@ public interface IStripeAdapter Task SubscriptionUpdateAsync(string id, Stripe.SubscriptionUpdateOptions options = null); Task SubscriptionCancelAsync(string Id, Stripe.SubscriptionCancelOptions options = null); Task InvoiceUpcomingAsync(Stripe.UpcomingInvoiceOptions options); + Task InvoiceCreateAsync(Stripe.InvoiceCreateOptions options); + Task InvoiceItemCreateAsync(Stripe.InvoiceItemCreateOptions options); Task InvoiceGetAsync(string id, Stripe.InvoiceGetOptions options); Task> InvoiceListAsync(Stripe.InvoiceListOptions options); + IEnumerable InvoiceItemListAsync(InvoiceItemListOptions options); Task InvoiceUpdateAsync(string id, Stripe.InvoiceUpdateOptions options); Task InvoiceFinalizeInvoiceAsync(string id, Stripe.InvoiceFinalizeOptions options); Task InvoiceSendInvoiceAsync(string id, Stripe.InvoiceSendOptions options); diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index b4776bc6ef..478d092fdc 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -1,4 +1,5 @@ using Bit.Core.Models.BitStripe; +using Stripe; namespace Bit.Core.Services; @@ -16,6 +17,7 @@ public class StripeAdapter : IStripeAdapter private readonly Stripe.BankAccountService _bankAccountService; private readonly Stripe.PriceService _priceService; private readonly Stripe.TestHelpers.TestClockService _testClockService; + private readonly Stripe.InvoiceItemService _invoiceItemService; public StripeAdapter() { @@ -31,6 +33,7 @@ public class StripeAdapter : IStripeAdapter _bankAccountService = new Stripe.BankAccountService(); _priceService = new Stripe.PriceService(); _testClockService = new Stripe.TestHelpers.TestClockService(); + _invoiceItemService = new Stripe.InvoiceItemService(); } public Task CustomerCreateAsync(Stripe.CustomerCreateOptions options) @@ -79,6 +82,16 @@ public class StripeAdapter : IStripeAdapter return _invoiceService.UpcomingAsync(options); } + public Task InvoiceCreateAsync(Stripe.InvoiceCreateOptions options) + { + return _invoiceService.CreateAsync(options); + } + + public Task InvoiceItemCreateAsync(Stripe.InvoiceItemCreateOptions options) + { + return _invoiceItemService.CreateAsync(options); + } + public Task InvoiceGetAsync(string id, Stripe.InvoiceGetOptions options) { return _invoiceService.GetAsync(id, options); @@ -89,6 +102,11 @@ public class StripeAdapter : IStripeAdapter return _invoiceService.ListAsync(options); } + public IEnumerable InvoiceItemListAsync(InvoiceItemListOptions options) + { + return _invoiceItemService.ListAutoPaging(options); + } + public Task InvoiceUpdateAsync(string id, Stripe.InvoiceUpdateOptions options) { return _invoiceService.UpdateAsync(id, options); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 0f7965db77..214b2bff10 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -6,6 +6,7 @@ using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Settings; using Microsoft.Extensions.Logging; +using Stripe; using StaticStore = Bit.Core.Models.StaticStore; using TaxRate = Bit.Core.Entities.TaxRate; @@ -749,16 +750,14 @@ public class StripePaymentService : IPaymentService prorationDate ??= DateTime.UtcNow; var collectionMethod = sub.CollectionMethod; var daysUntilDue = sub.DaysUntilDue; - var chargeNow = collectionMethod == "charge_automatically"; var updatedItemOptions = subscriptionUpdate.UpgradeItemsOptions(sub); var subUpdateOptions = new Stripe.SubscriptionUpdateOptions { Items = updatedItemOptions, - ProrationBehavior = "always_invoice", + ProrationBehavior = Constants.CreateProrations, DaysUntilDue = daysUntilDue ?? 1, - CollectionMethod = "send_invoice", - ProrationDate = prorationDate, + CollectionMethod = "send_invoice" }; if (!subscriptionUpdate.UpdateNeeded(sub)) @@ -792,66 +791,50 @@ public class StripePaymentService : IPaymentService string paymentIntentClientSecret = null; try { - var subResponse = await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, subUpdateOptions); + var subItemOptions = updatedItemOptions.Select(itemOption => + new Stripe.InvoiceSubscriptionItemOptions + { + Id = itemOption.Id, + Plan = itemOption.Plan, + Quantity = itemOption.Quantity, + }).ToList(); - var invoice = await _stripeAdapter.InvoiceGetAsync(subResponse?.LatestInvoiceId, new Stripe.InvoiceGetOptions()); + var reviewInvoiceResponse = await PreviewUpcomingInvoiceAndPayAsync(storableSubscriber, subItemOptions); + paymentIntentClientSecret = reviewInvoiceResponse.PaymentIntentClientSecret; + + var subResponse = await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, subUpdateOptions); + var invoice = + await _stripeAdapter.InvoiceGetAsync(subResponse?.LatestInvoiceId, new Stripe.InvoiceGetOptions()); if (invoice == null) { throw new BadRequestException("Unable to locate draft invoice for subscription update."); } - - if (invoice.AmountDue > 0 && updatedItemOptions.Any(i => i.Quantity > 0)) + } + catch (Exception e) + { + // Need to revert the subscription + await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new Stripe.SubscriptionUpdateOptions { - try - { - if (chargeNow) - { - paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync( - storableSubscriber, invoice); - } - else - { - invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(subResponse.LatestInvoiceId, new Stripe.InvoiceFinalizeOptions - { - AutoAdvance = false, - }); - await _stripeAdapter.InvoiceSendInvoiceAsync(invoice.Id, new Stripe.InvoiceSendOptions()); - paymentIntentClientSecret = null; - } - } - catch - { - // Need to revert the subscription - await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new Stripe.SubscriptionUpdateOptions - { - Items = subscriptionUpdate.RevertItemsOptions(sub), - // This proration behavior prevents a false "credit" from - // being applied forward to the next month's invoice - ProrationBehavior = "none", - CollectionMethod = collectionMethod, - DaysUntilDue = daysUntilDue, - }); - throw; - } - } - else if (!invoice.Paid) - { - // Pay invoice with no charge to customer this completes the invoice immediately without waiting the scheduled 1h - invoice = await _stripeAdapter.InvoicePayAsync(subResponse.LatestInvoiceId); - paymentIntentClientSecret = null; - } - + Items = subscriptionUpdate.RevertItemsOptions(sub), + // This proration behavior prevents a false "credit" from + // being applied forward to the next month's invoice + ProrationBehavior = "none", + CollectionMethod = collectionMethod, + DaysUntilDue = daysUntilDue, + }); + throw; } finally { // Change back the subscription collection method and/or days until due if (collectionMethod != "send_invoice" || daysUntilDue == null) { - await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new Stripe.SubscriptionUpdateOptions - { - CollectionMethod = collectionMethod, - DaysUntilDue = daysUntilDue, - }); + await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, + new Stripe.SubscriptionUpdateOptions + { + CollectionMethod = collectionMethod, + DaysUntilDue = daysUntilDue, + }); } } @@ -934,6 +917,7 @@ public class StripePaymentService : IPaymentService await _stripeAdapter.CustomerDeleteAsync(subscriber.GatewayCustomerId); } + //This method is no-longer is use because we return the dollar threshold feature on invoice will be generated. but we dont want to lose this implementation. public async Task PayInvoiceAfterSubscriptionChangeAsync(ISubscriber subscriber, Stripe.Invoice invoice) { var customerOptions = new Stripe.CustomerGetOptions(); @@ -1103,6 +1087,310 @@ public class StripePaymentService : IPaymentService return paymentIntentClientSecret; } + internal async Task PreviewUpcomingInvoiceAndPayAsync(ISubscriber subscriber, + List subItemOptions, int prorateThreshold = 50000) + { + var customer = await CheckInAppPurchaseMethod(subscriber); + + string paymentIntentClientSecret = null; + + var pendingInvoiceItems = GetPendingInvoiceItems(subscriber); + + var upcomingPreview = await GetUpcomingInvoiceAsync(subscriber, subItemOptions); + + var itemsForInvoice = GetItemsForInvoice(subItemOptions, upcomingPreview, pendingInvoiceItems); + var invoiceAmount = itemsForInvoice?.Sum(i => i.Amount) ?? 0; + var invoiceNow = invoiceAmount >= prorateThreshold; + if (invoiceNow) + { + await ProcessImmediateInvoiceAsync(subscriber, upcomingPreview, invoiceAmount, customer, itemsForInvoice, pendingInvoiceItems, paymentIntentClientSecret); + } + + return new InvoicePreviewResult { IsInvoicedNow = invoiceNow, PaymentIntentClientSecret = paymentIntentClientSecret }; + } + + private async Task ProcessImmediateInvoiceAsync(ISubscriber subscriber, Invoice upcomingPreview, long invoiceAmount, + Customer customer, IEnumerable itemsForInvoice, PendingInoviceItems pendingInvoiceItems, + string paymentIntentClientSecret) + { + // Owes more than prorateThreshold on the next invoice. + // Invoice them and pay now instead of waiting until the next billing cycle. + + string cardPaymentMethodId = null; + var invoiceAmountDue = upcomingPreview.StartingBalance + invoiceAmount; + cardPaymentMethodId = GetCardPaymentMethodId(invoiceAmountDue, customer, cardPaymentMethodId); + + Stripe.Invoice invoice = null; + var createdInvoiceItems = new List(); + Braintree.Transaction braintreeTransaction = null; + + try + { + await CreateInvoiceItemsAsync(subscriber, itemsForInvoice, pendingInvoiceItems, createdInvoiceItems); + + invoice = await CreateInvoiceAsync(subscriber, cardPaymentMethodId); + + var invoicePayOptions = new Stripe.InvoicePayOptions(); + await CreateBrainTreeTransactionRequestAsync(subscriber, invoice, customer, invoicePayOptions, + cardPaymentMethodId, braintreeTransaction); + + await InvoicePayAsync(invoicePayOptions, invoice, paymentIntentClientSecret); + } + catch (Exception e) + { + if (braintreeTransaction != null) + { + await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id); + } + + if (invoice != null) + { + if (invoice.Status == "paid") + { + // It's apparently paid, so we return without throwing an exception + return new InvoicePreviewResult + { + IsInvoicedNow = false, + PaymentIntentClientSecret = paymentIntentClientSecret + }; + } + + await RestoreInvoiceItemsAsync(invoice, customer, pendingInvoiceItems.PendingInvoiceItems); + } + else + { + foreach (var ii in createdInvoiceItems) + { + await _stripeAdapter.InvoiceDeleteAsync(ii.Id); + } + } + + if (e is Stripe.StripeException strEx && + (strEx.StripeError?.Message?.Contains("cannot be used because it is not verified") ?? false)) + { + throw new GatewayException("Bank account is not yet verified."); + } + + throw; + } + + return new InvoicePreviewResult + { + IsInvoicedNow = false, + PaymentIntentClientSecret = paymentIntentClientSecret + }; + } + + private static IEnumerable GetItemsForInvoice(List subItemOptions, Invoice upcomingPreview, + PendingInoviceItems pendingInvoiceItems) + { + var itemsForInvoice = upcomingPreview.Lines?.Data? + .Where(i => pendingInvoiceItems.PendingInvoiceItemsDict.ContainsKey(i.Id) || + (i.Plan.Id == subItemOptions[0]?.Plan && i.Proration)); + return itemsForInvoice; + } + + private PendingInoviceItems GetPendingInvoiceItems(ISubscriber subscriber) + { + var pendingInvoiceItems = new PendingInoviceItems(); + var invoiceItems = _stripeAdapter.InvoiceItemListAsync(new Stripe.InvoiceItemListOptions + { + Customer = subscriber.GatewayCustomerId + }).ToList().Where(i => i.InvoiceId == null); + pendingInvoiceItems.PendingInvoiceItemsDict = invoiceItems.ToDictionary(pii => pii.Id); + return pendingInvoiceItems; + } + + private async Task CheckInAppPurchaseMethod(ISubscriber subscriber) + { + var customerOptions = GetCustomerPaymentOptions(); + var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerOptions); + var usingInAppPaymentMethod = customer.Metadata.ContainsKey("appleReceipt"); + if (usingInAppPaymentMethod) + { + throw new BadRequestException("Cannot perform this action with in-app purchase payment method. " + + "Contact support."); + } + + return customer; + } + + private string GetCardPaymentMethodId(long invoiceAmountDue, Customer customer, string cardPaymentMethodId) + { + try + { + if (invoiceAmountDue <= 0 || customer.Metadata.ContainsKey("btCustomerId")) return cardPaymentMethodId; + var hasDefaultCardPaymentMethod = customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card"; + var hasDefaultValidSource = customer.DefaultSource != null && + (customer.DefaultSource is Stripe.Card || + customer.DefaultSource is Stripe.BankAccount); + if (hasDefaultCardPaymentMethod || hasDefaultValidSource) return cardPaymentMethodId; + cardPaymentMethodId = GetLatestCardPaymentMethod(customer.Id)?.Id; + if (cardPaymentMethodId == null) + { + throw new BadRequestException("No payment method is available."); + } + } + catch (Exception e) + { + throw new BadRequestException("No payment method is available."); + } + + + return cardPaymentMethodId; + } + + private async Task GetUpcomingInvoiceAsync(ISubscriber subscriber, List subItemOptions) + { + var upcomingPreview = await _stripeAdapter.InvoiceUpcomingAsync(new Stripe.UpcomingInvoiceOptions + { + Customer = subscriber.GatewayCustomerId, + Subscription = subscriber.GatewaySubscriptionId, + SubscriptionItems = subItemOptions + }); + return upcomingPreview; + } + + private async Task RestoreInvoiceItemsAsync(Invoice invoice, Customer customer, IEnumerable pendingInvoiceItems) + { + invoice = await _stripeAdapter.InvoiceVoidInvoiceAsync(invoice.Id, new Stripe.InvoiceVoidOptions()); + if (invoice.StartingBalance != 0) + { + await _stripeAdapter.CustomerUpdateAsync(customer.Id, + new Stripe.CustomerUpdateOptions { Balance = customer.Balance }); + } + + // Restore invoice items that were brought in + foreach (var item in pendingInvoiceItems) + { + var i = new Stripe.InvoiceItemCreateOptions + { + Currency = item.Currency, + Description = item.Description, + Customer = item.CustomerId, + Subscription = item.SubscriptionId, + Discountable = item.Discountable, + Metadata = item.Metadata, + Quantity = item.Proration ? 1 : item.Quantity, + UnitAmount = item.UnitAmount + }; + await _stripeAdapter.InvoiceItemCreateAsync(i); + } + } + + private async Task InvoicePayAsync(InvoicePayOptions invoicePayOptions, Invoice invoice, string paymentIntentClientSecret) + { + try + { + await _stripeAdapter.InvoicePayAsync(invoice.Id, invoicePayOptions); + } + catch (Stripe.StripeException e) + { + if (e.HttpStatusCode == System.Net.HttpStatusCode.PaymentRequired && + e.StripeError?.Code == "invoice_payment_intent_requires_action") + { + // SCA required, get intent client secret + var invoiceGetOptions = new Stripe.InvoiceGetOptions(); + invoiceGetOptions.AddExpand("payment_intent"); + invoice = await _stripeAdapter.InvoiceGetAsync(invoice.Id, invoiceGetOptions); + paymentIntentClientSecret = invoice?.PaymentIntent?.ClientSecret; + } + else + { + throw new GatewayException("Unable to pay invoice."); + } + } + } + + private async Task CreateBrainTreeTransactionRequestAsync(ISubscriber subscriber, Invoice invoice, Customer customer, + InvoicePayOptions invoicePayOptions, string cardPaymentMethodId, Braintree.Transaction braintreeTransaction) + { + if (invoice.AmountDue > 0) + { + if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false) + { + invoicePayOptions.PaidOutOfBand = true; + var btInvoiceAmount = (invoice.AmountDue / 100M); + var transactionResult = await _btGateway.Transaction.SaleAsync( + new Braintree.TransactionRequest + { + Amount = btInvoiceAmount, + CustomerId = customer.Metadata["btCustomerId"], + Options = new Braintree.TransactionOptionsRequest + { + SubmitForSettlement = true, + PayPal = new Braintree.TransactionOptionsPayPalRequest + { + CustomField = $"{subscriber.BraintreeIdField()}:{subscriber.Id}" + } + }, + CustomFields = new Dictionary + { + [subscriber.BraintreeIdField()] = subscriber.Id.ToString() + } + }); + + if (!transactionResult.IsSuccess()) + { + throw new GatewayException("Failed to charge PayPal customer."); + } + + braintreeTransaction = transactionResult.Target; + await _stripeAdapter.InvoiceUpdateAsync(invoice.Id, new Stripe.InvoiceUpdateOptions + { + Metadata = new Dictionary + { + ["btTransactionId"] = braintreeTransaction.Id, + ["btPayPalTransactionId"] = + braintreeTransaction.PayPalDetails.AuthorizationId + } + }); + } + else + { + invoicePayOptions.OffSession = true; + invoicePayOptions.PaymentMethod = cardPaymentMethodId; + } + } + } + + private async Task CreateInvoiceAsync(ISubscriber subscriber, string cardPaymentMethodId) + { + Invoice invoice; + invoice = await _stripeAdapter.InvoiceCreateAsync(new Stripe.InvoiceCreateOptions + { + CollectionMethod = "send_invoice", + DaysUntilDue = 1, + Customer = subscriber.GatewayCustomerId, + Subscription = subscriber.GatewaySubscriptionId, + DefaultPaymentMethod = cardPaymentMethodId + }); + return invoice; + } + + private async Task CreateInvoiceItemsAsync(ISubscriber subscriber, IEnumerable itemsForInvoice, + PendingInoviceItems pendingInvoiceItems, List createdInvoiceItems) + { + foreach (var invoiceLineItem in itemsForInvoice) + { + if (pendingInvoiceItems.PendingInvoiceItemsDict.ContainsKey(invoiceLineItem.Id)) + { + continue; + } + + var invoiceItem = await _stripeAdapter.InvoiceItemCreateAsync(new Stripe.InvoiceItemCreateOptions + { + Currency = invoiceLineItem.Currency, + Description = invoiceLineItem.Description, + Customer = subscriber.GatewayCustomerId, + Subscription = invoiceLineItem.Subscription, + Discountable = invoiceLineItem.Discountable, + Amount = invoiceLineItem.Amount + }); + createdInvoiceItems.Add(invoiceItem); + } + } + public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false, bool skipInAppPurchaseCheck = false) { diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index 2133a14a97..9ef4b0233c 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -739,4 +739,300 @@ public class StripePaymentServiceTests Assert.Null(result); } + + [Theory, BitAutoData] + public async Task PreviewUpcomingInvoiceAndPayAsync_WithInAppPaymentMethod_ThrowsBadRequestException(SutProvider sutProvider, + Organization subscriber, List subItemOptions) + { + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter.CustomerGetAsync(Arg.Any(), Arg.Any()) + .Returns(new Stripe.Customer { Metadata = new Dictionary { { "appleReceipt", "dummyData" } } }); + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.PreviewUpcomingInvoiceAndPayAsync(subscriber, subItemOptions)); + Assert.Equal("Cannot perform this action with in-app purchase payment method. Contact support.", ex.Message); + } + + [Theory, BitAutoData] + public async void PreviewUpcomingInvoiceAndPayAsync_UpcomingInvoiceBelowThreshold_DoesNotInvoiceNow(SutProvider sutProvider, + Organization subscriber, List subItemOptions) + { + var prorateThreshold = 50000; + var invoiceAmountBelowThreshold = prorateThreshold - 100; + var customer = MockStripeCustomer(subscriber); + sutProvider.GetDependency().CustomerGetAsync(default, default).ReturnsForAnyArgs(customer); + var invoiceItem = MockInoviceItemList(subscriber, "planId", invoiceAmountBelowThreshold, customer); + sutProvider.GetDependency().InvoiceItemListAsync(new Stripe.InvoiceItemListOptions + { + Customer = subscriber.GatewayCustomerId + }).ReturnsForAnyArgs(invoiceItem); + + var invoiceLineItem = CreateInvoiceLineTime(subscriber, "planId", invoiceAmountBelowThreshold); + sutProvider.GetDependency().InvoiceUpcomingAsync(new Stripe.UpcomingInvoiceOptions + { + Customer = subscriber.GatewayCustomerId, + Subscription = subscriber.GatewaySubscriptionId, + SubscriptionItems = subItemOptions + }).ReturnsForAnyArgs(invoiceLineItem); + + sutProvider.GetDependency().InvoiceCreateAsync(Arg.Is(options => + options.CollectionMethod == "send_invoice" && + options.DaysUntilDue == 1 && + options.Customer == subscriber.GatewayCustomerId && + options.Subscription == subscriber.GatewaySubscriptionId && + options.DefaultPaymentMethod == customer.InvoiceSettings.DefaultPaymentMethod.Id + )).ReturnsForAnyArgs(new Stripe.Invoice + { + Id = "mockInvoiceId", + CollectionMethod = "send_invoice", + DueDate = DateTime.Now.AddDays(1), + Customer = customer, + Subscription = new Stripe.Subscription + { + Id = "mockSubscriptionId", + Customer = customer, + Status = "active", + CurrentPeriodStart = DateTime.UtcNow, + CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1), + CollectionMethod = "charge_automatically", + }, + DefaultPaymentMethod = customer.InvoiceSettings.DefaultPaymentMethod, + AmountDue = invoiceAmountBelowThreshold, + Currency = "usd", + Status = "draft", + }); + + var result = await sutProvider.Sut.PreviewUpcomingInvoiceAndPayAsync(subscriber, new List(), prorateThreshold); + + Assert.False(result.IsInvoicedNow); + Assert.Null(result.PaymentIntentClientSecret); + } + + [Theory, BitAutoData] + public async void PreviewUpcomingInvoiceAndPayAsync_NoPaymentMethod_ThrowsBadRequestException(SutProvider sutProvider, + Organization subscriber, List subItemOptions, string planId) + { + var prorateThreshold = 120000; + var invoiceAmountBelowThreshold = prorateThreshold; + var customer = new Stripe.Customer + { + Metadata = new Dictionary(), + Id = subscriber.GatewayCustomerId, + DefaultSource = null, + InvoiceSettings = new Stripe.CustomerInvoiceSettings + { + DefaultPaymentMethod = null + } + }; + sutProvider.GetDependency().CustomerGetAsync(default, default).ReturnsForAnyArgs(customer); + var invoiceItem = MockInoviceItemList(subscriber, planId, invoiceAmountBelowThreshold, customer); + sutProvider.GetDependency().InvoiceItemListAsync(new Stripe.InvoiceItemListOptions + { + Customer = subscriber.GatewayCustomerId + }).ReturnsForAnyArgs(invoiceItem); + + var invoiceLineItem = CreateInvoiceLineTime(subscriber, planId, invoiceAmountBelowThreshold); + sutProvider.GetDependency().InvoiceUpcomingAsync(new Stripe.UpcomingInvoiceOptions + { + Customer = subscriber.GatewayCustomerId, + Subscription = subscriber.GatewaySubscriptionId, + SubscriptionItems = subItemOptions + }).ReturnsForAnyArgs(invoiceLineItem); + + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.PreviewUpcomingInvoiceAndPayAsync(subscriber, subItemOptions)); + Assert.Equal("No payment method is available.", ex.Message); + } + + [Theory, BitAutoData] + public async void PreviewUpcomingInvoiceAndPayAsync_UpcomingInvoiceAboveThreshold_DoesInvoiceNow(SutProvider sutProvider, + Organization subscriber, List subItemOptions, string planId) + { + var prorateThreshold = 50000; + var invoiceAmountBelowThreshold = 100000; + var customer = MockStripeCustomer(subscriber); + sutProvider.GetDependency().CustomerGetAsync(default, default).ReturnsForAnyArgs(customer); + var invoiceItem = MockInoviceItemList(subscriber, planId, invoiceAmountBelowThreshold, customer); + sutProvider.GetDependency().InvoiceItemListAsync(new Stripe.InvoiceItemListOptions + { + Customer = subscriber.GatewayCustomerId + }).ReturnsForAnyArgs(invoiceItem); + + var invoiceLineItem = CreateInvoiceLineTime(subscriber, planId, invoiceAmountBelowThreshold); + sutProvider.GetDependency().InvoiceUpcomingAsync(new Stripe.UpcomingInvoiceOptions + { + Customer = subscriber.GatewayCustomerId, + Subscription = subscriber.GatewaySubscriptionId, + SubscriptionItems = subItemOptions + }).ReturnsForAnyArgs(invoiceLineItem); + + var invoice = MockInVoice(customer, invoiceAmountBelowThreshold); + sutProvider.GetDependency().InvoiceCreateAsync(Arg.Is(options => + options.CollectionMethod == "send_invoice" && + options.DaysUntilDue == 1 && + options.Customer == subscriber.GatewayCustomerId && + options.Subscription == subscriber.GatewaySubscriptionId && + options.DefaultPaymentMethod == customer.InvoiceSettings.DefaultPaymentMethod.Id + )).ReturnsForAnyArgs(invoice); + + var result = await sutProvider.Sut.PreviewUpcomingInvoiceAndPayAsync(subscriber, new List(), prorateThreshold); + + await sutProvider.GetDependency().Received(1).InvoicePayAsync(invoice.Id, + Arg.Is((options => + options.OffSession == true + ))); + + + Assert.True(result.IsInvoicedNow); + Assert.Null(result.PaymentIntentClientSecret); + } + + private static Stripe.Invoice MockInVoice(Stripe.Customer customer, int invoiceAmountBelowThreshold) => + new() + { + Id = "mockInvoiceId", + CollectionMethod = "send_invoice", + DueDate = DateTime.Now.AddDays(1), + Customer = customer, + Subscription = new Stripe.Subscription + { + Id = "mockSubscriptionId", + Customer = customer, + Status = "active", + CurrentPeriodStart = DateTime.UtcNow, + CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1), + CollectionMethod = "charge_automatically", + }, + DefaultPaymentMethod = customer.InvoiceSettings.DefaultPaymentMethod, + AmountDue = invoiceAmountBelowThreshold, + Currency = "usd", + Status = "draft", + }; + + private static List MockInoviceItemList(Organization subscriber, string planId, int invoiceAmountBelowThreshold, Stripe.Customer customer) => + new() + { + new Stripe.InvoiceItem + { + Id = "ii_1234567890", + Amount = invoiceAmountBelowThreshold, + Currency = "usd", + CustomerId = subscriber.GatewayCustomerId, + Description = "Sample invoice item 1", + Date = DateTime.UtcNow, + Discountable = true, + InvoiceId = "548458365" + }, + new Stripe.InvoiceItem + { + Id = "ii_0987654321", + Amount = invoiceAmountBelowThreshold, + Currency = "usd", + CustomerId = customer.Id, + Description = "Sample invoice item 2", + Date = DateTime.UtcNow.AddDays(-5), + Discountable = false, + InvoiceId = null, + Proration = true, + Plan = new Stripe.Plan + { + Id = planId, + Amount = invoiceAmountBelowThreshold, + Currency = "usd", + Interval = "month", + IntervalCount = 1, + }, + } + }; + + private static Stripe.Customer MockStripeCustomer(Organization subscriber) + { + var customer = new Stripe.Customer + { + Metadata = new Dictionary(), + Id = subscriber.GatewayCustomerId, + DefaultSource = new Stripe.Card + { + Id = "card_12345", + Last4 = "1234", + Brand = "Visa", + ExpYear = 2025, + ExpMonth = 12 + }, + InvoiceSettings = new Stripe.CustomerInvoiceSettings + { + DefaultPaymentMethod = new Stripe.PaymentMethod + { + Id = "pm_12345", + Type = "card", + Card = new Stripe.PaymentMethodCard + { + Last4 = "1234", + Brand = "Visa", + ExpYear = 2025, + ExpMonth = 12 + } + } + } + }; + return customer; + } + + private static Stripe.Invoice CreateInvoiceLineTime(Organization subscriber, string planId, int invoiceAmountBelowThreshold) => + new() + { + AmountDue = invoiceAmountBelowThreshold, + AmountPaid = 0, + AmountRemaining = invoiceAmountBelowThreshold, + CustomerId = subscriber.GatewayCustomerId, + SubscriptionId = subscriber.GatewaySubscriptionId, + ApplicationFeeAmount = 0, + Currency = "usd", + Description = "Upcoming Invoice", + Discount = null, + DueDate = DateTime.UtcNow.AddDays(1), + EndingBalance = 0, + Number = "INV12345", + Paid = false, + PeriodStart = DateTime.UtcNow, + PeriodEnd = DateTime.UtcNow.AddMonths(1), + ReceiptNumber = null, + StartingBalance = 0, + Status = "draft", + Id = "ii_0987654321", + Total = invoiceAmountBelowThreshold, + Lines = new Stripe.StripeList + { + Data = new List + { + new Stripe.InvoiceLineItem + { + Amount = invoiceAmountBelowThreshold, + Currency = "usd", + Description = "Sample line item", + Id = "ii_0987654321", + Livemode = false, + Object = "line_item", + Discountable = false, + Period = new Stripe.InvoiceLineItemPeriod() + { + Start = DateTime.UtcNow, + End = DateTime.UtcNow.AddMonths(1) + }, + Plan = new Stripe.Plan + { + Id = planId, + Amount = invoiceAmountBelowThreshold, + Currency = "usd", + Interval = "month", + IntervalCount = 1, + }, + Proration = true, + Quantity = 1, + Subscription = subscriber.GatewaySubscriptionId, + SubscriptionItem = "si_12345", + Type = "subscription", + UnitAmountExcludingTax = invoiceAmountBelowThreshold, + } + } + } + }; } From 34a3d4a4df4bf8ebc293d8866a9ae02eb5b59ec6 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 1 Nov 2023 11:05:04 -0400 Subject: [PATCH 3/8] [AC-1593] Auto-Grant SM access to org owner when they add SM (#3349) * Auto grant SM access to org owner * Thomas' feedback --- .../Controllers/OrganizationsController.cs | 26 ++- .../OrganizationsControllerTests.cs | 196 ++++++++++++++++++ 2 files changed, 220 insertions(+), 2 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index a4d8a330ba..632230ebcd 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -320,8 +320,16 @@ public class OrganizationsController : Controller throw new NotFoundException(); } - var result = await _upgradeOrganizationPlanCommand.UpgradePlanAsync(orgIdGuid, model.ToOrganizationUpgrade()); - return new PaymentResponseModel { Success = result.Item1, PaymentIntentClientSecret = result.Item2 }; + var (success, paymentIntentClientSecret) = await _upgradeOrganizationPlanCommand.UpgradePlanAsync(orgIdGuid, model.ToOrganizationUpgrade()); + + if (model.UseSecretsManager && success) + { + var userId = _userService.GetProperUserId(User).Value; + + await TryGrantOwnerAccessToSecretsManagerAsync(orgIdGuid, userId); + } + + return new PaymentResponseModel { Success = success, PaymentIntentClientSecret = paymentIntentClientSecret }; } [HttpPost("{id}/subscription")] @@ -374,6 +382,9 @@ public class OrganizationsController : Controller model.AdditionalServiceAccounts); var userId = _userService.GetProperUserId(User).Value; + + await TryGrantOwnerAccessToSecretsManagerAsync(organization.Id, userId); + var organizationDetails = await _organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed); @@ -786,4 +797,15 @@ public class OrganizationsController : Controller await _organizationService.UpdateAsync(model.ToOrganization(organization)); return new OrganizationResponseModel(organization); } + + private async Task TryGrantOwnerAccessToSecretsManagerAsync(Guid organizationId, Guid userId) + { + var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId); + + if (organizationUser != null) + { + organizationUser.AccessSecretsManager = true; + await _organizationUserRepository.ReplaceAsync(organizationUser); + } + } } diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 0edc800800..7a3f4437ca 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -1,6 +1,8 @@ using System.Security.Claims; using AutoFixture.Xunit2; using Bit.Api.AdminConsole.Controllers; +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.Models.Request.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; @@ -10,13 +12,17 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Services; using Bit.Core.Context; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Business; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using NSubstitute; +using NSubstitute.ReturnsExtensions; using Xunit; namespace Bit.Api.Test.AdminConsole.Controllers; @@ -145,4 +151,194 @@ public class OrganizationsControllerTests : IDisposable await _organizationService.DeleteUserAsync(orgId, user.Id); await _organizationService.Received(1).DeleteUserAsync(orgId, user.Id); } + + [Theory, AutoData] + public async Task OrganizationsController_PostUpgrade_UserCannotEditSubscription_ThrowsNotFoundException( + Guid organizationId, + OrganizationUpgradeRequestModel model) + { + _currentContext.EditSubscription(organizationId).Returns(false); + + await Assert.ThrowsAsync(() => _sut.PostUpgrade(organizationId.ToString(), model)); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostUpgrade_NonSMUpgrade_ReturnsCorrectResponse( + Guid organizationId, + OrganizationUpgradeRequestModel model, + bool success, + string paymentIntentClientSecret) + { + model.UseSecretsManager = false; + + _currentContext.EditSubscription(organizationId).Returns(true); + + _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) + .Returns(new Tuple(success, paymentIntentClientSecret)); + + var response = await _sut.PostUpgrade(organizationId.ToString(), model); + + Assert.Equal(success, response.Success); + Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostUpgrade_SMUpgrade_ProvidesAccess_ReturnsCorrectResponse( + Guid organizationId, + Guid userId, + OrganizationUpgradeRequestModel model, + bool success, + string paymentIntentClientSecret, + OrganizationUser organizationUser) + { + model.UseSecretsManager = true; + organizationUser.AccessSecretsManager = false; + + _currentContext.EditSubscription(organizationId).Returns(true); + + _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) + .Returns(new Tuple(success, paymentIntentClientSecret)); + + _userService.GetProperUserId(Arg.Any()).Returns(userId); + + _organizationUserRepository.GetByOrganizationAsync(organizationId, userId).Returns(organizationUser); + + var response = await _sut.PostUpgrade(organizationId.ToString(), model); + + Assert.Equal(success, response.Success); + Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret); + + await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is(orgUser => + orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true)); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostUpgrade_SMUpgrade_NullOrgUser_ReturnsCorrectResponse( + Guid organizationId, + Guid userId, + OrganizationUpgradeRequestModel model, + bool success, + string paymentIntentClientSecret) + { + model.UseSecretsManager = true; + + _currentContext.EditSubscription(organizationId).Returns(true); + + _upgradeOrganizationPlanCommand.UpgradePlanAsync(organizationId, Arg.Any()) + .Returns(new Tuple(success, paymentIntentClientSecret)); + + _userService.GetProperUserId(Arg.Any()).Returns(userId); + + _organizationUserRepository.GetByOrganizationAsync(organizationId, userId).ReturnsNull(); + + var response = await _sut.PostUpgrade(organizationId.ToString(), model); + + Assert.Equal(success, response.Success); + Assert.Equal(paymentIntentClientSecret, response.PaymentIntentClientSecret); + + await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrg_ThrowsNotFoundException( + Guid organizationId, + SecretsManagerSubscribeRequestModel model) + { + _organizationRepository.GetByIdAsync(organizationId).ReturnsNull(); + + await Assert.ThrowsAsync(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model)); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_UserCannotEditSubscription_ThrowsNotFoundException( + Guid organizationId, + SecretsManagerSubscribeRequestModel model, + Organization organization) + { + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + _currentContext.EditSubscription(organizationId).Returns(false); + + await Assert.ThrowsAsync(() => _sut.PostSubscribeSecretsManagerAsync(organizationId, model)); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_ProvidesAccess_ReturnsCorrectResponse( + Guid organizationId, + SecretsManagerSubscribeRequestModel model, + Organization organization, + Guid userId, + OrganizationUser organizationUser, + OrganizationUserOrganizationDetails organizationUserOrganizationDetails) + { + organizationUser.AccessSecretsManager = false; + + var ssoConfigurationData = new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.KeyConnector, + KeyConnectorUrl = "https://example.com" + }; + + organizationUserOrganizationDetails.Permissions = string.Empty; + organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize(); + + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + _currentContext.EditSubscription(organizationId).Returns(true); + + _userService.GetProperUserId(Arg.Any()).Returns(userId); + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).Returns(organizationUser); + + _organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed) + .Returns(organizationUserOrganizationDetails); + + var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model); + + Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId); + Assert.Equal(response.Name, organizationUserOrganizationDetails.Name); + + await _addSecretsManagerSubscriptionCommand.Received(1) + .SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts); + await _organizationUserRepository.Received(1).ReplaceAsync(Arg.Is(orgUser => + orgUser.Id == organizationUser.Id && orgUser.AccessSecretsManager == true)); + } + + [Theory, AutoData] + public async Task OrganizationsController_PostSubscribeSecretsManagerAsync_NullOrgUser_ReturnsCorrectResponse( + Guid organizationId, + SecretsManagerSubscribeRequestModel model, + Organization organization, + Guid userId, + OrganizationUserOrganizationDetails organizationUserOrganizationDetails) + { + var ssoConfigurationData = new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.KeyConnector, + KeyConnectorUrl = "https://example.com" + }; + + organizationUserOrganizationDetails.Permissions = string.Empty; + organizationUserOrganizationDetails.SsoConfig = ssoConfigurationData.Serialize(); + + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + + _currentContext.EditSubscription(organizationId).Returns(true); + + _userService.GetProperUserId(Arg.Any()).Returns(userId); + + _organizationUserRepository.GetByOrganizationAsync(organization.Id, userId).ReturnsNull(); + + _organizationUserRepository.GetDetailsByUserAsync(userId, organization.Id, OrganizationUserStatusType.Confirmed) + .Returns(organizationUserOrganizationDetails); + + var response = await _sut.PostSubscribeSecretsManagerAsync(organizationId, model); + + Assert.Equal(response.Id, organizationUserOrganizationDetails.OrganizationId); + Assert.Equal(response.Name, organizationUserOrganizationDetails.Name); + + await _addSecretsManagerSubscriptionCommand.Received(1) + .SignUpAsync(organization, model.AdditionalSmSeats, model.AdditionalServiceAccounts); + await _organizationUserRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); + } } From 1fb5e49a059003e44ca16812b9d49b66194910f2 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 2 Nov 2023 01:14:40 +1000 Subject: [PATCH 4/8] Move remaining OrganizationAuth files to AC Team code ownership (#3382) --- .../Controllers/OrganizationAuthRequestsController.cs | 4 ++-- .../Models/Request/AdminAuthRequestUpdateRequestModel.cs | 2 +- .../Models/Request/BulkDenyAdminAuthRequestRequestModel.cs | 2 +- .../Response/PendingOrganizationAuthRequestResponseModel.cs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename src/Api/{Auth => AdminConsole}/Models/Request/AdminAuthRequestUpdateRequestModel.cs (84%) rename src/Api/{Auth => AdminConsole}/Models/Request/BulkDenyAdminAuthRequestRequestModel.cs (67%) rename src/Api/{Auth => AdminConsole}/Models/Response/PendingOrganizationAuthRequestResponseModel.cs (96%) diff --git a/src/Api/AdminConsole/Controllers/OrganizationAuthRequestsController.cs b/src/Api/AdminConsole/Controllers/OrganizationAuthRequestsController.cs index ac041fca2d..36555a7d2a 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationAuthRequestsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationAuthRequestsController.cs @@ -1,5 +1,5 @@ -using Bit.Api.Auth.Models.Request; -using Bit.Api.Auth.Models.Response; +using Bit.Api.AdminConsole.Models.Request; +using Bit.Api.AdminConsole.Models.Response; using Bit.Api.Models.Response; using Bit.Core; using Bit.Core.AdminConsole.OrganizationAuth.Interfaces; diff --git a/src/Api/Auth/Models/Request/AdminAuthRequestUpdateRequestModel.cs b/src/Api/AdminConsole/Models/Request/AdminAuthRequestUpdateRequestModel.cs similarity index 84% rename from src/Api/Auth/Models/Request/AdminAuthRequestUpdateRequestModel.cs rename to src/Api/AdminConsole/Models/Request/AdminAuthRequestUpdateRequestModel.cs index ebf9144c19..abcc6fdb74 100644 --- a/src/Api/Auth/Models/Request/AdminAuthRequestUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/AdminAuthRequestUpdateRequestModel.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Utilities; -namespace Bit.Api.Auth.Models.Request; +namespace Bit.Api.AdminConsole.Models.Request; public class AdminAuthRequestUpdateRequestModel { diff --git a/src/Api/Auth/Models/Request/BulkDenyAdminAuthRequestRequestModel.cs b/src/Api/AdminConsole/Models/Request/BulkDenyAdminAuthRequestRequestModel.cs similarity index 67% rename from src/Api/Auth/Models/Request/BulkDenyAdminAuthRequestRequestModel.cs rename to src/Api/AdminConsole/Models/Request/BulkDenyAdminAuthRequestRequestModel.cs index ff5e38948a..24386341a3 100644 --- a/src/Api/Auth/Models/Request/BulkDenyAdminAuthRequestRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/BulkDenyAdminAuthRequestRequestModel.cs @@ -1,4 +1,4 @@ -namespace Bit.Api.Auth.Models.Request; +namespace Bit.Api.AdminConsole.Models.Request; public class BulkDenyAdminAuthRequestRequestModel { diff --git a/src/Api/Auth/Models/Response/PendingOrganizationAuthRequestResponseModel.cs b/src/Api/AdminConsole/Models/Response/PendingOrganizationAuthRequestResponseModel.cs similarity index 96% rename from src/Api/Auth/Models/Response/PendingOrganizationAuthRequestResponseModel.cs rename to src/Api/AdminConsole/Models/Response/PendingOrganizationAuthRequestResponseModel.cs index 5257b49df5..1805bdb07e 100644 --- a/src/Api/Auth/Models/Response/PendingOrganizationAuthRequestResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/PendingOrganizationAuthRequestResponseModel.cs @@ -3,7 +3,7 @@ using System.Reflection; using Bit.Core.Auth.Models.Data; using Bit.Core.Models.Api; -namespace Bit.Api.Auth.Models.Response; +namespace Bit.Api.AdminConsole.Models.Response; public class PendingOrganizationAuthRequestResponseModel : ResponseModel { From f5f64059c5d5ef19e38f27cba7d0dad0fa36a706 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Wed, 1 Nov 2023 13:39:00 -0400 Subject: [PATCH 5/8] Auth/PM-3659 - Disable Passkey registration if Require SSO Policy Enabled (#3399) * PM-3659 - WebAuthnController.cs - Passkey Creation - Add RequireSSO login policy validation to prevent users from creating passkeys if require SSO applies to them. * PM-3659 - per PR feedback, apply new require SSO validation to options call * PM-3659 - Remove unneeded comment * PM-3659 - Per PR feedback, add unit tests for new require SSO scenarios on both Post and Options endpoints on the WebAuthnController * Remove duplicated line * Remove extra whitespace --- .../Auth/Controllers/WebAuthnController.cs | 19 ++++++- .../Controllers/WebAuthnControllerTests.cs | 50 +++++++++++++++++-- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/Api/Auth/Controllers/WebAuthnController.cs b/src/Api/Auth/Controllers/WebAuthnController.cs index b7e9c5bb8b..908915662d 100644 --- a/src/Api/Auth/Controllers/WebAuthnController.cs +++ b/src/Api/Auth/Controllers/WebAuthnController.cs @@ -5,6 +5,7 @@ using Bit.Api.Models.Response; using Bit.Core; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Tokens; @@ -22,15 +23,18 @@ public class WebAuthnController : Controller private readonly IUserService _userService; private readonly IWebAuthnCredentialRepository _credentialRepository; private readonly IDataProtectorTokenFactory _createOptionsDataProtector; + private readonly IPolicyService _policyService; public WebAuthnController( IUserService userService, IWebAuthnCredentialRepository credentialRepository, - IDataProtectorTokenFactory createOptionsDataProtector) + IDataProtectorTokenFactory createOptionsDataProtector, + IPolicyService policyService) { _userService = userService; _credentialRepository = credentialRepository; _createOptionsDataProtector = createOptionsDataProtector; + _policyService = policyService; } [HttpGet("")] @@ -46,6 +50,7 @@ public class WebAuthnController : Controller public async Task PostOptions([FromBody] SecretVerificationRequestModel model) { var user = await VerifyUserAsync(model); + await ValidateRequireSsoPolicyDisabledOrNotApplicable(user.Id); var options = await _userService.StartWebAuthnLoginRegistrationAsync(user); var tokenable = new WebAuthnCredentialCreateOptionsTokenable(user, options); @@ -62,7 +67,9 @@ public class WebAuthnController : Controller public async Task Post([FromBody] WebAuthnCredentialRequestModel model) { var user = await GetUserAsync(); + await ValidateRequireSsoPolicyDisabledOrNotApplicable(user.Id); var tokenable = _createOptionsDataProtector.Unprotect(model.Token); + if (!tokenable.TokenIsValid(user)) { throw new BadRequestException("The token associated with your request is expired. A valid token is required to continue."); @@ -75,6 +82,16 @@ public class WebAuthnController : Controller } } + private async Task ValidateRequireSsoPolicyDisabledOrNotApplicable(Guid userId) + { + var requireSsoLogin = await _policyService.AnyPoliciesApplicableToUserAsync(userId, PolicyType.RequireSso); + + if (requireSsoLogin) + { + throw new BadRequestException("Passkeys cannot be created for your account. SSO login is required."); + } + } + [HttpPost("{id}/delete")] public async Task Delete(Guid id, [FromBody] SecretVerificationRequestModel model) { diff --git a/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs b/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs index 32f2d5d491..dd5ffb15fb 100644 --- a/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs @@ -3,6 +3,7 @@ using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Models.Request.Webauthn; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Tokens; @@ -22,7 +23,7 @@ public class WebAuthnControllerTests [Theory, BitAutoData] public async Task Get_UserNotFound_ThrowsUnauthorizedAccessException(SutProvider sutProvider) { - // Arrange + // Arrange sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs(); // Act @@ -35,7 +36,7 @@ public class WebAuthnControllerTests [Theory, BitAutoData] public async Task PostOptions_UserNotFound_ThrowsUnauthorizedAccessException(SecretVerificationRequestModel requestModel, SutProvider sutProvider) { - // Arrange + // Arrange sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs(); // Act @@ -59,10 +60,25 @@ public class WebAuthnControllerTests await Assert.ThrowsAsync(result); } + [Theory, BitAutoData] + public async Task PostOptions_RequireSsoPolicyApplicable_ThrowsBadRequestException( + SecretVerificationRequestModel requestModel, User user, SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user); + sutProvider.GetDependency().VerifySecretAsync(user, default).ReturnsForAnyArgs(true); + sutProvider.GetDependency().AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso).ReturnsForAnyArgs(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PostOptions(requestModel)); + Assert.Contains("Passkeys cannot be created for your account. SSO login is required", exception.Message); + } + [Theory, BitAutoData] public async Task Post_UserNotFound_ThrowsUnauthorizedAccessException(WebAuthnCredentialRequestModel requestModel, SutProvider sutProvider) { - // Arrange + // Arrange sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs(); // Act @@ -113,10 +129,36 @@ public class WebAuthnControllerTests // Nothing to assert since return is void } + [Theory, BitAutoData] + public async Task Post_RequireSsoPolicyApplicable_ThrowsBadRequestException( + WebAuthnCredentialRequestModel requestModel, + CredentialCreateOptions createOptions, + User user, + SutProvider sutProvider) + { + // Arrange + var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions); + sutProvider.GetDependency() + .GetUserByPrincipalAsync(default) + .ReturnsForAnyArgs(user); + sutProvider.GetDependency() + .CompleteWebAuthLoginRegistrationAsync(user, requestModel.Name, createOptions, Arg.Any()) + .Returns(true); + sutProvider.GetDependency>() + .Unprotect(requestModel.Token) + .Returns(token); + sutProvider.GetDependency().AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso).ReturnsForAnyArgs(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.Post(requestModel)); + Assert.Contains("Passkeys cannot be created for your account. SSO login is required", exception.Message); + } + [Theory, BitAutoData] public async Task Delete_UserNotFound_ThrowsUnauthorizedAccessException(Guid credentialId, SecretVerificationRequestModel requestModel, SutProvider sutProvider) { - // Arrange + // Arrange sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsNullForAnyArgs(); // Act From a3e440209a8c9b20e6a9b82211b91947aefcc2d6 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Wed, 1 Nov 2023 21:25:50 -0400 Subject: [PATCH 6/8] Exclude all properties added in license version (#3401) --- src/Core/Models/Business/OrganizationLicense.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Core/Models/Business/OrganizationLicense.cs b/src/Core/Models/Business/OrganizationLicense.cs index c8060263c8..c9d6724184 100644 --- a/src/Core/Models/Business/OrganizationLicense.cs +++ b/src/Core/Models/Business/OrganizationLicense.cs @@ -184,8 +184,11 @@ public class OrganizationLicense : ILicense (Version >= 11 || !p.Name.Equals(nameof(UseCustomPermissions))) && // ExpirationWithoutGracePeriod was added in Version 12 (Version >= 12 || !p.Name.Equals(nameof(ExpirationWithoutGracePeriod))) && - // UseSecretsManager was added in Version 13 + // UseSecretsManager, UsePasswordManager, SmSeats, and SmServiceAccounts were added in Version 13 (Version >= 13 || !p.Name.Equals(nameof(UseSecretsManager))) && + (Version >= 13 || !p.Name.Equals(nameof(UsePasswordManager))) && + (Version >= 13 || !p.Name.Equals(nameof(SmSeats))) && + (Version >= 13 || !p.Name.Equals(nameof(SmServiceAccounts))) && ( !forHash || ( From abc7b35a3dfcbc8c7fe82c9ef3d9e7e1fb1751d6 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Thu, 2 Nov 2023 10:22:54 -0400 Subject: [PATCH 7/8] [AC-1783] PayPal IPN Changes (#3404) * Reverted changes to the domain for the PayPal IPN client, and added more logging * Removing User-Agent as it wasn't in the IPN client previously --- src/Billing/Utilities/PayPalIpnClient.cs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Billing/Utilities/PayPalIpnClient.cs b/src/Billing/Utilities/PayPalIpnClient.cs index 1762f92a11..0534faf76c 100644 --- a/src/Billing/Utilities/PayPalIpnClient.cs +++ b/src/Billing/Utilities/PayPalIpnClient.cs @@ -15,9 +15,9 @@ public class PayPalIpnClient public PayPalIpnClient(IOptions billingSettings, ILogger logger) { var bSettings = billingSettings?.Value; - _ipnUri = new Uri(bSettings.PayPal.Production ? "https://ipnpb.paypal.com/cgi-bin/webscr" : - "https://ipnpb.sandbox.paypal.com/cgi-bin/webscr"); _logger = logger; + _ipnUri = new Uri(bSettings.PayPal.Production ? "https://www.paypal.com/cgi-bin/webscr" : + "https://www.sandbox.paypal.com/cgi-bin/webscr"); } public async Task VerifyIpnAsync(string ipnBody) @@ -29,19 +29,16 @@ public class PayPalIpnClient throw new ArgumentException("No IPN body."); } - var request = new HttpRequestMessage - { - Method = HttpMethod.Post, - RequestUri = _ipnUri - }; - _httpClient.DefaultRequestHeaders.Add("User-Agent", "CSharp-IPN-VerificationScript"); + var request = new HttpRequestMessage { Method = HttpMethod.Post, RequestUri = _ipnUri }; var cmdIpnBody = string.Concat("cmd=_notify-validate&", ipnBody); request.Content = new StringContent(cmdIpnBody, Encoding.UTF8, "application/x-www-form-urlencoded"); var response = await _httpClient.SendAsync(request); if (!response.IsSuccessStatusCode) { + _logger.LogError("Failed to receive a successful response from PayPal IPN verification service. Response: {Response}", response); throw new Exception("Failed to verify IPN, status: " + response.StatusCode); } + var responseContent = await response.Content.ReadAsStringAsync(); if (responseContent.Equals("VERIFIED")) { @@ -53,6 +50,7 @@ public class PayPalIpnClient _logger.LogWarning("Received an INVALID response from PayPal: {ResponseContent}", responseContent); return false; } + _logger.LogError("Failed to verify IPN: {ResponseContent}", responseContent); throw new Exception("Failed to verify IPN."); } From cfe9812724717b9f9a5764a887ebb0c4244a386d Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 2 Nov 2023 10:23:28 -0400 Subject: [PATCH 8/8] [AC-1772] Check null or whitespace on `GatewayCustomerId` and `BillingEmail` (#3398) * Check null or whitespace on GatewayCustomerId and BillingEmail * Fixed expiration input --- src/Admin/Controllers/OrganizationsController.cs | 5 ++++- src/Admin/Views/Shared/_OrganizationForm.cshtml | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Admin/Controllers/OrganizationsController.cs b/src/Admin/Controllers/OrganizationsController.cs index b3a232c2c8..f671df558d 100644 --- a/src/Admin/Controllers/OrganizationsController.cs +++ b/src/Admin/Controllers/OrganizationsController.cs @@ -215,7 +215,10 @@ public class OrganizationsController : Controller try { - await _stripeSyncService.UpdateCustomerEmailAddress(organization.GatewayCustomerId, organization.BillingEmail); + if (!string.IsNullOrWhiteSpace(organization.GatewayCustomerId) && !string.IsNullOrWhiteSpace(organization.BillingEmail)) + { + await _stripeSyncService.UpdateCustomerEmailAddress(organization.GatewayCustomerId, organization.BillingEmail); + } } catch (StripeException stripeException) { diff --git a/src/Admin/Views/Shared/_OrganizationForm.cshtml b/src/Admin/Views/Shared/_OrganizationForm.cshtml index d0530849ac..9e1b488b17 100644 --- a/src/Admin/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/Views/Shared/_OrganizationForm.cshtml @@ -259,7 +259,7 @@
- +