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 var (organization, _, defaultCollection) = consolidatedBillingEnabled
? await _organizationService.SignupClientAsync(organizationSignup) ? await _organizationService.SignupClientAsync(organizationSignup)
: await _organizationService.SignUpAsync(organizationSignup, true); : await _organizationService.SignUpAsync(organizationSignup);
var providerOrganization = new ProviderOrganization var providerOrganization = new ProviderOrganization
{ {

View File

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

View File

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

View File

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

View File

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

View File

@ -25,7 +25,7 @@ public interface IOrganizationService
/// </summary> /// </summary>
/// <returns>A tuple containing the new organization, the initial organizationUser (if any) and the default collection (if any)</returns> /// <returns>A tuple containing the new organization, the initial organizationUser (if any) and the default collection (if any)</returns>
#nullable enable #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); Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup);
#nullable disable #nullable disable

View File

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

View File

@ -1,4 +1,6 @@
namespace Bit.Core.Billing.Models; using Stripe;
namespace Bit.Core.Billing.Models;
public record TaxInformation( public record TaxInformation(
string Country, string Country,
@ -9,6 +11,25 @@ public record TaxInformation(
string City, string City,
string State) 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() public string GetTaxIdType()
{ {
if (string.IsNullOrEmpty(Country) || string.IsNullOrEmpty(TaxId)) if (string.IsNullOrEmpty(Country) || string.IsNullOrEmpty(TaxId))

View File

@ -1,10 +1,29 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
namespace Bit.Core.Billing.Services; namespace Bit.Core.Billing.Services;
public interface IOrganizationBillingService 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> /// <summary>
/// Retrieve metadata about the organization represented bsy the provided <paramref name="organizationId"/>. /// Retrieve metadata about the organization represented bsy the provided <paramref name="organizationId"/>.
/// </summary> /// </summary>
@ -13,11 +32,15 @@ public interface IOrganizationBillingService
Task<OrganizationMetadata> GetMetadata(Guid organizationId); Task<OrganizationMetadata> GetMetadata(Guid organizationId);
/// <summary> /// <summary>
/// Purchase a subscription for the provided <paramref name="organization"/> using the provided <paramref name="organizationSubscriptionPurchase"/>. /// Updates the provided <paramref name="organization"/>'s payment source and tax information.
/// If successful, a Stripe <see cref="Stripe.Customer"/> and <see cref="Stripe.Subscription"/> will be created for the organization and the /// If the <paramref name="organization"/> does not have a Stripe <see cref="Stripe.Customer"/>, this method will create one using the provided
/// organization will be enabled. /// <paramref name="tokenizedPaymentSource"/> and <paramref name="taxInformation"/>.
/// </summary> /// </summary>
/// <param name="organization">The organization to purchase a subscription for.</param> /// <param name="organization">The <paramref name="organization"/> to update the payment source and tax information for.</param>
/// <param name="organizationSubscriptionPurchase">The purchase information for the organization's subscription.</param> /// <param name="tokenizedPaymentSource">The tokenized payment source (ex. Credit Card) to attach to the <paramref name="organization"/>.</param>
Task PurchaseSubscription(Organization organization, OrganizationSubscriptionPurchase organizationSubscriptionPurchase); /// <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.AdminConsole.Entities;
using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -28,6 +28,31 @@ public class OrganizationBillingService(
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IOrganizationBillingService 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) public async Task<OrganizationMetadata> GetMetadata(Guid organizationId)
{ {
var organization = await organizationRepository.GetByIdAsync(organizationId); var organization = await organizationRepository.GetByIdAsync(organizationId);
@ -54,97 +79,72 @@ public class OrganizationBillingService(
return new OrganizationMetadata(isOnSecretsManagerStandalone); return new OrganizationMetadata(isOnSecretsManagerStandalone);
} }
public async Task PurchaseSubscription( public async Task UpdatePaymentMethod(
Organization organization, Organization organization,
OrganizationSubscriptionPurchase organizationSubscriptionPurchase) TokenizedPaymentSource tokenizedPaymentSource,
TaxInformation taxInformation)
{ {
ArgumentNullException.ThrowIfNull(organization); if (string.IsNullOrEmpty(organization.GatewayCustomerId))
ArgumentNullException.ThrowIfNull(organizationSubscriptionPurchase); {
var customer = await CreateCustomerAsync(organization,
new CustomerSetup
{
TokenizedPaymentSource = tokenizedPaymentSource,
TaxInformation = taxInformation,
});
var (
metadata,
passwordManager,
paymentSource,
planType,
secretsManager,
taxInformation) = organizationSubscriptionPurchase;
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.Gateway = GatewayType.Stripe;
organization.GatewayCustomerId = customer.Id; 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 #region Utilities
private async Task<Customer> CreateCustomerAsync( private async Task<Customer> CreateCustomerAsync(
Organization organization, Organization organization,
OrganizationSubscriptionPurchaseMetadata metadata, CustomerSetup customerSetup,
TokenizedPaymentSource paymentSource, List<string> expand = null)
TaxInformation taxInformation)
{ {
if (paymentSource == null) if (customerSetup.TokenizedPaymentSource is not
{
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
Token: not null and not ""
})
{ {
logger.LogError( 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); organization.Id);
throw new BillingException(); 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( 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); organization.Id);
throw new BillingException(); throw new BillingException();
} }
var ( var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions();
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 organizationDisplayName = organization.DisplayName(); var organizationDisplayName = organization.DisplayName();
var customerCreateOptions = new CustomerCreateOptions var customerCreateOptions = new CustomerCreateOptions
{ {
Address = address, Address = address,
Coupon = coupon, Coupon = customerSetup.Coupon,
Description = organization.DisplayBusinessName(), Description = organization.DisplayBusinessName(),
Email = organization.BillingEmail, Email = organization.BillingEmail,
Expand = ["tax"], Expand = expand,
InvoiceSettings = new CustomerInvoiceSettingsOptions InvoiceSettings = new CustomerInvoiceSettingsOptions
{ {
CustomFields = [ CustomFields = [
@ -164,21 +164,10 @@ public class OrganizationBillingService(
{ {
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
}, },
TaxIdData = !string.IsNullOrEmpty(taxId) TaxIdData = taxIdData
? [new CustomerTaxIdDataOptions { Type = taxInformation.GetTaxIdType(), Value = taxId }]
: null
}; };
var (type, token) = paymentSource; var (type, token) = customerSetup.TokenizedPaymentSource;
if (string.IsNullOrEmpty(token))
{
logger.LogError(
"Cannot create customer for organization ({OrganizationID}) without a payment source token",
organization.Id);
throw new BillingException();
}
var braintreeCustomerId = ""; var braintreeCustomerId = "";
@ -270,31 +259,30 @@ public class OrganizationBillingService(
} }
private async Task<Subscription> CreateSubscriptionAsync( private async Task<Subscription> CreateSubscriptionAsync(
Customer customer,
Guid organizationId, Guid organizationId,
OrganizationPasswordManagerSubscriptionPurchase passwordManager, Customer customer,
PlanType planType, SubscriptionSetup subscriptionSetup)
OrganizationSecretsManagerSubscriptionPurchase secretsManager)
{ {
var plan = StaticStore.GetPlan(planType); var plan = subscriptionSetup.Plan;
if (passwordManager == null) var passwordManagerOptions = subscriptionSetup.PasswordManagerOptions;
{
logger.LogError("Cannot create subscription for organization ({OrganizationID}) without password manager purchase information", organizationId);
throw new BillingException();
}
var subscriptionItemOptionsList = new List<SubscriptionItemOptions> var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
{ {
new () plan.HasNonSeatBasedPasswordManagerPlan()
? new SubscriptionItemOptions
{
Price = plan.PasswordManager.StripePlanId,
Quantity = 1
}
: new SubscriptionItemOptions
{ {
Price = plan.PasswordManager.StripeSeatPlanId, Price = plan.PasswordManager.StripeSeatPlanId,
Quantity = passwordManager.Seats Quantity = passwordManagerOptions.Seats
} }
}; };
if (passwordManager.PremiumAccess) if (passwordManagerOptions.PremiumAccess is true)
{ {
subscriptionItemOptionsList.Add(new SubscriptionItemOptions subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{ {
@ -303,29 +291,31 @@ public class OrganizationBillingService(
}); });
} }
if (passwordManager.Storage > 0) if (passwordManagerOptions.Storage is > 0)
{ {
subscriptionItemOptionsList.Add(new SubscriptionItemOptions subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{ {
Price = plan.PasswordManager.StripeStoragePlanId, Price = plan.PasswordManager.StripeStoragePlanId,
Quantity = passwordManager.Storage Quantity = passwordManagerOptions.Storage
}); });
} }
if (secretsManager != null) var secretsManagerOptions = subscriptionSetup.SecretsManagerOptions;
if (secretsManagerOptions != null)
{ {
subscriptionItemOptionsList.Add(new SubscriptionItemOptions subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{ {
Price = plan.SecretsManager.StripeSeatPlanId, Price = plan.SecretsManager.StripeSeatPlanId,
Quantity = secretsManager.Seats Quantity = secretsManagerOptions.Seats
}); });
if (secretsManager.ServiceAccounts > 0) if (secretsManagerOptions.ServiceAccounts is > 0)
{ {
subscriptionItemOptionsList.Add(new SubscriptionItemOptions subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{ {
Price = plan.SecretsManager.StripeServiceAccountPlanId, Price = plan.SecretsManager.StripeServiceAccountPlanId,
Quantity = secretsManager.ServiceAccounts Quantity = secretsManagerOptions.ServiceAccounts
}); });
} }
} }
@ -347,16 +337,8 @@ public class OrganizationBillingService(
TrialPeriodDays = plan.TrialPeriodDays, TrialPeriodDays = plan.TrialPeriodDays,
}; };
try
{
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
} }
catch
{
await stripeAdapter.CustomerDeleteAsync(customer.Id);
throw;
}
}
private static bool IsOnSecretsManagerStandalone( private static bool IsOnSecretsManagerStandalone(
Organization organization, Organization organization,

View File

@ -1,4 +1,5 @@
using Bit.Core.Billing.Caches; using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Models; using Bit.Core.Billing.Models;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@ -585,7 +586,7 @@ public class SubscriberService(
var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions
{ {
Expand = ["tax_ids"] Expand = ["subscriptions", "tax", "tax_ids"]
}); });
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions 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( 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; using Bit.Core.Enums;
namespace Bit.Core.Models.Business; namespace Bit.Core.Models.Business;
@ -15,42 +14,6 @@ public class OrganizationSignup : OrganizationUpgrade
public string PaymentToken { get; set; } public string PaymentToken { get; set; }
public int? MaxAutoscaleSeats { get; set; } = null; public int? MaxAutoscaleSeats { get; set; } = null;
public string InitiationPath { get; set; } public string InitiationPath { get; set; }
public bool IsFromSecretsManagerTrial { get; set; }
public OrganizationSubscriptionPurchase ToSubscriptionPurchase(bool fromProvider = false) public bool IsFromProvider { get; set; }
{
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);
}
} }

View File

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

View File

@ -354,8 +354,9 @@ public class OrganizationServiceTests
signup.AdditionalServiceAccounts = 20; signup.AdditionalServiceAccounts = 20;
signup.PaymentMethodType = PaymentMethodType.Card; signup.PaymentMethodType = PaymentMethodType.Card;
signup.PremiumAccessAddon = false; 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); Assert.Contains("Organizations with a Managed Service Provider do not support Secrets Manager.", exception.Message);
} }