1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

[AC-1486] Feature: SM Billing (#3073)

* [AC-1423] Add AddonProduct and BitwardenProduct properties to BillingSubscriptionItem (#3037)

* [AC-1423] Add AddonProduct and BitwardenProduct properties to BillingSubscriptionItem

* [AC-1423] Add helper to StaticStore.cs to find a Plan by StripePlanId

* [AC-1423] Use the helper method to set SubscriptionInfo.BitwardenProduct

* Add SecretsManagerBilling feature flag to Constants

* [AC 1409] Secrets Manager Subscription Stripe Integration  (#3019)

* [AC-1418] Add missing SecretsManagerPlan property to OrganizationResponseModel (#3055)

* [AC 1460] Update Stripe Configuration (#3070)

* [AC 1410] Secrets Manager subscription adjustment back-end changes (#3036)

* Create UpgradeSecretsManagerSubscription command

* [AC-1495] Extract UpgradePlanAsync into a command (#3081)

* This is a pure lift & shift with no refactors

* [AC-1503] Fix Stripe integration on organization upgrade (#3084)

* Fix SM parameters not being passed to Stripe

* [AC-1504] Allow SM max autoscale limits to be disabled (#3085)

* [AC-1488] Changed SM Signup and Upgrade paths to set SmServiceAccounts to include the plan BaseServiceAccount (#3086)

* [AC-1510] Enable access to Secrets Manager to Organization owner for new Subscription (#3089)

* Revert changes to ReferenceEvent code (#3091)

This will be done in AC-1481

* Add UsePasswordManager to sync data (#3114)

* [AC-1522] Fix service account check on upgrading (#3111)

* [AC-1521] Address checkmarx security feedback (#3124)

* Reinstate target attribute but add noopener noreferrer

* Update date on migration script

---------

Co-authored-by: Shane Melton <smelton@bitwarden.com>
Co-authored-by: Thomas Rittson <trittson@bitwarden.com>
Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com>
Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
Co-authored-by: cyprain-okeke <cokeke@bitwarden.com>
Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com>
Co-authored-by: Conner Turnbull <cturnbull@bitwarden.com>
Co-authored-by: Rui Tome <rtome@bitwarden.com>
This commit is contained in:
Álison Fernandes 2023-07-24 23:05:05 +01:00 committed by GitHub
parent 4ec765ae19
commit 35111382e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 2880 additions and 381 deletions

View File

@ -130,6 +130,16 @@ public class ServiceAccountRepository : Repository<Core.SecretsManager.Entities.
return policy == null ? (false, false) : (policy.Read, policy.Write);
}
public async Task<int> GetServiceAccountCountByOrganizationIdAsync(Guid organizationId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
return await dbContext.ServiceAccount
.CountAsync(ou => ou.OrganizationId == organizationId);
}
}
private static Expression<Func<ServiceAccount, bool>> UserHasReadAccessToServiceAccount(Guid userId) => sa =>
sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
sa.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read));

View File

@ -18,6 +18,7 @@ using Bit.Core.Models.Business;
using Bit.Core.Models.Data.Organizations.Policies;
using Bit.Core.OrganizationFeatures.OrganizationApiKeys.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
@ -50,6 +51,8 @@ public class OrganizationsController : Controller
private readonly IFeatureService _featureService;
private readonly GlobalSettings _globalSettings;
private readonly ILicensingService _licensingService;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand;
public OrganizationsController(
IOrganizationRepository organizationRepository,
@ -70,7 +73,9 @@ public class OrganizationsController : Controller
ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery,
IFeatureService featureService,
GlobalSettings globalSettings,
ILicensingService licensingService)
ILicensingService licensingService,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -91,6 +96,8 @@ public class OrganizationsController : Controller
_featureService = featureService;
_globalSettings = globalSettings;
_licensingService = licensingService;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
_upgradeOrganizationPlanCommand = upgradeOrganizationPlanCommand;
}
[HttpGet("{id}")]
@ -306,7 +313,7 @@ public class OrganizationsController : Controller
throw new NotFoundException();
}
var result = await _organizationService.UpgradePlanAsync(orgIdGuid, model.ToOrganizationUpgrade());
var result = await _upgradeOrganizationPlanCommand.UpgradePlanAsync(orgIdGuid, model.ToOrganizationUpgrade());
return new PaymentResponseModel { Success = result.Item1, PaymentIntentClientSecret = result.Item2 };
}
@ -319,10 +326,34 @@ public class OrganizationsController : Controller
{
throw new NotFoundException();
}
await _organizationService.UpdateSubscription(orgIdGuid, model.SeatAdjustment, model.MaxAutoscaleSeats);
}
[HttpPost("{id}/sm-subscription")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostSmSubscription(Guid id, [FromBody] SecretsManagerSubscriptionUpdateRequestModel model)
{
var organization = await _organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
if (!await _currentContext.EditSubscription(id))
{
throw new NotFoundException();
}
var secretsManagerPlan = StaticStore.GetSecretsManagerPlan(organization.PlanType);
if (secretsManagerPlan == null)
{
throw new NotFoundException("Invalid Secrets Manager plan.");
}
var organizationUpdate = model.ToSecretsManagerSubscriptionUpdate(organization, secretsManagerPlan);
await _updateSecretsManagerSubscriptionCommand.UpdateSecretsManagerSubscription(organizationUpdate);
}
[HttpPost("{id}/seat")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostSeat(string id, [FromBody] OrganizationSeatRequestModel model)

View File

@ -40,6 +40,12 @@ public class OrganizationCreateRequestModel : IValidatableObject
[StringLength(2)]
public string BillingAddressCountry { get; set; }
public int? MaxAutoscaleSeats { get; set; }
[Range(0, int.MaxValue)]
public int? AdditionalSmSeats { get; set; }
[Range(0, int.MaxValue)]
public int? AdditionalServiceAccounts { get; set; }
[Required]
public bool UseSecretsManager { get; set; }
public virtual OrganizationSignup ToOrganizationSignup(User user)
{
@ -58,6 +64,9 @@ public class OrganizationCreateRequestModel : IValidatableObject
BillingEmail = BillingEmail,
BusinessName = BusinessName,
CollectionName = CollectionName,
AdditionalSmSeats = AdditionalSmSeats.GetValueOrDefault(),
AdditionalServiceAccounts = AdditionalServiceAccounts.GetValueOrDefault(),
UseSecretsManager = UseSecretsManager,
TaxInfo = new TaxInfo
{
TaxIdNumber = TaxIdNumber,

View File

@ -13,6 +13,12 @@ public class OrganizationUpgradeRequestModel
public int AdditionalSeats { get; set; }
[Range(0, 99)]
public short? AdditionalStorageGb { get; set; }
[Range(0, int.MaxValue)]
public int? AdditionalSmSeats { get; set; }
[Range(0, int.MaxValue)]
public int? AdditionalServiceAccounts { get; set; }
[Required]
public bool UseSecretsManager { get; set; }
public bool PremiumAccessAddon { get; set; }
public string BillingAddressCountry { get; set; }
public string BillingAddressPostalCode { get; set; }
@ -24,6 +30,9 @@ public class OrganizationUpgradeRequestModel
{
AdditionalSeats = AdditionalSeats,
AdditionalStorageGb = AdditionalStorageGb.GetValueOrDefault(),
AdditionalServiceAccounts = AdditionalServiceAccounts.GetValueOrDefault(0),
AdditionalSmSeats = AdditionalSmSeats.GetValueOrDefault(0),
UseSecretsManager = UseSecretsManager,
BusinessName = BusinessName,
Plan = PlanType,
PremiumAccessAddon = PremiumAccessAddon,

View File

@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Entities;
using Bit.Core.Models.Business;
using Bit.Core.Models.StaticStore;
namespace Bit.Api.Models.Request.Organizations;
public class SecretsManagerSubscriptionUpdateRequestModel
{
[Required]
public int SeatAdjustment { get; set; }
public int? MaxAutoscaleSeats { get; set; }
public int ServiceAccountAdjustment { get; set; }
public int? MaxAutoscaleServiceAccounts { get; set; }
public virtual SecretsManagerSubscriptionUpdate ToSecretsManagerSubscriptionUpdate(Organization organization, Plan plan)
{
var newTotalSeats = organization.SmSeats.GetValueOrDefault() + SeatAdjustment;
var newTotalServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + ServiceAccountAdjustment;
var orgUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organization.Id,
SmSeatsAdjustment = SeatAdjustment,
SmSeats = newTotalSeats,
SmSeatsExcludingBase = newTotalSeats - plan.BaseSeats,
MaxAutoscaleSmSeats = MaxAutoscaleSeats,
SmServiceAccountsAdjustment = ServiceAccountAdjustment,
SmServiceAccounts = newTotalServiceAccounts,
SmServiceAccountsExcludingBase = newTotalServiceAccounts - plan.BaseServiceAccount.GetValueOrDefault(),
MaxAutoscaleSmServiceAccounts = MaxAutoscaleServiceAccounts,
MaxAutoscaleSmSeatsChanged =
MaxAutoscaleSeats.GetValueOrDefault() != organization.MaxAutoscaleSmSeats.GetValueOrDefault(),
MaxAutoscaleSmServiceAccountsChanged =
MaxAutoscaleServiceAccounts.GetValueOrDefault() != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault()
};
return orgUpdate;
}
}

View File

@ -27,6 +27,7 @@ public class OrganizationResponseModel : ResponseModel
BusinessTaxNumber = organization.BusinessTaxNumber;
BillingEmail = organization.BillingEmail;
Plan = new PlanResponseModel(StaticStore.PasswordManagerPlans.FirstOrDefault(plan => plan.Type == organization.PlanType));
SecretsManagerPlan = new PlanResponseModel(StaticStore.SecretManagerPlans.FirstOrDefault(plan => plan.Type == organization.PlanType));
PlanType = organization.PlanType;
Seats = organization.Seats;
MaxAutoscaleSeats = organization.MaxAutoscaleSeats;
@ -65,6 +66,7 @@ public class OrganizationResponseModel : ResponseModel
public string BusinessTaxNumber { get; set; }
public string BillingEmail { get; set; }
public PlanResponseModel Plan { get; set; }
public PlanResponseModel SecretsManagerPlan { get; set; }
public PlanType PlanType { get; set; }
public int? Seats { get; set; }
public int? MaxAutoscaleSeats { get; set; } = null;

View File

@ -55,10 +55,12 @@ public class PlanResponseModel : ResponseModel
AdditionalPricePerServiceAccount = plan.AdditionalPricePerServiceAccount;
BaseServiceAccount = plan.BaseServiceAccount;
MaxServiceAccount = plan.MaxServiceAccount;
MaxServiceAccounts = plan.MaxServiceAccounts;
MaxAdditionalServiceAccounts = plan.MaxAdditionalServiceAccount;
HasAdditionalServiceAccountOption = plan.HasAdditionalServiceAccountOption;
MaxProjects = plan.MaxProjects;
BitwardenProduct = plan.BitwardenProduct;
StripeServiceAccountPlanId = plan.StripeServiceAccountPlanId;
}
public PlanType Type { get; set; }
@ -105,10 +107,11 @@ public class PlanResponseModel : ResponseModel
public decimal SeatPrice { get; set; }
public decimal AdditionalStoragePricePerGb { get; set; }
public decimal PremiumAccessOptionPrice { get; set; }
public string StripeServiceAccountPlanId { get; set; }
public decimal? AdditionalPricePerServiceAccount { get; set; }
public short? BaseServiceAccount { get; set; }
public short? MaxServiceAccount { get; set; }
public short? MaxServiceAccounts { get; set; }
public short? MaxAdditionalServiceAccounts { get; set; }
public bool HasAdditionalServiceAccountOption { get; set; }
public short? MaxProjects { get; set; }
public BitwardenProductType BitwardenProduct { get; set; }

View File

@ -29,6 +29,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
UseApi = organization.UseApi;
UseResetPassword = organization.UseResetPassword;
UseSecretsManager = organization.UseSecretsManager;
UsePasswordManager = organization.UsePasswordManager;
UsersGetPremium = organization.UsersGetPremium;
UseCustomPermissions = organization.UseCustomPermissions;
UseActivateAutofillPolicy = organization.PlanType == PlanType.EnterpriseAnnually ||
@ -82,6 +83,7 @@ public class ProfileOrganizationResponseModel : ResponseModel
public bool UseApi { get; set; }
public bool UseResetPassword { get; set; }
public bool UseSecretsManager { get; set; }
public bool UsePasswordManager { get; set; }
public bool UsersGetPremium { get; set; }
public bool UseCustomPermissions { get; set; }
public bool UseActivateAutofillPolicy { get; set; }

View File

@ -1,4 +1,5 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Models.Business;
using Bit.Core.Utilities;
@ -82,6 +83,8 @@ public class BillingSubscription
Interval = item.Interval;
Quantity = item.Quantity;
SponsoredSubscriptionItem = item.SponsoredSubscriptionItem;
AddonSubscriptionItem = item.AddonSubscriptionItem;
BitwardenProduct = item.BitwardenProduct;
}
public string Name { get; set; }
@ -89,6 +92,8 @@ public class BillingSubscription
public int Quantity { get; set; }
public string Interval { get; set; }
public bool SponsoredSubscriptionItem { get; set; }
public bool AddonSubscriptionItem { get; set; }
public BitwardenProductType BitwardenProduct { get; set; }
}
}

View File

@ -15,6 +15,7 @@ using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Bit.Core.Auth.Identity;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
#if !OSS
using Bit.Commercial.Core.SecretsManager;
@ -133,6 +134,7 @@ public class Startup
// Services
services.AddBaseServices(globalSettings);
services.AddDefaultServices(globalSettings);
services.AddOrganizationSubscriptionServices();
services.AddCoreLocalizationServices();
//health check

View File

@ -36,6 +36,7 @@ public static class FeatureFlagKeys
public const string DisplayEuEnvironment = "display-eu-environment";
public const string DisplayLowKdfIterationWarning = "display-kdf-iteration-warning";
public const string TrustedDeviceEncryption = "trusted-device-encryption";
public const string SecretsManagerBilling = "sm-ga-billing";
public static List<string> GetAllKeys()
{

View File

@ -0,0 +1,34 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0"
style="margin: 0; box-sizing: border-box; ">
<tr
style="margin: 0; box-sizing: border-box; ">
<td class="content-block"
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;"
valign="top">
Your organization has reached the Secrets Manager seat limit of {{MaxSeatCount}} and new members cannot be invited.
</td>
</tr>
<tr
style="margin: 0; box-sizing: border-box; ">
<td class="content-block last"
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;"
valign="top">
For more information, please refer to the following help article:
<a href="https://bitwarden.com/help/managing-users" class="inline-link">
Member management
</a>
<br class="line-break" />
<br class="line-break" />
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="https://vault.bitwarden.com/#/organizations/{{{OrganizationId}}}/billing/subscription" clicktracking=off target="_blank" rel="noopener noreferrer" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Manage subscription
</a>
<br class="line-break" />
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -0,0 +1,5 @@
{{#>BasicTextLayout}}
Your organization has reached the Secrets Manager seat limit of {{MaxSeatCount}} and new members cannot be invited.
For more information, please refer to the following help article: https://bitwarden.com/help/managing-users
{{/BasicTextLayout}}

View File

@ -0,0 +1,34 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0"
style="margin: 0; box-sizing: border-box; ">
<tr
style="margin: 0; box-sizing: border-box; ">
<td class="content-block"
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;"
valign="top">
Your organization has reached the Secrets Manager service accounts limit of {{MaxServiceAccountsCount}}. New service accounts cannot be created
</td>
</tr>
<tr
style="margin: 0; box-sizing: border-box; ">
<td class="content-block last"
style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;"
valign="top">
For more information, please refer to the following help article:
<a href="https://bitwarden.com/help/managing-users" class="inline-link">
Member management
</a>
<br class="line-break" />
<br class="line-break" />
</td>
</tr>
<tr style="margin: 0; box-sizing: border-box; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
<a href="https://vault.bitwarden.com/#/organizations/{{{OrganizationId}}}/billing/subscription" clicktracking=off target="_blank" rel="noopener noreferrer" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #175DDC; border-color: #175DDC; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
Manage subscription
</a>
<br class="line-break" />
</td>
</tr>
</table>
{{/FullHtmlLayout}}

View File

@ -0,0 +1,5 @@
{{#>BasicTextLayout}}
Your organization has reached the Secrets Manager service accounts limit of {{MaxServiceAccountsCount}}. New service accounts cannot be created
For more information, please refer to the following help article: https://bitwarden.com/help/managing-users
{{/BasicTextLayout}}

View File

@ -12,4 +12,7 @@ public class OrganizationUpgrade
public TaxInfo TaxInfo { get; set; }
public string PublicKey { get; set; }
public string PrivateKey { get; set; }
public int? AdditionalSmSeats { get; set; }
public int? AdditionalServiceAccounts { get; set; }
public bool UseSecretsManager { get; set; }
}

View File

@ -0,0 +1,56 @@
namespace Bit.Core.Models.Business;
public class SecretsManagerSubscriptionUpdate
{
public Guid OrganizationId { get; set; }
/// <summary>
/// The seats to be added or removed from the organization
/// </summary>
public int SmSeatsAdjustment { get; set; }
/// <summary>
/// The total seats the organization will have after the update, including any base seats included in the plan
/// </summary>
public int SmSeats { get; set; }
/// <summary>
/// The seats the organization will have after the update, excluding the base seats included in the plan
/// Usually this is what the organization is billed for
/// </summary>
public int SmSeatsExcludingBase { get; set; }
/// <summary>
/// The new autoscale limit for seats, expressed as a total (not an adjustment).
/// This may or may not be the same as the current autoscale limit.
/// </summary>
public int? MaxAutoscaleSmSeats { get; set; }
/// <summary>
/// The service accounts to be added or removed from the organization
/// </summary>
public int SmServiceAccountsAdjustment { get; set; }
/// <summary>
/// The total service accounts the organization will have after the update, including the base service accounts
/// included in the plan
/// </summary>
public int SmServiceAccounts { get; set; }
/// <summary>
/// The seats the organization will have after the update, excluding the base seats included in the plan
/// Usually this is what the organization is billed for
/// </summary>
public int SmServiceAccountsExcludingBase { get; set; }
/// <summary>
/// The new autoscale limit for service accounts, expressed as a total (not an adjustment).
/// This may or may not be the same as the current autoscale limit.
/// </summary>
public int? MaxAutoscaleSmServiceAccounts { get; set; }
public bool SmSeatsChanged => SmSeatsAdjustment != 0;
public bool SmServiceAccountsChanged => SmServiceAccountsAdjustment != 0;
public bool MaxAutoscaleSmSeatsChanged { get; set; }
public bool MaxAutoscaleSmServiceAccountsChanged { get; set; }
}

View File

@ -1,36 +1,61 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Stripe;
namespace Bit.Core.Models.Business;
public class OrganizationSubscriptionOptionsBase : Stripe.SubscriptionCreateOptions
{
public OrganizationSubscriptionOptionsBase(Organization org, StaticStore.Plan plan, TaxInfo taxInfo, int additionalSeats, int additionalStorageGb, bool premiumAccessAddon)
public OrganizationSubscriptionOptionsBase(Organization org, List<StaticStore.Plan> plans, TaxInfo taxInfo, int additionalSeats,
int additionalStorageGb, bool premiumAccessAddon, int additionalSmSeats, int additionalServiceAccounts)
{
Items = new List<SubscriptionItemOptions>();
Metadata = new Dictionary<string, string>
{
[org.GatewayIdField()] = org.Id.ToString()
};
foreach (var plan in plans)
{
AddPlanIdToSubscription(plan);
if (plan.StripePlanId != null)
switch (plan.BitwardenProduct)
{
case BitwardenProductType.PasswordManager:
{
AddPremiumAccessAddon(premiumAccessAddon, plan);
AddAdditionalSeatToSubscription(additionalSeats, plan);
AddAdditionalStorage(additionalStorageGb, plan);
break;
}
case BitwardenProductType.SecretsManager:
{
AddAdditionalSeatToSubscription(additionalSmSeats, plan);
AddServiceAccount(additionalServiceAccounts, plan);
break;
}
}
}
if (!string.IsNullOrWhiteSpace(taxInfo?.StripeTaxRateId))
{
DefaultTaxRates = new List<string> { taxInfo.StripeTaxRateId };
}
}
private void AddServiceAccount(int additionalServiceAccounts, StaticStore.Plan plan)
{
if (additionalServiceAccounts > 0 && plan.StripeServiceAccountPlanId != null)
{
Items.Add(new SubscriptionItemOptions
{
Plan = plan.StripePlanId,
Quantity = 1
});
}
if (additionalSeats > 0 && plan.StripeSeatPlanId != null)
{
Items.Add(new SubscriptionItemOptions
{
Plan = plan.StripeSeatPlanId,
Quantity = additionalSeats
Plan = plan.StripeServiceAccountPlanId,
Quantity = additionalServiceAccounts
});
}
}
private void AddAdditionalStorage(int additionalStorageGb, StaticStore.Plan plan)
{
if (additionalStorageGb > 0)
{
Items.Add(new SubscriptionItemOptions
@ -39,19 +64,29 @@ public class OrganizationSubscriptionOptionsBase : Stripe.SubscriptionCreateOpti
Quantity = additionalStorageGb
});
}
}
private void AddPremiumAccessAddon(bool premiumAccessAddon, StaticStore.Plan plan)
{
if (premiumAccessAddon && plan.StripePremiumAccessPlanId != null)
{
Items.Add(new SubscriptionItemOptions
{
Plan = plan.StripePremiumAccessPlanId,
Quantity = 1
});
Items.Add(new SubscriptionItemOptions { Plan = plan.StripePremiumAccessPlanId, Quantity = 1 });
}
}
if (!string.IsNullOrWhiteSpace(taxInfo?.StripeTaxRateId))
private void AddAdditionalSeatToSubscription(int additionalSeats, StaticStore.Plan plan)
{
if (additionalSeats > 0 && plan.StripeSeatPlanId != null)
{
DefaultTaxRates = new List<string> { taxInfo.StripeTaxRateId };
Items.Add(new SubscriptionItemOptions { Plan = plan.StripeSeatPlanId, Quantity = additionalSeats });
}
}
private void AddPlanIdToSubscription(StaticStore.Plan plan)
{
if (plan.StripePlanId != null)
{
Items.Add(new SubscriptionItemOptions { Plan = plan.StripePlanId, Quantity = 1 });
}
}
}
@ -59,13 +94,14 @@ public class OrganizationSubscriptionOptionsBase : Stripe.SubscriptionCreateOpti
public class OrganizationPurchaseSubscriptionOptions : OrganizationSubscriptionOptionsBase
{
public OrganizationPurchaseSubscriptionOptions(
Organization org, StaticStore.Plan plan,
TaxInfo taxInfo, int additionalSeats = 0,
int additionalStorageGb = 0, bool premiumAccessAddon = false) :
base(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon)
Organization org, List<StaticStore.Plan> plans,
TaxInfo taxInfo, int additionalSeats,
int additionalStorageGb, bool premiumAccessAddon,
int additionalSmSeats, int additionalServiceAccounts) :
base(org, plans, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon, additionalSmSeats, additionalServiceAccounts)
{
OffSession = true;
TrialPeriodDays = plan.TrialPeriodDays;
TrialPeriodDays = plans.FirstOrDefault(x => x.BitwardenProduct == BitwardenProductType.PasswordManager)!.TrialPeriodDays;
}
}
@ -73,10 +109,10 @@ public class OrganizationUpgradeSubscriptionOptions : OrganizationSubscriptionOp
{
public OrganizationUpgradeSubscriptionOptions(
string customerId, Organization org,
StaticStore.Plan plan, TaxInfo taxInfo,
int additionalSeats = 0, int additionalStorageGb = 0,
bool premiumAccessAddon = false) :
base(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon)
List<StaticStore.Plan> plans, OrganizationUpgrade upgrade) :
base(org, plans, upgrade.TaxInfo, upgrade.AdditionalSeats, upgrade.AdditionalStorageGb,
upgrade.PremiumAccessAddon, upgrade.AdditionalSmSeats.GetValueOrDefault(),
upgrade.AdditionalServiceAccounts.GetValueOrDefault())
{
Customer = customerId;
}

View File

@ -1,4 +1,5 @@
using Stripe;
using Bit.Core.Enums;
using Stripe;
namespace Bit.Core.Models.Business;
@ -46,12 +47,20 @@ public class SubscriptionInfo
Name = item.Plan.Nickname;
Amount = item.Plan.Amount.GetValueOrDefault() / 100M;
Interval = item.Plan.Interval;
AddonSubscriptionItem =
Utilities.StaticStore.IsAddonSubscriptionItem(item.Plan.Id);
BitwardenProduct =
Utilities.StaticStore.GetPlanByStripeId(item.Plan.Id)?.BitwardenProduct ?? BitwardenProductType.PasswordManager;
}
Quantity = (int)item.Quantity;
SponsoredSubscriptionItem = Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id);
}
public BitwardenProductType BitwardenProduct { get; set; }
public bool AddonSubscriptionItem { get; set; }
public string Name { get; set; }
public decimal Amount { get; set; }
public int Quantity { get; set; }

View File

@ -1,4 +1,5 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Stripe;
namespace Bit.Core.Models.Business;
@ -42,7 +43,15 @@ public class SeatSubscriptionUpdate : SubscriptionUpdate
{
_plan = plan;
_additionalSeats = additionalSeats;
_previousSeats = organization.Seats ?? 0;
switch (plan.BitwardenProduct)
{
case BitwardenProductType.PasswordManager:
_previousSeats = organization.Seats.GetValueOrDefault();
break;
case BitwardenProductType.SecretsManager:
_previousSeats = organization.SmSeats.GetValueOrDefault();
break;
}
}
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
@ -77,6 +86,52 @@ public class SeatSubscriptionUpdate : SubscriptionUpdate
}
}
public class ServiceAccountSubscriptionUpdate : SubscriptionUpdate
{
private long? _prevServiceAccounts;
private readonly StaticStore.Plan _plan;
private readonly long? _additionalServiceAccounts;
protected override List<string> PlanIds => new() { _plan.StripeServiceAccountPlanId };
public ServiceAccountSubscriptionUpdate(Organization organization, StaticStore.Plan plan, long? additionalServiceAccounts)
{
_plan = plan;
_additionalServiceAccounts = additionalServiceAccounts;
_prevServiceAccounts = organization.SmServiceAccounts ?? 0;
}
public override List<SubscriptionItemOptions> UpgradeItemsOptions(Subscription subscription)
{
var item = SubscriptionItem(subscription, PlanIds.Single());
_prevServiceAccounts = item?.Quantity ?? 0;
return new()
{
new SubscriptionItemOptions
{
Id = item?.Id,
Plan = PlanIds.Single(),
Quantity = _additionalServiceAccounts,
Deleted = (item?.Id != null && _additionalServiceAccounts == 0) ? true : (bool?)null,
}
};
}
public override List<SubscriptionItemOptions> RevertItemsOptions(Subscription subscription)
{
var item = SubscriptionItem(subscription, PlanIds.Single());
return new()
{
new SubscriptionItemOptions
{
Id = item?.Id,
Plan = PlanIds.Single(),
Quantity = _prevServiceAccounts,
Deleted = _prevServiceAccounts == 0 ? true : (bool?)null,
}
};
}
}
public class StorageSubscriptionUpdate : SubscriptionUpdate
{
private long? _prevStorage;

View File

@ -0,0 +1,7 @@
namespace Bit.Core.Models.Mail;
public class OrganizationServiceAccountsMaxReachedViewModel
{
public Guid OrganizationId { get; set; }
public int MaxServiceAccountsCount { get; set; }
}

View File

@ -15,8 +15,11 @@ public class Plan
public short? BaseStorageGb { get; set; }
public short? MaxCollections { get; set; }
public short? MaxUsers { get; set; }
public short? MaxServiceAccounts { get; set; }
public bool AllowSeatAutoscale { get; set; }
public bool AllowServiceAccountsAutoscale { get; set; }
public bool HasAdditionalSeatsOption { get; set; }
public int? MaxAdditionalSeats { get; set; }
public bool HasAdditionalStorageOption { get; set; }
@ -55,7 +58,7 @@ public class Plan
public decimal PremiumAccessOptionPrice { get; set; }
public decimal? AdditionalPricePerServiceAccount { get; set; }
public short? BaseServiceAccount { get; set; }
public short? MaxServiceAccount { get; set; }
public short? MaxAdditionalServiceAccount { get; set; }
public bool HasAdditionalServiceAccountOption { get; set; }
public short? MaxProjects { get; set; }
public BitwardenProductType BitwardenProduct { get; set; }

View File

@ -0,0 +1,8 @@
using Bit.Core.Models.Business;
namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
public interface IUpdateSecretsManagerSubscriptionCommand
{
Task UpdateSecretsManagerSubscription(SecretsManagerSubscriptionUpdate update);
}

View File

@ -0,0 +1,6 @@
namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
public interface IUpgradeOrganizationPlanCommand
{
Task<Tuple<bool, string>> UpgradePlanAsync(Guid organizationId, Models.Business.OrganizationUpgrade upgrade);
}

View File

@ -0,0 +1,13 @@
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
public static class OrganizationSubscriptionServiceCollectionExtensions
{
public static void AddOrganizationSubscriptionServices(this IServiceCollection services)
{
services.AddScoped<IUpdateSecretsManagerSubscriptionCommand, UpdateSecretsManagerSubscriptionCommand>();
services.AddScoped<IUpgradeOrganizationPlanCommand, UpgradeOrganizationPlanCommand>();
}
}

View File

@ -0,0 +1,356 @@
#nullable enable
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.StaticStore;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubscriptionCommand
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPaymentService _paymentService;
private readonly IOrganizationService _organizationService;
private readonly IMailService _mailService;
private readonly ILogger<UpdateSecretsManagerSubscriptionCommand> _logger;
private readonly IServiceAccountRepository _serviceAccountRepository;
public UpdateSecretsManagerSubscriptionCommand(
IOrganizationRepository organizationRepository,
IOrganizationService organizationService,
IOrganizationUserRepository organizationUserRepository,
IPaymentService paymentService,
IMailService mailService,
ILogger<UpdateSecretsManagerSubscriptionCommand> logger,
IServiceAccountRepository serviceAccountRepository)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_paymentService = paymentService;
_organizationService = organizationService;
_mailService = mailService;
_logger = logger;
_serviceAccountRepository = serviceAccountRepository;
}
public async Task UpdateSecretsManagerSubscription(SecretsManagerSubscriptionUpdate update)
{
var organization = await _organizationRepository.GetByIdAsync(update.OrganizationId);
ValidateOrganization(organization);
var plan = GetPlanForOrganization(organization);
if (update.SmSeatsChanged)
{
await ValidateSmSeatsUpdateAsync(organization, update, plan);
}
if (update.SmServiceAccountsChanged)
{
await ValidateSmServiceAccountsUpdateAsync(organization, update, plan);
}
if (update.MaxAutoscaleSmSeatsChanged)
{
ValidateMaxAutoscaleSmSeatsUpdateAsync(organization, update.MaxAutoscaleSmSeats, plan);
}
if (update.MaxAutoscaleSmServiceAccountsChanged)
{
ValidateMaxAutoscaleSmServiceAccountUpdate(organization, update.MaxAutoscaleSmServiceAccounts, plan);
}
await FinalizeSubscriptionAdjustmentAsync(organization, plan, update);
await SendEmailIfAutoscaleLimitReached(organization);
}
private Plan GetPlanForOrganization(Organization organization)
{
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(p => p.Type == organization.PlanType);
if (plan == null)
{
throw new BadRequestException("Existing plan not found.");
}
return plan;
}
private static void ValidateOrganization(Organization organization)
{
if (organization == null)
{
throw new NotFoundException("Organization is not found");
}
if (!organization.UseSecretsManager)
{
throw new BadRequestException("Organization has no access to Secrets Manager.");
}
}
private async Task FinalizeSubscriptionAdjustmentAsync(Organization organization,
Plan plan, SecretsManagerSubscriptionUpdate update)
{
if (update.SmSeatsChanged)
{
await ProcessChargesAndRaiseEventsForAdjustSeatsAsync(organization, plan, update);
organization.SmSeats = update.SmSeats;
}
if (update.SmServiceAccountsChanged)
{
await ProcessChargesAndRaiseEventsForAdjustServiceAccountsAsync(organization, plan, update);
organization.SmServiceAccounts = update.SmServiceAccounts;
}
if (update.MaxAutoscaleSmSeatsChanged)
{
organization.MaxAutoscaleSmSeats = update.MaxAutoscaleSmSeats;
}
if (update.MaxAutoscaleSmServiceAccountsChanged)
{
organization.MaxAutoscaleSmServiceAccounts = update.MaxAutoscaleSmServiceAccounts;
}
await _organizationService.ReplaceAndUpdateCacheAsync(organization);
}
private async Task ProcessChargesAndRaiseEventsForAdjustSeatsAsync(Organization organization, Plan plan,
SecretsManagerSubscriptionUpdate update)
{
await _paymentService.AdjustSeatsAsync(organization, plan, update.SmSeatsExcludingBase);
// TODO: call ReferenceEventService - see AC-1481
}
private async Task ProcessChargesAndRaiseEventsForAdjustServiceAccountsAsync(Organization organization, Plan plan,
SecretsManagerSubscriptionUpdate update)
{
await _paymentService.AdjustServiceAccountsAsync(organization, plan,
update.SmServiceAccountsExcludingBase);
// TODO: call ReferenceEventService - see AC-1481
}
private async Task SendEmailIfAutoscaleLimitReached(Organization organization)
{
if (organization.SmSeats.HasValue && organization.MaxAutoscaleSmSeats.HasValue && organization.SmSeats == organization.MaxAutoscaleSmSeats)
{
await SendSeatLimitEmailAsync(organization, organization.MaxAutoscaleSmSeats.Value);
}
if (organization.SmServiceAccounts.HasValue && organization.MaxAutoscaleSmServiceAccounts.HasValue && organization.SmServiceAccounts == organization.MaxAutoscaleSmServiceAccounts)
{
await SendServiceAccountLimitEmailAsync(organization, organization.MaxAutoscaleSmServiceAccounts.Value);
}
}
private async Task SendSeatLimitEmailAsync(Organization organization, int MaxAutoscaleValue)
{
try
{
var ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id,
OrganizationUserType.Owner))
.Select(u => u.Email).Distinct();
await _mailService.SendSecretsManagerMaxSeatLimitReachedEmailAsync(organization, MaxAutoscaleValue, ownerEmails);
}
catch (Exception e)
{
_logger.LogError(e, $"Error encountered notifying organization owners of Seats limit reached.");
}
}
private async Task SendServiceAccountLimitEmailAsync(Organization organization, int MaxAutoscaleValue)
{
try
{
var ownerEmails = (await _organizationUserRepository.GetManyByMinimumRoleAsync(organization.Id,
OrganizationUserType.Owner))
.Select(u => u.Email).Distinct();
await _mailService.SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(organization, MaxAutoscaleValue, ownerEmails);
}
catch (Exception e)
{
_logger.LogError(e, $"Error encountered notifying organization owners of Service Accounts limit reached.");
}
}
private async Task ValidateSmSeatsUpdateAsync(Organization organization, SecretsManagerSubscriptionUpdate update, Plan plan)
{
if (organization.SmSeats == null)
{
throw new BadRequestException("Organization has no Secrets Manager seat limit, no need to adjust seats");
}
if (update.MaxAutoscaleSmSeats.HasValue && update.SmSeats > update.MaxAutoscaleSmSeats.Value)
{
throw new BadRequestException("Cannot set max seat autoscaling below seat count.");
}
if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
{
throw new BadRequestException("No payment method found.");
}
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
throw new BadRequestException("No subscription found.");
}
if (!plan.HasAdditionalSeatsOption)
{
throw new BadRequestException("Plan does not allow additional Secrets Manager seats.");
}
if (plan.BaseSeats > update.SmSeats)
{
throw new BadRequestException($"Plan has a minimum of {plan.BaseSeats} Secrets Manager seats.");
}
if (update.SmSeats <= 0)
{
throw new BadRequestException("You must have at least 1 Secrets Manager seat.");
}
if (plan.MaxAdditionalSeats.HasValue && update.SmSeatsExcludingBase > plan.MaxAdditionalSeats.Value)
{
throw new BadRequestException($"Organization plan allows a maximum of " +
$"{plan.MaxAdditionalSeats.Value} additional Secrets Manager seats.");
}
if (organization.SmSeats.Value > update.SmSeats)
{
var currentSeats = await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id);
if (currentSeats > update.SmSeats)
{
throw new BadRequestException($"Your organization currently has {currentSeats} Secrets Manager seats. " +
$"Your plan only allows {update.SmSeats} Secrets Manager seats. Remove some Secrets Manager users.");
}
}
}
private async Task ValidateSmServiceAccountsUpdateAsync(Organization organization, SecretsManagerSubscriptionUpdate update, Plan plan)
{
if (organization.SmServiceAccounts == null)
{
throw new BadRequestException("Organization has no Service Accounts limit, no need to adjust Service Accounts");
}
if (update.MaxAutoscaleSmServiceAccounts.HasValue && update.SmServiceAccounts > update.MaxAutoscaleSmServiceAccounts.Value)
{
throw new BadRequestException("Cannot set max Service Accounts autoscaling below Service Accounts count.");
}
if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
{
throw new BadRequestException("No payment method found.");
}
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
throw new BadRequestException("No subscription found.");
}
if (!plan.HasAdditionalServiceAccountOption)
{
throw new BadRequestException("Plan does not allow additional Service Accounts.");
}
if (plan.BaseServiceAccount > update.SmServiceAccounts)
{
throw new BadRequestException($"Plan has a minimum of {plan.BaseServiceAccount} Service Accounts.");
}
if (update.SmServiceAccounts <= 0)
{
throw new BadRequestException("You must have at least 1 Service Account.");
}
if (plan.MaxAdditionalServiceAccount.HasValue && update.SmServiceAccountsExcludingBase > plan.MaxAdditionalServiceAccount.Value)
{
throw new BadRequestException($"Organization plan allows a maximum of " +
$"{plan.MaxAdditionalServiceAccount.Value} additional Service Accounts.");
}
if (!organization.SmServiceAccounts.HasValue || organization.SmServiceAccounts.Value > update.SmServiceAccounts)
{
var currentServiceAccounts = await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(organization.Id);
if (currentServiceAccounts > update.SmServiceAccounts)
{
throw new BadRequestException($"Your organization currently has {currentServiceAccounts} Service Accounts. " +
$"Your plan only allows {update.SmServiceAccounts} Service Accounts. Remove some Service Accounts.");
}
}
}
private void ValidateMaxAutoscaleSmSeatsUpdateAsync(Organization organization, int? maxAutoscaleSeats, Plan plan)
{
if (!maxAutoscaleSeats.HasValue)
{
// autoscale limit has been turned off, no validation required
return;
}
if (organization.SmSeats.HasValue && maxAutoscaleSeats.Value < organization.SmSeats.Value)
{
throw new BadRequestException($"Cannot set max Secrets Manager seat autoscaling below current Secrets Manager seat count.");
}
if (plan.MaxUsers.HasValue && maxAutoscaleSeats.Value > plan.MaxUsers)
{
throw new BadRequestException(string.Concat(
$"Your plan has a Secrets Manager seat limit of {plan.MaxUsers}, ",
$"but you have specified a max autoscale count of {maxAutoscaleSeats}.",
"Reduce your max autoscale count."));
}
if (!plan.AllowSeatAutoscale)
{
throw new BadRequestException("Your plan does not allow Secrets Manager seat autoscaling.");
}
}
private void ValidateMaxAutoscaleSmServiceAccountUpdate(Organization organization, int? maxAutoscaleServiceAccounts, Plan plan)
{
if (!maxAutoscaleServiceAccounts.HasValue)
{
// autoscale limit has been turned off, no validation required
return;
}
if (organization.SmServiceAccounts.HasValue && maxAutoscaleServiceAccounts.Value < organization.SmServiceAccounts.Value)
{
throw new BadRequestException(
$"Cannot set max Service Accounts autoscaling below current Service Accounts count.");
}
if (!plan.AllowServiceAccountsAutoscale)
{
throw new BadRequestException("Your plan does not allow Service Accounts autoscaling.");
}
if (plan.MaxServiceAccounts.HasValue && maxAutoscaleServiceAccounts.Value > plan.MaxServiceAccounts)
{
throw new BadRequestException(string.Concat(
$"Your plan has a Service Accounts limit of {plan.MaxServiceAccounts}, ",
$"but you have specified a max autoscale count of {maxAutoscaleServiceAccounts}.",
"Reduce your max autoscale count."));
}
}
}

