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

[PM-11728] Upgrade free organizations without Stripe Sources API (#4757)

* Refactor: Update metadata in OrganizationSignup and OrganizationUpgrade

This commit moves the IsFromSecretsManagerTrial flag from the OrganizationUpgrade to the OrganizationSignup because it will only be passed in on organization creation. Additionally, it removes the nullable boolean 'provider' flag passed to OrganizationService.SignUpAsync and instead adds that boolean flag to the OrganizationSignup which seems more appropriate.

* Introduce OrganizationSale

While I'm trying to ingrain a singular model that can be used to purchase or upgrade organizations, I disliked my previously implemented OrganizationSubscriptionPurchase for being a little too wordy and specific. This sale class aligns more closely with the work we need to complete against Stripe and also uses a private constructor so that it can only be created and utilized via an Organiztion and either OrganizationSignup or OrganizationUpgrade object.

* Use OrganizationSale in OrganizationBillingService

This commit renames the OrganizationBillingService.PurchaseSubscription to Finalize and passes it the OrganizationSale object. It also updates the method so that, if the organization already has a customer, it retrieves that customer instead of automatically trying to create one which we'll need for upgraded free organizations.

* Add functionality for free organization upgrade

This commit adds an UpdatePaymentMethod to the OrganizationBillingService that will check if a customer exists for the organization and if not, creates one with the updated payment source and tax information. Then, in the UpgradeOrganizationPlanCommand, we can use the OrganizationUpgrade to get an OrganizationSale and finalize it, which will create a subscription using the customer created as part of the payment method update that takes place right before it on the client-side. Additionally, it adds some tax ID backfill logic to SubscriberService.UpdateTaxInformation

* (No Logic) Re-order OrganizationBillingService methods alphabetically

* (No Logic) Run dotnet format
This commit is contained in:
Alex Morask 2024-09-11 09:04:15 -04:00 committed by GitHub
parent f2180aa7b7
commit 68b421fa2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 340 additions and 203 deletions

View File

@ -551,7 +551,7 @@ public class ProviderService : IProviderService
var (organization, _, defaultCollection) = consolidatedBillingEnabled
? await _organizationService.SignupClientAsync(organizationSignup)
: await _organizationService.SignUpAsync(organizationSignup, true);
: await _organizationService.SignUpAsync(organizationSignup);
var providerOrganization = new ProviderOrganization
{

View File

@ -667,7 +667,7 @@ public class ProviderServiceTests
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup, true)
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup)
.Returns((organization, null as OrganizationUser, new Collection()));
var providerOrganization =
@ -775,7 +775,7 @@ public class ProviderServiceTests
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup, true)
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup)
.Returns((organization, null as OrganizationUser, defaultCollection));
var providerOrganization =

View File

@ -84,6 +84,7 @@ public class ProviderOrganizationsController : Controller
}
var organizationSignup = model.OrganizationCreateRequest.ToOrganizationSignup(user);
organizationSignup.IsFromProvider = true;
var result = await _providerService.CreateOrganizationAsync(providerId, organizationSignup, model.ClientOwnerEmail, user);
return new ProviderOrganizationResponseModel(result);
}

View File

@ -182,11 +182,9 @@ public class OrganizationBillingController(
var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain();
await subscriberService.UpdatePaymentSource(organization, tokenizedPaymentSource);
var taxInformation = requestBody.TaxInformation.ToDomain();
await subscriberService.UpdateTaxInformation(organization, taxInformation);
await organizationBillingService.UpdatePaymentMethod(organization, tokenizedPaymentSource, taxInformation);
return TypedResults.Ok();
}

View File

@ -52,7 +52,8 @@ public class ProviderClientsController(
OwnerKey = requestBody.Key,
PublicKey = requestBody.KeyPair.PublicKey,
PrivateKey = requestBody.KeyPair.EncryptedPrivateKey,
CollectionName = requestBody.CollectionName
CollectionName = requestBody.CollectionName,
IsFromProvider = true
};
var providerOrganization = await providerService.CreateOrganizationAsync(

View File

@ -25,7 +25,7 @@ public interface IOrganizationService
/// </summary>
/// <returns>A tuple containing the new organization, the initial organizationUser (if any) and the default collection (if any)</returns>
#nullable enable
Task<(Organization organization, OrganizationUser? organizationUser, Collection? defaultCollection)> SignUpAsync(OrganizationSignup organizationSignup, bool provider = false);
Task<(Organization organization, OrganizationUser? organizationUser, Collection? defaultCollection)> SignUpAsync(OrganizationSignup organizationSignup);
Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup);
#nullable disable

