1
0
mirror of https://github.com/bitwarden/server.git synced 2025-03-12 13:29:14 +01:00

[BEEEP] [PM-18518] Cleanup StripePaymentService (#5435)

This commit is contained in:
Jonas Hendrickx 2025-03-07 09:52:04 +01:00 committed by GitHub
parent 6cb97d9bf9
commit c589f9a330
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 58 additions and 1573 deletions

View File

@ -22,4 +22,9 @@ public static class CustomerExtensions
/// <returns></returns>
public static bool HasTaxLocationVerified(this Customer customer) =>
customer?.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported;
public static decimal GetBillingBalance(this Customer customer)
{
return customer != null ? customer.Balance / 100M : default;
}
}

View File

@ -0,0 +1,26 @@
using Bit.Core.Entities;
namespace Bit.Core.Billing.Extensions;
public static class SubscriberExtensions
{
/// <summary>
/// We are taking only first 30 characters of the SubscriberName because stripe provide for 30 characters for
/// custom_fields,see the link: https://stripe.com/docs/api/invoices/create
/// </summary>
/// <param name="subscriber"></param>
/// <returns></returns>
public static string GetFormattedInvoiceName(this ISubscriber subscriber)
{
var subscriberName = subscriber.SubscriberName();
if (string.IsNullOrWhiteSpace(subscriberName))
{
return string.Empty;
}
return subscriberName.Length <= 30
? subscriberName
: subscriberName[..30];
}
}

View File

@ -14,18 +14,8 @@ namespace Bit.Core.Services;
public interface IPaymentService
{
Task CancelAndRecoverChargesAsync(ISubscriber subscriber);
Task<string> PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType,
string paymentToken, Plan plan, short additionalStorageGb, int additionalSeats,
bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false, int additionalSmSeats = 0,
int additionalServiceAccount = 0, bool signupIsFromSecretsManagerTrial = false);
Task<string> PurchaseOrganizationNoPaymentMethod(Organization org, Plan plan, int additionalSeats,
bool premiumAccessAddon, int additionalSmSeats = 0, int additionalServiceAccount = 0,
bool signupIsFromSecretsManagerTrial = false);
Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship);
Task RemoveOrganizationSponsorshipAsync(Organization org, OrganizationSponsorship sponsorship);
Task<string> UpgradeFreeOrganizationAsync(Organization org, Plan plan, OrganizationUpgrade upgrade);
Task<string> PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType, string paymentToken,
short additionalStorageGb, TaxInfo taxInfo);
Task<string> AdjustSubscription(
Organization organization,
Plan updatedPlan,
@ -56,9 +46,7 @@ public interface IPaymentService
Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo);
Task<string> AddSecretsManagerToSubscription(Organization org, Plan plan, int additionalSmSeats,
int additionalServiceAccount);
Task<bool> RisksSubscriptionFailure(Organization organization);
Task<bool> HasSecretsManagerStandalone(Organization organization);
Task<(DateTime?, DateTime?)> GetSuspensionDateAsync(Stripe.Subscription subscription);
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);
Task<PreviewInvoiceResponseModel> PreviewInvoiceAsync(PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId);

View File