View File

@ -0,0 +1,337 @@
using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
namespace Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
{
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly ICollectionRepository _collectionRepository;
private readonly IGroupRepository _groupRepository;
private readonly IPaymentService _paymentService;
private readonly IPolicyRepository _policyRepository;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IReferenceEventService _referenceEventService;
private readonly IOrganizationConnectionRepository _organizationConnectionRepository;
private readonly ICurrentContext _currentContext;
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService;
public UpgradeOrganizationPlanCommand(
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository,
IGroupRepository groupRepository,
IPaymentService paymentService,
IPolicyRepository policyRepository,
ISsoConfigRepository ssoConfigRepository,
IReferenceEventService referenceEventService,
IOrganizationConnectionRepository organizationConnectionRepository,
ICurrentContext currentContext,
IServiceAccountRepository serviceAccountRepository,
IOrganizationRepository organizationRepository,
IOrganizationService organizationService)
{
_organizationUserRepository = organizationUserRepository;
_collectionRepository = collectionRepository;
_groupRepository = groupRepository;
_paymentService = paymentService;
_policyRepository = policyRepository;
_ssoConfigRepository = ssoConfigRepository;
_referenceEventService = referenceEventService;
_organizationConnectionRepository = organizationConnectionRepository;
_currentContext = currentContext;
_serviceAccountRepository = serviceAccountRepository;
_organizationRepository = organizationRepository;
_organizationService = organizationService;
}
public async Task<Tuple<bool, string>> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade)
{
var organization = await GetOrgById(organizationId);
if (organization == null)
{
throw new NotFoundException();
}
if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
{
throw new BadRequestException("Your account has no payment method available.");
}
var existingPasswordManagerPlan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == organization.PlanType);
if (existingPasswordManagerPlan == null)
{
throw new BadRequestException("Existing plan not found.");
}
var newPasswordManagerPlan =
StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == upgrade.Plan && !p.Disabled);
if (newPasswordManagerPlan == null)
{
throw new BadRequestException("Plan not found.");
}
if (existingPasswordManagerPlan.Type == newPasswordManagerPlan.Type)
{
throw new BadRequestException("Organization is already on this plan.");
}
if (existingPasswordManagerPlan.UpgradeSortOrder >= newPasswordManagerPlan.UpgradeSortOrder)
{
throw new BadRequestException("You cannot upgrade to this plan.");
}
if (existingPasswordManagerPlan.Type != PlanType.Free)
{
throw new BadRequestException("You can only upgrade from the free plan. Contact support.");
}
_organizationService.ValidatePasswordManagerPlan(newPasswordManagerPlan, upgrade);
var newSecretsManagerPlan =
StaticStore.SecretManagerPlans.FirstOrDefault(p => p.Type == upgrade.Plan && !p.Disabled);
if (upgrade.UseSecretsManager)
{
_organizationService.ValidateSecretsManagerPlan(newSecretsManagerPlan, upgrade);
}
var newPasswordManagerPlanSeats = (short)(newPasswordManagerPlan.BaseSeats +
(newPasswordManagerPlan.HasAdditionalSeatsOption ? upgrade.AdditionalSeats : 0));
if (!organization.Seats.HasValue || organization.Seats.Value > newPasswordManagerPlanSeats)
{
var occupiedSeats =
await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
if (occupiedSeats > newPasswordManagerPlanSeats)
{
throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " +
$"Your new plan only has ({newPasswordManagerPlanSeats}) seats. Remove some users.");
}
}
if (newPasswordManagerPlan.MaxCollections.HasValue && (!organization.MaxCollections.HasValue ||
organization.MaxCollections.Value >
newPasswordManagerPlan.MaxCollections.Value))
{
var collectionCount = await _collectionRepository.GetCountByOrganizationIdAsync(organization.Id);
if (collectionCount > newPasswordManagerPlan.MaxCollections.Value)
{
throw new BadRequestException($"Your organization currently has {collectionCount} collections. " +
$"Your new plan allows for a maximum of ({newPasswordManagerPlan.MaxCollections.Value}) collections. " +
"Remove some collections.");
}
}
if (!newPasswordManagerPlan.HasGroups && organization.UseGroups)
{
var groups = await _groupRepository.GetManyByOrganizationIdAsync(organization.Id);
if (groups.Any())
{
throw new BadRequestException($"Your new plan does not allow the groups feature. " +
$"Remove your groups.");
}
}
if (!newPasswordManagerPlan.HasPolicies && organization.UsePolicies)
{
var policies = await _policyRepository.GetManyByOrganizationIdAsync(organization.Id);
if (policies.Any(p => p.Enabled))
{
throw new BadRequestException($"Your new plan does not allow the policies feature. " +
$"Disable your policies.");
}
}
if (!newPasswordManagerPlan.HasSso && organization.UseSso)
{
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id);
if (ssoConfig != null && ssoConfig.Enabled)
{
throw new BadRequestException($"Your new plan does not allow the SSO feature. " +
$"Disable your SSO configuration.");
}
}
if (!newPasswordManagerPlan.HasKeyConnector && organization.UseKeyConnector)
{
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id);
if (ssoConfig != null && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.KeyConnector)
{
throw new BadRequestException("Your new plan does not allow the Key Connector feature. " +
"Disable your Key Connector.");
}
}
if (!newPasswordManagerPlan.HasResetPassword && organization.UseResetPassword)
{
var resetPasswordPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled)
{
throw new BadRequestException("Your new plan does not allow the Password Reset feature. " +
"Disable your Password Reset policy.");
}
}
if (!newPasswordManagerPlan.HasScim && organization.UseScim)
{
var scimConnections = await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(organization.Id,
OrganizationConnectionType.Scim);
if (scimConnections != null && scimConnections.Any(c => c.GetConfig<ScimConfig>()?.Enabled == true))
{
throw new BadRequestException("Your new plan does not allow the SCIM feature. " +
"Disable your SCIM configuration.");
}
}
if (!newPasswordManagerPlan.HasCustomPermissions && organization.UseCustomPermissions)
{
var organizationCustomUsers =
await _organizationUserRepository.GetManyByOrganizationAsync(organization.Id,
OrganizationUserType.Custom);
if (organizationCustomUsers.Any())
{
throw new BadRequestException("Your new plan does not allow the Custom Permissions feature. " +
"Disable your Custom Permissions configuration.");
}
}
if (upgrade.UseSecretsManager && newSecretsManagerPlan != null)
{
await ValidateSecretsManagerSeatsAndServiceAccountAsync(upgrade, organization, newSecretsManagerPlan);
}
// TODO: Check storage?
string paymentIntentClientSecret = null;
var success = true;
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
var organizationUpgradePlan = upgrade.UseSecretsManager
? StaticStore.Plans.Where(p => p.Type == upgrade.Plan).ToList()
: StaticStore.Plans.Where(p => p.Type == upgrade.Plan && p.BitwardenProduct == BitwardenProductType.PasswordManager).ToList();
paymentIntentClientSecret = await _paymentService.UpgradeFreeOrganizationAsync(organization,
organizationUpgradePlan, upgrade);
success = string.IsNullOrWhiteSpace(paymentIntentClientSecret);
}
else
{
// TODO: Update existing sub
throw new BadRequestException("You can only upgrade from the free plan. Contact support.");
}
organization.BusinessName = upgrade.BusinessName;
organization.PlanType = newPasswordManagerPlan.Type;
organization.Seats = (short)(newPasswordManagerPlan.BaseSeats + upgrade.AdditionalSeats);
organization.MaxCollections = newPasswordManagerPlan.MaxCollections;
organization.UseGroups = newPasswordManagerPlan.HasGroups;
organization.UseDirectory = newPasswordManagerPlan.HasDirectory;
organization.UseEvents = newPasswordManagerPlan.HasEvents;
organization.UseTotp = newPasswordManagerPlan.HasTotp;
organization.Use2fa = newPasswordManagerPlan.Has2fa;
organization.UseApi = newPasswordManagerPlan.HasApi;
organization.SelfHost = newPasswordManagerPlan.HasSelfHost;
organization.UsePolicies = newPasswordManagerPlan.HasPolicies;
organization.MaxStorageGb = !newPasswordManagerPlan.BaseStorageGb.HasValue
? (short?)null
: (short)(newPasswordManagerPlan.BaseStorageGb.Value + upgrade.AdditionalStorageGb);
organization.UseGroups = newPasswordManagerPlan.HasGroups;
organization.UseDirectory = newPasswordManagerPlan.HasDirectory;
organization.UseEvents = newPasswordManagerPlan.HasEvents;
organization.UseTotp = newPasswordManagerPlan.HasTotp;
organization.Use2fa = newPasswordManagerPlan.Has2fa;
organization.UseApi = newPasswordManagerPlan.HasApi;
organization.UseSso = newPasswordManagerPlan.HasSso;
organization.UseKeyConnector = newPasswordManagerPlan.HasKeyConnector;
organization.UseScim = newPasswordManagerPlan.HasScim;
organization.UseResetPassword = newPasswordManagerPlan.HasResetPassword;
organization.SelfHost = newPasswordManagerPlan.HasSelfHost;
organization.UsersGetPremium = newPasswordManagerPlan.UsersGetPremium || upgrade.PremiumAccessAddon;
organization.UseCustomPermissions = newPasswordManagerPlan.HasCustomPermissions;
organization.Plan = newPasswordManagerPlan.Name;
organization.Enabled = success;
organization.PublicKey = upgrade.PublicKey;
organization.PrivateKey = upgrade.PrivateKey;
organization.UsePasswordManager = true;
organization.SmSeats = (short)(newSecretsManagerPlan.BaseSeats + upgrade.AdditionalSmSeats.GetValueOrDefault());
organization.SmServiceAccounts = newSecretsManagerPlan.BaseServiceAccount + upgrade.AdditionalServiceAccounts.GetValueOrDefault();
organization.UseSecretsManager = upgrade.UseSecretsManager;
await _organizationService.ReplaceAndUpdateCacheAsync(organization);
if (success)
{
await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.UpgradePlan, organization, _currentContext)
{
PlanName = newPasswordManagerPlan.Name,
PlanType = newPasswordManagerPlan.Type,
OldPlanName = existingPasswordManagerPlan.Name,
OldPlanType = existingPasswordManagerPlan.Type,
Seats = organization.Seats,
Storage = organization.MaxStorageGb,
// TODO: add reference events for SmSeats and Service Accounts - see AC-1481
});
}
return new Tuple<bool, string>(success, paymentIntentClientSecret);
}
private async Task ValidateSecretsManagerSeatsAndServiceAccountAsync(OrganizationUpgrade upgrade, Organization organization,
Models.StaticStore.Plan newSecretsManagerPlan)
{
var newPlanSmSeats = (short)(newSecretsManagerPlan.BaseSeats +
(newSecretsManagerPlan.HasAdditionalSeatsOption
? upgrade.AdditionalSmSeats
: 0));
var occupiedSmSeats =
await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id);
if (!organization.SmSeats.HasValue || organization.SmSeats.Value > newPlanSmSeats)
{
if (occupiedSmSeats > newPlanSmSeats)
{
throw new BadRequestException(
$"Your organization currently has {occupiedSmSeats} Secrets Manager seats filled. " +
$"Your new plan only has {newPlanSmSeats} seats. Remove some users or increase your subscription.");
}
}
var additionalServiceAccounts = newSecretsManagerPlan.HasAdditionalServiceAccountOption
? upgrade.AdditionalServiceAccounts
: 0;
var newPlanServiceAccounts = newSecretsManagerPlan.BaseServiceAccount + additionalServiceAccounts;
if (!organization.SmServiceAccounts.HasValue || organization.SmServiceAccounts.Value > newPlanServiceAccounts)
{
var currentServiceAccounts =
await _serviceAccountRepository.GetServiceAccountCountByOrganizationIdAsync(organization.Id);
if (currentServiceAccounts > newPlanServiceAccounts)
{
throw new BadRequestException(
$"Your organization currently has {currentServiceAccounts} service accounts. " +
$"Your new plan only allows {newSecretsManagerPlan.MaxServiceAccounts} service accounts. " +
"Remove some service accounts or increase your subscription.");
}
}
}
private async Task<Organization> GetOrgById(Guid id)
{
return await _organizationRepository.GetByIdAsync(id);
}
}

