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

[AC-2965] Use OrganizationBillingService to purchase org when FF is on (#4737)

* Add PurchaseSubscription to OrganizationBillingService and call from OrganizationService.SignUpAsync when FF is on

* Run dotnet format

* Missed billing service DI for SCIM which uses the OrganizationService
This commit is contained in:
Alex Morask 2024-09-06 10:24:05 -04:00 committed by GitHub
parent 8491c58595
commit c0a4ba8de1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 481 additions and 39 deletions

View File

@ -1,4 +1,5 @@
using System.Globalization;
using Bit.Core.Billing.Extensions;
using Bit.Core.Context;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.SecretsManager.Repositories.Noop;
@ -68,6 +69,7 @@ public class Startup
// Services
services.AddBaseServices(globalSettings);
services.AddDefaultServices(globalSettings);
services.AddBillingOperations();
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();

View File

@ -1,6 +1,7 @@
using System.Globalization;
using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Core.Billing.Extensions;
using Bit.Core.Context;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.SecretsManager.Repositories.Noop;
@ -74,6 +75,7 @@ public class Startup
// Services
services.AddBaseServices(globalSettings);
services.AddDefaultServices(globalSettings);
services.AddBillingOperations();
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();

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.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@ -70,6 +71,7 @@ public class OrganizationService : IOrganizationService
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IFeatureService _featureService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IOrganizationBillingService _organizationBillingService;
public OrganizationService(
IOrganizationRepository organizationRepository,
@ -103,7 +105,8 @@ public class OrganizationService : IOrganizationService
IDataProtectorTokenFactory<OrgDeleteTokenable> orgDeleteTokenDataFactory,
IProviderRepository providerRepository,
IFeatureService featureService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IOrganizationBillingService organizationBillingService)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -137,6 +140,7 @@ public class OrganizationService : IOrganizationService
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_featureService = featureService;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_organizationBillingService = organizationBillingService;
}
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
@ -577,10 +581,21 @@ public class OrganizationService : IOrganizationService
}
else if (plan.Type != PlanType.Free)
{
await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value,
signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats,
signup.PremiumAccessAddon, signup.TaxInfo, provider, signup.AdditionalSmSeats.GetValueOrDefault(),
signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial);
var deprecateStripeSourcesAPI = _featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI);
if (deprecateStripeSourcesAPI)
{
var subscriptionPurchase = signup.ToSubscriptionPurchase(provider);
await _organizationBillingService.PurchaseSubscription(organization, subscriptionPurchase);
}
else
{
await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value,
signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats,
signup.PremiumAccessAddon, signup.TaxInfo, provider, signup.AdditionalSmSeats.GetValueOrDefault(),
signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial);
}
}
var ownerId = provider ? default : signup.Owner.Id;

View File

@ -18,6 +18,7 @@ public static class StripeConstants
public static class CouponIDs
{
public const string MSPDiscount35 = "msp-discount-35";
public const string SecretsManagerStandalone = "sm-standalone";
}
@ -51,4 +52,10 @@ public static class StripeConstants
public const string Unpaid = "unpaid";
public const string Paused = "paused";
}
public static class ValidateTaxLocationTiming
{
public const string Deferred = "deferred";
public const string Immediately = "immediately";
}
}

View File

@ -0,0 +1,27 @@
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

@ -1,8 +1,23 @@
using Bit.Core.Billing.Models;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Models;
namespace Bit.Core.Billing.Services;
public interface IOrganizationBillingService
{
/// <summary>
/// Retrieve metadata about the organization represented bsy the provided <paramref name="organizationId"/>.
/// </summary>
/// <param name="organizationId">The ID of the organization to retrieve metadata for.</param>
/// <returns>An <see cref="OrganizationMetadata"/> record.</returns>
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.
/// </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);
}

View File

@ -22,6 +22,16 @@ public interface ISubscriberService
OffboardingSurveyResponse offboardingSurveyResponse,
bool cancelImmediately);
/// <summary>
/// Creates a Braintree <see cref="Braintree.Customer"/> for the provided <paramref name="subscriber"/> while attaching the provided <paramref name="paymentMethodNonce"/>.
/// </summary>
/// <param name="subscriber">The subscriber to create a Braintree customer for.</param>
/// <param name="paymentMethodNonce">A nonce representing the PayPal payment method the customer will use for payments.</param>
/// <returns>The <see cref="Braintree.Customer.Id"/> of the created Braintree customer.</returns>
Task<string> CreateBraintreeCustomer(
ISubscriber subscriber,
string paymentMethodNonce);
/// <summary>
/// Retrieves a Stripe <see cref="Customer"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> property.
/// </summary>