View File

@ -15,6 +15,7 @@ using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
@ -502,8 +503,7 @@ public class OrganizationService : IOrganizationService
/// <summary>
/// Create a new organization in a cloud environment
/// </summary>
public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignUpAsync(OrganizationSignup signup,
bool provider = false)
public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignUpAsync(OrganizationSignup signup)
{
var plan = StaticStore.GetPlan(signup.Plan);
@ -511,7 +511,7 @@ public class OrganizationService : IOrganizationService
if (signup.UseSecretsManager)
{
if (provider)
if (signup.IsFromProvider)
{
throw new BadRequestException(
"Organizations with a Managed Service Provider do not support Secrets Manager.");
@ -519,7 +519,7 @@ public class OrganizationService : IOrganizationService
ValidateSecretsManagerPlan(plan, signup);
}
if (!provider)
if (!signup.IsFromProvider)
{
await ValidateSignUpPoliciesAsync(signup.Owner.Id);
}
@ -570,7 +570,7 @@ public class OrganizationService : IOrganizationService
signup.AdditionalServiceAccounts.GetValueOrDefault();
}
if (plan.Type == PlanType.Free && !provider)
if (plan.Type == PlanType.Free && !signup.IsFromProvider)
{
var adminCount =
await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id);
@ -585,20 +585,19 @@ public class OrganizationService : IOrganizationService
if (deprecateStripeSourcesAPI)
{
var subscriptionPurchase = signup.ToSubscriptionPurchase(provider);
await _organizationBillingService.PurchaseSubscription(organization, subscriptionPurchase);
var sale = OrganizationSale.From(organization, signup);
await _organizationBillingService.Finalize(sale);
}
else
{
await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value,
signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats,
signup.PremiumAccessAddon, signup.TaxInfo, provider, signup.AdditionalSmSeats.GetValueOrDefault(),
signup.PremiumAccessAddon, signup.TaxInfo, signup.IsFromProvider, signup.AdditionalSmSeats.GetValueOrDefault(),
signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial);
}
}
var ownerId = provider ? default : signup.Owner.Id;
var ownerId = signup.IsFromProvider ? default : signup.Owner.Id;
var returnValue = await SignUpAsync(organization, ownerId, signup.OwnerKey, signup.CollectionName, true);
await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.Signup, organization, _currentContext)

View File

@ -1,27 +0,0 @@
using Bit.Core.Billing.Enums;
namespace Bit.Core.Billing.Models;
public record OrganizationSubscriptionPurchase(
OrganizationSubscriptionPurchaseMetadata Metadata,
OrganizationPasswordManagerSubscriptionPurchase PasswordManagerSubscription,
TokenizedPaymentSource PaymentSource,
PlanType PlanType,
OrganizationSecretsManagerSubscriptionPurchase SecretsManagerSubscription,
TaxInformation TaxInformation);
public record OrganizationPasswordManagerSubscriptionPurchase(
int Storage,
bool PremiumAccess,
int Seats);
public record OrganizationSecretsManagerSubscriptionPurchase(
int Seats,
int ServiceAccounts);
public record OrganizationSubscriptionPurchaseMetadata(
bool FromProvider,
bool FromSecretsManagerStandalone)
{
public static OrganizationSubscriptionPurchaseMetadata Default => new(false, false);
}

View File

@ -0,0 +1,10 @@
namespace Bit.Core.Billing.Models.Sales;
#nullable enable
public class CustomerSetup
{
public required TokenizedPaymentSource TokenizedPaymentSource { get; set; }
public required TaxInformation TaxInformation { get; set; }
public string? Coupon { get; set; }
}

View File