View File

@ -40,4 +40,5 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
Task RevokeAsync(Guid id);
Task RestoreAsync(Guid id, OrganizationUserStatusType status);
Task<IEnumerable<OrganizationUserPolicyDetails>> GetByUserIdWithPolicyDetailsAsync(Guid userId, PolicyType policyType);
Task<int> GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId);
}

View File

@ -15,4 +15,5 @@ public interface IServiceAccountRepository
Task<bool> UserHasWriteAccessToServiceAccount(Guid id, Guid userId);
Task<IEnumerable<ServiceAccount>> GetManyByOrganizationIdWriteAccessAsync(Guid organizationId, Guid userId, AccessClientType accessType);
Task<(bool Read, bool Write)> AccessToServiceAccountAsync(Guid id, Guid userId, AccessClientType accessType);
Task<int> GetServiceAccountCountByOrganizationIdAsync(Guid organizationId);
}

View File

@ -0,0 +1,59 @@
using Bit.Core.Enums;
using Bit.Core.SecretsManager.Entities;
namespace Bit.Core.SecretsManager.Repositories.Noop;
public class NoopServiceAccountRepository : IServiceAccountRepository
{
public Task<IEnumerable<ServiceAccount>> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType)
{
return Task.FromResult(null as IEnumerable<ServiceAccount>);
}
public Task<ServiceAccount> GetByIdAsync(Guid id)
{
return Task.FromResult(null as ServiceAccount);
}
public Task<IEnumerable<ServiceAccount>> GetManyByIds(IEnumerable<Guid> ids)
{
return Task.FromResult(null as IEnumerable<ServiceAccount>);
}
public Task<ServiceAccount> CreateAsync(ServiceAccount serviceAccount)
{
return Task.FromResult(null as ServiceAccount);
}
public Task ReplaceAsync(ServiceAccount serviceAccount)
{
return Task.FromResult(0);
}
public Task DeleteManyByIdAsync(IEnumerable<Guid> ids)
{
return Task.FromResult(0);
}
public Task<bool> UserHasReadAccessToServiceAccount(Guid id, Guid userId)
{
return Task.FromResult(false);
}
public Task<bool> UserHasWriteAccessToServiceAccount(Guid id, Guid userId)
{
return Task.FromResult(false);
}
public Task<IEnumerable<ServiceAccount>> GetManyByOrganizationIdWriteAccessAsync(Guid organizationId, Guid userId, AccessClientType accessType) => throw new NotImplementedException();
public Task<(bool Read, bool Write)> AccessToServiceAccountAsync(Guid id, Guid userId, AccessClientType accessType)
{
return Task.FromResult((false, false));
}
public Task<int> GetServiceAccountCountByOrganizationIdAsync(Guid organizationId)
{
return Task.FromResult(0);
}
}

