1
0
mirror of https://github.com/bitwarden/server.git synced 2025-05-25 13:24:50 -05:00
2025-05-20 10:58:10 -04:00

964 lines
36 KiB
C#

using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
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 PaymentMethod = Bit.Core.Billing.Models.PaymentMethod;
using Subscription = Stripe.Subscription;
namespace Bit.Core.Billing.Services.Implementations;
public class SubscriberService(
IBraintreeGateway braintreeGateway,
IFeatureService featureService,
IGlobalSettings globalSettings,
ILogger<SubscriberService> logger,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
ITaxService taxService) : ISubscriberService
{
public async Task CancelSubscription(
ISubscriber subscriber,
OffboardingSurveyResponse offboardingSurveyResponse,
bool cancelImmediately)
{
var subscription = await GetSubscriptionOrThrow(subscriber);
if (subscription.CanceledAt.HasValue ||
subscription.Status == "canceled" ||
subscription.Status == "unpaid" ||
subscription.Status == "incomplete_expired")
{
logger.LogWarning("Cannot cancel subscription ({ID}) that's already inactive", subscription.Id);
throw new BillingException();
}
var metadata = new Dictionary<string, string>
{
{ "cancellingUserId", offboardingSurveyResponse.UserId.ToString() }
};
List<string> validCancellationReasons = [
"customer_service",
"low_quality",
"missing_features",
"other",
"switched_service",
"too_complex",
"too_expensive",
"unused"
];
if (cancelImmediately)
{
if (subscription.Metadata != null && subscription.Metadata.ContainsKey("organizationId"))
{
await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, new SubscriptionUpdateOptions
{
Metadata = metadata
});
}
var options = new SubscriptionCancelOptions
{
CancellationDetails = new SubscriptionCancellationDetailsOptions
{
Comment = offboardingSurveyResponse.Feedback
}
};
if (validCancellationReasons.Contains(offboardingSurveyResponse.Reason))
{
options.CancellationDetails.Feedback = offboardingSurveyResponse.Reason;
}
await stripeAdapter.SubscriptionCancelAsync(subscription.Id, options);
}
else
{
var options = new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = true,
CancellationDetails = new SubscriptionCancellationDetailsOptions
{
Comment = offboardingSurveyResponse.Feedback
},
Metadata = metadata
};
if (validCancellationReasons.Contains(offboardingSurveyResponse.Reason))
{
options.CancellationDetails.Feedback = offboardingSurveyResponse.Reason;
}
await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, options);
}
}
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)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewayCustomerId))
{
logger.LogError("Cannot retrieve customer for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewayCustomerId));
return null;
}
try
{
var customer = await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions);
if (customer != null)
{
return customer;
}
logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})",
subscriber.GatewayCustomerId, subscriber.Id);
return null;
}
catch (StripeException exception)
{
logger.LogError("An error occurred while trying to retrieve Stripe customer ({CustomerID}) for subscriber ({SubscriberID}): {Error}",
subscriber.GatewayCustomerId, subscriber.Id, exception.Message);
return null;
}
}
public async Task<Customer> GetCustomerOrThrow(
ISubscriber subscriber,
CustomerGetOptions customerGetOptions = null)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewayCustomerId))
{
logger.LogError("Cannot retrieve customer for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewayCustomerId));
throw new BillingException();
}
try
{
var customer = await stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions);
if (customer != null)
{
return customer;
}
logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})",
subscriber.GatewayCustomerId, subscriber.Id);
throw new BillingException();
}
catch (StripeException stripeException)
{
logger.LogError("An error occurred while trying to retrieve Stripe customer ({CustomerID}) for subscriber ({SubscriberID}): {Error}",
subscriber.GatewayCustomerId, subscriber.Id, stripeException.Message);
throw new BillingException(
message: "An error occurred while trying to retrieve a Stripe customer",
innerException: stripeException);
}
}
public async Task<PaymentMethod> GetPaymentMethod(
ISubscriber subscriber)
{
ArgumentNullException.ThrowIfNull(subscriber);
var customer = await GetCustomer(subscriber, new CustomerGetOptions
{
Expand = ["default_source", "invoice_settings.default_payment_method", "subscriptions", "tax_ids"]
});
if (customer == null)
{
return PaymentMethod.Empty;
}
var accountCredit = customer.Balance * -1 / 100;
var paymentMethod = await GetPaymentSourceAsync(subscriber.Id, customer);
var subscriptionStatus = customer.Subscriptions
.FirstOrDefault(subscription => subscription.Id == subscriber.GatewaySubscriptionId)?
.Status;
var taxInformation = GetTaxInformation(customer);
return new PaymentMethod(
accountCredit,
paymentMethod,
subscriptionStatus,
taxInformation);
}
public async Task<PaymentSource> GetPaymentSource(
ISubscriber subscriber)
{
ArgumentNullException.ThrowIfNull(subscriber);
var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions
{
Expand = ["default_source", "invoice_settings.default_payment_method"]
});
return await GetPaymentSourceAsync(subscriber.Id, customer);
}
public async Task<Subscription> GetSubscription(
ISubscriber subscriber,
SubscriptionGetOptions subscriptionGetOptions = null)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
{
logger.LogError("Cannot retrieve subscription for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewaySubscriptionId));
return null;
}
try
{
var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions);
if (subscription != null)
{
return subscription;
}
logger.LogError("Could not find Stripe subscription ({SubscriptionID}) for subscriber ({SubscriberID})",
subscriber.GatewaySubscriptionId, subscriber.Id);
return null;
}
catch (StripeException exception)
{
logger.LogError("An error occurred while trying to retrieve Stripe subscription ({SubscriptionID}) for subscriber ({SubscriberID}): {Error}",
subscriber.GatewaySubscriptionId, subscriber.Id, exception.Message);
return null;
}
}
public async Task<Subscription> GetSubscriptionOrThrow(
ISubscriber subscriber,
SubscriptionGetOptions subscriptionGetOptions = null)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewaySubscriptionId))
{
logger.LogError("Cannot retrieve subscription for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewaySubscriptionId));
throw new BillingException();
}
try
{
var subscription = await stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions);
if (subscription != null)
{
return subscription;
}
logger.LogError("Could not find Stripe subscription ({SubscriptionID}) for subscriber ({SubscriberID})",
subscriber.GatewaySubscriptionId, subscriber.Id);
throw new BillingException();
}
catch (StripeException stripeException)
{
logger.LogError("An error occurred while trying to retrieve Stripe subscription ({SubscriptionID}) for subscriber ({SubscriberID}): {Error}",
subscriber.GatewaySubscriptionId, subscriber.Id, stripeException.Message);
throw new BillingException(
message: "An error occurred while trying to retrieve a Stripe subscription",
innerException: stripeException);
}
}
public async Task<TaxInformation> GetTaxInformation(
ISubscriber subscriber)
{
ArgumentNullException.ThrowIfNull(subscriber);
var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions { Expand = ["tax_ids"] });
return GetTaxInformation(customer);
}
public async Task RemovePaymentSource(
ISubscriber subscriber)
{
ArgumentNullException.ThrowIfNull(subscriber);
if (string.IsNullOrEmpty(subscriber.GatewayCustomerId))
{
throw new BillingException();
}
var stripeCustomer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions
{
Expand = ["invoice_settings.default_payment_method", "sources"]
});
if (stripeCustomer.Metadata?.TryGetValue(BraintreeCustomerIdKey, out var braintreeCustomerId) ?? false)
{
var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);
if (braintreeCustomer == null)
{
logger.LogError("Failed to retrieve Braintree customer ({ID}) when removing payment method", braintreeCustomerId);
throw new BillingException();
}
if (braintreeCustomer.DefaultPaymentMethod != null)
{
var existingDefaultPaymentMethod = braintreeCustomer.DefaultPaymentMethod;
var updateCustomerResult = await braintreeGateway.Customer.UpdateAsync(
braintreeCustomerId,
new CustomerRequest { DefaultPaymentMethodToken = null });
if (!updateCustomerResult.IsSuccess())
{
logger.LogError("Failed to update payment method for Braintree customer ({ID}) | Message: {Message}",
braintreeCustomerId, updateCustomerResult.Message);
throw new BillingException();
}
var deletePaymentMethodResult = await braintreeGateway.PaymentMethod.DeleteAsync(existingDefaultPaymentMethod.Token);
if (!deletePaymentMethodResult.IsSuccess())
{
await braintreeGateway.Customer.UpdateAsync(
braintreeCustomerId,
new CustomerRequest { DefaultPaymentMethodToken = existingDefaultPaymentMethod.Token });
logger.LogError(
"Failed to delete Braintree payment method for Customer ({ID}), re-linked payment method. Message: {Message}",
braintreeCustomerId, deletePaymentMethodResult.Message);
throw new BillingException();
}
}
else
{
logger.LogWarning("Tried to remove non-existent Braintree payment method for Customer ({ID})", braintreeCustomerId);
}
}
else
{
if (stripeCustomer.Sources != null && stripeCustomer.Sources.Any())
{
foreach (var source in stripeCustomer.Sources)
{
switch (source)
{
case BankAccount:
await stripeAdapter.BankAccountDeleteAsync(stripeCustomer.Id, source.Id);
break;
case Card:
await stripeAdapter.CardDeleteAsync(stripeCustomer.Id, source.Id);
break;
}
}
}
var paymentMethods = stripeAdapter.PaymentMethodListAutoPagingAsync(new PaymentMethodListOptions
{
Customer = stripeCustomer.Id
});
await foreach (var paymentMethod in paymentMethods)
{
await stripeAdapter.PaymentMethodDetachAsync(paymentMethod.Id);
}
}
}
public async Task UpdatePaymentSource(
ISubscriber subscriber,
TokenizedPaymentSource tokenizedPaymentSource)
{
ArgumentNullException.ThrowIfNull(subscriber);
ArgumentNullException.ThrowIfNull(tokenizedPaymentSource);
var customerGetOptions = new CustomerGetOptions { Expand = ["tax", "tax_ids"] };
var customer = await GetCustomerOrThrow(subscriber, customerGetOptions);
var (type, token) = tokenizedPaymentSource;
if (string.IsNullOrEmpty(token))
{
logger.LogError("Updated payment method for ({SubscriberID}) must contain a token", subscriber.Id);
throw new BillingException();
}
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (type)
{
case PaymentMethodType.BankAccount:
{
var getSetupIntentsForUpdatedPaymentMethod = stripeAdapter.SetupIntentList(new SetupIntentListOptions
{
PaymentMethod = token
});
var getExistingSetupIntentsForCustomer = stripeAdapter.SetupIntentList(new SetupIntentListOptions
{
Customer = subscriber.GatewayCustomerId
});
// Find the setup intent for the incoming payment method token.
var setupIntentsForUpdatedPaymentMethod = await getSetupIntentsForUpdatedPaymentMethod;
if (setupIntentsForUpdatedPaymentMethod.Count != 1)
{
logger.LogError("There were more than 1 setup intents for subscriber's ({SubscriberID}) updated payment method", subscriber.Id);
throw new BillingException();
}
var matchingSetupIntent = setupIntentsForUpdatedPaymentMethod.First();
// Find the customer's existing setup intents that should be canceled.
var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer)
.Where(si =>
si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action");
// Store the incoming payment method's setup intent ID in the cache for the subscriber so it can be verified later.
await setupIntentCache.Set(subscriber.Id, matchingSetupIntent.Id);
// Cancel the customer's other open setup intents.
var postProcessing = existingSetupIntentsForCustomer.Select(si =>
stripeAdapter.SetupIntentCancel(si.Id,
new SetupIntentCancelOptions { CancellationReason = "abandoned" })).ToList();
// Remove the customer's other attached Stripe payment methods.
postProcessing.Add(RemoveStripePaymentMethodsAsync(customer));
// Remove the customer's Braintree customer ID.
postProcessing.Add(RemoveBraintreeCustomerIdAsync(customer));
await Task.WhenAll(postProcessing);
break;
}
case PaymentMethodType.Card:
{
var getExistingSetupIntentsForCustomer = stripeAdapter.SetupIntentList(new SetupIntentListOptions
{
Customer = subscriber.GatewayCustomerId
});
// Remove the customer's other attached Stripe payment methods.
await RemoveStripePaymentMethodsAsync(customer);
// Attach the incoming payment method.
await stripeAdapter.PaymentMethodAttachAsync(token,
new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId });
// Find the customer's existing setup intents that should be canceled.
var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer)
.Where(si =>
si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action");
// Cancel the customer's other open setup intents.
var postProcessing = existingSetupIntentsForCustomer.Select(si =>
stripeAdapter.SetupIntentCancel(si.Id,
new SetupIntentCancelOptions { CancellationReason = "abandoned" })).ToList();
var metadata = customer.Metadata;
if (metadata.TryGetValue(BraintreeCustomerIdKey, out var value))
{
metadata[BraintreeCustomerIdOldKey] = value;
metadata[BraintreeCustomerIdKey] = null;
}
// Set the customer's default payment method in Stripe and remove their Braintree customer ID.
postProcessing.Add(stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = token
},
Metadata = metadata
}));
await Task.WhenAll(postProcessing);
break;
}
case PaymentMethodType.PayPal:
{
string braintreeCustomerId;
if (customer.Metadata != null)
{
var hasBraintreeCustomerId = customer.Metadata.TryGetValue(BraintreeCustomerIdKey, out braintreeCustomerId);
if (hasBraintreeCustomerId)
{
var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);
if (braintreeCustomer == null)
{
logger.LogError("Failed to retrieve Braintree customer ({BraintreeCustomerId}) when updating payment method for subscriber ({SubscriberID})", braintreeCustomerId, subscriber.Id);
throw new BillingException();
}
await ReplaceBraintreePaymentMethodAsync(braintreeCustomer, token);
return;
}
}
braintreeCustomerId = await CreateBraintreeCustomer(subscriber, token);
await AddBraintreeCustomerIdAsync(customer, braintreeCustomerId);
break;
}
default:
{
logger.LogError("Cannot update subscriber's ({SubscriberID}) payment method to type ({PaymentMethodType}) as it is not supported", subscriber.Id, type.ToString());
throw new BillingException();
}
}
}
public async Task UpdateTaxInformation(
ISubscriber subscriber,
TaxInformation taxInformation)
{
ArgumentNullException.ThrowIfNull(subscriber);
ArgumentNullException.ThrowIfNull(taxInformation);
var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions
{
Expand = ["subscriptions", "tax", "tax_ids"]
});
customer = await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Address = new AddressOptions
{
Country = taxInformation.Country,
PostalCode = taxInformation.PostalCode,
Line1 = taxInformation.Line1 ?? string.Empty,
Line2 = taxInformation.Line2,
City = taxInformation.City,
State = taxInformation.State
},
Expand = ["subscriptions", "tax", "tax_ids"]
});
var taxId = customer.TaxIds?.FirstOrDefault();
if (taxId != null)
{
await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id);
}
if (!string.IsNullOrWhiteSpace(taxInformation.TaxId))
{
var taxIdType = taxInformation.TaxIdType;
if (string.IsNullOrWhiteSpace(taxIdType))
{
taxIdType = taxService.GetStripeTaxCode(taxInformation.Country,
taxInformation.TaxId);
if (taxIdType == null)
{
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
taxInformation.Country,
taxInformation.TaxId);
throw new BadRequestException("billingTaxIdTypeInferenceError");
}
}
try
{
await stripeAdapter.TaxIdCreateAsync(customer.Id,
new TaxIdCreateOptions { Type = taxIdType, Value = taxInformation.TaxId });
if (taxIdType == StripeConstants.TaxIdType.SpanishNIF)
{
await stripeAdapter.TaxIdCreateAsync(customer.Id,
new TaxIdCreateOptions { Type = StripeConstants.TaxIdType.EUVAT, Value = $"ES{taxInformation.TaxId}" });
}
}
catch (StripeException e)
{
switch (e.StripeError.Code)
{
case StripeConstants.ErrorCodes.TaxIdInvalid:
logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.",
taxInformation.TaxId,
taxInformation.Country);
throw new BadRequestException("billingInvalidTaxIdError");
default:
logger.LogError(e,
"Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.",
taxInformation.TaxId,
taxInformation.Country,
customer.Id);
throw new BadRequestException("billingTaxIdCreationError");
}
}
}
var subscription =
customer.Subscriptions.First(subscription => subscription.Id == subscriber.GatewaySubscriptionId);
var isBusinessUseSubscriber = subscriber switch
{
Organization organization => organization.PlanType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families,
Provider => true,
_ => false
};
var setNonUSBusinessUseToReverseCharge =
featureService.IsEnabled(FeatureFlagKeys.PM21092_SetNonUSBusinessUseToReverseCharge);
if (setNonUSBusinessUseToReverseCharge && isBusinessUseSubscriber)
{
switch (customer)
{
case
{
Address.Country: not "US",
TaxExempt: not StripeConstants.TaxExempt.Reverse
}:
await stripeAdapter.CustomerUpdateAsync(customer.Id,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse });
break;
case
{
Address.Country: "US",
TaxExempt: StripeConstants.TaxExempt.Reverse
}:
await stripeAdapter.CustomerUpdateAsync(customer.Id,
new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.None });
break;
}
if (!subscription.AutomaticTax.Enabled)
{
await stripeAdapter.SubscriptionUpdateAsync(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
}
else
{
var automaticTaxShouldBeEnabled = subscriber switch
{
User => true,
Organization organization => organization.PlanType.GetProductTier() == ProductTierType.Families ||
customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false),
Provider => customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false),
_ => false
};
if (automaticTaxShouldBeEnabled && !subscription.AutomaticTax.Enabled)
{
await stripeAdapter.SubscriptionUpdateAsync(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
}
}
public async Task VerifyBankAccount(
ISubscriber subscriber,
string descriptorCode)
{
var setupIntentId = await setupIntentCache.Get(subscriber.Id);
if (string.IsNullOrEmpty(setupIntentId))
{
logger.LogError("No setup intent ID exists to verify for subscriber with ID ({SubscriberID})", subscriber.Id);
throw new BillingException();
}
try
{
await stripeAdapter.SetupIntentVerifyMicroDeposit(setupIntentId,
new SetupIntentVerifyMicrodepositsOptions { DescriptorCode = descriptorCode });
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId);
await stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId,
new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId });
await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId,
new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = setupIntent.PaymentMethodId
}
});
}
catch (StripeException stripeException)
{
if (!string.IsNullOrEmpty(stripeException.StripeError?.Code))
{
var message = stripeException.StripeError.Code switch
{
StripeConstants.ErrorCodes.PaymentMethodMicroDepositVerificationAttemptsExceeded => "You have exceeded the number of allowed verification attempts. Please contact support.",
StripeConstants.ErrorCodes.PaymentMethodMicroDepositVerificationDescriptorCodeMismatch => "The verification code you provided does not match the one sent to your bank account. Please try again.",
StripeConstants.ErrorCodes.PaymentMethodMicroDepositVerificationTimeout => "Your bank account was not verified within the required time period. Please contact support.",
_ => BillingException.DefaultMessage
};
throw new BadRequestException(message);
}
logger.LogError(stripeException, "An unhandled Stripe exception was thrown while verifying subscriber's ({SubscriberID}) bank account", subscriber.Id);
throw new BillingException();
}
}
#region Shared Utilities
private async Task AddBraintreeCustomerIdAsync(
Customer customer,
string braintreeCustomerId)
{
var metadata = customer.Metadata ?? new Dictionary<string, string>();
metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Metadata = metadata
});
}
private async Task<PaymentSource> GetPaymentSourceAsync(
Guid subscriberId,
Customer customer)
{
if (customer.Metadata != null)
{
var hasBraintreeCustomerId = customer.Metadata.TryGetValue(BraintreeCustomerIdKey, out var braintreeCustomerId);
if (hasBraintreeCustomerId)
{
var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);
return PaymentSource.From(braintreeCustomer);
}
}
var attachedPaymentMethodDTO = PaymentSource.From(customer);
if (attachedPaymentMethodDTO != null)
{
return attachedPaymentMethodDTO;
}
/*
* attachedPaymentMethodDTO being null represents a case where we could be looking for the SetupIntent for an unverified "us_bank_account".
* We store the ID of this SetupIntent in the cache when we originally update the payment method.
*/
var setupIntentId = await setupIntentCache.Get(subscriberId);
if (string.IsNullOrEmpty(setupIntentId))
{
return null;
}
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
{
Expand = ["payment_method"]
});
return PaymentSource.From(setupIntent);
}
private static TaxInformation GetTaxInformation(
Customer customer)
{
if (customer.Address == null)
{
return null;
}
return new TaxInformation(
customer.Address.Country,
customer.Address.PostalCode,
customer.TaxIds?.FirstOrDefault()?.Value,
customer.TaxIds?.FirstOrDefault()?.Type,
customer.Address.Line1,
customer.Address.Line2,
customer.Address.City,
customer.Address.State);
}
private async Task RemoveBraintreeCustomerIdAsync(
Customer customer)
{
var metadata = customer.Metadata ?? new Dictionary<string, string>();
if (metadata.TryGetValue(BraintreeCustomerIdKey, out var value))
{
metadata[BraintreeCustomerIdOldKey] = value;
metadata[BraintreeCustomerIdKey] = null;
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
{
Metadata = metadata
});
}
}
private async Task RemoveStripePaymentMethodsAsync(
Customer customer)
{
if (customer.Sources != null && customer.Sources.Any())
{
foreach (var source in customer.Sources)
{
switch (source)
{
case BankAccount:
await stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id);
break;
case Card:
await stripeAdapter.CardDeleteAsync(customer.Id, source.Id);
break;
}
}
}
var paymentMethods = await stripeAdapter.CustomerListPaymentMethods(customer.Id);
await Task.WhenAll(paymentMethods.Select(pm => stripeAdapter.PaymentMethodDetachAsync(pm.Id)));
}
private async Task ReplaceBraintreePaymentMethodAsync(
Braintree.Customer customer,
string defaultPaymentMethodToken)
{
var existingDefaultPaymentMethod = customer.DefaultPaymentMethod;
var createPaymentMethodResult = await braintreeGateway.PaymentMethod.CreateAsync(new PaymentMethodRequest
{
CustomerId = customer.Id,
PaymentMethodNonce = defaultPaymentMethodToken
});
if (!createPaymentMethodResult.IsSuccess())
{
logger.LogError("Failed to replace payment method for Braintree customer ({ID}) - Creation of new payment method failed | Error: {Error}", customer.Id, createPaymentMethodResult.Message);
throw new BillingException();
}
var updateCustomerResult = await braintreeGateway.Customer.UpdateAsync(
customer.Id,
new CustomerRequest { DefaultPaymentMethodToken = createPaymentMethodResult.Target.Token });
if (!updateCustomerResult.IsSuccess())
{
logger.LogError("Failed to replace payment method for Braintree customer ({ID}) - Customer update failed | Error: {Error}",
customer.Id, updateCustomerResult.Message);
await braintreeGateway.PaymentMethod.DeleteAsync(createPaymentMethodResult.Target.Token);
throw new BillingException();
}
if (existingDefaultPaymentMethod != null)
{
var deletePaymentMethodResult = await braintreeGateway.PaymentMethod.DeleteAsync(existingDefaultPaymentMethod.Token);
if (!deletePaymentMethodResult.IsSuccess())
{
logger.LogWarning(
"Failed to delete replaced payment method for Braintree customer ({ID}) - outdated payment method still exists | Error: {Error}",
customer.Id, deletePaymentMethodResult.Message);
}
}
}
#endregion
}