mirror of
https://github.com/bitwarden/server.git
synced 2024-11-24 12:35:25 +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:
parent
8491c58595
commit
c0a4ba8de1
@ -1,4 +1,5 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.SecretsManager.Repositories;
|
using Bit.Core.SecretsManager.Repositories;
|
||||||
using Bit.Core.SecretsManager.Repositories.Noop;
|
using Bit.Core.SecretsManager.Repositories.Noop;
|
||||||
@ -68,6 +69,7 @@ public class Startup
|
|||||||
// Services
|
// Services
|
||||||
services.AddBaseServices(globalSettings);
|
services.AddBaseServices(globalSettings);
|
||||||
services.AddDefaultServices(globalSettings);
|
services.AddDefaultServices(globalSettings);
|
||||||
|
services.AddBillingOperations();
|
||||||
|
|
||||||
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
|
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Bit.Billing.Services;
|
using Bit.Billing.Services;
|
||||||
using Bit.Billing.Services.Implementations;
|
using Bit.Billing.Services.Implementations;
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.SecretsManager.Repositories;
|
using Bit.Core.SecretsManager.Repositories;
|
||||||
using Bit.Core.SecretsManager.Repositories.Noop;
|
using Bit.Core.SecretsManager.Repositories.Noop;
|
||||||
@ -74,6 +75,7 @@ public class Startup
|
|||||||
// Services
|
// Services
|
||||||
services.AddBaseServices(globalSettings);
|
services.AddBaseServices(globalSettings);
|
||||||
services.AddDefaultServices(globalSettings);
|
services.AddDefaultServices(globalSettings);
|
||||||
|
services.AddBillingOperations();
|
||||||
|
|
||||||
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
|
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
|
||||||
|
|
||||||
|
@ -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.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
@ -70,6 +71,7 @@ public class OrganizationService : IOrganizationService
|
|||||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||||
|
private readonly IOrganizationBillingService _organizationBillingService;
|
||||||
|
|
||||||
public OrganizationService(
|
public OrganizationService(
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -103,7 +105,8 @@ public class OrganizationService : IOrganizationService
|
|||||||
IDataProtectorTokenFactory<OrgDeleteTokenable> orgDeleteTokenDataFactory,
|
IDataProtectorTokenFactory<OrgDeleteTokenable> orgDeleteTokenDataFactory,
|
||||||
IProviderRepository providerRepository,
|
IProviderRepository providerRepository,
|
||||||
IFeatureService featureService,
|
IFeatureService featureService,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery)
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||||
|
IOrganizationBillingService organizationBillingService)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
@ -137,6 +140,7 @@ public class OrganizationService : IOrganizationService
|
|||||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
|
||||||
|
_organizationBillingService = organizationBillingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
|
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
|
||||||
@ -577,10 +581,21 @@ public class OrganizationService : IOrganizationService
|
|||||||
}
|
}
|
||||||
else if (plan.Type != PlanType.Free)
|
else if (plan.Type != PlanType.Free)
|
||||||
{
|
{
|
||||||
await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value,
|
var deprecateStripeSourcesAPI = _featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI);
|
||||||
signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats,
|
|
||||||
signup.PremiumAccessAddon, signup.TaxInfo, provider, signup.AdditionalSmSeats.GetValueOrDefault(),
|
if (deprecateStripeSourcesAPI)
|
||||||
signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial);
|
{
|
||||||
|
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;
|
var ownerId = provider ? default : signup.Owner.Id;
|
||||||
|
@ -18,6 +18,7 @@ public static class StripeConstants
|
|||||||
|
|
||||||
public static class CouponIDs
|
public static class CouponIDs
|
||||||
{
|
{
|
||||||
|
public const string MSPDiscount35 = "msp-discount-35";
|
||||||
public const string SecretsManagerStandalone = "sm-standalone";
|
public const string SecretsManagerStandalone = "sm-standalone";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,4 +52,10 @@ public static class StripeConstants
|
|||||||
public const string Unpaid = "unpaid";
|
public const string Unpaid = "unpaid";
|
||||||
public const string Paused = "paused";
|
public const string Paused = "paused";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class ValidateTaxLocationTiming
|
||||||
|
{
|
||||||
|
public const string Deferred = "deferred";
|
||||||
|
public const string Immediately = "immediately";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
27
src/Core/Billing/Models/OrganizationSubscriptionPurchase.cs
Normal file
27
src/Core/Billing/Models/OrganizationSubscriptionPurchase.cs
Normal 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);
|
||||||
|
}
|
@ -1,8 +1,23 @@
|
|||||||
using Bit.Core.Billing.Models;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.Billing.Models;
|
||||||
|
|
||||||
namespace Bit.Core.Billing.Services;
|
namespace Bit.Core.Billing.Services;
|
||||||
|
|
||||||
public interface IOrganizationBillingService
|
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);
|
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);
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,16 @@ public interface ISubscriberService
|
|||||||
OffboardingSurveyResponse offboardingSurveyResponse,
|
OffboardingSurveyResponse offboardingSurveyResponse,
|
||||||
bool cancelImmediately);
|
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>
|
/// <summary>
|
||||||
/// Retrieves a Stripe <see cref="Customer"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> property.
|
/// Retrieves a Stripe <see cref="Customer"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> property.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -1,14 +1,31 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
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.Enums;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
using Braintree;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
|
|
||||||
|
using static Bit.Core.Billing.Utilities;
|
||||||
|
using Customer = Stripe.Customer;
|
||||||
|
using Subscription = Stripe.Subscription;
|
||||||
|
|
||||||
namespace Bit.Core.Billing.Services.Implementations;
|
namespace Bit.Core.Billing.Services.Implementations;
|
||||||
|
|
||||||
public class OrganizationBillingService(
|
public class OrganizationBillingService(
|
||||||
|
IBraintreeGateway braintreeGateway,
|
||||||
|
IGlobalSettings globalSettings,
|
||||||
|
ILogger<OrganizationBillingService> logger,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
|
ISetupIntentCache setupIntentCache,
|
||||||
|
IStripeAdapter stripeAdapter,
|
||||||
ISubscriberService subscriberService) : IOrganizationBillingService
|
ISubscriberService subscriberService) : IOrganizationBillingService
|
||||||
{
|
{
|
||||||
public async Task<OrganizationMetadata> GetMetadata(Guid organizationId)
|
public async Task<OrganizationMetadata> GetMetadata(Guid organizationId)
|
||||||
@ -37,6 +54,310 @@ public class OrganizationBillingService(
|
|||||||
return new OrganizationMetadata(isOnSecretsManagerStandalone);
|
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(
|
private static bool IsOnSecretsManagerStandalone(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
Customer customer,
|
Customer customer,
|
||||||
@ -62,4 +383,6 @@ public class OrganizationBillingService(
|
|||||||
|
|
||||||
return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any();
|
return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
|
@ -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(
|
public async Task<Customer> GetCustomer(
|
||||||
ISubscriber subscriber,
|
ISubscriber subscriber,
|
||||||
CustomerGetOptions customerGetOptions = null)
|
CustomerGetOptions customerGetOptions = null)
|
||||||
@ -530,7 +561,7 @@ public class SubscriberService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
braintreeCustomerId = await CreateBraintreeCustomerAsync(subscriber, token);
|
braintreeCustomerId = await CreateBraintreeCustomer(subscriber, token);
|
||||||
|
|
||||||
await AddBraintreeCustomerIdAsync(customer, braintreeCustomerId);
|
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(
|
private async Task<PaymentSource> GetPaymentSourceAsync(
|
||||||
Guid subscriberId,
|
Guid subscriberId,
|
||||||
Customer customer)
|
Customer customer)
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.Entities;
|
using Bit.Core.Billing.Models;
|
||||||
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
|
|
||||||
namespace Bit.Core.Models.Business;
|
namespace Bit.Core.Models.Business;
|
||||||
@ -14,4 +15,42 @@ 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 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ using System.IdentityModel.Tokens.Jwt;
|
|||||||
using AspNetCoreRateLimit;
|
using AspNetCoreRateLimit;
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.SecretsManager.Repositories;
|
using Bit.Core.SecretsManager.Repositories;
|
||||||
using Bit.Core.SecretsManager.Repositories.Noop;
|
using Bit.Core.SecretsManager.Repositories.Noop;
|
||||||
@ -145,6 +146,7 @@ public class Startup
|
|||||||
services.AddBaseServices(globalSettings);
|
services.AddBaseServices(globalSettings);
|
||||||
services.AddDefaultServices(globalSettings);
|
services.AddDefaultServices(globalSettings);
|
||||||
services.AddCoreLocalizationServices();
|
services.AddCoreLocalizationServices();
|
||||||
|
services.AddBillingOperations();
|
||||||
|
|
||||||
// TODO: Remove when OrganizationUser methods are moved out of OrganizationService, this noop dependency should
|
// TODO: Remove when OrganizationUser methods are moved out of OrganizationService, this noop dependency should
|
||||||
// TODO: no longer be required - see PM-1880
|
// TODO: no longer be required - see PM-1880
|
||||||
|
Loading…
Reference in New Issue
Block a user