View File

@ -55,6 +55,8 @@ public interface IMailService
Task SendFailedLoginAttemptsEmailAsync(string email, DateTime utcNow, string ip);
Task SendFailedTwoFactorAttemptsEmailAsync(string email, DateTime utcNow, string ip);
Task SendUnverifiedOrganizationDomainEmailAsync(IEnumerable<string> adminEmails, string organizationId, string domainName);
Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier);
}

View File

@ -13,7 +13,6 @@ public interface IOrganizationService
TaxInfo taxInfo);
Task CancelSubscriptionAsync(Guid organizationId, bool? endOfPeriod = null);
Task ReinstateSubscriptionAsync(Guid organizationId);
Task<Tuple<bool, string>> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade);
Task<string> AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb);
Task UpdateSubscription(Guid organizationId, int seatAdjustment, int? maxAutoscaleSeats);
Task AutoAddSeatsAsync(Organization organization, int seatsToAdd, DateTime? prorationDate = null);
@ -79,4 +78,7 @@ public interface IOrganizationService
/// </remarks>
Task InitPendingOrganization(Guid userId, Guid organizationId, string publicKey, string privateKey, string collectionName);
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);
void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade);
void ValidateSecretsManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade);
}

View File

@ -9,16 +9,19 @@ public interface IPaymentService
{
Task CancelAndRecoverChargesAsync(ISubscriber subscriber);
Task<string> PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType,
string paymentToken, Plan plan, short additionalStorageGb, int additionalSeats,
bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false);
string paymentToken, List<Plan> plans, short additionalStorageGb, int additionalSeats,
bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false, int additionalSmSeats = 0,
int additionalServiceAccount = 0);
Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship);
Task RemoveOrganizationSponsorshipAsync(Organization org, OrganizationSponsorship sponsorship);
Task<string> UpgradeFreeOrganizationAsync(Organization org, Plan plan,
short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo);
Task<string> UpgradeFreeOrganizationAsync(Organization org, List<Plan> plans, OrganizationUpgrade upgrade);
Task<string> PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken,
short additionalStorageGb, TaxInfo taxInfo);
Task<string> AdjustSeatsAsync(Organization organization, Plan plan, int additionalSeats, DateTime? prorationDate = null);
Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId, DateTime? prorationDate = null);
Task<string> AdjustServiceAccountsAsync(Organization organization, Plan plan, int additionalServiceAccounts,
DateTime? prorationDate = null);
Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false,
bool skipInAppPurchaseCheck = false);
Task ReinstateSubscriptionAsync(ISubscriber subscriber);

View File