View File

@ -1,14 +1,31 @@
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.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Braintree;
using Microsoft.Extensions.Logging;
using Stripe;
using static Bit.Core.Billing.Utilities;
using Customer = Stripe.Customer;
using Subscription = Stripe.Subscription;
namespace Bit.Core.Billing.Services.Implementations;
public class OrganizationBillingService(
IBraintreeGateway braintreeGateway,
IGlobalSettings globalSettings,
ILogger<OrganizationBillingService> logger,
IOrganizationRepository organizationRepository,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService) : IOrganizationBillingService
{
public async Task<OrganizationMetadata> GetMetadata(Guid organizationId)
@ -37,6 +54,310 @@ public class OrganizationBillingService(
return new OrganizationMetadata(isOnSecretsManagerStandalone);
}
public async Task PurchaseSubscription(
Organization organization,
OrganizationSubscriptionPurchase organizationSubscriptionPurchase)
{
ArgumentNullException.ThrowIfNull(organization);
ArgumentNullException.ThrowIfNull(organizationSubscriptionPurchase);
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.GatewayCustomerId = customer.Id;
organization.GatewaySubscriptionId = subscription.Id;
await organizationRepository.ReplaceAsync(organization);
}
#region Utilities
private async Task<Customer> CreateCustomerAsync(
Organization organization,
OrganizationSubscriptionPurchaseMetadata metadata,
TokenizedPaymentSource paymentSource,
TaxInformation taxInformation)
{
if (paymentSource == null)
{
logger.LogError(
"Cannot create customer for organization ({OrganizationID}) without a payment source",
organization.Id);
throw new BillingException();
}
if (taxInformation is not { Country: not null, PostalCode: not null })
{
logger.LogError(
"Cannot create customer for organization ({OrganizationID}) without both a country and postal code",
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 organizationDisplayName = organization.DisplayName();
var customerCreateOptions = new CustomerCreateOptions
{
Address = address,
Coupon = coupon,
Description = organization.DisplayBusinessName(),
Email = organization.BillingEmail,
Expand = ["tax"],
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields = [
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = organization.SubscriberType(),
Value = organizationDisplayName.Length <= 30
? organizationDisplayName
: organizationDisplayName[..30]
}]
},
Metadata = new Dictionary<string, string>
{
{ "region", globalSettings.BaseServiceUri.CloudRegion }
},
Tax = new CustomerTaxOptions
{
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
},
TaxIdData = !string.IsNullOrEmpty(taxId)
? [new CustomerTaxIdDataOptions { Type = taxInformation.GetTaxIdType(), Value = taxId }]
: null
};
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 braintreeCustomerId = "";
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (type)
{
case PaymentMethodType.BankAccount:
{
var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
.FirstOrDefault();
if (setupIntent == null)
{
logger.LogError("Cannot create customer for organization ({OrganizationID}) without a setup intent for their bank account", organization.Id);
throw new BillingException();
}
await setupIntentCache.Set(organization.Id, setupIntent.Id);
break;
}
case PaymentMethodType.Card:
{
customerCreateOptions.PaymentMethod = token;
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = token;
break;
}
case PaymentMethodType.PayPal:
{
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, token);
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break;
}
default:
{
logger.LogError("Cannot create customer for organization ({OrganizationID}) using payment method type ({PaymentMethodType}) as it is not supported", organization.Id, type.ToString());
throw new BillingException();
}
}
try
{
return await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
{
await Revert();
throw new BadRequestException(
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
StripeConstants.ErrorCodes.TaxIdInvalid)
{
await Revert();
throw new BadRequestException(
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
}
catch
{
await Revert();
throw;
}
async Task Revert()
{
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (type)
{
case PaymentMethodType.BankAccount:
{
await setupIntentCache.Remove(organization.Id);
break;
}
case PaymentMethodType.PayPal:
{
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
break;
}
}
}
}
private async Task<Subscription> CreateSubscriptionAsync(
Customer customer,
Guid organizationId,
OrganizationPasswordManagerSubscriptionPurchase passwordManager,
PlanType planType,
OrganizationSecretsManagerSubscriptionPurchase secretsManager)
{
var plan = StaticStore.GetPlan(planType);
if (passwordManager == null)
{
logger.LogError("Cannot create subscription for organization ({OrganizationID}) without password manager purchase information", organizationId);
throw new BillingException();
}
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
{
new ()
{
Price = plan.PasswordManager.StripeSeatPlanId,
Quantity = passwordManager.Seats
}
};
if (passwordManager.PremiumAccess)
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = plan.PasswordManager.StripePremiumAccessPlanId,
Quantity = 1
});
}
if (passwordManager.Storage > 0)
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = plan.PasswordManager.StripeStoragePlanId,
Quantity = passwordManager.Storage
});
}
if (secretsManager != null)
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = plan.SecretsManager.StripeSeatPlanId,
Quantity = secretsManager.Seats
});
if (secretsManager.ServiceAccounts > 0)
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = plan.SecretsManager.StripeServiceAccountPlanId,
Quantity = secretsManager.ServiceAccounts
});
}
}
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported
},
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
Customer = customer.Id,
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
["organizationId"] = organizationId.ToString()
},
OffSession = true,
TrialPeriodDays = plan.TrialPeriodDays,
};
try
{
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
}
catch
{
await stripeAdapter.CustomerDeleteAsync(customer.Id);
throw;
}
}
private static bool IsOnSecretsManagerStandalone(
Organization organization,
Customer customer,
@ -62,4 +383,6 @@ public class OrganizationBillingService(
return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any();
}
#endregion
}