@ -25,9 +25,6 @@ namespace Bit.Core.Services;
public class StripePaymentService : IPaymentService
{
private const string PremiumPlanId = "premium-annually";
private const string StoragePlanId = "storage-gb-annually";
private const string ProviderDiscountId = "msp-discount-35";
private const string SecretsManagerStandaloneDiscountId = "sm-standalone";
private readonly ITransactionRepository _transactionRepository;
@ -62,240 +59,6 @@ public class StripePaymentService : IPaymentService
_pricingClient = pricingClient;
}
public async Task<string> PurchaseOrganizationAsync(Organization org, PaymentMethodType paymentMethodType,
string paymentToken, StaticStore.Plan plan, short additionalStorageGb,
int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false,
int additionalSmSeats = 0, int additionalServiceAccount = 0, bool signupIsFromSecretsManagerTrial = false)
{
Braintree.Customer braintreeCustomer = null;
string stipeCustomerSourceToken = null;
string stipeCustomerPaymentMethodId = null;
var stripeCustomerMetadata = new Dictionary<string, string>
{
{ "region", _globalSettings.BaseServiceUri.CloudRegion }
};
var stripePaymentMethod = paymentMethodType == PaymentMethodType.Card ||
paymentMethodType == PaymentMethodType.BankAccount;
if (stripePaymentMethod && !string.IsNullOrWhiteSpace(paymentToken))
{
if (paymentToken.StartsWith("pm_"))
{
stipeCustomerPaymentMethodId = paymentToken;
}
else
{
stipeCustomerSourceToken = paymentToken;
}
}
else if (paymentMethodType == PaymentMethodType.PayPal)
{
var randomSuffix = Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false);
var customerResult = await _btGateway.Customer.CreateAsync(new Braintree.CustomerRequest
{
PaymentMethodNonce = paymentToken,
Email = org.BillingEmail,
Id = org.BraintreeCustomerIdPrefix() + org.Id.ToString("N").ToLower() + randomSuffix,
CustomFields = new Dictionary<string, string>
{
[org.BraintreeIdField()] = org.Id.ToString(),
[org.BraintreeCloudRegionField()] = _globalSettings.BaseServiceUri.CloudRegion
}
});
if (!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0)
{
throw new GatewayException("Failed to create PayPal customer record.");
}
braintreeCustomer = customerResult.Target;
stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id);
}
else
{
throw new GatewayException("Payment method is not supported at this time.");
}
var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plan, taxInfo, additionalSeats, additionalStorageGb, premiumAccessAddon
, additionalSmSeats, additionalServiceAccount);
Customer customer = null;
Subscription subscription;
try
{
if (!string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber))
{
taxInfo.TaxIdType = _taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
if (taxInfo.TaxIdType == null)
{
_logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
throw new BadRequestException("billingTaxIdTypeInferenceError");
}
}
var customerCreateOptions = new CustomerCreateOptions
{
Description = org.DisplayBusinessName(),
Email = org.BillingEmail,
Source = stipeCustomerSourceToken,
PaymentMethod = stipeCustomerPaymentMethodId,
Metadata = stripeCustomerMetadata,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = stipeCustomerPaymentMethodId,
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = org.SubscriberType(),
Value = GetFirstThirtyCharacters(org.SubscriberName()),
}
],
},
Coupon = signupIsFromSecretsManagerTrial
? SecretsManagerStandaloneDiscountId
: provider
? ProviderDiscountId
: null,
Address = new AddressOptions
{
Country = taxInfo?.BillingAddressCountry,
PostalCode = taxInfo?.BillingAddressPostalCode,
// Line1 is required in Stripe's API, suggestion in Docs is to use Business Name instead.
Line1 = taxInfo?.BillingAddressLine1 ?? string.Empty,
Line2 = taxInfo?.BillingAddressLine2,
City = taxInfo?.BillingAddressCity,
State = taxInfo?.BillingAddressState,
},
TaxIdData = !string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber)
? [new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber }]
: null
};
customerCreateOptions.AddExpand("tax");
customer = await _stripeAdapter.CustomerCreateAsync(customerCreateOptions);
subCreateOptions.AddExpand("latest_invoice.payment_intent");
subCreateOptions.Customer = customer.Id;
subCreateOptions.EnableAutomaticTax(customer);
subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions);
if (subscription.Status == "incomplete" && subscription.LatestInvoice?.PaymentIntent != null)
{
if (subscription.LatestInvoice.PaymentIntent.Status == "requires_payment_method")
{
await _stripeAdapter.SubscriptionCancelAsync(subscription.Id, new SubscriptionCancelOptions());
throw new GatewayException("Payment method was declined.");
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating customer, walking back operation.");
if (customer != null)
{
await _stripeAdapter.CustomerDeleteAsync(customer.Id);
}
if (braintreeCustomer != null)
{
await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id);
}
throw;
}
org.Gateway = GatewayType.Stripe;
org.GatewayCustomerId = customer.Id;
org.GatewaySubscriptionId = subscription.Id;
if (subscription.Status == "incomplete" &&
subscription.LatestInvoice?.PaymentIntent?.Status == "requires_action")
{
org.Enabled = false;
return subscription.LatestInvoice.PaymentIntent.ClientSecret;
}
else
{
org.Enabled = true;
org.ExpirationDate = subscription.CurrentPeriodEnd;
return null;
}
}
public async Task<string> PurchaseOrganizationNoPaymentMethod(Organization org, StaticStore.Plan plan, int additionalSeats, bool premiumAccessAddon,
int additionalSmSeats = 0, int additionalServiceAccount = 0, bool signupIsFromSecretsManagerTrial = false)
{
var stripeCustomerMetadata = new Dictionary<string, string>
{
{ "region", _globalSettings.BaseServiceUri.CloudRegion }
};
var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plan, new TaxInfo(), additionalSeats, 0, premiumAccessAddon
, additionalSmSeats, additionalServiceAccount);
Customer customer = null;
Subscription subscription;
try
{
var customerCreateOptions = new CustomerCreateOptions
{
Description = org.DisplayBusinessName(),
Email = org.BillingEmail,
Metadata = stripeCustomerMetadata,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = org.SubscriberType(),
Value = GetFirstThirtyCharacters(org.SubscriberName()),
}
],
},
Coupon = signupIsFromSecretsManagerTrial
? SecretsManagerStandaloneDiscountId
: null,
TaxIdData = null,
};
customer = await _stripeAdapter.CustomerCreateAsync(customerCreateOptions);
subCreateOptions.AddExpand("latest_invoice.payment_intent");
subCreateOptions.Customer = customer.Id;
subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating customer, walking back operation.");
if (customer != null)
{
await _stripeAdapter.CustomerDeleteAsync(customer.Id);
}
throw;
}
org.Gateway = GatewayType.Stripe;
org.GatewayCustomerId = customer.Id;
org.GatewaySubscriptionId = subscription.Id;
if (subscription.Status == "incomplete" &&
subscription.LatestInvoice?.PaymentIntent?.Status == "requires_action")
{
org.Enabled = false;
return subscription.LatestInvoice.PaymentIntent.ClientSecret;
}
org.Enabled = true;
org.ExpirationDate = subscription.CurrentPeriodEnd;
return null;
}
private async Task ChangeOrganizationSponsorship(
Organization org,
OrganizationSponsorship sponsorship,
@ -324,458 +87,6 @@ public class StripePaymentService : IPaymentService
public Task RemoveOrganizationSponsorshipAsync(Organization org, OrganizationSponsorship sponsorship) =>
ChangeOrganizationSponsorship(org, sponsorship, false);
public async Task<string> UpgradeFreeOrganizationAsync(Organization org, StaticStore.Plan plan,
OrganizationUpgrade upgrade)
{
if (!string.IsNullOrWhiteSpace(org.GatewaySubscriptionId))
{
throw new BadRequestException("Organization already has a subscription.");
}
var customerOptions = new CustomerGetOptions();
customerOptions.AddExpand("default_source");
customerOptions.AddExpand("invoice_settings.default_payment_method");
customerOptions.AddExpand("tax");
var customer = await _stripeAdapter.CustomerGetAsync(org.GatewayCustomerId, customerOptions);
if (customer == null)
{
throw new GatewayException("Could not find customer payment profile.");
}
if (!string.IsNullOrEmpty(upgrade.TaxInfo?.BillingAddressCountry) &&
!string.IsNullOrEmpty(upgrade.TaxInfo?.BillingAddressPostalCode))
{
var addressOptions = new AddressOptions
{
Country = upgrade.TaxInfo.BillingAddressCountry,
PostalCode = upgrade.TaxInfo.BillingAddressPostalCode,
// Line1 is required in Stripe's API, suggestion in Docs is to use Business Name instead.
Line1 = upgrade.TaxInfo.BillingAddressLine1 ?? string.Empty,
Line2 = upgrade.TaxInfo.BillingAddressLine2,
City = upgrade.TaxInfo.BillingAddressCity,
State = upgrade.TaxInfo.BillingAddressState,
};
var customerUpdateOptions = new CustomerUpdateOptions { Address = addressOptions };
customerUpdateOptions.AddExpand("default_source");
customerUpdateOptions.AddExpand("invoice_settings.default_payment_method");
customerUpdateOptions.AddExpand("tax");
customer = await _stripeAdapter.CustomerUpdateAsync(org.GatewayCustomerId, customerUpdateOptions);
}
var subCreateOptions = new OrganizationUpgradeSubscriptionOptions(customer.Id, org, plan, upgrade);
subCreateOptions.EnableAutomaticTax(customer);
var (stripePaymentMethod, paymentMethodType) = IdentifyPaymentMethod(customer, subCreateOptions);
var subscription = await ChargeForNewSubscriptionAsync(org, customer, false,
stripePaymentMethod, paymentMethodType, subCreateOptions, null);
org.GatewaySubscriptionId = subscription.Id;
if (subscription.Status == "incomplete" &&
subscription.LatestInvoice?.PaymentIntent?.Status == "requires_action")
{
org.Enabled = false;
return subscription.LatestInvoice.PaymentIntent.ClientSecret;
}
else
{
org.Enabled = true;
org.ExpirationDate = subscription.CurrentPeriodEnd;
return null;
}
}
private (bool stripePaymentMethod, PaymentMethodType PaymentMethodType) IdentifyPaymentMethod(
Customer customer, SubscriptionCreateOptions subCreateOptions)
{
var stripePaymentMethod = false;
var paymentMethodType = PaymentMethodType.Credit;
var hasBtCustomerId = customer.Metadata.ContainsKey("btCustomerId");
if (hasBtCustomerId)
{
paymentMethodType = PaymentMethodType.PayPal;
}
else
{
if (customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card")
{
paymentMethodType = PaymentMethodType.Card;
stripePaymentMethod = true;
}
else if (customer.DefaultSource != null)
{
if (customer.DefaultSource is Card || customer.DefaultSource is SourceCard)
{
paymentMethodType = PaymentMethodType.Card;
stripePaymentMethod = true;
}
else if (customer.DefaultSource is BankAccount || customer.DefaultSource is SourceAchDebit)
{
paymentMethodType = PaymentMethodType.BankAccount;
stripePaymentMethod = true;
}
}
else
{
var paymentMethod = GetLatestCardPaymentMethod(customer.Id);
if (paymentMethod != null)
{
paymentMethodType = PaymentMethodType.Card;
stripePaymentMethod = true;
subCreateOptions.DefaultPaymentMethod = paymentMethod.Id;
}
}
}
return (stripePaymentMethod, paymentMethodType);
}
public async Task<string> PurchasePremiumAsync(User user, PaymentMethodType paymentMethodType,
string paymentToken, short additionalStorageGb, TaxInfo taxInfo)
{
if (paymentMethodType != PaymentMethodType.Credit && string.IsNullOrWhiteSpace(paymentToken))
{
throw new BadRequestException("Payment token is required.");
}
if (paymentMethodType == PaymentMethodType.Credit &&
(user.Gateway != GatewayType.Stripe || string.IsNullOrWhiteSpace(user.GatewayCustomerId)))
{
throw new BadRequestException("Your account does not have any credit available.");
}
if (paymentMethodType is PaymentMethodType.BankAccount)
{
throw new GatewayException("Payment method is not supported at this time.");
}
var createdStripeCustomer = false;
Customer customer = null;
Braintree.Customer braintreeCustomer = null;
var stripePaymentMethod = paymentMethodType is PaymentMethodType.Card or PaymentMethodType.BankAccount
or PaymentMethodType.Credit;
string stipeCustomerPaymentMethodId = null;
string stipeCustomerSourceToken = null;
if (stripePaymentMethod && !string.IsNullOrWhiteSpace(paymentToken))
{
if (paymentToken.StartsWith("pm_"))
{
stipeCustomerPaymentMethodId = paymentToken;
}
else
{
stipeCustomerSourceToken = paymentToken;
}
}
if (user.Gateway == GatewayType.Stripe && !string.IsNullOrWhiteSpace(user.GatewayCustomerId))
{
if (!string.IsNullOrWhiteSpace(paymentToken))
{
await UpdatePaymentMethodAsync(user, paymentMethodType, paymentToken, taxInfo);
}
try
{
var customerGetOptions = new CustomerGetOptions();
customerGetOptions.AddExpand("tax");
customer = await _stripeAdapter.CustomerGetAsync(user.GatewayCustomerId, customerGetOptions);
}
catch
{
_logger.LogWarning(
"Attempted to get existing customer from Stripe, but customer ID was not found. Attempting to recreate customer...");
}
}
if (customer == null && !string.IsNullOrWhiteSpace(paymentToken))
{
var stripeCustomerMetadata = new Dictionary<string, string>
{
{ "region", _globalSettings.BaseServiceUri.CloudRegion }
};
if (paymentMethodType == PaymentMethodType.PayPal)
{
var randomSuffix = Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false);
var customerResult = await _btGateway.Customer.CreateAsync(new Braintree.CustomerRequest
{
PaymentMethodNonce = paymentToken,
Email = user.Email,
Id = user.BraintreeCustomerIdPrefix() + user.Id.ToString("N").ToLower() + randomSuffix,
CustomFields = new Dictionary<string, string>
{
[user.BraintreeIdField()] = user.Id.ToString(),
[user.BraintreeCloudRegionField()] = _globalSettings.BaseServiceUri.CloudRegion
}
});
if (!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0)
{
throw new GatewayException("Failed to create PayPal customer record.");
}
braintreeCustomer = customerResult.Target;
stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id);
}
else if (!stripePaymentMethod)
{
throw new GatewayException("Payment method is not supported at this time.");
}
var customerCreateOptions = new CustomerCreateOptions
{
Description = user.Name,
Email = user.Email,
Metadata = stripeCustomerMetadata,
PaymentMethod = stipeCustomerPaymentMethodId,
Source = stipeCustomerSourceToken,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = stipeCustomerPaymentMethodId,
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions()
{
Name = user.SubscriberType(),
Value = GetFirstThirtyCharacters(user.SubscriberName()),
}
]
},
Address = new AddressOptions
{
Line1 = string.Empty,
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode,
},
};
customerCreateOptions.AddExpand("tax");
customer = await _stripeAdapter.CustomerCreateAsync(customerCreateOptions);
createdStripeCustomer = true;
}
if (customer == null)
{
throw new GatewayException("Could not set up customer payment profile.");
}
var subCreateOptions = new SubscriptionCreateOptions
{
Customer = customer.Id,
Items = [],
Metadata = new Dictionary<string, string>
{
[user.GatewayIdField()] = user.Id.ToString()
}
};
subCreateOptions.Items.Add(new SubscriptionItemOptions
{
Plan = PremiumPlanId,
Quantity = 1
});
if (additionalStorageGb > 0)
{
subCreateOptions.Items.Add(new SubscriptionItemOptions
{
Plan = StoragePlanId,
Quantity = additionalStorageGb
});
}
subCreateOptions.EnableAutomaticTax(customer);
var subscription = await ChargeForNewSubscriptionAsync(user, customer, createdStripeCustomer,
stripePaymentMethod, paymentMethodType, subCreateOptions, braintreeCustomer);
user.Gateway = GatewayType.Stripe;
user.GatewayCustomerId = customer.Id;
user.GatewaySubscriptionId = subscription.Id;
if (subscription.Status == "incomplete" &&
subscription.LatestInvoice?.PaymentIntent?.Status == "requires_action")
{
return subscription.LatestInvoice.PaymentIntent.ClientSecret;
}
user.Premium = true;
user.PremiumExpirationDate = subscription.CurrentPeriodEnd;
return null;
}
private async Task<Subscription> ChargeForNewSubscriptionAsync(ISubscriber subscriber, Customer customer,
bool createdStripeCustomer, bool stripePaymentMethod, PaymentMethodType paymentMethodType,
SubscriptionCreateOptions subCreateOptions, Braintree.Customer braintreeCustomer)
{
var addedCreditToStripeCustomer = false;
Braintree.Transaction braintreeTransaction = null;
var subInvoiceMetadata = new Dictionary<string, string>();
Subscription subscription = null;
try
{
if (!stripePaymentMethod)
{
var previewInvoice = await _stripeAdapter.InvoiceUpcomingAsync(new UpcomingInvoiceOptions
{
Customer = customer.Id,
SubscriptionItems = ToInvoiceSubscriptionItemOptions(subCreateOptions.Items)
});
if (customer.HasTaxLocationVerified())
{
previewInvoice.AutomaticTax = new InvoiceAutomaticTax { Enabled = true };
}
if (previewInvoice.AmountDue > 0)
{
var braintreeCustomerId = customer.Metadata != null &&
customer.Metadata.ContainsKey("btCustomerId") ? customer.Metadata["btCustomerId"] : null;
if (!string.IsNullOrWhiteSpace(braintreeCustomerId))
{
var btInvoiceAmount = (previewInvoice.AmountDue / 100M);
var transactionResult = await _btGateway.Transaction.SaleAsync(
new Braintree.TransactionRequest
{
Amount = btInvoiceAmount,
CustomerId = braintreeCustomerId,
Options = new Braintree.TransactionOptionsRequest
{
SubmitForSettlement = true,
PayPal = new Braintree.TransactionOptionsPayPalRequest
{
CustomField = $"{subscriber.BraintreeIdField()}:{subscriber.Id},{subscriber.BraintreeCloudRegionField()}:{_globalSettings.BaseServiceUri.CloudRegion}"
}
},
CustomFields = new Dictionary<string, string>
{
[subscriber.BraintreeIdField()] = subscriber.Id.ToString(),
[subscriber.BraintreeCloudRegionField()] = _globalSettings.BaseServiceUri.CloudRegion
}
});
if (!transactionResult.IsSuccess())
{
throw new GatewayException("Failed to charge PayPal customer.");
}
braintreeTransaction = transactionResult.Target;
subInvoiceMetadata.Add("btTransactionId", braintreeTransaction.Id);
subInvoiceMetadata.Add("btPayPalTransactionId",
braintreeTransaction.PayPalDetails.AuthorizationId);
}
else
{
throw new GatewayException("No payment was able to be collected.");
}
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Balance = customer.Balance - previewInvoice.AmountDue
});
addedCreditToStripeCustomer = true;
}
}
else if (paymentMethodType == PaymentMethodType.Credit)
{
var upcomingInvoiceOptions = new UpcomingInvoiceOptions
{
Customer = customer.Id,
SubscriptionItems = ToInvoiceSubscriptionItemOptions(subCreateOptions.Items),
SubscriptionDefaultTaxRates = subCreateOptions.DefaultTaxRates,
};
upcomingInvoiceOptions.EnableAutomaticTax(customer, null);
var previewInvoice = await _stripeAdapter.InvoiceUpcomingAsync(upcomingInvoiceOptions);
if (previewInvoice.AmountDue > 0)
{
throw new GatewayException("Your account does not have enough credit available.");
}
}
subCreateOptions.OffSession = true;
subCreateOptions.AddExpand("latest_invoice.payment_intent");
subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions);
if (subscription.Status == "incomplete" && subscription.LatestInvoice?.PaymentIntent != null)
{
if (subscription.LatestInvoice.PaymentIntent.Status == "requires_payment_method")
{
await _stripeAdapter.SubscriptionCancelAsync(subscription.Id, new SubscriptionCancelOptions());
throw new GatewayException("Payment method was declined.");
}
}
if (!stripePaymentMethod && subInvoiceMetadata.Any())
{
var invoices = await _stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions
{
Subscription = subscription.Id
});
var invoice = invoices?.FirstOrDefault();
if (invoice == null)
{
throw new GatewayException("Invoice not found.");
}
await _stripeAdapter.InvoiceUpdateAsync(invoice.Id, new InvoiceUpdateOptions
{
Metadata = subInvoiceMetadata
});
}
return subscription;
}
catch (Exception e)
{
if (customer != null)
{
if (createdStripeCustomer)
{
await _stripeAdapter.CustomerDeleteAsync(customer.Id);
}
else if (addedCreditToStripeCustomer || customer.Balance < 0)
{
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Balance = customer.Balance
});
}
}
if (braintreeTransaction != null)
{
await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id);
}
if (braintreeCustomer != null)
{
await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id);
}
if (e is StripeException strEx &&
(strEx.StripeError?.Message?.Contains("cannot be used because it is not verified") ?? false))
{
throw new GatewayException("Bank account is not yet verified.");
}
throw;
}
}
private List<InvoiceSubscriptionItemOptions> ToInvoiceSubscriptionItemOptions(
List<SubscriptionItemOptions> subItemOptions)
{
return subItemOptions.Select(si => new InvoiceSubscriptionItemOptions
{
Plan = si.Plan,
Price = si.Price,
Quantity = si.Quantity,
Id = si.Id
}).ToList();
}
private async Task<string> FinalizeSubscriptionChangeAsync(ISubscriber subscriber,
SubscriptionUpdate subscriptionUpdate, bool invoiceNow = false)
{
@ -1400,7 +711,7 @@ public class StripePaymentService : IPaymentService
new CustomerInvoiceSettingsCustomFieldOptions()
{
Name = subscriber.SubscriberType(),
Value = GetFirstThirtyCharacters(subscriber.SubscriberName()),
Value = subscriber.GetFormattedInvoiceName()
}
]
@ -1492,7 +803,7 @@ public class StripePaymentService : IPaymentService
new CustomerInvoiceSettingsCustomFieldOptions()
{
Name = subscriber.SubscriberType(),
Value = GetFirstThirtyCharacters(subscriber.SubscriberName())
Value = subscriber.GetFormattedInvoiceName()
}
]
},
@ -1560,7 +871,7 @@ public class StripePaymentService : IPaymentService
var customer = await GetCustomerAsync(subscriber.GatewayCustomerId, GetCustomerPaymentOptions());
var billingInfo = new BillingInfo
{
Balance = GetBillingBalance(customer),
Balance = customer.GetBillingBalance(),
PaymentSource = await GetBillingPaymentSourceAsync(customer)
};
@ -1768,27 +1079,6 @@ public class StripePaymentService : IPaymentService
new SecretsManagerSubscribeUpdate(org, plan, additionalSmSeats, additionalServiceAccount),
true);
public async Task<bool> RisksSubscriptionFailure(Organization organization)
{
var subscriptionInfo = await GetSubscriptionAsync(organization);
if (subscriptionInfo.Subscription is not
{
Status: "active" or "trialing" or "past_due",
CollectionMethod: "charge_automatically"
}
|| subscriptionInfo.UpcomingInvoice == null)
{
return false;
}
var customer = await GetCustomerAsync(organization.GatewayCustomerId, GetCustomerPaymentOptions());
var paymentSource = await GetBillingPaymentSourceAsync(customer);
return paymentSource == null;
}
public async Task<bool> HasSecretsManagerStandalone(Organization organization)
{
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
@ -1801,7 +1091,7 @@ public class StripePaymentService : IPaymentService
return customer?.Discount?.Coupon?.Id == SecretsManagerStandaloneDiscountId;
}
public async Task<(DateTime?, DateTime?)> GetSuspensionDateAsync(Subscription subscription)
private async Task<(DateTime?, DateTime?)> GetSuspensionDateAsync(Subscription subscription)
{
if (subscription.Status is not "past_due" && subscription.Status is not "unpaid")
{
@ -2117,11 +1407,6 @@ public class StripePaymentService : IPaymentService
return cardPaymentMethods.OrderByDescending(m => m.Created).FirstOrDefault();
}
private decimal GetBillingBalance(Customer customer)
{
return customer != null ? customer.Balance / 100M : default;
}
private async Task<BillingInfo.BillingSource> GetBillingPaymentSourceAsync(Customer customer)
{
if (customer == null)
@ -2252,18 +1537,4 @@ public class StripePaymentService : IPaymentService
throw new GatewayException("Failed to retrieve current invoices", exception);
}
}
// We are taking only first 30 characters of the SubscriberName because stripe provide
// for 30 characters for custom_fields,see the link: https://stripe.com/docs/api/invoices/create
private static string GetFirstThirtyCharacters(string subscriberName)
{
if (string.IsNullOrWhiteSpace(subscriberName))
{
return string.Empty;
}
return subscriberName.Length <= 30
? subscriberName
: subscriberName[..30];
}
}