@ -897,6 +897,36 @@ public class HandlebarsMailService : IMailService
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount,
IEnumerable<string> ownerEmails)
{
var message = CreateDefaultMessage($"{organization.Name} Secrets Manager Seat Limit Reached", ownerEmails);
var model = new OrganizationSeatsMaxReachedViewModel
{
OrganizationId = organization.Id,
MaxSeatCount = maxSeatCount,
};
await AddMessageContentAsync(message, "OrganizationSmSeatsMaxReached", model);
message.Category = "OrganizationSmSeatsMaxReached";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization, int maxSeatCount,
IEnumerable<string> ownerEmails)
{
var message = CreateDefaultMessage($"{organization.Name} Secrets Manager Service Accounts Limit Reached", ownerEmails);
var model = new OrganizationServiceAccountsMaxReachedViewModel
{
OrganizationId = organization.Id,
MaxServiceAccountsCount = maxSeatCount,
};
await AddMessageContentAsync(message, "OrganizationSmServiceAccountsMaxReached", model);
message.Category = "OrganizationSmServiceAccountsMaxReached";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip,
string deviceTypeAndIdentifier)
{

View File

@ -1,6 +1,5 @@
using System.Security.Claims;
using System.Text.Json;
using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Repositories;
@ -38,7 +37,6 @@ public class OrganizationService : IOrganizationService
private readonly IDeviceRepository _deviceRepository;
private readonly ILicensingService _licensingService;
private readonly IEventService _eventService;
private readonly IInstallationRepository _installationRepository;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IPaymentService _paymentService;
private readonly IPolicyRepository _policyRepository;
@ -48,7 +46,6 @@ public class OrganizationService : IOrganizationService
private readonly IReferenceEventService _referenceEventService;
private readonly IGlobalSettings _globalSettings;
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
private readonly IOrganizationConnectionRepository _organizationConnectionRepository;
private readonly ICurrentContext _currentContext;
private readonly ILogger<OrganizationService> _logger;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
@ -67,7 +64,6 @@ public class OrganizationService : IOrganizationService
IDeviceRepository deviceRepository,
ILicensingService licensingService,
IEventService eventService,
IInstallationRepository installationRepository,
IApplicationCacheService applicationCacheService,
IPaymentService paymentService,
IPolicyRepository policyRepository,
@ -77,7 +73,6 @@ public class OrganizationService : IOrganizationService
IReferenceEventService referenceEventService,
IGlobalSettings globalSettings,
IOrganizationApiKeyRepository organizationApiKeyRepository,
IOrganizationConnectionRepository organizationConnectionRepository,
ICurrentContext currentContext,
ILogger<OrganizationService> logger,
IProviderOrganizationRepository providerOrganizationRepository,
@ -95,7 +90,6 @@ public class OrganizationService : IOrganizationService
_deviceRepository = deviceRepository;
_licensingService = licensingService;
_eventService = eventService;
_installationRepository = installationRepository;
_applicationCacheService = applicationCacheService;
_paymentService = paymentService;
_policyRepository = policyRepository;
@ -105,7 +99,6 @@ public class OrganizationService : IOrganizationService
_referenceEventService = referenceEventService;
_globalSettings = globalSettings;
_organizationApiKeyRepository = organizationApiKeyRepository;
_organizationConnectionRepository = organizationConnectionRepository;
_currentContext = currentContext;
_logger = logger;
_providerOrganizationRepository = providerOrganizationRepository;
@ -166,211 +159,6 @@ public class OrganizationService : IOrganizationService
new ReferenceEvent(ReferenceEventType.ReinstateSubscription, organization, _currentContext));
}
public async Task<Tuple<bool, string>> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade)
{
var organization = await GetOrgById(organizationId);
if (organization == null)
{
throw new NotFoundException();
}
if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
{
throw new BadRequestException("Your account has no payment method available.");
}
var existingPlan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == organization.PlanType);
if (existingPlan == null)
{
throw new BadRequestException("Existing plan not found.");
}
var newPlan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == upgrade.Plan && !p.Disabled);
if (newPlan == null)
{
throw new BadRequestException("Plan not found.");
}
if (existingPlan.Type == newPlan.Type)
{
throw new BadRequestException("Organization is already on this plan.");
}
if (existingPlan.UpgradeSortOrder >= newPlan.UpgradeSortOrder)
{
throw new BadRequestException("You cannot upgrade to this plan.");
}
if (existingPlan.Type != PlanType.Free)
{
throw new BadRequestException("You can only upgrade from the free plan. Contact support.");
}
ValidateOrganizationUpgradeParameters(newPlan, upgrade);
var newPlanSeats = (short)(newPlan.BaseSeats +
(newPlan.HasAdditionalSeatsOption ? upgrade.AdditionalSeats : 0));
if (!organization.Seats.HasValue || organization.Seats.Value > newPlanSeats)
{
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
if (occupiedSeats > newPlanSeats)
{
throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " +
$"Your new plan only has ({newPlanSeats}) seats. Remove some users.");
}
}
if (newPlan.MaxCollections.HasValue && (!organization.MaxCollections.HasValue ||
organization.MaxCollections.Value > newPlan.MaxCollections.Value))
{
var collectionCount = await _collectionRepository.GetCountByOrganizationIdAsync(organization.Id);
if (collectionCount > newPlan.MaxCollections.Value)
{
throw new BadRequestException($"Your organization currently has {collectionCount} collections. " +
$"Your new plan allows for a maximum of ({newPlan.MaxCollections.Value}) collections. " +
"Remove some collections.");
}
}
if (!newPlan.HasGroups && organization.UseGroups)
{
var groups = await _groupRepository.GetManyByOrganizationIdAsync(organization.Id);
if (groups.Any())
{
throw new BadRequestException($"Your new plan does not allow the groups feature. " +
$"Remove your groups.");
}
}
if (!newPlan.HasPolicies && organization.UsePolicies)
{
var policies = await _policyRepository.GetManyByOrganizationIdAsync(organization.Id);
if (policies.Any(p => p.Enabled))
{
throw new BadRequestException($"Your new plan does not allow the policies feature. " +
$"Disable your policies.");
}
}
if (!newPlan.HasSso && organization.UseSso)
{
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id);
if (ssoConfig != null && ssoConfig.Enabled)
{
throw new BadRequestException($"Your new plan does not allow the SSO feature. " +
$"Disable your SSO configuration.");
}
}
if (!newPlan.HasKeyConnector && organization.UseKeyConnector)
{
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organization.Id);
if (ssoConfig != null && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.KeyConnector)
{
throw new BadRequestException("Your new plan does not allow the Key Connector feature. " +
"Disable your Key Connector.");
}
}
if (!newPlan.HasResetPassword && organization.UseResetPassword)
{
var resetPasswordPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled)
{
throw new BadRequestException("Your new plan does not allow the Password Reset feature. " +
"Disable your Password Reset policy.");
}
}
if (!newPlan.HasScim && organization.UseScim)
{
var scimConnections = await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(organization.Id,
OrganizationConnectionType.Scim);
if (scimConnections != null && scimConnections.Any(c => c.GetConfig<ScimConfig>()?.Enabled == true))
{
throw new BadRequestException("Your new plan does not allow the SCIM feature. " +
"Disable your SCIM configuration.");
}
}
if (!newPlan.HasCustomPermissions && organization.UseCustomPermissions)
{
var organizationCustomUsers =
await _organizationUserRepository.GetManyByOrganizationAsync(organization.Id,
OrganizationUserType.Custom);
if (organizationCustomUsers.Any())
{
throw new BadRequestException("Your new plan does not allow the Custom Permissions feature. " +
"Disable your Custom Permissions configuration.");
}
}
// TODO: Check storage?
string paymentIntentClientSecret = null;
var success = true;
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
paymentIntentClientSecret = await _paymentService.UpgradeFreeOrganizationAsync(organization, newPlan,
upgrade.AdditionalStorageGb, upgrade.AdditionalSeats, upgrade.PremiumAccessAddon, upgrade.TaxInfo);
success = string.IsNullOrWhiteSpace(paymentIntentClientSecret);
}
else
{
// TODO: Update existing sub
throw new BadRequestException("You can only upgrade from the free plan. Contact support.");
}
organization.BusinessName = upgrade.BusinessName;
organization.PlanType = newPlan.Type;
organization.Seats = (short)(newPlan.BaseSeats + upgrade.AdditionalSeats);
organization.MaxCollections = newPlan.MaxCollections;
organization.UseGroups = newPlan.HasGroups;
organization.UseDirectory = newPlan.HasDirectory;
organization.UseEvents = newPlan.HasEvents;
organization.UseTotp = newPlan.HasTotp;
organization.Use2fa = newPlan.Has2fa;
organization.UseApi = newPlan.HasApi;
organization.SelfHost = newPlan.HasSelfHost;
organization.UsePolicies = newPlan.HasPolicies;
organization.MaxStorageGb = !newPlan.BaseStorageGb.HasValue ?
(short?)null : (short)(newPlan.BaseStorageGb.Value + upgrade.AdditionalStorageGb);
organization.UseGroups = newPlan.HasGroups;
organization.UseDirectory = newPlan.HasDirectory;
organization.UseEvents = newPlan.HasEvents;
organization.UseTotp = newPlan.HasTotp;
organization.Use2fa = newPlan.Has2fa;
organization.UseApi = newPlan.HasApi;
organization.UseSso = newPlan.HasSso;
organization.UseKeyConnector = newPlan.HasKeyConnector;
organization.UseScim = newPlan.HasScim;
organization.UseResetPassword = newPlan.HasResetPassword;
organization.SelfHost = newPlan.HasSelfHost;
organization.UsersGetPremium = newPlan.UsersGetPremium || upgrade.PremiumAccessAddon;
organization.UseCustomPermissions = newPlan.HasCustomPermissions;
organization.Plan = newPlan.Name;
organization.Enabled = success;
organization.PublicKey = upgrade.PublicKey;
organization.PrivateKey = upgrade.PrivateKey;
await ReplaceAndUpdateCacheAsync(organization);
if (success)
{
await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.UpgradePlan, organization, _currentContext)
{
PlanName = newPlan.Name,
PlanType = newPlan.Type,
OldPlanName = existingPlan.Name,
OldPlanType = existingPlan.Type,
Seats = organization.Seats,
Storage = organization.MaxStorageGb,
});
}
return new Tuple<bool, string>(success, paymentIntentClientSecret);
}
public async Task<string> AdjustStorageAsync(Guid organizationId, short storageAdjustmentGb)
{
var organization = await GetOrgById(organizationId);
@ -607,15 +395,14 @@ public class OrganizationService : IOrganizationService
public async Task<Tuple<Organization, OrganizationUser>> SignUpAsync(OrganizationSignup signup,
bool provider = false)
{
var plan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == signup.Plan);
if (plan is not { LegacyYear: null })
{
throw new BadRequestException("Invalid plan selected.");
}
var passwordManagerPlan = StaticStore.PasswordManagerPlans.FirstOrDefault(p => p.Type == signup.Plan);
if (plan.Disabled)
ValidatePasswordManagerPlan(passwordManagerPlan, signup);
var secretsManagerPlan = StaticStore.SecretManagerPlans.FirstOrDefault(p => p.Type == signup.Plan);
if (signup.UseSecretsManager)
{
throw new BadRequestException("Plan not found.");
ValidateSecretsManagerPlan(secretsManagerPlan, signup);
}
if (!provider)
@ -623,8 +410,6 @@ public class OrganizationService : IOrganizationService
await ValidateSignUpPoliciesAsync(signup.Owner.Id);
}
ValidateOrganizationUpgradeParameters(plan, signup);
var organization = new Organization
{
// Pre-generate the org id so that we can save it with the Stripe subscription..
@ -632,25 +417,25 @@ public class OrganizationService : IOrganizationService
Name = signup.Name,
BillingEmail = signup.BillingEmail,
BusinessName = signup.BusinessName,
PlanType = plan.Type,
Seats = (short)(plan.BaseSeats + signup.AdditionalSeats),
MaxCollections = plan.MaxCollections,
MaxStorageGb = !plan.BaseStorageGb.HasValue ?
(short?)null : (short)(plan.BaseStorageGb.Value + signup.AdditionalStorageGb),
UsePolicies = plan.HasPolicies,
UseSso = plan.HasSso,
UseGroups = plan.HasGroups,
UseEvents = plan.HasEvents,
UseDirectory = plan.HasDirectory,
UseTotp = plan.HasTotp,
Use2fa = plan.Has2fa,
UseApi = plan.HasApi,
UseResetPassword = plan.HasResetPassword,
SelfHost = plan.HasSelfHost,
UsersGetPremium = plan.UsersGetPremium || signup.PremiumAccessAddon,
UseCustomPermissions = plan.HasCustomPermissions,
UseScim = plan.HasScim,
Plan = plan.Name,
PlanType = passwordManagerPlan.Type,
Seats = (short)(passwordManagerPlan.BaseSeats + signup.AdditionalSeats),
MaxCollections = passwordManagerPlan.MaxCollections,
MaxStorageGb = !passwordManagerPlan.BaseStorageGb.HasValue ?
(short?)null : (short)(passwordManagerPlan.BaseStorageGb.Value + signup.AdditionalStorageGb),
UsePolicies = passwordManagerPlan.HasPolicies,
UseSso = passwordManagerPlan.HasSso,
UseGroups = passwordManagerPlan.HasGroups,
UseEvents = passwordManagerPlan.HasEvents,
UseDirectory = passwordManagerPlan.HasDirectory,
UseTotp = passwordManagerPlan.HasTotp,
Use2fa = passwordManagerPlan.Has2fa,
UseApi = passwordManagerPlan.HasApi,
UseResetPassword = passwordManagerPlan.HasResetPassword,
SelfHost = passwordManagerPlan.HasSelfHost,
UsersGetPremium = passwordManagerPlan.UsersGetPremium || signup.PremiumAccessAddon,
UseCustomPermissions = passwordManagerPlan.HasCustomPermissions,
UseScim = passwordManagerPlan.HasScim,
Plan = passwordManagerPlan.Name,
Gateway = null,
ReferenceData = signup.Owner.ReferenceData,
Enabled = true,
@ -659,10 +444,14 @@ public class OrganizationService : IOrganizationService
PrivateKey = signup.PrivateKey,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
Status = OrganizationStatusType.Created
Status = OrganizationStatusType.Created,
UsePasswordManager = true,
SmSeats = (short)(secretsManagerPlan.BaseSeats + signup.AdditionalSmSeats.GetValueOrDefault()),
SmServiceAccounts = secretsManagerPlan.BaseServiceAccount + signup.AdditionalServiceAccounts.GetValueOrDefault(),
UseSecretsManager = signup.UseSecretsManager
};
if (plan.Type == PlanType.Free && !provider)
if (passwordManagerPlan.Type == PlanType.Free && !provider)
{
var adminCount =
await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id);
@ -671,11 +460,16 @@ public class OrganizationService : IOrganizationService
throw new BadRequestException("You can only be an admin of one free organization.");
}
}
else if (plan.Type != PlanType.Free)
else if (passwordManagerPlan.Type != PlanType.Free)
{
var purchaseOrganizationPlan = signup.UseSecretsManager
? StaticStore.Plans.Where(p => p.Type == signup.Plan).ToList()
: StaticStore.PasswordManagerPlans.Where(p => p.Type == signup.Plan).Take(1).ToList();
await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value,
signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats,
signup.PremiumAccessAddon, signup.TaxInfo, provider);
signup.PaymentToken, purchaseOrganizationPlan, signup.AdditionalStorageGb, signup.AdditionalSeats,
signup.PremiumAccessAddon, signup.TaxInfo, provider, signup.AdditionalSmSeats.GetValueOrDefault(),
signup.AdditionalServiceAccounts.GetValueOrDefault());
}
var ownerId = provider ? default : signup.Owner.Id;
@ -683,10 +477,11 @@ public class OrganizationService : IOrganizationService
await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.Signup, organization, _currentContext)
{
PlanName = plan.Name,
PlanType = plan.Type,
PlanName = passwordManagerPlan.Name,
PlanType = passwordManagerPlan.Type,
Seats = returnValue.Item1.Seats,
Storage = returnValue.Item1.MaxStorageGb,
// TODO: add reference events for SmSeats and Service Accounts - see AC-1481
});
return returnValue;
}
@ -807,6 +602,7 @@ public class OrganizationService : IOrganizationService
OrganizationId = organization.Id,
UserId = ownerId,
Key = ownerKey,
AccessSecretsManager = organization.UseSecretsManager,
Type = OrganizationUserType.Owner,
Status = OrganizationUserStatusType.Confirmed,
AccessAll = true,
@ -2060,8 +1856,43 @@ public class OrganizationService : IOrganizationService
return await _organizationRepository.GetByIdAsync(id);
}
private void ValidateOrganizationUpgradeParameters(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade)
private static void ValidatePlan(Models.StaticStore.Plan plan, int additionalSeats, string productType)
{
if (plan is not { LegacyYear: null })
{
throw new BadRequestException($"Invalid {productType} plan selected.");
}
if (plan.Disabled)
{
throw new BadRequestException($"{productType} Plan not found.");
}
if (plan.BaseSeats + additionalSeats <= 0)
{
throw new BadRequestException($"You do not have any {productType} seats!");
}
if (additionalSeats < 0)
{
throw new BadRequestException($"You can't subtract {productType} seats!");
}
}
public void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade)
{
ValidatePlan(plan, upgrade.AdditionalSeats, "Password Manager");
if (plan.BaseSeats + upgrade.AdditionalSeats <= 0)
{
throw new BadRequestException($"You do not have any Password Manager seats!");
}
if (upgrade.AdditionalSeats < 0)
{
throw new BadRequestException($"You can't subtract Password Manager seats!");
}
if (!plan.HasAdditionalStorageOption && upgrade.AdditionalStorageGb > 0)
{
throw new BadRequestException("Plan does not allow additional storage.");
@ -2077,16 +1908,6 @@ public class OrganizationService : IOrganizationService
throw new BadRequestException("This plan does not allow you to buy the premium access addon.");
}
if (plan.BaseSeats + upgrade.AdditionalSeats <= 0)
{
throw new BadRequestException("You do not have any seats!");
}
if (upgrade.AdditionalSeats < 0)
{
throw new BadRequestException("You can't subtract seats!");
}
if (!plan.HasAdditionalSeatsOption && upgrade.AdditionalSeats > 0)
{
throw new BadRequestException("Plan does not allow additional users.");
@ -2096,7 +1917,37 @@ public class OrganizationService : IOrganizationService
upgrade.AdditionalSeats > plan.MaxAdditionalSeats.Value)
{
throw new BadRequestException($"Selected plan allows a maximum of " +
$"{plan.MaxAdditionalSeats.GetValueOrDefault(0)} additional users.");
$"{plan.MaxAdditionalSeats.GetValueOrDefault(0)} additional users.");
}
}
public void ValidateSecretsManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade)
{
ValidatePlan(plan, upgrade.AdditionalSmSeats.GetValueOrDefault(), "Secrets Manager");
if (!plan.HasAdditionalServiceAccountOption && upgrade.AdditionalServiceAccounts > 0)
{
throw new BadRequestException("Plan does not allow additional Service Accounts.");
}
if (upgrade.AdditionalSmSeats.GetValueOrDefault() > upgrade.AdditionalSeats)
{
throw new BadRequestException("You cannot have more Secrets Manager seats than Password Manager seats.");
}
if (upgrade.AdditionalServiceAccounts.GetValueOrDefault() < 0)
{
throw new BadRequestException("You can't subtract Service Accounts!");
}
switch (plan.HasAdditionalSeatsOption)
{
case false when upgrade.AdditionalSmSeats > 0:
throw new BadRequestException("Plan does not allow additional users.");
case true when plan.MaxAdditionalSeats.HasValue &&
upgrade.AdditionalSmSeats > plan.MaxAdditionalSeats.Value:
throw new BadRequestException($"Selected plan allows a maximum of " +
$"{plan.MaxAdditionalSeats.GetValueOrDefault(0)} additional users.");
}
}

View File

@ -49,8 +49,9 @@ public class StripePaymentService : IPaymentService
}
public async Task<string> PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType,
string paymentToken, StaticStore.Plan plan, short additionalStorageGb,
int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false)
string paymentToken, List<StaticStore.Plan> plans, short additionalStorageGb,
int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false,
int additionalSmSeats = 0, int additionalServiceAccount = 0)
{
Braintree.Customer braintreeCustomer = null;
string stipeCustomerSourceToken = null;
@ -118,7 +119,8 @@ public class StripePaymentService : IPaymentService
}
}
var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon);
var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plans, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon
, additionalSmSeats, additionalServiceAccount);
Stripe.Customer customer = null;
Stripe.Subscription subscription;
@ -229,8 +231,8 @@ public class StripePaymentService : IPaymentService
public Task RemoveOrganizationSponsorshipAsync(Organization org, OrganizationSponsorship sponsorship) =>
ChangeOrganizationSponsorship(org, sponsorship, false);
public async Task<string> UpgradeFreeOrganizationAsync(Organization org, StaticStore.Plan plan,
short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo)
public async Task<string> UpgradeFreeOrganizationAsync(Organization org, List<StaticStore.Plan> plans,
OrganizationUpgrade upgrade)
{
if (!string.IsNullOrWhiteSpace(org.GatewaySubscriptionId))
{
@ -246,6 +248,7 @@ public class StripePaymentService : IPaymentService
throw new GatewayException("Could not find customer payment profile.");
}
var taxInfo = upgrade.TaxInfo;
if (taxInfo != null && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressCountry) && !string.IsNullOrWhiteSpace(taxInfo.BillingAddressPostalCode))
{
var taxRateSearch = new TaxRate
@ -263,7 +266,7 @@ public class StripePaymentService : IPaymentService
}
}
var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon);
var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plans, upgrade);
var (stripePaymentMethod, paymentMethodType) = IdentifyPaymentMethod(customer, subCreateOptions);
var subscription = await ChargeForNewSubscriptionAsync(org, customer, false,
@ -860,6 +863,11 @@ public class StripePaymentService : IPaymentService
return FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats), prorationDate);
}
public Task<string> AdjustServiceAccountsAsync(Organization organization, StaticStore.Plan plan, int additionalServiceAccounts, DateTime? prorationDate = null)
{
return FinalizeSubscriptionChangeAsync(organization, new ServiceAccountSubscriptionUpdate(organization, plan, additionalServiceAccounts), prorationDate);
}
public Task<string> AdjustStorageAsync(IStorableSubscriber storableSubscriber, int additionalStorage,
string storagePlanId, DateTime? prorationDate = null)
{

View File

@ -239,6 +239,19 @@ public class NoopMailService : IMailService
return Task.FromResult(0);
}
public Task SendSecretsManagerMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount,
IEnumerable<string> ownerEmails)
{
return Task.FromResult(0);
}
public Task SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(Organization organization,
int maxSeatCount,
IEnumerable<string> ownerEmails)
{
return Task.FromResult(0);
}
public Task SendTrustedDeviceAdminApprovalEmailAsync(string email, DateTime utcNow, string ip, string deviceTypeAndIdentifier)
{
return Task.FromResult(0);

View File

@ -39,12 +39,13 @@ public static class SecretsManagerPlanStore
HasCustomPermissions = true,
UpgradeSortOrder = 3,
DisplaySortOrder = 3,
StripeSeatPlanId = "sm-enterprise-seat-monthly",
StripeServiceAccountPlanId = "service-account-monthly",
StripeSeatPlanId = "secrets-manager-enterprise-seat-monthly",
StripeServiceAccountPlanId = "secrets-manager-service-account-monthly",
BasePrice = 0,
SeatPrice = 13,
AdditionalPricePerServiceAccount = 0.5M,
AllowSeatAutoscale = true,
AllowServiceAccountsAutoscale = true
},
new Plan
{
@ -77,12 +78,13 @@ public static class SecretsManagerPlanStore
HasCustomPermissions = true,
UpgradeSortOrder = 3,
DisplaySortOrder = 3,
StripeSeatPlanId = "sm-enterprise-seat-annually",
StripeServiceAccountPlanId = "service-account-annually",
StripeSeatPlanId = "secrets-manager-enterprise-seat-annually",
StripeServiceAccountPlanId = "secrets-manager-service-account-annually",
BasePrice = 0,
SeatPrice = 144,
AdditionalPricePerServiceAccount = 6,
AllowSeatAutoscale = true,
AllowServiceAccountsAutoscale = true
},
new Plan
{
@ -107,12 +109,13 @@ public static class SecretsManagerPlanStore
UsersGetPremium = true,
UpgradeSortOrder = 2,
DisplaySortOrder = 2,
StripeSeatPlanId = "sm-teams-seat-monthly",
StripeServiceAccountPlanId = "service-account-monthly",
StripeSeatPlanId = "secrets-manager-teams-seat-monthly",
StripeServiceAccountPlanId = "secrets-manager-service-account-monthly",
BasePrice = 0,
SeatPrice = 7,
AdditionalPricePerServiceAccount = 0.5M,
AllowSeatAutoscale = true,
AllowServiceAccountsAutoscale = true
},
new Plan
{
@ -139,12 +142,13 @@ public static class SecretsManagerPlanStore
UpgradeSortOrder = 2,
DisplaySortOrder = 2,
StripeSeatPlanId = "sm-teams-seat-annually",
StripeServiceAccountPlanId = "service-account-annually",
StripeSeatPlanId = "secrets-manager-teams-seat-annually",
StripeServiceAccountPlanId = "secrets-manager-service-account-annually",
BasePrice = 0,
SeatPrice = 72,
AdditionalPricePerServiceAccount = 6,
AllowSeatAutoscale = true,
AllowServiceAccountsAutoscale = true
},
new Plan
{
@ -158,7 +162,7 @@ public static class SecretsManagerPlanStore
BaseServiceAccount = 3,
MaxProjects = 3,
MaxUsers = 2,
MaxServiceAccount = 3,
MaxServiceAccounts = 3,
UpgradeSortOrder = -1, // Always the lowest plan, cannot be upgraded to
DisplaySortOrder = -1,
AllowSeatAutoscale = false,

View File

@ -139,4 +139,53 @@ public class StaticStore
public static SponsoredPlan GetSponsoredPlan(PlanSponsorshipType planSponsorshipType) =>
SponsoredPlans.FirstOrDefault(p => p.PlanSponsorshipType == planSponsorshipType);
/// <summary>
/// Determines if the stripe plan id is an addon item by checking if the provided stripe plan id
/// matches either the <see cref="Plan.StripeStoragePlanId"/> or <see cref="Plan.StripeServiceAccountPlanId"/>
/// in any <see cref="Plans"/>.
/// </summary>
/// <param name="stripePlanId"></param>
/// <returns>
/// True if the stripePlanId is a addon product, false otherwise
/// </returns>
public static bool IsAddonSubscriptionItem(string stripePlanId)
{
if (PasswordManagerPlans.Select(p => p.StripeStoragePlanId).Contains(stripePlanId))
{
return true;
}
if (SecretManagerPlans.Select(p => p.StripeServiceAccountPlanId).Contains(stripePlanId))
{
return true;
}
return false;
}
/// <summary>
/// Get a <see cref="Plan"/> by comparing the provided stripeId to the various
/// Stripe plan ids within a <see cref="Plan"/>.
/// The following <see cref="Plan"/> properties are checked:
/// <list type="bullet">
/// <item><see cref="Plan.StripePlanId"/></item>
/// <item><see cref="Plan.StripeSeatPlanId"/></item>
/// <item><see cref="Plan.StripeStoragePlanId"/></item>
/// <item><see cref="Plan.StripeServiceAccountPlanId"/></item>
/// <item><see cref="Plan.StripePremiumAccessPlanId"/></item>
/// </list>
/// </summary>
/// <param name="stripeId"></param>
/// <returns>The plan if a matching stripeId was found, null otherwise</returns>
public static Plan GetPlanByStripeId(string stripeId)
{
return Plans.FirstOrDefault(p =>
p.StripePlanId == stripeId ||
p.StripeSeatPlanId == stripeId ||
p.StripeStoragePlanId == stripeId ||
p.StripeServiceAccountPlanId == stripeId ||
p.StripePremiumAccessPlanId == stripeId
);
}
}

