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:
parent
f2180aa7b7
commit
68b421fa2b
@ -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
|
||||
{
|
||||
|
@ -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 =
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
}
|
10
src/Core/Billing/Models/Sales/CustomerSetup.cs
Normal file
10
src/Core/Billing/Models/Sales/CustomerSetup.cs
Normal 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; }
|
||||
}
|
104
src/Core/Billing/Models/Sales/OrganizationSale.cs
Normal file
104
src/Core/Billing/Models/Sales/OrganizationSale.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
25
src/Core/Billing/Models/Sales/SubscriptionSetup.cs
Normal file
25
src/Core/Billing/Models/Sales/SubscriptionSetup.cs
Normal 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; }
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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))
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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; }
|
||||
}
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user