2025-07-08 11:46:24 -04:00
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.Entities ;
2025-05-19 14:53:48 -04:00
using Bit.Core.AdminConsole.Entities.Provider ;
2025-07-14 12:39:49 -05:00
using Bit.Core.AdminConsole.Repositories ;
2025-05-19 14:53:48 -04:00
using Bit.Core.Billing.Caches ;
2024-09-11 09:04:15 -04:00
using Bit.Core.Billing.Constants ;
2025-05-19 14:53:48 -04:00
using Bit.Core.Billing.Enums ;
using Bit.Core.Billing.Extensions ;
2024-06-03 11:00:52 -04:00
using Bit.Core.Billing.Models ;
2025-05-13 09:28:31 -04:00
using Bit.Core.Billing.Tax.Models ;
using Bit.Core.Billing.Tax.Services ;
2024-05-23 10:17:00 -04:00
using Bit.Core.Entities ;
2024-06-03 11:00:52 -04:00
using Bit.Core.Enums ;
2024-11-20 14:36:50 -05:00
using Bit.Core.Exceptions ;
2025-07-14 12:39:49 -05:00
using Bit.Core.Repositories ;
2024-05-23 10:17:00 -04:00
using Bit.Core.Services ;
2024-06-03 11:00:52 -04:00
using Bit.Core.Settings ;
using Bit.Core.Utilities ;
2024-05-23 10:17:00 -04:00
using Braintree ;
using Microsoft.Extensions.Logging ;
using Stripe ;
using static Bit . Core . Billing . Utilities ;
using Customer = Stripe . Customer ;
2024-08-28 10:48:14 -04:00
using PaymentMethod = Bit . Core . Billing . Models . PaymentMethod ;
2024-05-23 10:17:00 -04:00
using Subscription = Stripe . Subscription ;
namespace Bit.Core.Billing.Services.Implementations ;
2025-07-14 12:39:49 -05:00
using static StripeConstants ;
2024-05-23 10:17:00 -04:00
public class SubscriberService (
IBraintreeGateway braintreeGateway ,
2024-06-03 11:00:52 -04:00
IGlobalSettings globalSettings ,
2024-05-23 10:17:00 -04:00
ILogger < SubscriberService > logger ,
2025-07-14 12:39:49 -05:00
IOrganizationRepository organizationRepository ,
IProviderRepository providerRepository ,
2024-06-03 11:00:52 -04:00
ISetupIntentCache setupIntentCache ,
2025-01-02 20:27:53 +01:00
IStripeAdapter stripeAdapter ,
2025-07-14 12:39:49 -05:00
ITaxService taxService ,
IUserRepository userRepository ) : ISubscriberService
2024-05-23 10:17:00 -04:00
{
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 ) ;
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-05-23 10:17:00 -04:00
}
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 ) ;
}
}
2024-09-06 10:24:05 -04:00
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 ( ) ,
2025-05-19 14:53:48 -04:00
PaymentMethodNonce = paymentMethodNonce
2024-09-06 10:24:05 -04:00
} ) ;
if ( customerResult . IsSuccess ( ) )
{
return customerResult . Target . Id ;
}
logger . LogError ( "Failed to create Braintree customer for subscriber ({ID})" , subscriber . Id ) ;
throw new BillingException ( ) ;
}
2025-07-14 12:39:49 -05:00
#nullable enable
public async Task < Customer > CreateStripeCustomer ( ISubscriber subscriber )
{
if ( ! string . IsNullOrEmpty ( subscriber . GatewayCustomerId ) )
{
throw new ConflictException ( "Subscriber already has a linked Stripe Customer" ) ;
}
var options = subscriber switch
{
Organization organization = > new CustomerCreateOptions
{
Description = organization . DisplayBusinessName ( ) ,
Email = organization . BillingEmail ,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = organization . SubscriberType ( ) ,
Value = Max30Characters ( organization . DisplayName ( ) )
}
]
} ,
Metadata = new Dictionary < string , string >
{
[MetadataKeys.OrganizationId] = organization . Id . ToString ( ) ,
[MetadataKeys.Region] = globalSettings . BaseServiceUri . CloudRegion
}
} ,
Provider provider = > new CustomerCreateOptions
{
Description = provider . DisplayBusinessName ( ) ,
Email = provider . BillingEmail ,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = provider . SubscriberType ( ) ,
Value = Max30Characters ( provider . DisplayName ( ) )
}
]
} ,
Metadata = new Dictionary < string , string >
{
[MetadataKeys.ProviderId] = provider . Id . ToString ( ) ,
[MetadataKeys.Region] = globalSettings . BaseServiceUri . CloudRegion
}
} ,
User user = > new CustomerCreateOptions
{
Description = user . Name ,
Email = user . Email ,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = user . SubscriberType ( ) ,
Value = Max30Characters ( user . SubscriberName ( ) )
}
]
} ,
Metadata = new Dictionary < string , string >
{
[MetadataKeys.Region] = globalSettings . BaseServiceUri . CloudRegion ,
[MetadataKeys.UserId] = user . Id . ToString ( )
}
} ,
_ = > throw new ArgumentOutOfRangeException ( nameof ( subscriber ) )
} ;
var customer = await stripeAdapter . CustomerCreateAsync ( options ) ;
switch ( subscriber )
{
case Organization organization :
organization . Gateway = GatewayType . Stripe ;
organization . GatewayCustomerId = customer . Id ;
await organizationRepository . ReplaceAsync ( organization ) ;
break ;
case Provider provider :
provider . Gateway = GatewayType . Stripe ;
provider . GatewayCustomerId = customer . Id ;
await providerRepository . ReplaceAsync ( provider ) ;
break ;
case User user :
user . Gateway = GatewayType . Stripe ;
user . GatewayCustomerId = customer . Id ;
await userRepository . ReplaceAsync ( user ) ;
break ;
}
return customer ;
string? Max30Characters ( string? input )
= > input ? . Length < = 30 ? input : input ? [ . . 30 ] ;
}
#nullable disable
2024-05-23 10:17:00 -04:00
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 ;
}
}
2024-06-05 13:33:28 -04:00
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 ) ) ;
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-06-05 13:33:28 -04:00
}
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 ) ;
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-06-05 13:33:28 -04:00
}
2024-07-31 09:26:44 -04:00
catch ( StripeException stripeException )
2024-06-05 13:33:28 -04:00
{
logger . LogError ( "An error occurred while trying to retrieve Stripe customer ({CustomerID}) for subscriber ({SubscriberID}): {Error}" ,
2024-07-31 09:26:44 -04:00
subscriber . GatewayCustomerId , subscriber . Id , stripeException . Message ) ;
2024-06-05 13:33:28 -04:00
2024-07-31 09:26:44 -04:00
throw new BillingException (
message : "An error occurred while trying to retrieve a Stripe customer" ,
innerException : stripeException ) ;
2024-06-05 13:33:28 -04:00
}
}
2024-08-28 10:48:14 -04:00
public async Task < PaymentMethod > GetPaymentMethod (
2024-06-03 11:00:52 -04:00
ISubscriber subscriber )
{
ArgumentNullException . ThrowIfNull ( subscriber ) ;
2024-09-12 08:47:34 -04:00
var customer = await GetCustomer ( subscriber , new CustomerGetOptions
2024-06-03 11:00:52 -04:00
{
2024-08-28 10:48:14 -04:00
Expand = [ "default_source" , "invoice_settings.default_payment_method" , "subscriptions" , "tax_ids" ]
2024-06-03 11:00:52 -04:00
} ) ;
2024-09-12 08:47:34 -04:00
if ( customer = = null )
{
return PaymentMethod . Empty ;
}
2025-09-05 11:15:01 -04:00
var accountCredit = customer . Balance * - 1 / 100 M ;
2024-06-03 11:00:52 -04:00
2024-08-28 10:48:14 -04:00
var paymentMethod = await GetPaymentSourceAsync ( subscriber . Id , customer ) ;
var subscriptionStatus = customer . Subscriptions
. FirstOrDefault ( subscription = > subscription . Id = = subscriber . GatewaySubscriptionId ) ?
. Status ;
2024-06-03 11:00:52 -04:00
2024-08-28 10:48:14 -04:00
var taxInformation = GetTaxInformation ( customer ) ;
2024-06-03 11:00:52 -04:00
2024-08-28 10:48:14 -04:00
return new PaymentMethod (
2024-06-03 11:00:52 -04:00
accountCredit ,
paymentMethod ,
2024-08-28 10:48:14 -04:00
subscriptionStatus ,
2024-06-03 11:00:52 -04:00
taxInformation ) ;
}
2024-08-28 10:48:14 -04:00
public async Task < PaymentSource > GetPaymentSource (
2024-06-03 11:00:52 -04:00
ISubscriber subscriber )
{
ArgumentNullException . ThrowIfNull ( subscriber ) ;
var customer = await GetCustomerOrThrow ( subscriber , new CustomerGetOptions
{
Expand = [ "default_source" , "invoice_settings.default_payment_method" ]
} ) ;
2024-08-28 10:48:14 -04:00
return await GetPaymentSourceAsync ( subscriber . Id , customer ) ;
2024-06-03 11:00:52 -04:00
}
2024-05-23 10:17:00 -04:00
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 ) ) ;
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-05-23 10:17:00 -04:00
}
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 ) ;
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-05-23 10:17:00 -04:00
}
2024-07-31 09:26:44 -04:00
catch ( StripeException stripeException )
2024-05-23 10:17:00 -04:00
{
logger . LogError ( "An error occurred while trying to retrieve Stripe subscription ({SubscriptionID}) for subscriber ({SubscriberID}): {Error}" ,
2024-07-31 09:26:44 -04:00
subscriber . GatewaySubscriptionId , subscriber . Id , stripeException . Message ) ;
2024-05-23 10:17:00 -04:00
2024-07-31 09:26:44 -04:00
throw new BillingException (
message : "An error occurred while trying to retrieve a Stripe subscription" ,
innerException : stripeException ) ;
2024-05-23 10:17:00 -04:00
}
}
2024-07-31 09:26:44 -04:00
public async Task < TaxInformation > GetTaxInformation (
2024-06-03 11:00:52 -04:00
ISubscriber subscriber )
{
ArgumentNullException . ThrowIfNull ( subscriber ) ;
var customer = await GetCustomerOrThrow ( subscriber , new CustomerGetOptions { Expand = [ "tax_ids" ] } ) ;
2024-08-28 10:48:14 -04:00
return GetTaxInformation ( customer ) ;
2024-06-03 11:00:52 -04:00
}
2024-08-28 10:48:14 -04:00
public async Task RemovePaymentSource (
2024-05-23 10:17:00 -04:00
ISubscriber subscriber )
{
ArgumentNullException . ThrowIfNull ( subscriber ) ;
if ( string . IsNullOrEmpty ( subscriber . GatewayCustomerId ) )
{
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-05-23 10:17:00 -04:00
}
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 ) ;
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-05-23 10:17:00 -04:00
}
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 ) ;
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-05-23 10:17:00 -04:00
}
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 ) ;
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-05-23 10:17:00 -04:00
}
}
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 ) ;
}
}
}
2024-08-28 10:48:14 -04:00
public async Task UpdatePaymentSource (
2024-06-03 11:00:52 -04:00
ISubscriber subscriber ,
2024-08-28 10:48:14 -04:00
TokenizedPaymentSource tokenizedPaymentSource )
2024-05-23 10:17:00 -04:00
{
ArgumentNullException . ThrowIfNull ( subscriber ) ;
2024-08-28 10:48:14 -04:00
ArgumentNullException . ThrowIfNull ( tokenizedPaymentSource ) ;
2024-06-03 11:00:52 -04:00
2025-04-02 19:47:48 +02:00
var customerGetOptions = new CustomerGetOptions { Expand = [ "tax" , "tax_ids" ] } ;
var customer = await GetCustomerOrThrow ( subscriber , customerGetOptions ) ;
2024-06-03 11:00:52 -04:00
2024-08-28 10:48:14 -04:00
var ( type , token ) = tokenizedPaymentSource ;
2024-05-23 10:17:00 -04:00
2024-06-03 11:00:52 -04:00
if ( string . IsNullOrEmpty ( token ) )
2024-05-23 10:17:00 -04:00
{
2024-06-03 11:00:52 -04:00
logger . LogError ( "Updated payment method for ({SubscriberID}) must contain a token" , subscriber . Id ) ;
2024-05-23 10:17:00 -04:00
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-05-23 10:17:00 -04:00
}
2024-06-03 11:00:52 -04:00
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch ( type )
2024-05-23 10:17:00 -04:00
{
2024-06-03 11:00:52 -04:00
case PaymentMethodType . BankAccount :
{
var getSetupIntentsForUpdatedPaymentMethod = stripeAdapter . SetupIntentList ( new SetupIntentListOptions
{
PaymentMethod = token
} ) ;
2024-05-23 10:17:00 -04:00
2024-06-03 11:00:52 -04:00
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 ) ;
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-06-03 11:00:52 -04:00
}
var matchingSetupIntent = setupIntentsForUpdatedPaymentMethod . First ( ) ;
2025-05-19 14:53:48 -04:00
// Find the customer's existing setup intents that should be canceled.
2024-06-03 11:00:52 -04:00
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 } ) ;
2025-05-19 14:53:48 -04:00
// Find the customer's existing setup intents that should be canceled.
2024-06-03 11:00:52 -04:00
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 ;
2024-11-19 11:38:30 -05:00
if ( metadata . TryGetValue ( BraintreeCustomerIdKey , out var value ) )
2024-06-03 11:00:52 -04:00
{
2024-11-19 11:38:30 -05:00
metadata [ BraintreeCustomerIdOldKey ] = value ;
2024-06-03 11:00:52 -04:00
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 ) ;
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-06-03 11:00:52 -04:00
}
await ReplaceBraintreePaymentMethodAsync ( braintreeCustomer , token ) ;
return ;
}
}
2024-09-06 10:24:05 -04:00
braintreeCustomerId = await CreateBraintreeCustomer ( subscriber , token ) ;
2024-06-03 11:00:52 -04:00
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 ( ) ) ;
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-06-03 11:00:52 -04:00
}
2024-05-23 10:17:00 -04:00
}
2024-06-03 11:00:52 -04:00
}
2024-05-23 10:17:00 -04:00
2024-06-03 11:00:52 -04:00
public async Task UpdateTaxInformation (
ISubscriber subscriber ,
2024-07-31 09:26:44 -04:00
TaxInformation taxInformation )
2024-06-03 11:00:52 -04:00
{
ArgumentNullException . ThrowIfNull ( subscriber ) ;
ArgumentNullException . ThrowIfNull ( taxInformation ) ;
2024-05-23 10:17:00 -04:00
2024-06-03 11:00:52 -04:00
var customer = await GetCustomerOrThrow ( subscriber , new CustomerGetOptions
2024-05-23 10:17:00 -04:00
{
2024-09-11 09:04:15 -04:00
Expand = [ "subscriptions" , "tax" , "tax_ids" ]
2024-06-03 11:00:52 -04:00
} ) ;
2025-04-02 19:47:48 +02:00
customer = await stripeAdapter . CustomerUpdateAsync ( customer . Id , new CustomerUpdateOptions
2024-06-03 11:00:52 -04:00
{
Address = new AddressOptions
{
Country = taxInformation . Country ,
PostalCode = taxInformation . PostalCode ,
Line1 = taxInformation . Line1 ? ? string . Empty ,
Line2 = taxInformation . Line2 ,
City = taxInformation . City ,
State = taxInformation . State
2025-04-02 19:47:48 +02:00
} ,
Expand = [ "subscriptions" , "tax" , "tax_ids" ]
2024-06-03 11:00:52 -04:00
} ) ;
2025-01-02 20:27:53 +01:00
var taxId = customer . TaxIds ? . FirstOrDefault ( ) ;
if ( taxId ! = null )
{
await stripeAdapter . TaxIdDeleteAsync ( customer . Id , taxId . Id ) ;
}
2025-04-08 21:54:52 +02:00
if ( ! string . IsNullOrWhiteSpace ( taxInformation . TaxId ) )
2024-06-03 11:00:52 -04:00
{
2025-04-08 21:54:52 +02:00
var taxIdType = taxInformation . TaxIdType ;
if ( string . IsNullOrWhiteSpace ( taxIdType ) )
2024-06-03 11:00:52 -04:00
{
2025-04-08 21:54:52 +02:00
taxIdType = taxService . GetStripeTaxCode ( taxInformation . Country ,
2025-01-02 20:27:53 +01:00
taxInformation . TaxId ) ;
2025-04-08 21:54:52 +02:00
if ( taxIdType = = null )
{
logger . LogWarning ( "Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'." ,
taxInformation . Country ,
taxInformation . TaxId ) ;
2025-05-19 14:53:48 -04:00
throw new BadRequestException ( "billingTaxIdTypeInferenceError" ) ;
2025-04-08 21:54:52 +02:00
}
2024-06-03 11:00:52 -04:00
}
2025-04-08 21:54:52 +02:00
try
2024-12-04 11:45:11 +01:00
{
2025-04-08 21:54:52 +02:00
await stripeAdapter . TaxIdCreateAsync ( customer . Id ,
new TaxIdCreateOptions { Type = taxIdType , Value = taxInformation . TaxId } ) ;
2025-06-09 09:30:26 -05:00
if ( taxIdType = = StripeConstants . TaxIdType . SpanishNIF )
{
await stripeAdapter . TaxIdCreateAsync ( customer . Id ,
new TaxIdCreateOptions { Type = StripeConstants . TaxIdType . EUVAT , Value = $"ES{taxInformation.TaxId}" } ) ;
}
2025-04-08 21:54:52 +02:00
}
catch ( StripeException e )
{
switch ( e . StripeError . Code )
{
case StripeConstants . ErrorCodes . TaxIdInvalid :
logger . LogWarning ( "Invalid tax ID '{TaxID}' for country '{Country}'." ,
taxInformation . TaxId ,
taxInformation . Country ) ;
2025-05-19 14:53:48 -04:00
throw new BadRequestException ( "billingInvalidTaxIdError" ) ;
2025-04-08 21:54:52 +02:00
default :
logger . LogError ( e ,
"Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'." ,
taxInformation . TaxId ,
taxInformation . Country ,
customer . Id ) ;
2025-05-19 14:53:48 -04:00
throw new BadRequestException ( "billingTaxIdCreationError" ) ;
2025-04-08 21:54:52 +02:00
}
2024-06-03 11:00:52 -04:00
}
}
2024-09-11 09:04:15 -04:00
2025-05-19 14:53:48 -04:00
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
} ;
2025-08-21 16:24:16 -04:00
if ( isBusinessUseSubscriber )
2025-02-20 16:01:48 +01:00
{
2025-05-19 14:53:48 -04:00
switch ( customer )
2025-04-02 19:47:48 +02:00
{
2025-05-19 14:53:48 -04:00
case
2025-02-20 16:01:48 +01:00
{
2025-09-03 10:03:49 -05:00
Address . Country : not Core . Constants . CountryAbbreviations . UnitedStates ,
2025-08-21 16:24:16 -04:00
TaxExempt : not TaxExempt . Reverse
2025-05-19 14:53:48 -04:00
} :
await stripeAdapter . CustomerUpdateAsync ( customer . Id ,
2025-08-21 16:24:16 -04:00
new CustomerUpdateOptions { TaxExempt = TaxExempt . Reverse } ) ;
2025-05-19 14:53:48 -04:00
break ;
case
2025-04-02 19:47:48 +02:00
{
2025-09-03 10:03:49 -05:00
Address . Country : Core . Constants . CountryAbbreviations . UnitedStates ,
2025-08-21 16:24:16 -04:00
TaxExempt : TaxExempt . Reverse
2025-05-19 14:53:48 -04:00
} :
await stripeAdapter . CustomerUpdateAsync ( customer . Id ,
2025-08-21 16:24:16 -04:00
new CustomerUpdateOptions { TaxExempt = TaxExempt . None } ) ;
2025-05-19 14:53:48 -04:00
break ;
}
if ( ! subscription . AutomaticTax . Enabled )
{
await stripeAdapter . SubscriptionUpdateAsync ( subscription . Id ,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
} ) ;
2025-04-02 19:47:48 +02:00
}
2025-02-20 16:01:48 +01:00
}
2025-04-02 19:47:48 +02:00
else
{
2025-05-19 14:53:48 -04:00
var automaticTaxShouldBeEnabled = subscriber switch
{
User = > true ,
Organization organization = > organization . PlanType . GetProductTier ( ) = = ProductTierType . Families | |
2025-09-03 10:03:49 -05:00
customer . Address . Country = = Core . Constants . CountryAbbreviations . UnitedStates | | ( customer . TaxIds ? . Any ( ) ? ? false ) ,
Provider = > customer . Address . Country = = Core . Constants . CountryAbbreviations . UnitedStates | | ( customer . TaxIds ? . Any ( ) ? ? false ) ,
2025-05-19 14:53:48 -04:00
_ = > false
} ;
if ( automaticTaxShouldBeEnabled & & ! subscription . AutomaticTax . Enabled )
2025-04-02 19:47:48 +02:00
{
2025-05-19 14:53:48 -04:00
await stripeAdapter . SubscriptionUpdateAsync ( subscription . Id ,
2025-04-02 19:47:48 +02:00
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
} ) ;
}
}
2024-05-23 10:17:00 -04:00
}
2024-06-03 11:00:52 -04:00
public async Task VerifyBankAccount (
ISubscriber subscriber ,
2024-11-20 14:36:50 -05:00
string descriptorCode )
2024-05-23 10:17:00 -04:00
{
2024-06-03 11:00:52 -04:00
var setupIntentId = await setupIntentCache . Get ( subscriber . Id ) ;
if ( string . IsNullOrEmpty ( setupIntentId ) )
2024-05-23 10:17:00 -04:00
{
2024-06-03 11:00:52 -04:00
logger . LogError ( "No setup intent ID exists to verify for subscriber with ID ({SubscriberID})" , subscriber . Id ) ;
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-05-23 10:17:00 -04:00
}
2024-11-20 14:36:50 -05:00
try
2024-05-23 10:17:00 -04:00
{
2024-11-20 14:36:50 -05:00
await stripeAdapter . SetupIntentVerifyMicroDeposit ( setupIntentId ,
new SetupIntentVerifyMicrodepositsOptions { DescriptorCode = descriptorCode } ) ;
2024-06-03 11:00:52 -04:00
2024-11-20 14:36:50 -05:00
var setupIntent = await stripeAdapter . SetupIntentGet ( setupIntentId ) ;
2024-06-03 11:00:52 -04:00
2024-11-20 14:36:50 -05:00
await stripeAdapter . PaymentMethodAttachAsync ( setupIntent . PaymentMethodId ,
new PaymentMethodAttachOptions { Customer = subscriber . GatewayCustomerId } ) ;
2024-06-03 11:00:52 -04:00
2024-11-20 14:36:50 -05:00
await stripeAdapter . CustomerUpdateAsync ( subscriber . GatewayCustomerId ,
new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = setupIntent . PaymentMethodId
}
} ) ;
}
catch ( StripeException stripeException )
{
if ( ! string . IsNullOrEmpty ( stripeException . StripeError ? . Code ) )
2024-05-23 10:17:00 -04:00
{
2024-11-20 14:36:50 -05:00
var message = stripeException . StripeError . Code switch
2024-05-23 10:17:00 -04:00
{
2024-11-20 14:36:50 -05:00
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 ( ) ;
}
2024-06-03 11:00:52 -04:00
}
2025-08-21 13:54:20 -05:00
public async Task < bool > IsValidGatewayCustomerIdAsync ( ISubscriber subscriber )
{
ArgumentNullException . ThrowIfNull ( subscriber ) ;
if ( string . IsNullOrEmpty ( subscriber . GatewayCustomerId ) )
{
// subscribers are allowed to have no customer id as a business rule
return true ;
}
try
{
await stripeAdapter . CustomerGetAsync ( subscriber . GatewayCustomerId ) ;
return true ;
}
catch ( StripeException e ) when ( e . StripeError . Code = = "resource_missing" )
{
return false ;
}
}
public async Task < bool > IsValidGatewaySubscriptionIdAsync ( ISubscriber subscriber )
{
ArgumentNullException . ThrowIfNull ( subscriber ) ;
if ( string . IsNullOrEmpty ( subscriber . GatewaySubscriptionId ) )
{
// subscribers are allowed to have no subscription id as a business rule
return true ;
}
try
{
await stripeAdapter . SubscriptionGetAsync ( subscriber . GatewaySubscriptionId ) ;
return true ;
}
catch ( StripeException e ) when ( e . StripeError . Code = = "resource_missing" )
{
return false ;
}
}
2024-06-03 11:00:52 -04:00
#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
} ) ;
}
2024-08-28 10:48:14 -04:00
private async Task < PaymentSource > GetPaymentSourceAsync (
2024-06-03 11:00:52 -04:00
Guid subscriberId ,
Customer customer )
{
if ( customer . Metadata ! = null )
{
var hasBraintreeCustomerId = customer . Metadata . TryGetValue ( BraintreeCustomerIdKey , out var braintreeCustomerId ) ;
if ( hasBraintreeCustomerId )
2024-05-23 10:17:00 -04:00
{
2024-06-03 11:00:52 -04:00
var braintreeCustomer = await braintreeGateway . Customer . FindAsync ( braintreeCustomerId ) ;
2024-08-28 10:48:14 -04:00
return PaymentSource . From ( braintreeCustomer ) ;
2024-05-23 10:17:00 -04:00
}
}
2024-08-28 10:48:14 -04:00
var attachedPaymentMethodDTO = PaymentSource . From ( customer ) ;
2024-06-03 11:00:52 -04:00
if ( attachedPaymentMethodDTO ! = null )
2024-05-23 10:17:00 -04:00
{
2024-06-03 11:00:52 -04:00
return attachedPaymentMethodDTO ;
2024-05-23 10:17:00 -04:00
}
2024-06-03 11:00:52 -04:00
/ *
* 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 ) )
2024-05-23 10:17:00 -04:00
{
2024-06-03 11:00:52 -04:00
return null ;
2024-05-23 10:17:00 -04:00
}
2024-06-03 11:00:52 -04:00
var setupIntent = await stripeAdapter . SetupIntentGet ( setupIntentId , new SetupIntentGetOptions
{
Expand = [ "payment_method" ]
} ) ;
2024-08-28 10:48:14 -04:00
return PaymentSource . From ( setupIntent ) ;
2024-05-23 10:17:00 -04:00
}
2024-08-28 10:48:14 -04:00
private static TaxInformation GetTaxInformation (
2024-06-03 11:00:52 -04:00
Customer customer )
2024-05-23 10:17:00 -04:00
{
2024-06-03 11:00:52 -04:00
if ( customer . Address = = null )
{
return null ;
}
2024-07-31 09:26:44 -04:00
return new TaxInformation (
2024-06-03 11:00:52 -04:00
customer . Address . Country ,
customer . Address . PostalCode ,
customer . TaxIds ? . FirstOrDefault ( ) ? . Value ,
2025-01-02 20:27:53 +01:00
customer . TaxIds ? . FirstOrDefault ( ) ? . Type ,
2024-06-03 11:00:52 -04:00
customer . Address . Line1 ,
customer . Address . Line2 ,
customer . Address . City ,
customer . Address . State ) ;
2024-05-23 10:17:00 -04:00
}
2024-06-03 11:00:52 -04:00
private async Task RemoveBraintreeCustomerIdAsync (
Customer customer )
2024-05-23 10:17:00 -04:00
{
2024-06-03 11:00:52 -04:00
var metadata = customer . Metadata ? ? new Dictionary < string , string > ( ) ;
2024-11-20 09:32:53 -05:00
if ( metadata . TryGetValue ( BraintreeCustomerIdKey , out var value ) )
2024-06-03 11:00:52 -04:00
{
2024-11-20 09:32:53 -05:00
metadata [ BraintreeCustomerIdOldKey ] = value ;
2024-06-03 11:00:52 -04:00
metadata [ BraintreeCustomerIdKey ] = null ;
await stripeAdapter . CustomerUpdateAsync ( customer . Id , new CustomerUpdateOptions
{
Metadata = metadata
} ) ;
}
2024-05-23 10:17:00 -04:00
}
2024-06-03 11:00:52 -04:00
private async Task RemoveStripePaymentMethodsAsync (
Customer customer )
2024-05-23 10:17:00 -04:00
{
2024-06-03 11:00:52 -04:00
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 ) ;
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-06-03 11:00:52 -04:00
}
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 ) ;
2024-07-31 09:26:44 -04:00
throw new BillingException ( ) ;
2024-06-03 11:00:52 -04:00
}
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 ) ;
}
}
2024-05-23 10:17:00 -04:00
}
2024-06-03 11:00:52 -04:00
#endregion
2024-05-23 10:17:00 -04:00
}