View File

@ -99,6 +99,19 @@ public class OrganizationUserRepository : Repository<OrganizationUser, Guid>, IO
}
}
public async Task<int> GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var result = await connection.ExecuteScalarAsync<int>(
"[dbo].[OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId]",
new { OrganizationId = organizationId },
commandType: CommandType.StoredProcedure);
return result;
}
}
public async Task<ICollection<string>> SelectKnownEmailsAsync(Guid organizationId, IEnumerable<string> emails,
bool onlyRegisteredUsers)
{

View File

@ -621,4 +621,11 @@ public class OrganizationUserRepository : Repository<Core.Entities.OrganizationU
return await query.ToListAsync();
}
}
public async Task<int> GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId)
{
var query = new OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery(organizationId);
return await GetCountFromQuery(query);
}
}

View File

@ -0,0 +1,22 @@
using Bit.Core.Enums;
using Bit.Infrastructure.EntityFramework.Models;
namespace Bit.Infrastructure.EntityFramework.Repositories.Queries;
public class OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery : IQuery<OrganizationUser>
{
private readonly Guid _organizationId;
public OrganizationUserReadOccupiedSmSeatCountByOrganizationIdQuery(Guid organizationId)
{
_organizationId = organizationId;
}
public IQueryable<OrganizationUser> Run(DatabaseContext dbContext)
{
var query = from ou in dbContext.OrganizationUsers
where ou.OrganizationId == _organizationId && ou.Status >= OrganizationUserStatusType.Invited && ou.AccessSecretsManager == true
select ou;
return query;
}
}

View File

@ -18,6 +18,8 @@ using Bit.Core.IdentityServer;
using Bit.Core.OrganizationFeatures;
using Bit.Core.Repositories;
using Bit.Core.Resources;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.SecretsManager.Repositories.Noop;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
@ -329,6 +331,7 @@ public static class ServiceCollectionExtensions
public static void AddOosServices(this IServiceCollection services)
{
services.AddScoped<IProviderService, NoopProviderService>();
services.AddScoped<IServiceAccountRepository, NoopServiceAccountRepository>();
}
public static void AddNoopServices(this IServiceCollection services)

View File

@ -0,0 +1,16 @@
CREATE PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId]
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
COUNT(1)
FROM
[dbo].[OrganizationUserView]
WHERE
OrganizationId = @OrganizationId
AND Status >= 0 --Invited
AND AccessSecretsManager = 1
END
GO

View File

@ -11,6 +11,7 @@ using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationApiKeys.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
@ -40,6 +41,8 @@ public class OrganizationsControllerTests : IDisposable
private readonly IUpdateOrganizationLicenseCommand _updateOrganizationLicenseCommand;
private readonly IFeatureService _featureService;
private readonly ILicensingService _licensingService;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand;
private readonly OrganizationsController _sut;
@ -64,12 +67,15 @@ public class OrganizationsControllerTests : IDisposable
_updateOrganizationLicenseCommand = Substitute.For<IUpdateOrganizationLicenseCommand>();
_featureService = Substitute.For<IFeatureService>();
_licensingService = Substitute.For<ILicensingService>();
_updateSecretsManagerSubscriptionCommand = Substitute.For<IUpdateSecretsManagerSubscriptionCommand>();
_upgradeOrganizationPlanCommand = Substitute.For<IUpgradeOrganizationPlanCommand>();
_sut = new OrganizationsController(_organizationRepository, _organizationUserRepository,
_policyRepository, _providerRepository, _organizationService, _userService, _paymentService, _currentContext,
_ssoConfigRepository, _ssoConfigService, _getOrganizationApiKeyQuery, _rotateOrganizationApiKeyCommand,
_createOrganizationApiKeyCommand, _organizationApiKeyRepository, _updateOrganizationLicenseCommand,
_cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, _licensingService);
_cloudGetOrganizationLicenseQuery, _featureService, _globalSettings, _licensingService,
_updateSecretsManagerSubscriptionCommand, _upgradeOrganizationPlanCommand);
}
public void Dispose()

View File

@ -0,0 +1,748 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.StaticStore;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.OrganizationFeatures.OrganizationSubscriptionUpdate;
[SutProviderCustomize]
public class UpdateSecretsManagerSubscriptionCommandTests
{
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_NoOrganization_Throws(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organizationId)
.Returns((Organization)null);
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = null,
SmSeatsAdjustment = 0
};
var exception = await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Organization is not found", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_NoSecretsManagerAccess_ThrowsException(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 10,
SmServiceAccounts = 5,
UseSecretsManager = false,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 10
};
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organizationId)
.Returns(organization);
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
SmSeatsAdjustment = 1,
MaxAutoscaleSmSeats = 1
};
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Organization has no access to Secrets Manager.", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_SeatsAdustmentGreaterThanMaxAutoscaleSeats_ThrowsException(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 10,
UseSecretsManager = true,
SmServiceAccounts = 5,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 10,
PlanType = PlanType.EnterpriseAnnually,
};
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType);
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 10,
SmSeatsAdjustment = 15,
SmSeats = organization.SmSeats.GetValueOrDefault() + 10,
SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 10) - plan.BaseSeats,
SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 5,
SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 5) - (int)plan.BaseServiceAccount
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Cannot set max seat autoscaling below seat count.", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_ServiceAccountsGreaterThanMaxAutoscaleSeats_ThrowsException(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 10,
UseSecretsManager = true,
SmServiceAccounts = 5,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 10,
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "1",
GatewaySubscriptionId = "9"
};
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType);
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 1,
MaxAutoscaleSmServiceAccounts = 10,
SmServiceAccountsAdjustment = 11,
SmSeats = organization.SmSeats.GetValueOrDefault() + 1,
SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 1) - plan.BaseSeats,
SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 11,
SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 11) - (int)plan.BaseServiceAccount
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Cannot set max Service Accounts autoscaling below Service Accounts count", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_NullGatewayCustomerId_ThrowsException(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 10,
SmServiceAccounts = 5,
UseSecretsManager = true,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 15,
PlanType = PlanType.EnterpriseAnnually
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 1,
MaxAutoscaleSmServiceAccounts = 15,
SmServiceAccountsAdjustment = 1
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("No payment method found.", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_NullGatewaySubscriptionId_ThrowsException(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 10,
UseSecretsManager = true,
SmServiceAccounts = 5,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 15,
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "1"
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 1,
MaxAutoscaleSmServiceAccounts = 15,
SmServiceAccountsAdjustment = 1
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("No subscription found.", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_OrgWithNullSmSeatOnSeatsAdjustment_ThrowsException(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = null,
UseSecretsManager = true,
SmServiceAccounts = 5,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 15,
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "1"
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 1,
MaxAutoscaleSmServiceAccounts = 15,
SmServiceAccountsAdjustment = 1
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Organization has no Secrets Manager seat limit, no need to adjust seats", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData(PlanType.Custom)]
[BitAutoData(PlanType.FamiliesAnnually)]
[BitAutoData(PlanType.FamiliesAnnually2019)]
[BitAutoData(PlanType.EnterpriseMonthly2019)]
[BitAutoData(PlanType.EnterpriseAnnually2019)]
[BitAutoData(PlanType.TeamsMonthly2019)]
[BitAutoData(PlanType.TeamsAnnually2019)]
public async Task UpdateSecretsManagerSubscription_WithNonSecretsManagerPlanType_ThrowsBadRequestException(
PlanType planType,
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 10,
UseSecretsManager = true,
SmServiceAccounts = 200,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 300,
PlanType = planType,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organization.Id,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 1,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 1
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Existing plan not found", exception.Message, StringComparison.InvariantCultureIgnoreCase);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData(PlanType.Free)]
public async Task UpdateSecretsManagerSubscription_WithHasAdditionalSeatsOptionfalse_ThrowsBadRequestException(
PlanType planType,
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 10,
SmServiceAccounts = 200,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 300,
PlanType = planType,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organization.Id,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 1,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 1
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Plan does not allow additional Secrets Manager seats.", exception.Message, StringComparison.InvariantCultureIgnoreCase);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData(PlanType.Free)]
public async Task UpdateSecretsManagerSubscription_WithHasAdditionalServiceAccountOptionFalse_ThrowsBadRequestException(
PlanType planType,
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 10,
SmServiceAccounts = 200,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 300,
PlanType = planType,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organization.Id,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 0,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 1
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Plan does not allow additional Service Accounts", exception.Message, StringComparison.InvariantCultureIgnoreCase);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData(PlanType.EnterpriseAnnually)]
[BitAutoData(PlanType.EnterpriseMonthly)]
[BitAutoData(PlanType.TeamsMonthly)]
[BitAutoData(PlanType.TeamsAnnually)]
public async Task UpdateSecretsManagerSubscription_ValidInput_Passes(
PlanType planType,
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
const int organizationServiceAccounts = 200;
const int seatAdjustment = 5;
const int maxAutoscaleSeats = 15;
const int serviceAccountAdjustment = 100;
const int maxAutoScaleServiceAccounts = 300;
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 10,
MaxAutoscaleSmSeats = 20,
SmServiceAccounts = organizationServiceAccounts,
MaxAutoscaleSmServiceAccounts = 350,
PlanType = planType,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType);
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
SmSeatsAdjustment = seatAdjustment,
SmSeats = organization.SmSeats.GetValueOrDefault() + seatAdjustment,
SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + seatAdjustment) - plan.BaseSeats,
MaxAutoscaleSmSeats = maxAutoscaleSeats,
SmServiceAccountsAdjustment = serviceAccountAdjustment,
SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + serviceAccountAdjustment,
SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + serviceAccountAdjustment) - (int)plan.BaseServiceAccount,
MaxAutoscaleSmServiceAccounts = maxAutoScaleServiceAccounts,
MaxAutoscaleSmSeatsChanged = maxAutoscaleSeats != organization.MaxAutoscaleSeats.GetValueOrDefault(),
MaxAutoscaleSmServiceAccountsChanged = 200 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault()
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
await sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate);
if (organizationUpdate.SmSeatsAdjustment != 0)
{
await sutProvider.GetDependency<IPaymentService>().Received(1)
.AdjustServiceAccountsAsync(organization, plan, organizationUpdate.SmServiceAccountsExcludingBase);
// TODO: call ReferenceEventService - see AC-1481
await sutProvider.GetDependency<IOrganizationService>().Received(1).ReplaceAndUpdateCacheAsync(
Arg.Is<Organization>(org => org.SmSeats == organizationUpdate.SmSeats));
}
if (organizationUpdate.SmServiceAccountsAdjustment != 0)
{
await sutProvider.GetDependency<IPaymentService>().Received(1)
.AdjustSeatsAsync(organization, plan, organizationUpdate.SmSeatsExcludingBase);
// TODO: call ReferenceEventService - see AC-1481
await sutProvider.GetDependency<IOrganizationService>().Received(1).ReplaceAndUpdateCacheAsync(
Arg.Is<Organization>(org =>
org.SmServiceAccounts == (organizationServiceAccounts + organizationUpdate.SmServiceAccountsAdjustment)));
}
if (organizationUpdate.MaxAutoscaleSmSeats != organization.MaxAutoscaleSmSeats)
{
await sutProvider.GetDependency<IOrganizationService>().Received(1).ReplaceAndUpdateCacheAsync(
Arg.Is<Organization>(org =>
org.MaxAutoscaleSmSeats == organizationUpdate.MaxAutoscaleSmServiceAccounts));
}
if (organizationUpdate.MaxAutoscaleSmServiceAccounts != organization.MaxAutoscaleSmServiceAccounts)
{
await sutProvider.GetDependency<IOrganizationService>().Received(1).ReplaceAndUpdateCacheAsync(
Arg.Is<Organization>(org =>
org.MaxAutoscaleSmServiceAccounts == organizationUpdate.MaxAutoscaleSmServiceAccounts));
}
await sutProvider.GetDependency<IMailService>().Received(1).SendSecretsManagerMaxSeatLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmSeats.Value, Arg.Any<IEnumerable<string>>());
await sutProvider.GetDependency<IMailService>().Received(1).SendSecretsManagerMaxServiceAccountLimitReachedEmailAsync(organization, organization.MaxAutoscaleSmServiceAccounts.Value, Arg.Any<IEnumerable<string>>());
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_ThrowsBadRequestException_WhenMaxAutoscaleSeatsBelowSeatCount(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 5,
SmServiceAccounts = 200,
MaxAutoscaleSmSeats = 4,
MaxAutoscaleSmServiceAccounts = 300,
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType);
var update = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 4,
SmSeatsAdjustment = 1,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 5,
SmSeats = organization.SmSeats.GetValueOrDefault() + 1,
SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 1) - plan.BaseSeats,
SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 5,
SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 5) - (int)plan.BaseServiceAccount
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(update));
Assert.Contains("Cannot set max seat autoscaling below seat count.", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task UpdateSecretsManagerSubscription_ThrowsBadRequestException_WhenOccupiedSeatsExceedNewSeatTotal(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 10,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2",
PlanType = PlanType.EnterpriseAnnually
};
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType);
var update = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 7,
SmSeatsAdjustment = -3,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 5,
SmSeats = organization.SmSeats.GetValueOrDefault() - 3,
SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() - 3) - plan.BaseSeats,
SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 5,
SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 5) - (int)plan.BaseServiceAccount
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>().GetOccupiedSmSeatCountByOrganizationIdAsync(organizationId).Returns(8);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(update));
Assert.Contains("Your organization currently has 8 Secrets Manager seats. Your plan only allows 7 Secrets Manager seats. Remove some Secrets Manager users", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task AdjustServiceAccountsAsync_ThrowsBadRequestException_WhenSmServiceAccountsIsNull(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 10,
UseSecretsManager = true,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2",
SmServiceAccounts = null,
PlanType = PlanType.EnterpriseAnnually
};
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType);
var update = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 21,
SmSeatsAdjustment = 10,
MaxAutoscaleSmServiceAccounts = 250,
SmServiceAccountsAdjustment = 1,
SmSeats = organization.SmSeats.GetValueOrDefault() + 10,
SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 10) - plan.BaseSeats,
SmServiceAccounts = organization.SmServiceAccounts.GetValueOrDefault() + 1,
SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 1) - (int)plan.BaseServiceAccount
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(update));
Assert.Contains("Organization has no Service Accounts limit, no need to adjust Service Accounts", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData]
public async Task AutoscaleSeatsAsync_ThrowsBadRequestException_WhenMaxAutoscaleSeatsExceedPlanMaxUsers(
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
SmSeats = 3,
UseSecretsManager = true,
SmServiceAccounts = 100,
PlanType = PlanType.Free,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2",
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 0,
MaxAutoscaleSmServiceAccounts = 200,
SmServiceAccountsAdjustment = 0,
MaxAutoscaleSmSeatsChanged = 15 != organization.MaxAutoscaleSeats.GetValueOrDefault(),
MaxAutoscaleSmServiceAccountsChanged = 200 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault()
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Your plan has a Secrets Manager seat limit of 2, but you have specified a max autoscale count of 15.Reduce your max autoscale count.", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData(PlanType.Free)]
public async Task AutoscaleSeatsAsync_ThrowsBadRequestException_WhenPlanDoesNotAllowSeatAutoscale(
PlanType planType,
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 1,
SmServiceAccounts = 200,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 350,
PlanType = planType,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 1,
SmSeatsAdjustment = 0,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 0,
MaxAutoscaleSmSeatsChanged = 1 != organization.MaxAutoscaleSeats.GetValueOrDefault(),
MaxAutoscaleSmServiceAccountsChanged = 200 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault()
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Your plan does not allow Secrets Manager seat autoscaling", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData(PlanType.Free)]
public async Task UpdateServiceAccountAutoscaling_ThrowsBadRequestException_WhenPlanDoesNotAllowServiceAccountAutoscale(
PlanType planType,
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 10,
SmServiceAccounts = 200,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 350,
PlanType = planType,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = null,
SmSeatsAdjustment = 0,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 0,
MaxAutoscaleSmSeatsChanged = false,
MaxAutoscaleSmServiceAccountsChanged = 300 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault()
};
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Your plan does not allow Service Accounts autoscaling.", exception.Message);
await VerifyDependencyNotCalledAsync(sutProvider);
}
[Theory]
[BitAutoData(PlanType.EnterpriseAnnually)]
[BitAutoData(PlanType.EnterpriseMonthly)]
[BitAutoData(PlanType.TeamsMonthly)]
[BitAutoData(PlanType.TeamsAnnually)]
public async Task UpdateServiceAccountAutoscaling_WhenCurrentServiceAccountsIsGreaterThanNew_ThrowsBadRequestException(
PlanType planType,
Guid organizationId,
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
var organization = new Organization
{
Id = organizationId,
UseSecretsManager = true,
SmSeats = 10,
SmServiceAccounts = 301,
MaxAutoscaleSmSeats = 20,
MaxAutoscaleSmServiceAccounts = 350,
PlanType = planType,
GatewayCustomerId = "1",
GatewaySubscriptionId = "2"
};
var plan = StaticStore.SecretManagerPlans.FirstOrDefault(x => x.Type == organization.PlanType);
var organizationUpdate = new SecretsManagerSubscriptionUpdate
{
OrganizationId = organizationId,
MaxAutoscaleSmSeats = 15,
SmSeatsAdjustment = 5,
MaxAutoscaleSmServiceAccounts = 300,
SmServiceAccountsAdjustment = 100,
SmSeats = organization.SmSeats.GetValueOrDefault() + 5,
SmSeatsExcludingBase = (organization.SmSeats.GetValueOrDefault() + 5) - plan.BaseSeats,
SmServiceAccounts = 300,
SmServiceAccountsExcludingBase = (organization.SmServiceAccounts.GetValueOrDefault() + 100) - (int)plan.BaseServiceAccount,
MaxAutoscaleSmSeatsChanged = 15 != organization.MaxAutoscaleSeats.GetValueOrDefault(),
MaxAutoscaleSmServiceAccountsChanged = 200 != organization.MaxAutoscaleSmServiceAccounts.GetValueOrDefault()
};
var currentServiceAccounts = 301;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
sutProvider.GetDependency<IServiceAccountRepository>()
.GetServiceAccountCountByOrganizationIdAsync(organization.Id)
.Returns(currentServiceAccounts);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateSecretsManagerSubscription(organizationUpdate));
Assert.Contains("Your organization currently has 301 Service Accounts. Your plan only allows 300 Service Accounts. Remove some Service Accounts", exception.Message);
await sutProvider.GetDependency<IServiceAccountRepository>().Received(1).GetServiceAccountCountByOrganizationIdAsync(organization.Id);
await VerifyDependencyNotCalledAsync(sutProvider);
}
private static async Task VerifyDependencyNotCalledAsync(SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
await sutProvider.GetDependency<IPaymentService>().DidNotReceive()
.AdjustSeatsAsync(Arg.Any<Organization>(), Arg.Any<Plan>(), Arg.Any<int>());
await sutProvider.GetDependency<IPaymentService>().DidNotReceive()
.AdjustServiceAccountsAsync(Arg.Any<Organization>(), Arg.Any<Plan>(), Arg.Any<int>());
// TODO: call ReferenceEventService - see AC-1481
await sutProvider.GetDependency<IOrganizationService>().DidNotReceive()
.ReplaceAndUpdateCacheAsync(Arg.Any<Organization>());
await sutProvider.GetDependency<IMailService>().DidNotReceive()
.SendOrganizationMaxSeatLimitReachedEmailAsync(Arg.Any<Organization>(), Arg.Any<int>(),
Arg.Any<IEnumerable<string>>());
}
}