@ -0,0 +1,104 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Models.Business;
namespace Bit.Core.Billing.Models.Sales;
#nullable enable
public class OrganizationSale
{
private OrganizationSale() { }
public void Deconstruct(
out Organization organization,
out CustomerSetup? customerSetup,
out SubscriptionSetup subscriptionSetup)
{
organization = Organization;
customerSetup = CustomerSetup;
subscriptionSetup = SubscriptionSetup;
}
public required Organization Organization { get; init; }
public CustomerSetup? CustomerSetup { get; init; }
public required SubscriptionSetup SubscriptionSetup { get; init; }
public static OrganizationSale From(
Organization organization,
OrganizationSignup signup) => new()
{
Organization = organization,
CustomerSetup = string.IsNullOrEmpty(organization.GatewayCustomerId) ? GetCustomerSetup(signup) : null,
SubscriptionSetup = GetSubscriptionSetup(signup)
};
public static OrganizationSale From(
Organization organization,
OrganizationUpgrade upgrade) => new()
{
Organization = organization,
SubscriptionSetup = GetSubscriptionSetup(upgrade)
};
private static CustomerSetup? GetCustomerSetup(OrganizationSignup signup)
{
if (!signup.PaymentMethodType.HasValue)
{
return null;
}
var tokenizedPaymentSource = new TokenizedPaymentSource(
signup.PaymentMethodType!.Value,
signup.PaymentToken);
var taxInformation = new TaxInformation(
signup.TaxInfo.BillingAddressCountry,
signup.TaxInfo.BillingAddressPostalCode,
signup.TaxInfo.TaxIdNumber,
signup.TaxInfo.BillingAddressLine1,
signup.TaxInfo.BillingAddressLine2,
signup.TaxInfo.BillingAddressCity,
signup.TaxInfo.BillingAddressState);
var coupon = signup.IsFromProvider
? StripeConstants.CouponIDs.MSPDiscount35
: signup.IsFromSecretsManagerTrial
? StripeConstants.CouponIDs.SecretsManagerStandalone
: null;
return new CustomerSetup
{
TokenizedPaymentSource = tokenizedPaymentSource,
TaxInformation = taxInformation,
Coupon = coupon
};
}
private static SubscriptionSetup GetSubscriptionSetup(OrganizationUpgrade upgrade)
{
var plan = Core.Utilities.StaticStore.GetPlan(upgrade.Plan);
var passwordManagerOptions = new SubscriptionSetup.PasswordManager
{
Seats = upgrade.AdditionalSeats,
Storage = upgrade.AdditionalStorageGb,
PremiumAccess = upgrade.PremiumAccessAddon
};
var secretsManagerOptions = upgrade.UseSecretsManager
? new SubscriptionSetup.SecretsManager
{
Seats = upgrade.AdditionalSmSeats ?? 0,
ServiceAccounts = upgrade.AdditionalServiceAccounts
}
: null;
return new SubscriptionSetup
{
Plan = plan,
PasswordManagerOptions = passwordManagerOptions,
SecretsManagerOptions = secretsManagerOptions
};
}
}

View File

@ -0,0 +1,25 @@
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.Sales;
#nullable enable
public class SubscriptionSetup
{
public required Plan Plan { get; set; }
public required PasswordManager PasswordManagerOptions { get; set; }
public SecretsManager? SecretsManagerOptions { get; set; }
public class PasswordManager
{
public required int Seats { get; set; }
public short? Storage { get; set; }
public bool? PremiumAccess { get; set; }
}
public class SecretsManager
{
public required int Seats { get; set; }
public int? ServiceAccounts { get; set; }
}
}

View File