View File

@ -102,6 +102,37 @@ public class SubscriberService(
}
}
public async Task<string> CreateBraintreeCustomer(
ISubscriber subscriber,
string paymentMethodNonce)
{
var braintreeCustomerId =
subscriber.BraintreeCustomerIdPrefix() +
subscriber.Id.ToString("N").ToLower() +
CoreHelpers.RandomString(3, upper: false, numeric: false);
var customerResult = await braintreeGateway.Customer.CreateAsync(new CustomerRequest
{
Id = braintreeCustomerId,
CustomFields = new Dictionary<string, string>
{
[subscriber.BraintreeIdField()] = subscriber.Id.ToString(),
[subscriber.BraintreeCloudRegionField()] = globalSettings.BaseServiceUri.CloudRegion
},
Email = subscriber.BillingEmailAddress(),
PaymentMethodNonce = paymentMethodNonce,
});
if (customerResult.IsSuccess())
{
return customerResult.Target.Id;
}
logger.LogError("Failed to create Braintree customer for subscriber ({ID})", subscriber.Id);
throw new BillingException();
}
public async Task<Customer> GetCustomer(
ISubscriber subscriber,
CustomerGetOptions customerGetOptions = null)
@ -530,7 +561,7 @@ public class SubscriberService(
}
}
braintreeCustomerId = await CreateBraintreeCustomerAsync(subscriber, token);
braintreeCustomerId = await CreateBraintreeCustomer(subscriber, token);
await AddBraintreeCustomerIdAsync(customer, braintreeCustomerId);
@ -648,37 +679,6 @@ public class SubscriberService(
});
}
private async Task<string> CreateBraintreeCustomerAsync(
ISubscriber subscriber,
string paymentMethodNonce)
{
var braintreeCustomerId =
subscriber.BraintreeCustomerIdPrefix() +
subscriber.Id.ToString("N").ToLower() +
CoreHelpers.RandomString(3, upper: false, numeric: false);
var customerResult = await braintreeGateway.Customer.CreateAsync(new CustomerRequest
{
Id = braintreeCustomerId,
CustomFields = new Dictionary<string, string>
{
[subscriber.BraintreeIdField()] = subscriber.Id.ToString(),
[subscriber.BraintreeCloudRegionField()] = globalSettings.BaseServiceUri.CloudRegion
},
Email = subscriber.BillingEmailAddress(),
PaymentMethodNonce = paymentMethodNonce,
});
if (customerResult.IsSuccess())
{
return customerResult.Target.Id;
}
logger.LogError("Failed to create Braintree customer for subscriber ({ID})", subscriber.Id);
throw new BillingException();
}
private async Task<PaymentSource> GetPaymentSourceAsync(
Guid subscriberId,
Customer customer)

View File

@ -1,4 +1,5 @@
using Bit.Core.Entities;
using Bit.Core.Billing.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
namespace Bit.Core.Models.Business;
@ -14,4 +15,42 @@ 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);
}
}

View File

@ -3,6 +3,7 @@ using System.IdentityModel.Tokens.Jwt;
using AspNetCoreRateLimit;
using Bit.Core;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Billing.Extensions;
using Bit.Core.Context;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.SecretsManager.Repositories.Noop;
@ -145,6 +146,7 @@ public class Startup
services.AddBaseServices(globalSettings);
services.AddDefaultServices(globalSettings);
services.AddCoreLocalizationServices();
services.AddBillingOperations();
// TODO: Remove when OrganizationUser methods are moved out of OrganizationService, this noop dependency should
// TODO: no longer be required - see PM-1880