View File

@ -0,0 +1,185 @@
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.AutoFixture.OrganizationFixtures;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
using Organization = Bit.Core.Entities.Organization;
namespace Bit.Core.Test.OrganizationFeatures.OrganizationSubscriptionUpdate;
[SutProviderCustomize]
public class UpgradeOrganizationPlanCommandTests
{
[Theory, BitAutoData]
public async Task UpgradePlan_OrganizationIsNull_Throws(Guid organizationId, OrganizationUpgrade upgrade,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(Task.FromResult<Organization>(null));
var exception = await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.UpgradePlanAsync(organizationId, upgrade));
}
[Theory, BitAutoData]
public async Task UpgradePlan_GatewayCustomIdIsNull_Throws(Organization organization, OrganizationUpgrade upgrade,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
organization.GatewayCustomerId = string.Empty;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade));
Assert.Contains("no payment method", exception.Message);
}
[Theory, BitAutoData]
public async Task UpgradePlan_AlreadyInPlan_Throws(Organization organization, OrganizationUpgrade upgrade,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
upgrade.Plan = organization.PlanType;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade));
Assert.Contains("already on this plan", exception.Message);
}
[Theory, BitAutoData]
public async Task UpgradePlan_SM_AlreadyInPlan_Throws(Organization organization, OrganizationUpgrade upgrade,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
upgrade.Plan = organization.PlanType;
upgrade.UseSecretsManager = true;
upgrade.AdditionalSmSeats = 10;
upgrade.AdditionalServiceAccounts = 10;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade));
Assert.Contains("already on this plan", exception.Message);
}
[Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.Free), BitAutoData]
public async Task UpgradePlan_UpgradeFromPaidPlan_Throws(Organization organization, OrganizationUpgrade upgrade,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade));
Assert.Contains("can only upgrade", exception.Message);
}
[Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.Free), BitAutoData]
public async Task UpgradePlan_SM_UpgradeFromPaidPlan_Throws(Organization organization, OrganizationUpgrade upgrade,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
upgrade.UseSecretsManager = true;
upgrade.AdditionalSmSeats = 10;
upgrade.AdditionalServiceAccounts = 10;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade));
Assert.Contains("can only upgrade", exception.Message);
}
[Theory]
[FreeOrganizationUpgradeCustomize, BitAutoData]
public async Task UpgradePlan_Passes(Organization organization, OrganizationUpgrade upgrade,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
upgrade.AdditionalSmSeats = 10;
upgrade.AdditionalSeats = 10;
await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade);
await sutProvider.GetDependency<IOrganizationService>().Received(1).ReplaceAndUpdateCacheAsync(organization);
}
[Theory, FreeOrganizationUpgradeCustomize]
[BitAutoData(PlanType.EnterpriseMonthly)]
[BitAutoData(PlanType.EnterpriseAnnually)]
[BitAutoData(PlanType.TeamsMonthly)]
[BitAutoData(PlanType.TeamsAnnually)]
public async Task UpgradePlan_SM_Passes(PlanType planType, Organization organization, OrganizationUpgrade upgrade,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
upgrade.Plan = planType;
var passwordManagerPlan = StaticStore.GetPasswordManagerPlan(upgrade.Plan);
var secretsManagerPlan = StaticStore.GetSecretsManagerPlan(upgrade.Plan);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
upgrade.AdditionalSeats = 15;
upgrade.AdditionalSmSeats = 10;
upgrade.AdditionalServiceAccounts = 20;
var result = await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade);
await sutProvider.GetDependency<IOrganizationService>().Received(1).ReplaceAndUpdateCacheAsync(
Arg.Is<Organization>(o =>
o.Seats == passwordManagerPlan.BaseSeats + upgrade.AdditionalSeats
&& o.SmSeats == secretsManagerPlan.BaseSeats + upgrade.AdditionalSmSeats
&& o.SmServiceAccounts == secretsManagerPlan.BaseServiceAccount + upgrade.AdditionalServiceAccounts));
Assert.True(result.Item1);
Assert.NotNull(result.Item2);
}
[Theory, FreeOrganizationUpgradeCustomize]
[BitAutoData(PlanType.EnterpriseMonthly)]
[BitAutoData(PlanType.EnterpriseAnnually)]
[BitAutoData(PlanType.TeamsMonthly)]
[BitAutoData(PlanType.TeamsAnnually)]
public async Task UpgradePlan_SM_NotEnoughSmSeats_Throws(PlanType planType, Organization organization, OrganizationUpgrade upgrade,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
upgrade.Plan = planType;
upgrade.AdditionalSeats = 15;
upgrade.AdditionalSmSeats = 1;
upgrade.AdditionalServiceAccounts = 0;
organization.SmSeats = 2;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(2);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade));
Assert.Contains("Your organization currently has 2 Secrets Manager seats filled. Your new plan only has", exception.Message);
sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(default);
}
[Theory, FreeOrganizationUpgradeCustomize]
[BitAutoData(PlanType.EnterpriseMonthly, 201)]
[BitAutoData(PlanType.EnterpriseAnnually, 201)]
[BitAutoData(PlanType.TeamsMonthly, 51)]
[BitAutoData(PlanType.TeamsAnnually, 51)]
public async Task UpgradePlan_SM_NotEnoughServiceAccounts_Throws(PlanType planType, int currentServiceAccounts,
Organization organization, OrganizationUpgrade upgrade, SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
upgrade.Plan = planType;
upgrade.AdditionalSeats = 15;
upgrade.AdditionalSmSeats = 1;
upgrade.AdditionalServiceAccounts = 0;
organization.SmSeats = 1;
organization.SmServiceAccounts = currentServiceAccounts;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(1);
sutProvider.GetDependency<IServiceAccountRepository>()
.GetServiceAccountCountByOrganizationIdAsync(organization.Id).Returns(currentServiceAccounts);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade));
Assert.Contains($"Your organization currently has {currentServiceAccounts} service accounts. Your new plan only allows", exception.Message);
sutProvider.GetDependency<IOrganizationService>().DidNotReceiveWithAnyArgs().ReplaceAndUpdateCacheAsync(default);
}
}

View File

@ -13,6 +13,7 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Models.StaticStore;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
@ -22,6 +23,7 @@ using Bit.Core.Test.AutoFixture.PolicyFixtures;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
@ -143,55 +145,117 @@ public class OrganizationServiceTests
referenceEvent.Users == expectedNewUsersCount));
}
[Theory, BitAutoData]
public async Task UpgradePlan_OrganizationIsNull_Throws(Guid organizationId, OrganizationUpgrade upgrade,
SutProvider<OrganizationService> sutProvider)
{
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(Task.FromResult<Organization>(null));
var exception = await Assert.ThrowsAsync<NotFoundException>(
() => sutProvider.Sut.UpgradePlanAsync(organizationId, upgrade));
}
[Theory, BitAutoData]
public async Task UpgradePlan_GatewayCustomIdIsNull_Throws(Organization organization, OrganizationUpgrade upgrade,
SutProvider<OrganizationService> sutProvider)
[Theory]
[BitAutoData(PlanType.EnterpriseAnnually)]
[BitAutoData(PlanType.EnterpriseMonthly)]
[BitAutoData(PlanType.TeamsAnnually)]
[BitAutoData(PlanType.TeamsMonthly)]
public async Task SignUp_SM_Passes(PlanType planType, OrganizationSignup signup, SutProvider<OrganizationService> sutProvider)
{
organization.GatewayCustomerId = string.Empty;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade));
Assert.Contains("no payment method", exception.Message);
}
signup.Plan = planType;
[Theory, BitAutoData]
public async Task UpgradePlan_AlreadyInPlan_Throws(Organization organization, OrganizationUpgrade upgrade,
SutProvider<OrganizationService> sutProvider)
{
upgrade.Plan = organization.PlanType;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade));
Assert.Contains("already on this plan", exception.Message);
}
var passwordManagerPlan = StaticStore.GetPasswordManagerPlan(signup.Plan);
var secretsManagerPlan = StaticStore.GetSecretsManagerPlan(signup.Plan);
[Theory, PaidOrganizationCustomize(CheckedPlanType = PlanType.Free), BitAutoData]
public async Task UpgradePlan_UpgradeFromPaidPlan_Throws(Organization organization, OrganizationUpgrade upgrade,
SutProvider<OrganizationService> sutProvider)
{
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade));
Assert.Contains("can only upgrade", exception.Message);
signup.UseSecretsManager = true;
signup.AdditionalSeats = 15;
signup.AdditionalSmSeats = 10;
signup.AdditionalServiceAccounts = 20;
signup.PaymentMethodType = PaymentMethodType.Card;
signup.PremiumAccessAddon = false;
var purchaseOrganizationPlan = StaticStore.Plans.Where(x => x.Type == signup.Plan).ToList();
var result = await sutProvider.Sut.SignUpAsync(signup);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).CreateAsync(
Arg.Is<Organization>(o =>
o.Seats == passwordManagerPlan.BaseSeats + signup.AdditionalSeats
&& o.SmSeats == secretsManagerPlan.BaseSeats + signup.AdditionalSmSeats
&& o.SmServiceAccounts == secretsManagerPlan.BaseServiceAccount + signup.AdditionalServiceAccounts));
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).CreateAsync(
Arg.Is<OrganizationUser>(o => o.AccessSecretsManager == signup.UseSecretsManager));
await sutProvider.GetDependency<IReferenceEventService>().Received(1)
.RaiseEventAsync(Arg.Is<ReferenceEvent>(referenceEvent =>
referenceEvent.Type == ReferenceEventType.Signup &&
referenceEvent.PlanName == purchaseOrganizationPlan[0].Name &&
referenceEvent.PlanType == purchaseOrganizationPlan[0].Type &&
referenceEvent.Seats == result.Item1.Seats &&
referenceEvent.Storage == result.Item1.MaxStorageGb));
// TODO: add reference events for SmSeats and Service Accounts - see AC-1481
Assert.NotNull(result);
Assert.NotNull(result.Item1);
Assert.NotNull(result.Item2);
Assert.IsType<Tuple<Organization, OrganizationUser>>(result);
await sutProvider.GetDependency<IPaymentService>().Received(1).PurchaseOrganizationAsync(
Arg.Any<Organization>(),
signup.PaymentMethodType.Value,
signup.PaymentToken,
Arg.Is<List<Plan>>(plan => plan.All(p => purchaseOrganizationPlan.Contains(p))),
signup.AdditionalStorageGb,
signup.AdditionalSeats,
signup.PremiumAccessAddon,
signup.TaxInfo,
false,
signup.AdditionalSmSeats.GetValueOrDefault(),
signup.AdditionalServiceAccounts.GetValueOrDefault()
);
}
[Theory]
[FreeOrganizationUpgradeCustomize, BitAutoData]
public async Task UpgradePlan_Passes(Organization organization, OrganizationUpgrade upgrade,
SutProvider<OrganizationService> sutProvider)
[BitAutoData]
public async Task SignUpAsync_SecretManager_AdditionalServiceAccounts_NotAllowedByPlan_ShouldThrowException(OrganizationSignup signup, SutProvider<OrganizationService> sutProvider)
{
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade);
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).ReplaceAsync(organization);
signup.AdditionalSmSeats = 0;
signup.AdditionalSeats = 0;
signup.Plan = PlanType.Free;
signup.UseSecretsManager = true;
signup.PaymentMethodType = PaymentMethodType.Card;
signup.PremiumAccessAddon = false;
signup.AdditionalServiceAccounts = 10;
signup.AdditionalStorageGb = 0;
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SignUpAsync(signup));
Assert.Contains("Plan does not allow additional Service Accounts.", exception.Message);
}
[Theory]
[BitAutoData]
public async Task SignUpAsync_SMSeatsGreatThanPMSeat_ShouldThrowException(OrganizationSignup signup, SutProvider<OrganizationService> sutProvider)
{
signup.AdditionalSmSeats = 100;
signup.AdditionalSeats = 10;
signup.Plan = PlanType.EnterpriseAnnually;
signup.UseSecretsManager = true;
signup.PaymentMethodType = PaymentMethodType.Card;
signup.PremiumAccessAddon = false;
signup.AdditionalServiceAccounts = 10;
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SignUpAsync(signup));
Assert.Contains("You cannot have more Secrets Manager seats than Password Manager seats", exception.Message);
}
[Theory]
[BitAutoData]
public async Task SignUpAsync_InvalidateServiceAccount_ShouldThrowException(OrganizationSignup signup, SutProvider<OrganizationService> sutProvider)
{
signup.AdditionalSmSeats = 10;
signup.AdditionalSeats = 10;
signup.Plan = PlanType.EnterpriseAnnually;
signup.UseSecretsManager = true;
signup.PaymentMethodType = PaymentMethodType.Card;
signup.PremiumAccessAddon = false;
signup.AdditionalServiceAccounts = -10;
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SignUpAsync(signup));
Assert.Contains("You can't subtract Service Accounts!", exception.Message);
}
[Theory]
@ -1469,4 +1533,5 @@ public class OrganizationServiceTests
Assert.Equal(includeProvider, result);
}
}