View File

@ -0,0 +1,23 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Extensions;
using Xunit;
namespace Bit.Core.Test.Extensions;
public class SubscriberExtensionsTests
{
[Theory]
[InlineData("Alexandria Villanueva Gonzalez Pablo", "Alexandria Villanueva Gonzalez")]
[InlineData("John Snow", "John Snow")]
public void GetFormattedInvoiceName_Returns_FirstThirtyCaractersOfName(string name, string expected)
{
// arrange
var provider = new Provider { Name = name };
// act
var actual = provider.GetFormattedInvoiceName();
// assert
Assert.Equal(expected, actual);
}
}

View File

@ -1,828 +0,0 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Braintree;
using NSubstitute;
using Xunit;
using Customer = Braintree.Customer;
using PaymentMethod = Braintree.PaymentMethod;
using PaymentMethodType = Bit.Core.Enums.PaymentMethodType;
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
public class StripePaymentServiceTests
{
[Theory]
[BitAutoData(PaymentMethodType.BitPay)]
[BitAutoData(PaymentMethodType.BitPay)]
[BitAutoData(PaymentMethodType.Credit)]
[BitAutoData(PaymentMethodType.WireTransfer)]
[BitAutoData(PaymentMethodType.Check)]
public async Task PurchaseOrganizationAsync_Invalid(PaymentMethodType paymentMethodType, SutProvider<StripePaymentService> sutProvider)
{
var exception = await Assert.ThrowsAsync<GatewayException>(
() => sutProvider.Sut.PurchaseOrganizationAsync(null, paymentMethodType, null, null, 0, 0, false, null, false, -1, -1));
Assert.Equal("Payment method is not supported at this time.", exception.Message);
}
[Theory, BitAutoData]
public async Task PurchaseOrganizationAsync_Stripe_ProviderOrg_Coupon_Add(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo, bool provider = true)
{
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
sutProvider
.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(p => p == taxInfo.BillingAddressCountry), Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
});
sutProvider.GetDependency<IGlobalSettings>()
.BaseServiceUri.CloudRegion
.Returns("US");
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo, provider);
Assert.Null(result);
Assert.Equal(GatewayType.Stripe, organization.Gateway);
Assert.Equal("C-1", organization.GatewayCustomerId);
Assert.Equal("S-1", organization.GatewaySubscriptionId);
Assert.True(organization.Enabled);
Assert.Equal(DateTime.Today.AddDays(10), organization.ExpirationDate);
await stripeAdapter.Received().CustomerCreateAsync(Arg.Is<Stripe.CustomerCreateOptions>(c =>
c.Description == organization.BusinessName &&
c.Email == organization.BillingEmail &&
c.Source == paymentToken &&
c.PaymentMethod == null &&
c.Coupon == "msp-discount-35" &&
c.Metadata.Count == 1 &&
c.Metadata["region"] == "US" &&
c.InvoiceSettings.DefaultPaymentMethod == null &&
c.Address.Country == taxInfo.BillingAddressCountry &&
c.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
c.Address.Line1 == taxInfo.BillingAddressLine1 &&
c.Address.Line2 == taxInfo.BillingAddressLine2 &&
c.Address.City == taxInfo.BillingAddressCity &&
c.Address.State == taxInfo.BillingAddressState &&
c.TaxIdData.First().Value == taxInfo.TaxIdNumber &&
c.TaxIdData.First().Type == taxInfo.TaxIdType
));
await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is<Stripe.SubscriptionCreateOptions>(s =>
s.Customer == "C-1" &&
s.Expand[0] == "latest_invoice.payment_intent" &&
s.Metadata[organization.GatewayIdField()] == organization.Id.ToString() &&
s.Items.Count == 0
));
}
[Theory, BitAutoData]
public async Task PurchaseOrganizationAsync_SM_Stripe_ProviderOrg_Coupon_Add(SutProvider<StripePaymentService> sutProvider, Organization organization,
string paymentToken, TaxInfo taxInfo, bool provider = true)
{
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
organization.UseSecretsManager = true;
sutProvider
.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(p => p == taxInfo.BillingAddressCountry), Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
});
sutProvider.GetDependency<IGlobalSettings>()
.BaseServiceUri.CloudRegion
.Returns("US");
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 1, 1,
false, taxInfo, provider, 1, 1);
Assert.Null(result);
Assert.Equal(GatewayType.Stripe, organization.Gateway);
Assert.Equal("C-1", organization.GatewayCustomerId);
Assert.Equal("S-1", organization.GatewaySubscriptionId);
Assert.True(organization.Enabled);
Assert.Equal(DateTime.Today.AddDays(10), organization.ExpirationDate);
await stripeAdapter.Received().CustomerCreateAsync(Arg.Is<Stripe.CustomerCreateOptions>(c =>
c.Description == organization.BusinessName &&
c.Email == organization.BillingEmail &&
c.Source == paymentToken &&
c.PaymentMethod == null &&
c.Coupon == "msp-discount-35" &&
c.Metadata.Count == 1 &&
c.Metadata["region"] == "US" &&
c.InvoiceSettings.DefaultPaymentMethod == null &&
c.Address.Country == taxInfo.BillingAddressCountry &&
c.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
c.Address.Line1 == taxInfo.BillingAddressLine1 &&
c.Address.Line2 == taxInfo.BillingAddressLine2 &&
c.Address.City == taxInfo.BillingAddressCity &&
c.Address.State == taxInfo.BillingAddressState &&
c.TaxIdData.First().Value == taxInfo.TaxIdNumber &&
c.TaxIdData.First().Type == taxInfo.TaxIdType
));
await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is<Stripe.SubscriptionCreateOptions>(s =>
s.Customer == "C-1" &&
s.Expand[0] == "latest_invoice.payment_intent" &&
s.Metadata[organization.GatewayIdField()] == organization.Id.ToString() &&
s.Items.Count == 4
));
}
[Theory, BitAutoData]
public async Task PurchaseOrganizationAsync_Stripe(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
organization.UseSecretsManager = true;
sutProvider
.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(p => p == taxInfo.BillingAddressCountry), Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
});
sutProvider.GetDependency<IGlobalSettings>()
.BaseServiceUri.CloudRegion
.Returns("US");
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0
, false, taxInfo, false, 8, 10);
Assert.Null(result);
Assert.Equal(GatewayType.Stripe, organization.Gateway);
Assert.Equal("C-1", organization.GatewayCustomerId);
Assert.Equal("S-1", organization.GatewaySubscriptionId);
Assert.True(organization.Enabled);
Assert.Equal(DateTime.Today.AddDays(10), organization.ExpirationDate);
await stripeAdapter.Received().CustomerCreateAsync(Arg.Is<Stripe.CustomerCreateOptions>(c =>
c.Description == organization.BusinessName &&
c.Email == organization.BillingEmail &&
c.Source == paymentToken &&
c.PaymentMethod == null &&
c.Metadata.Count == 1 &&
c.Metadata["region"] == "US" &&
c.InvoiceSettings.DefaultPaymentMethod == null &&
c.InvoiceSettings.CustomFields != null &&
c.InvoiceSettings.CustomFields[0].Name == "Organization" &&
c.InvoiceSettings.CustomFields[0].Value == organization.SubscriberName().Substring(0, 30) &&
c.Address.Country == taxInfo.BillingAddressCountry &&
c.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
c.Address.Line1 == taxInfo.BillingAddressLine1 &&
c.Address.Line2 == taxInfo.BillingAddressLine2 &&
c.Address.City == taxInfo.BillingAddressCity &&
c.Address.State == taxInfo.BillingAddressState &&
c.TaxIdData.First().Value == taxInfo.TaxIdNumber &&
c.TaxIdData.First().Type == taxInfo.TaxIdType
));
await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is<Stripe.SubscriptionCreateOptions>(s =>
s.Customer == "C-1" &&
s.Expand[0] == "latest_invoice.payment_intent" &&
s.Metadata[organization.GatewayIdField()] == organization.Id.ToString() &&
s.Items.Count == 2
));
}
[Theory, BitAutoData]
public async Task PurchaseOrganizationAsync_Stripe_PM(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
paymentToken = "pm_" + paymentToken;
sutProvider
.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(p => p == taxInfo.BillingAddressCountry), Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
});
sutProvider.GetDependency<IGlobalSettings>()
.BaseServiceUri.CloudRegion
.Returns("US");
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo);
Assert.Null(result);
Assert.Equal(GatewayType.Stripe, organization.Gateway);
Assert.Equal("C-1", organization.GatewayCustomerId);
Assert.Equal("S-1", organization.GatewaySubscriptionId);
Assert.True(organization.Enabled);
Assert.Equal(DateTime.Today.AddDays(10), organization.ExpirationDate);
await stripeAdapter.Received().CustomerCreateAsync(Arg.Is<Stripe.CustomerCreateOptions>(c =>
c.Description == organization.BusinessName &&
c.Email == organization.BillingEmail &&
c.Source == null &&
c.PaymentMethod == paymentToken &&
c.Metadata.Count == 1 &&
c.Metadata["region"] == "US" &&
c.InvoiceSettings.DefaultPaymentMethod == paymentToken &&
c.InvoiceSettings.CustomFields != null &&
c.InvoiceSettings.CustomFields[0].Name == "Organization" &&
c.InvoiceSettings.CustomFields[0].Value == organization.SubscriberName().Substring(0, 30) &&
c.Address.Country == taxInfo.BillingAddressCountry &&
c.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
c.Address.Line1 == taxInfo.BillingAddressLine1 &&
c.Address.Line2 == taxInfo.BillingAddressLine2 &&
c.Address.City == taxInfo.BillingAddressCity &&
c.Address.State == taxInfo.BillingAddressState &&
c.TaxIdData.First().Value == taxInfo.TaxIdNumber &&
c.TaxIdData.First().Type == taxInfo.TaxIdType
));
await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is<Stripe.SubscriptionCreateOptions>(s =>
s.Customer == "C-1" &&
s.Expand[0] == "latest_invoice.payment_intent" &&
s.Metadata[organization.GatewayIdField()] == organization.Id.ToString() &&
s.Items.Count == 0
));
}
[Theory, BitAutoData]
public async Task PurchaseOrganizationAsync_Stripe_Declined(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
paymentToken = "pm_" + paymentToken;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
Status = "incomplete",
LatestInvoice = new Stripe.Invoice
{
PaymentIntent = new Stripe.PaymentIntent
{
Status = "requires_payment_method",
},
},
});
var exception = await Assert.ThrowsAsync<GatewayException>(
() => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo));
Assert.Equal("Payment method was declined.", exception.Message);
await stripeAdapter.Received(1).CustomerDeleteAsync("C-1");
}
[Theory, BitAutoData]
public async Task PurchaseOrganizationAsync_SM_Stripe_Declined(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
paymentToken = "pm_" + paymentToken;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
Status = "incomplete",
LatestInvoice = new Stripe.Invoice
{
PaymentIntent = new Stripe.PaymentIntent
{
Status = "requires_payment_method",
},
},
});
var exception = await Assert.ThrowsAsync<GatewayException>(
() => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan,
1, 12, false, taxInfo, false, 10, 10));
Assert.Equal("Payment method was declined.", exception.Message);
await stripeAdapter.Received(1).CustomerDeleteAsync("C-1");
}
[Theory, BitAutoData]
public async Task PurchaseOrganizationAsync_Stripe_RequiresAction(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
Status = "incomplete",
LatestInvoice = new Stripe.Invoice
{
PaymentIntent = new Stripe.PaymentIntent
{
Status = "requires_action",
ClientSecret = "clientSecret",
},
},
});
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo);
Assert.Equal("clientSecret", result);
Assert.False(organization.Enabled);
}
[Theory, BitAutoData]
public async Task PurchaseOrganizationAsync_SM_Stripe_RequiresAction(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
Status = "incomplete",
LatestInvoice = new Stripe.Invoice
{
PaymentIntent = new Stripe.PaymentIntent
{
Status = "requires_action",
ClientSecret = "clientSecret",
},
},
});
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan,
10, 10, false, taxInfo, false, 10, 10);
Assert.Equal("clientSecret", result);
Assert.False(organization.Enabled);
}
[Theory, BitAutoData]
public async Task PurchaseOrganizationAsync_Paypal(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
sutProvider
.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(p => p == taxInfo.BillingAddressCountry), Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
});
sutProvider.GetDependency<IGlobalSettings>()
.BaseServiceUri.CloudRegion
.Returns("US");
var customer = Substitute.For<Customer>();
customer.Id.ReturnsForAnyArgs("Braintree-Id");
customer.PaymentMethods.ReturnsForAnyArgs(new[] { Substitute.For<PaymentMethod>() });
var customerResult = Substitute.For<Result<Customer>>();
customerResult.IsSuccess().Returns(true);
customerResult.Target.ReturnsForAnyArgs(customer);
var braintreeGateway = sutProvider.GetDependency<IBraintreeGateway>();
braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult);
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plan, 0, 0, false, taxInfo);
Assert.Null(result);
Assert.Equal(GatewayType.Stripe, organization.Gateway);
Assert.Equal("C-1", organization.GatewayCustomerId);
Assert.Equal("S-1", organization.GatewaySubscriptionId);
Assert.True(organization.Enabled);
Assert.Equal(DateTime.Today.AddDays(10), organization.ExpirationDate);
await stripeAdapter.Received().CustomerCreateAsync(Arg.Is<Stripe.CustomerCreateOptions>(c =>
c.Description == organization.BusinessName &&
c.Email == organization.BillingEmail &&
c.PaymentMethod == null &&
c.Metadata.Count == 2 &&
c.Metadata["btCustomerId"] == "Braintree-Id" &&
c.Metadata["region"] == "US" &&
c.InvoiceSettings.DefaultPaymentMethod == null &&
c.Address.Country == taxInfo.BillingAddressCountry &&
c.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
c.Address.Line1 == taxInfo.BillingAddressLine1 &&
c.Address.Line2 == taxInfo.BillingAddressLine2 &&
c.Address.City == taxInfo.BillingAddressCity &&
c.Address.State == taxInfo.BillingAddressState &&
c.TaxIdData.First().Value == taxInfo.TaxIdNumber &&
c.TaxIdData.First().Type == taxInfo.TaxIdType
));
await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is<Stripe.SubscriptionCreateOptions>(s =>
s.Customer == "C-1" &&
s.Expand[0] == "latest_invoice.payment_intent" &&
s.Metadata[organization.GatewayIdField()] == organization.Id.ToString() &&
s.Items.Count == 0
));
}
[Theory, BitAutoData]
public async Task PurchaseOrganizationAsync_SM_Paypal(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
organization.UseSecretsManager = true;
sutProvider
.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(p => p == taxInfo.BillingAddressCountry), Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
.Returns(taxInfo.TaxIdType);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
});
var customer = Substitute.For<Customer>();
customer.Id.ReturnsForAnyArgs("Braintree-Id");
customer.PaymentMethods.ReturnsForAnyArgs(new[] { Substitute.For<PaymentMethod>() });
var customerResult = Substitute.For<Result<Customer>>();
customerResult.IsSuccess().Returns(true);
customerResult.Target.ReturnsForAnyArgs(customer);
var braintreeGateway = sutProvider.GetDependency<IBraintreeGateway>();
braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult);
sutProvider.GetDependency<IGlobalSettings>()
.BaseServiceUri.CloudRegion
.Returns("US");
var additionalStorage = (short)2;
var additionalSeats = 10;
var additionalSmSeats = 5;
var additionalServiceAccounts = 20;
var result = await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plan,
additionalStorage, additionalSeats, false, taxInfo, false, additionalSmSeats, additionalServiceAccounts);
Assert.Null(result);
Assert.Equal(GatewayType.Stripe, organization.Gateway);
Assert.Equal("C-1", organization.GatewayCustomerId);
Assert.Equal("S-1", organization.GatewaySubscriptionId);
Assert.True(organization.Enabled);
Assert.Equal(DateTime.Today.AddDays(10), organization.ExpirationDate);
await stripeAdapter.Received().CustomerCreateAsync(Arg.Is<Stripe.CustomerCreateOptions>(c =>
c.Description == organization.BusinessName &&
c.Email == organization.BillingEmail &&
c.PaymentMethod == null &&
c.Metadata.Count == 2 &&
c.Metadata["region"] == "US" &&
c.Metadata["btCustomerId"] == "Braintree-Id" &&
c.InvoiceSettings.DefaultPaymentMethod == null &&
c.Address.Country == taxInfo.BillingAddressCountry &&
c.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
c.Address.Line1 == taxInfo.BillingAddressLine1 &&
c.Address.Line2 == taxInfo.BillingAddressLine2 &&
c.Address.City == taxInfo.BillingAddressCity &&
c.Address.State == taxInfo.BillingAddressState &&
c.TaxIdData.First().Value == taxInfo.TaxIdNumber &&
c.TaxIdData.First().Type == taxInfo.TaxIdType
));
await stripeAdapter.Received().SubscriptionCreateAsync(Arg.Is<Stripe.SubscriptionCreateOptions>(s =>
s.Customer == "C-1" &&
s.Expand[0] == "latest_invoice.payment_intent" &&
s.Metadata[organization.GatewayIdField()] == organization.Id.ToString() &&
s.Items.Count == 4 &&
s.Items.Count(i => i.Plan == plan.PasswordManager.StripeSeatPlanId && i.Quantity == additionalSeats) == 1 &&
s.Items.Count(i => i.Plan == plan.PasswordManager.StripeStoragePlanId && i.Quantity == additionalStorage) == 1 &&
s.Items.Count(i => i.Plan == plan.SecretsManager.StripeSeatPlanId && i.Quantity == additionalSmSeats) == 1 &&
s.Items.Count(i => i.Plan == plan.SecretsManager.StripeServiceAccountPlanId && i.Quantity == additionalServiceAccounts) == 1
));
}
[Theory, BitAutoData]
public async Task PurchaseOrganizationAsync_Paypal_FailedCreate(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
var customerResult = Substitute.For<Result<Customer>>();
customerResult.IsSuccess().Returns(false);
var braintreeGateway = sutProvider.GetDependency<IBraintreeGateway>();
braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult);
var exception = await Assert.ThrowsAsync<GatewayException>(
() => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plan, 0, 0, false, taxInfo));
Assert.Equal("Failed to create PayPal customer record.", exception.Message);
}
[Theory, BitAutoData]
public async Task PurchaseOrganizationAsync_SM_Paypal_FailedCreate(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
var customerResult = Substitute.For<Result<Customer>>();
customerResult.IsSuccess().Returns(false);
var braintreeGateway = sutProvider.GetDependency<IBraintreeGateway>();
braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult);
var exception = await Assert.ThrowsAsync<GatewayException>(
() => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plan,
1, 1, false, taxInfo, false, 8, 8));
Assert.Equal("Failed to create PayPal customer record.", exception.Message);
}
[Theory, BitAutoData]
public async Task PurchaseOrganizationAsync_PayPal_Declined(SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
var plans = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
paymentToken = "pm_" + paymentToken;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
Status = "incomplete",
LatestInvoice = new Stripe.Invoice
{
PaymentIntent = new Stripe.PaymentIntent
{
Status = "requires_payment_method",
},
},
});
var customer = Substitute.For<Customer>();
customer.Id.ReturnsForAnyArgs("Braintree-Id");
customer.PaymentMethods.ReturnsForAnyArgs(new[] { Substitute.For<PaymentMethod>() });
var customerResult = Substitute.For<Result<Customer>>();
customerResult.IsSuccess().Returns(true);
customerResult.Target.ReturnsForAnyArgs(customer);
var braintreeGateway = sutProvider.GetDependency<IBraintreeGateway>();
braintreeGateway.Customer.CreateAsync(default).ReturnsForAnyArgs(customerResult);
var exception = await Assert.ThrowsAsync<GatewayException>(
() => sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.PayPal, paymentToken, plans, 0, 0, false, taxInfo));
Assert.Equal("Payment method was declined.", exception.Message);
await stripeAdapter.Received(1).CustomerDeleteAsync("C-1");
await braintreeGateway.Customer.Received(1).DeleteAsync("Braintree-Id");
}
[Theory]
[BitAutoData("ES", "A5372895732985327895237")]
public async Task PurchaseOrganizationAsync_ThrowsBadRequestException_WhenTaxIdInvalid(string country, string taxId, SutProvider<StripePaymentService> sutProvider, Organization organization, string paymentToken, TaxInfo taxInfo)
{
taxInfo.BillingAddressCountry = country;
taxInfo.TaxIdNumber = taxId;
taxInfo.TaxIdType = null;
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
organization.UseSecretsManager = true;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerCreateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
});
sutProvider.GetDependency<IGlobalSettings>()
.BaseServiceUri.CloudRegion
.Returns("US");
sutProvider
.GetDependency<ITaxService>()
.GetStripeTaxCode(Arg.Is<string>(p => p == country), Arg.Is<string>(p => p == taxId))
.Returns((string)null);
var actual = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.PurchaseOrganizationAsync(organization, PaymentMethodType.Card, paymentToken, plan, 0, 0, false, taxInfo, false, 8, 10));
Assert.Equal("billingTaxIdTypeInferenceError", actual.Message);
await stripeAdapter.Received(0).CustomerCreateAsync(Arg.Any<Stripe.CustomerCreateOptions>());
await stripeAdapter.Received(0).SubscriptionCreateAsync(Arg.Any<Stripe.SubscriptionCreateOptions>());
}
[Theory, BitAutoData]
public async Task UpgradeFreeOrganizationAsync_Success(SutProvider<StripePaymentService> sutProvider,
Organization organization, TaxInfo taxInfo)
{
organization.GatewaySubscriptionId = null;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerGetAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
Metadata = new Dictionary<string, string>
{
{ "btCustomerId", "B-123" },
}
});
stripeAdapter.CustomerUpdateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
Metadata = new Dictionary<string, string>
{
{ "btCustomerId", "B-123" },
}
});
stripeAdapter.InvoiceUpcomingAsync(default).ReturnsForAnyArgs(new Stripe.Invoice
{
PaymentIntent = new Stripe.PaymentIntent { Status = "requires_payment_method", },
AmountDue = 0
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription { });
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
var upgrade = new OrganizationUpgrade()
{
AdditionalStorageGb = 0,
AdditionalSeats = 0,
PremiumAccessAddon = false,
TaxInfo = taxInfo,
AdditionalSmSeats = 0,
AdditionalServiceAccounts = 0
};
var result = await sutProvider.Sut.UpgradeFreeOrganizationAsync(organization, plan, upgrade);
Assert.Null(result);
}
[Theory, BitAutoData]
public async Task UpgradeFreeOrganizationAsync_SM_Success(SutProvider<StripePaymentService> sutProvider,
Organization organization, TaxInfo taxInfo)
{
organization.GatewaySubscriptionId = null;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerGetAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
Metadata = new Dictionary<string, string>
{
{ "btCustomerId", "B-123" },
}
});
stripeAdapter.CustomerUpdateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
Metadata = new Dictionary<string, string>
{
{ "btCustomerId", "B-123" },
}
});
stripeAdapter.InvoiceUpcomingAsync(default).ReturnsForAnyArgs(new Stripe.Invoice
{
PaymentIntent = new Stripe.PaymentIntent { Status = "requires_payment_method", },
AmountDue = 0
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription { });
var upgrade = new OrganizationUpgrade()
{
AdditionalStorageGb = 1,
AdditionalSeats = 10,
PremiumAccessAddon = false,
TaxInfo = taxInfo,
AdditionalSmSeats = 5,
AdditionalServiceAccounts = 50
};
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
var result = await sutProvider.Sut.UpgradeFreeOrganizationAsync(organization, plan, upgrade);
Assert.Null(result);
}
[Theory, BitAutoData]
public async Task UpgradeFreeOrganizationAsync_WhenCustomerHasNoAddress_UpdatesCustomerAddressWithTaxInfo(
SutProvider<StripePaymentService> sutProvider,
Organization organization,
TaxInfo taxInfo)
{
organization.GatewaySubscriptionId = null;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var featureService = sutProvider.GetDependency<IFeatureService>();
stripeAdapter.CustomerGetAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
Metadata = new Dictionary<string, string>
{
{ "btCustomerId", "B-123" },
}
});
stripeAdapter.CustomerUpdateAsync(default).ReturnsForAnyArgs(new Stripe.Customer
{
Id = "C-1",
Metadata = new Dictionary<string, string>
{
{ "btCustomerId", "B-123" },
}
});
stripeAdapter.InvoiceUpcomingAsync(default).ReturnsForAnyArgs(new Stripe.Invoice
{
PaymentIntent = new Stripe.PaymentIntent { Status = "requires_payment_method", },
AmountDue = 0
});
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription { });
var upgrade = new OrganizationUpgrade()
{
AdditionalStorageGb = 1,
AdditionalSeats = 10,
PremiumAccessAddon = false,
TaxInfo = taxInfo,
AdditionalSmSeats = 5,
AdditionalServiceAccounts = 50
};
var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually);
_ = await sutProvider.Sut.UpgradeFreeOrganizationAsync(organization, plan, upgrade);
await stripeAdapter.Received()
.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<Stripe.CustomerUpdateOptions>(c =>
c.Address.Country == taxInfo.BillingAddressCountry &&
c.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
c.Address.Line1 == taxInfo.BillingAddressLine1 &&
c.Address.Line2 == taxInfo.BillingAddressLine2 &&
c.Address.City == taxInfo.BillingAddressCity &&
c.Address.State == taxInfo.BillingAddressState));
}
}