@ -34,6 +34,9 @@ public abstract record Plan
public SecretsManagerPlanFeatures SecretsManager { get; protected init; }
public bool SupportsSecretsManager => SecretsManager != null;
public bool HasNonSeatBasedPasswordManagerPlan() =>
PasswordManager is { StripePlanId: not null and not "", StripeSeatPlanId: null or "" };
public record SecretsManagerPlanFeatures
{
// Service accounts

View File

@ -1,4 +1,6 @@
namespace Bit.Core.Billing.Models;
using Stripe;
namespace Bit.Core.Billing.Models;
public record TaxInformation(
string Country,
@ -9,6 +11,25 @@ public record TaxInformation(
string City,
string State)
{
public (AddressOptions, List<CustomerTaxIdDataOptions>) GetStripeOptions()
{
var address = new AddressOptions
{
Country = Country,
PostalCode = PostalCode,
Line1 = Line1,
Line2 = Line2,
City = City,
State = State
};
var customerTaxIdDataOptionsList = !string.IsNullOrEmpty(TaxId)
? new List<CustomerTaxIdDataOptions> { new() { Type = GetTaxIdType(), Value = TaxId } }
: null;
return (address, customerTaxIdDataOptionsList);
}
public string GetTaxIdType()
{
if (string.IsNullOrEmpty(Country) || string.IsNullOrEmpty(TaxId))

View File

@ -1,10 +1,29 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
namespace Bit.Core.Billing.Services;
public interface IOrganizationBillingService
{
/// <summary>
/// <para>Establishes the billing configuration for a Bitwarden <see cref="Organization"/> using the provided <paramref name="sale"/>.</para>
/// <para>
/// The method first checks to see if the
/// provided <see cref="OrganizationSale.Organization"/> already has a Stripe <see cref="Stripe.Customer"/> using the <see cref="Organization.GatewayCustomerId"/>.
/// If it doesn't, the method creates one using the <paramref name="sale"/>'s <see cref="OrganizationSale.CustomerSetup"/>. The method then creates a Stripe <see cref="Stripe.Subscription"/>
/// for the created or existing customer using the provided <see cref="OrganizationSale.SubscriptionSetup"/>.
/// </para>
/// </summary>
/// <param name="sale">The purchase details necessary to establish the Stripe entities responsible for billing the organization.</param>
/// <example>
/// <code>
/// var sale = OrganizationSale.From(organization, organizationSignup);
/// await organizationBillingService.Finalize(sale);
/// </code>
/// </example>
Task Finalize(OrganizationSale sale);
/// <summary>
/// Retrieve metadata about the organization represented bsy the provided <paramref name="organizationId"/>.
/// </summary>
@ -13,11 +32,15 @@ public interface IOrganizationBillingService
Task<OrganizationMetadata> GetMetadata(Guid organizationId);
/// <summary>
/// Purchase a subscription for the provided <paramref name="organization"/> using the provided <paramref name="organizationSubscriptionPurchase"/>.
/// If successful, a Stripe <see cref="Stripe.Customer"/> and <see cref="Stripe.Subscription"/> will be created for the organization and the
/// organization will be enabled.
/// Updates the provided <paramref name="organization"/>'s payment source and tax information.
/// If the <paramref name="organization"/> does not have a Stripe <see cref="Stripe.Customer"/>, this method will create one using the provided
/// <paramref name="tokenizedPaymentSource"/> and <paramref name="taxInformation"/>.
/// </summary>
/// <param name="organization">The organization to purchase a subscription for.</param>
/// <param name="organizationSubscriptionPurchase">The purchase information for the organization's subscription.</param>
Task PurchaseSubscription(Organization organization, OrganizationSubscriptionPurchase organizationSubscriptionPurchase);
/// <param name="organization">The <paramref name="organization"/> to update the payment source and tax information for.</param>
/// <param name="tokenizedPaymentSource">The tokenized payment source (ex. Credit Card) to attach to the <paramref name="organization"/>.</param>
/// <param name="taxInformation">The <paramref name="organization"/>'s updated tax information.</param>
Task UpdatePaymentMethod(
Organization organization,
TokenizedPaymentSource tokenizedPaymentSource,
TaxInformation taxInformation);
}

View File

@ -1,8 +1,8 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
@ -28,6 +28,31 @@ public class OrganizationBillingService(
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IOrganizationBillingService
{
public async Task Finalize(OrganizationSale sale)
{
var (organization, customerSetup, subscriptionSetup) = sale;
List<string> expand = ["tax"];
var customer = customerSetup != null
? await CreateCustomerAsync(organization, customerSetup, expand)
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = expand });
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
if (subscription.Status is StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active)
{
organization.Enabled = true;
organization.ExpirationDate = subscription.CurrentPeriodEnd;
}
organization.Gateway = GatewayType.Stripe;
organization.GatewayCustomerId = customer.Id;
organization.GatewaySubscriptionId = subscription.Id;
await organizationRepository.ReplaceAsync(organization);
}
public async Task<OrganizationMetadata> GetMetadata(Guid organizationId)
{
var organization = await organizationRepository.GetByIdAsync(organizationId);
@ -54,97 +79,72 @@ public class OrganizationBillingService(
return new OrganizationMetadata(isOnSecretsManagerStandalone);
}
public async Task PurchaseSubscription(
public async Task UpdatePaymentMethod(
Organization organization,
OrganizationSubscriptionPurchase organizationSubscriptionPurchase)
TokenizedPaymentSource tokenizedPaymentSource,
TaxInformation taxInformation)
{
ArgumentNullException.ThrowIfNull(organization);
ArgumentNullException.ThrowIfNull(organizationSubscriptionPurchase);
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
{
var customer = await CreateCustomerAsync(organization,
new CustomerSetup
{
TokenizedPaymentSource = tokenizedPaymentSource,
TaxInformation = taxInformation,
});
var (
metadata,
passwordManager,
paymentSource,
planType,
secretsManager,
taxInformation) = organizationSubscriptionPurchase;
organization.Gateway = GatewayType.Stripe;
organization.GatewayCustomerId = customer.Id;
var customer = await CreateCustomerAsync(organization, metadata, paymentSource, taxInformation);
var subscription =
await CreateSubscriptionAsync(customer, organization.Id, passwordManager, planType, secretsManager);
organization.Enabled = true;
organization.ExpirationDate = subscription.CurrentPeriodEnd;
organization.Gateway = GatewayType.Stripe;
organization.GatewayCustomerId = customer.Id;
organization.GatewaySubscriptionId = subscription.Id;
await organizationRepository.ReplaceAsync(organization);
await organizationRepository.ReplaceAsync(organization);
}
else
{
await subscriberService.UpdatePaymentSource(organization, tokenizedPaymentSource);
await subscriberService.UpdateTaxInformation(organization, taxInformation);
}
}
#region Utilities
private async Task<Customer> CreateCustomerAsync(
Organization organization,
OrganizationSubscriptionPurchaseMetadata metadata,
TokenizedPaymentSource paymentSource,
TaxInformation taxInformation)
CustomerSetup customerSetup,
List<string> expand = null)
{
if (paymentSource == null)
if (customerSetup.TokenizedPaymentSource is not
{
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
Token: not null and not ""
})
{
logger.LogError(
"Cannot create customer for organization ({OrganizationID}) without a payment source",
"Cannot create customer for organization ({OrganizationID}) without a valid payment source",
organization.Id);
throw new BillingException();
}
if (taxInformation is not { Country: not null, PostalCode: not null })
if (customerSetup.TaxInformation is not { Country: not null and not "", PostalCode: not null and not "" })
{
logger.LogError(
"Cannot create customer for organization ({OrganizationID}) without both a country and postal code",
"Cannot create customer for organization ({OrganizationID}) without valid tax information",
organization.Id);
throw new BillingException();
}
var (
country,
postalCode,
taxId,
line1,
line2,
city,
state) = taxInformation;
var address = new AddressOptions
{
Country = country,
PostalCode = postalCode,
City = city,
Line1 = line1,
Line2 = line2,
State = state
};
var (fromProvider, fromSecretsManagerStandalone) = metadata ?? OrganizationSubscriptionPurchaseMetadata.Default;
var coupon = fromProvider
? StripeConstants.CouponIDs.MSPDiscount35
: fromSecretsManagerStandalone
? StripeConstants.CouponIDs.SecretsManagerStandalone
: null;
var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions();
var organizationDisplayName = organization.DisplayName();
var customerCreateOptions = new CustomerCreateOptions
{
Address = address,
Coupon = coupon,
Coupon = customerSetup.Coupon,
Description = organization.DisplayBusinessName(),
Email = organization.BillingEmail,
Expand = ["tax"],
Expand = expand,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields = [
@ -164,21 +164,10 @@ public class OrganizationBillingService(
{
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
},
TaxIdData = !string.IsNullOrEmpty(taxId)
? [new CustomerTaxIdDataOptions { Type = taxInformation.GetTaxIdType(), Value = taxId }]
: null
TaxIdData = taxIdData
};
var (type, token) = paymentSource;
if (string.IsNullOrEmpty(token))
{
logger.LogError(
"Cannot create customer for organization ({OrganizationID}) without a payment source token",
organization.Id);
throw new BillingException();
}
var (type, token) = customerSetup.TokenizedPaymentSource;
var braintreeCustomerId = "";
@ -270,31 +259,30 @@ public class OrganizationBillingService(
}
private async Task<Subscription> CreateSubscriptionAsync(
Customer customer,
Guid organizationId,
OrganizationPasswordManagerSubscriptionPurchase passwordManager,
PlanType planType,
OrganizationSecretsManagerSubscriptionPurchase secretsManager)
Customer customer,
SubscriptionSetup subscriptionSetup)
{
var plan = StaticStore.GetPlan(planType);
var plan = subscriptionSetup.Plan;
if (passwordManager == null)
{
logger.LogError("Cannot create subscription for organization ({OrganizationID}) without password manager purchase information", organizationId);
throw new BillingException();
}
var passwordManagerOptions = subscriptionSetup.PasswordManagerOptions;
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
{
new ()
{
Price = plan.PasswordManager.StripeSeatPlanId,
Quantity = passwordManager.Seats
}
plan.HasNonSeatBasedPasswordManagerPlan()
? new SubscriptionItemOptions
{
Price = plan.PasswordManager.StripePlanId,
Quantity = 1
}
: new SubscriptionItemOptions
{
Price = plan.PasswordManager.StripeSeatPlanId,
Quantity = passwordManagerOptions.Seats
}
};
if (passwordManager.PremiumAccess)
if (passwordManagerOptions.PremiumAccess is true)
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
@ -303,29 +291,31 @@ public class OrganizationBillingService(
});
}
if (passwordManager.Storage > 0)
if (passwordManagerOptions.Storage is > 0)
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = plan.PasswordManager.StripeStoragePlanId,
Quantity = passwordManager.Storage
Quantity = passwordManagerOptions.Storage
});
}
if (secretsManager != null)
var secretsManagerOptions = subscriptionSetup.SecretsManagerOptions;
if (secretsManagerOptions != null)
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = plan.SecretsManager.StripeSeatPlanId,
Quantity = secretsManager.Seats
Quantity = secretsManagerOptions.Seats
});
if (secretsManager.ServiceAccounts > 0)
if (secretsManagerOptions.ServiceAccounts is > 0)
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = plan.SecretsManager.StripeServiceAccountPlanId,
Quantity = secretsManager.ServiceAccounts
Quantity = secretsManagerOptions.ServiceAccounts
});
}
}
@ -347,15 +337,7 @@ public class OrganizationBillingService(
TrialPeriodDays = plan.TrialPeriodDays,
};
try
{
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
}
catch
{
await stripeAdapter.CustomerDeleteAsync(customer.Id);
throw;
}
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
}
private static bool IsOnSecretsManagerStandalone(

View File

@ -1,4 +1,5 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
@ -585,7 +586,7 @@ public class SubscriberService(
var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions
{
Expand = ["tax_ids"]
Expand = ["subscriptions", "tax", "tax_ids"]
});
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
@ -622,6 +623,23 @@ public class SubscriberService(
});
}
}
if (SubscriberIsEligibleForAutomaticTax(subscriber, customer))
{
await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
DefaultTaxRates = []
});
}
return;
bool SubscriberIsEligibleForAutomaticTax(ISubscriber localSubscriber, Customer localCustomer)
=> !string.IsNullOrEmpty(localSubscriber.GatewaySubscriptionId) &&
(localCustomer.Subscriptions?.Any(sub => sub.Id == localSubscriber.GatewaySubscriptionId && !sub.AutomaticTax.Enabled) ?? false) &&
localCustomer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported;
}
public async Task VerifyBankAccount(

View File

@ -1,5 +1,4 @@
using Bit.Core.Billing.Models;
using Bit.Core.Entities;
using Bit.Core.Entities;
using Bit.Core.Enums;
namespace Bit.Core.Models.Business;
@ -15,42 +14,6 @@ public class OrganizationSignup : OrganizationUpgrade
public string PaymentToken { get; set; }
public int? MaxAutoscaleSeats { get; set; } = null;
public string InitiationPath { get; set; }
public OrganizationSubscriptionPurchase ToSubscriptionPurchase(bool fromProvider = false)
{
if (!PaymentMethodType.HasValue)
{
return null;
}
var metadata = new OrganizationSubscriptionPurchaseMetadata(fromProvider, IsFromSecretsManagerTrial);
var passwordManager = new OrganizationPasswordManagerSubscriptionPurchase(
AdditionalStorageGb,
PremiumAccessAddon,
AdditionalSeats);
var paymentSource = new TokenizedPaymentSource(PaymentMethodType.Value, PaymentToken);
var secretsManager = new OrganizationSecretsManagerSubscriptionPurchase(
AdditionalSmSeats ?? 0,
AdditionalServiceAccounts ?? 0);
var taxInformation = new TaxInformation(
TaxInfo.BillingAddressCountry,
TaxInfo.BillingAddressPostalCode,
TaxInfo.TaxIdNumber,
TaxInfo.BillingAddressLine1,
TaxInfo.BillingAddressLine2,
TaxInfo.BillingAddressCity,
TaxInfo.BillingAddressState);
return new OrganizationSubscriptionPurchase(
metadata,
passwordManager,
paymentSource,
Plan,
UseSecretsManager ? secretsManager : null,
taxInformation);
}
public bool IsFromSecretsManagerTrial { get; set; }
public bool IsFromProvider { get; set; }
}