View File

@ -32,7 +32,7 @@ public class StripePaymentServiceTests
public async void PurchaseOrganizationAsync_Invalid(PaymentMethodType paymentMethodType, SutProvider<StripePaymentService> sutProvider)
{
var exception = await Assert.ThrowsAsync<GatewayException>(
() => sutProvider.Sut.PurchaseOrganizationAsync(null, paymentMethodType, null, null, 0, 0, false, null));
() => sutProvider.Sut.PurchaseOrganizationAsync(null, paymentMethodType, null, null, 0, 0, false, null, false, -1, -1));
Assert.Equal("Payment method is not supported at this time.", exception.Message);
}
@ -40,7 +40,7 @@ public class StripePaymentServiceTests
[Theory, BitAutoData]
public async void PurchaseOrganizationAsync_Stripe_ProviderOrg_Coupon_Add(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo, bool provider = true)
{
var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually);
var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
@ -56,7 +56,7 @@ public class StripePaymentServiceTests
.BaseServiceUri.CloudRegion
.Returns("US");
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo, provider);
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0, false, taxInfo, provider);
Assert.Null(result);
Assert.Equal(GatewayType.Stripe, organization.Gateway);
@ -91,10 +91,67 @@ public class StripePaymentServiceTests
));
}
[Theory, BitAutoData]
public async void PurchaseOrganizationAsync_SM_Stripe_ProviderOrg_Coupon_Add(SutProvider<StripePaymentService> sutProvider, Organization organization,
string paymentToken, TaxInfo taxInfo, bool provider = true)
{
var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
});
sutProvider.GetDependency<IGlobalSettings>()
.BaseServiceUri.CloudRegion
.Returns("US");
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 1, 1,
false, taxInfo, provider, 1, 1);
Assert.Null(result);
Assert.Equal(GatewayType.Stripe, organization.Gateway);
Assert.Equal("C-1", organization.GatewayCustomerId);
Assert.Equal("S-1", organization.GatewaySubscriptionId);
Assert.True(organization.Enabled);
Assert.Equal(DateTime.Today.AddDays(10), organization.ExpirationDate);
await stripeAdapter.Received().CustomerCreateAsync(Arg.Is<Stripe.CustomerCreateOptions>(c =>
c.Description == organization.BusinessName &&
c.Email == organization.BillingEmail &&
c.Source == paymentToken &&
c.PaymentMethod == null &&
c.Coupon == "msp-discount-35" &&
c.Metadata.Count == 1 &&
c.Metadata["region"] == "US" &&
c.InvoiceSettings.DefaultPaymentMethod == null &&
c.Address.Country == taxInfo.BillingAddressCountry &&
c.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
c.Address.Line1 == taxInfo.BillingAddressLine1 &&
c.Address.Line2 == taxInfo.BillingAddressLine2 &&
c.Address.City == taxInfo.BillingAddressCity &&
c.Address.State == taxInfo.BillingAddressState &&
c.TaxIdData == null
));
await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is<Stripe.SubscriptionCreateOptions>(s =>
s.Customer == "C-1" &&
s.Expand[0] == "latest_invoice.payment_intent" &&
s.Metadata[organization.GatewayIdField()] == organization.Id.ToString() &&
s.Items.Count == 4
));
}
[Theory, BitAutoData]
public async void PurchaseOrganizationAsync_Stripe(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually);
var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
@ -110,7 +167,8 @@ public class StripePaymentServiceTests
.BaseServiceUri.CloudRegion
.Returns("US");
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo);
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0
, false, taxInfo, false, 8, 10);
Assert.Null(result);
Assert.Equal(GatewayType.Stripe, organization.Gateway);
@ -118,7 +176,6 @@ public class StripePaymentServiceTests
Assert.Equal("S-1", organization.GatewaySubscriptionId);
Assert.True(organization.Enabled);
Assert.Equal(DateTime.Today.AddDays(10), organization.ExpirationDate);
var res = organization.SubscriberName();
await stripeAdapter.Received().CustomerCreateAsync(Arg.Is<Stripe.CustomerCreateOptions>(c =>
c.Description == organization.BusinessName &&
c.Email == organization.BillingEmail &&
@ -143,14 +200,14 @@ public class StripePaymentServiceTests
s.Customer == "C-1" &&
s.Expand[0] == "latest_invoice.payment_intent" &&
s.Metadata[organization.GatewayIdField()] == organization.Id.ToString() &&
s.Items.Count == 0
s.Items.Count == 2
));
}
[Theory, BitAutoData]
public async void PurchaseOrganizationAsync_Stripe_PM(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually);
var plans = StaticStore.PasswordManagerPlans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
paymentToken = "pm_" + paymentToken;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
@ -167,7 +224,7 @@ public class StripePaymentServiceTests
.BaseServiceUri.CloudRegion
.Returns("US");
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo);
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0, false, taxInfo);
Assert.Null(result);
Assert.Equal(GatewayType.Stripe, organization.Gateway);
@ -207,7 +264,7 @@ public class StripePaymentServiceTests
[Theory, BitAutoData]
public async void PurchaseOrganizationAsync_Stripe_TaxRate(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually);
var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
@ -223,7 +280,37 @@ public class StripePaymentServiceTests
t.Country == taxInfo.BillingAddressCountry && t.PostalCode == taxInfo.BillingAddressPostalCode))
.Returns(new List<TaxRate> { new() { Id = "T-1" } });
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo);
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0, false, taxInfo);
Assert.Null(result);
await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is<Stripe.SubscriptionCreateOptions>(s =>
s.DefaultTaxRates.Count == 1 &&
s.DefaultTaxRates[0] == "T-1"
));
}
[Theory, BitAutoData]
public async void PurchaseOrganizationAsync_Stripe_TaxRate_SM(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
});
sutProvider.GetDependency<ITaxRateRepository>().GetByLocationAsync(Arg.Is<TaxRate>(t =>
t.Country == taxInfo.BillingAddressCountry && t.PostalCode == taxInfo.BillingAddressPostalCode))
.Returns(new List<TaxRate> { new() { Id = "T-1" } });
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 2, 2,
false, taxInfo, false, 2, 2);
Assert.Null(result);
@ -236,7 +323,7 @@ public class StripePaymentServiceTests
[Theory, BitAutoData]
public async void PurchaseOrganizationAsync_Stripe_Declined(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually);
var plan = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
paymentToken = "pm_" + paymentToken;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
@ -266,10 +353,44 @@ public class StripePaymentServiceTests
await stripeAdapter.Received(1).CustomerDeleteAsync("C-1");
}
[Theory, BitAutoData]
public async void PurchaseOrganizationAsync_SM_Stripe_Declined(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
paymentToken = "pm_" + paymentToken;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
Status = "incomplete",
LatestInvoice = new Stripe.Invoice
{
PaymentIntent = new Stripe.PaymentIntent
{
Status = "requires_payment_method",
},
},
});
var exception = await Assert.ThrowsAsync<GatewayException>(
() => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan,
1, 12, false, taxInfo, false, 10, 10));
Assert.Equal("Payment method was declined.", exception.Message);
await stripeAdapter.Received(1).CustomerDeleteAsync("C-1");
}
[Theory, BitAutoData]
public async void PurchaseOrganizationAsync_Stripe_RequiresAction(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually);
var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
@ -291,7 +412,39 @@ public class StripePaymentServiceTests
},
});
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo);
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans, 0, 0, false, taxInfo);
Assert.Equal("clientSecret", result);
Assert.False(organization.Enabled);
}
[Theory, BitAutoData]
public async void PurchaseOrganizationAsync_SM_Stripe_RequiresAction(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
Status = "incomplete",
LatestInvoice = new Stripe.Invoice
{
PaymentIntent = new Stripe.PaymentIntent
{
Status = "requires_action",
ClientSecret = "clientSecret",
},
},
});
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plans,
10, 10, false, taxInfo, false, 10, 10);
Assert.Equal("clientSecret", result);
Assert.False(organization.Enabled);
@ -300,7 +453,7 @@ public class StripePaymentServiceTests
[Theory, BitAutoData]
public async void PurchaseOrganizationAsync_Paypal(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually);
var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
@ -327,7 +480,7 @@ public class StripePaymentServiceTests
var braintreeGateway = sutProvider.GetDependency<IBraintreeGateway>();
braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult);
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plan, 0, 0, false, taxInfo);
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, 0, 0, false, taxInfo);
Assert.Null(result);
Assert.Equal(GatewayType.Stripe, organization.Gateway);
@ -361,10 +514,85 @@ public class StripePaymentServiceTests
));
}
[Theory, BitAutoData]
public async void PurchaseOrganizationAsync_SM_Paypal(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
var passwordManagerPlan = plans.Single(p => p.BitwardenProduct == BitwardenProductType.PasswordManager);
var secretsManagerPlan = plans.Single(p => p.BitwardenProduct == BitwardenProductType.SecretsManager);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
});
var customer = Substitute.For<Customer>();
customer.Id.ReturnsForAnyArgs("Braintree-Id");
customer.PaymentMethods.ReturnsForAnyArgs(new[] { Substitute.For<PaymentMethod>() });
var customerResult = Substitute.For<Result<Customer>>();
customerResult.IsSuccess().Returns(true);
customerResult.Target.ReturnsForAnyArgs(customer);
var braintreeGateway = sutProvider.GetDependency<IBraintreeGateway>();
braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult);
sutProvider.GetDependency<IGlobalSettings>()
.BaseServiceUri.CloudRegion
.Returns("US");
var additionalStorage = (short)2;
var additionalSeats = 10;
var additionalSmSeats = 5;
var additionalServiceAccounts = 20;
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plans,
additionalStorage, additionalSeats, false, taxInfo, false, additionalSmSeats, additionalServiceAccounts);
Assert.Null(result);
Assert.Equal(GatewayType.Stripe, organization.Gateway);
Assert.Equal("C-1", organization.GatewayCustomerId);
Assert.Equal("S-1", organization.GatewaySubscriptionId);
Assert.True(organization.Enabled);
Assert.Equal(DateTime.Today.AddDays(10), organization.ExpirationDate);
await stripeAdapter.Received().CustomerCreateAsync(Arg.Is<Stripe.CustomerCreateOptions>(c =>
c.Description == organization.BusinessName &&
c.Email == organization.BillingEmail &&
c.PaymentMethod == null &&
c.Metadata.Count == 2 &&
c.Metadata["region"] == "US" &&
c.Metadata["btCustomerId"] == "Braintree-Id" &&
c.InvoiceSettings.DefaultPaymentMethod == null &&
c.Address.Country == taxInfo.BillingAddressCountry &&
c.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
c.Address.Line1 == taxInfo.BillingAddressLine1 &&
c.Address.Line2 == taxInfo.BillingAddressLine2 &&
c.Address.City == taxInfo.BillingAddressCity &&
c.Address.State == taxInfo.BillingAddressState &&
c.TaxIdData == null
));
await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is<Stripe.SubscriptionCreateOptions>(s =>
s.Customer == "C-1" &&
s.Expand[0] == "latest_invoice.payment_intent" &&
s.Metadata[organization.GatewayIdField()] == organization.Id.ToString() &&
s.Items.Count == 4 &&
s.Items.Count(i => i.Plan == passwordManagerPlan.StripeSeatPlanId && i.Quantity == additionalSeats) == 1 &&
s.Items.Count(i => i.Plan == passwordManagerPlan.StripeStoragePlanId && i.Quantity == additionalStorage) == 1 &&
s.Items.Count(i => i.Plan == secretsManagerPlan.StripeSeatPlanId && i.Quantity == additionalSmSeats) == 1 &&
s.Items.Count(i => i.Plan == secretsManagerPlan.StripeServiceAccountPlanId && i.Quantity == additionalServiceAccounts) == 1
));
}
[Theory, BitAutoData]
public async void PurchaseOrganizationAsync_Paypal_FailedCreate(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually);
var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
var customerResult = Substitute.For<Result<Customer>>();
customerResult.IsSuccess().Returns(false);
@ -373,7 +601,25 @@ public class StripePaymentServiceTests
braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult);
var exception = await Assert.ThrowsAsync<GatewayException>(
() => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plan, 0, 0, false, taxInfo));
() => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, 0, 0, false, taxInfo));
Assert.Equal("Failed to create PayPal customer record.", exception.Message);
}
[Theory, BitAutoData]
public async void PurchaseOrganizationAsync_SM_Paypal_FailedCreate(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
var customerResult = Substitute.For<Result<Customer>>();
customerResult.IsSuccess().Returns(false);
var braintreeGateway = sutProvider.GetDependency<IBraintreeGateway>();
braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult);
var exception = await Assert.ThrowsAsync<GatewayException>(
() => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plans,
1, 1, false, taxInfo, false, 8, 8));
Assert.Equal("Failed to create PayPal customer record.", exception.Message);
}
@ -381,7 +627,7 @@ public class StripePaymentServiceTests
[Theory, BitAutoData]
public async void PurchaseOrganizationAsync_PayPal_Declined(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually);
var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
paymentToken = "pm_" + paymentToken;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
@ -414,7 +660,7 @@ public class StripePaymentServiceTests
braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult);
var exception = await Assert.ThrowsAsync<GatewayException>(
() => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plan, 0, 0, false, taxInfo));
() => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, 0, 0, false, taxInfo));
Assert.Equal("Payment method was declined.", exception.Message);
@ -443,8 +689,55 @@ public class StripePaymentServiceTests
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription { });
var plan = StaticStore.PasswordManagerPlans.First(p => p.Type == PlanType.EnterpriseAnnually);
var result = await sutProvider.Sut.UpgradeFreeOrganizationAsync(organization, plan, 0, 0, false, taxInfo);
var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
var upgrade = new OrganizationUpgrade()
{
AdditionalStorageGb = 0,
AdditionalSeats = 0,
PremiumAccessAddon = false,
TaxInfo = taxInfo,
AdditionalSmSeats = 0,
AdditionalServiceAccounts = 0
};
var result = await sutProvider.Sut.UpgradeFreeOrganizationAsync(organization, plans, upgrade);
Assert.Null(result);
}
[Theory, BitAutoData]
public async void UpgradeFreeOrganizationAsync_SM_Success(SutProvider<StripePaymentService> sutProvider,
Organization organization, TaxInfo taxInfo)
{
organization.GatewaySubscriptionId = null;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerGetAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
Metadata = new Dictionary<string, string>
{
{ "btCustomerId", "B-123" },
}
});
stripeAdapter.InvoiceUpcomingAsync(default).ReturnsForAnyArgs(new Stripe.Invoice
{
PaymentIntent = new Stripe.PaymentIntent { Status = "requires_payment_method", },
AmountDue = 0
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription { });
var upgrade = new OrganizationUpgrade()
{
AdditionalStorageGb = 1,
AdditionalSeats = 10,
PremiumAccessAddon = false,
TaxInfo = taxInfo,
AdditionalSmSeats = 5,
AdditionalServiceAccounts = 50
};
var plans = StaticStore.Plans.Where(p => p.Type == PlanType.EnterpriseAnnually).ToList();
var result = await sutProvider.Sut.UpgradeFreeOrganizationAsync(organization, plans, upgrade);
Assert.Null(result);
}

View File

@ -0,0 +1,32 @@
CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSmSeatCountByOrganizationId]
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
COUNT(1)
FROM
[dbo].[OrganizationUserView]
WHERE
OrganizationId = @OrganizationId
AND Status >= 0 --Invited
AND AccessSecretsManager = 1
END
GO
CREATE OR ALTER PROCEDURE [dbo].[ServiceAccount_ReadCountByOrganizationId]
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
COUNT(1)
FROM
[dbo].[ServiceAccount]
WHERE
OrganizationId = @OrganizationId
END
GO