View File

@ -15,5 +15,4 @@ public class OrganizationUpgrade
public int? AdditionalSmSeats { get; set; }
public int? AdditionalServiceAccounts { get; set; }
public bool UseSecretsManager { get; set; }
public bool IsFromSecretsManagerTrial { get; set; }
}

View File

@ -5,6 +5,8 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -34,6 +36,8 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService;
private readonly IFeatureService _featureService;
private readonly IOrganizationBillingService _organizationBillingService;
public UpgradeOrganizationPlanCommand(
IOrganizationUserRepository organizationUserRepository,
@ -47,7 +51,9 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
ICurrentContext currentContext,
IServiceAccountRepository serviceAccountRepository,
IOrganizationRepository organizationRepository,
IOrganizationService organizationService)
IOrganizationService organizationService,
IFeatureService featureService,
IOrganizationBillingService organizationBillingService)
{
_organizationUserRepository = organizationUserRepository;
_collectionRepository = collectionRepository;
@ -61,6 +67,8 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
_serviceAccountRepository = serviceAccountRepository;
_organizationRepository = organizationRepository;
_organizationService = organizationService;
_featureService = featureService;
_organizationBillingService = organizationBillingService;
}
public async Task<Tuple<bool, string>> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade)
@ -216,9 +224,17 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
paymentIntentClientSecret = await _paymentService.UpgradeFreeOrganizationAsync(organization,
newPlan, upgrade);
success = string.IsNullOrWhiteSpace(paymentIntentClientSecret);
if (_featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI))
{
var sale = OrganizationSale.From(organization, upgrade);
await _organizationBillingService.Finalize(sale);
}
else
{
paymentIntentClientSecret = await _paymentService.UpgradeFreeOrganizationAsync(organization,
newPlan, upgrade);
success = string.IsNullOrWhiteSpace(paymentIntentClientSecret);
}
}
else
{

View File

@ -354,8 +354,9 @@ public class OrganizationServiceTests
signup.AdditionalServiceAccounts = 20;
signup.PaymentMethodType = PaymentMethodType.Card;
signup.PremiumAccessAddon = false;
signup.IsFromProvider = true;
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.SignUpAsync(signup, true));
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.SignUpAsync(signup));
Assert.Contains("Organizations with a Managed Service Provider do not support Secrets Manager.", exception.Message);
}