mirror of
https://github.com/bitwarden/server.git
synced 2025-07-03 09:02:48 -05:00
[AC-2576] Replace Billing commands and queries with services (#4070)
* Replace SubscriberQueries with SubscriberService * Replace OrganizationBillingQueries with OrganizationBillingService * Replace ProviderBillingQueries with ProviderBillingService, move to Commercial * Replace AssignSeatsToClientOrganizationCommand with ProviderBillingService, move to commercial * Replace ScaleSeatsCommand with ProviderBillingService and move to Commercial * Replace CancelSubscriptionCommand with SubscriberService * Replace CreateCustomerCommand with ProviderBillingService and move to Commercial * Replace StartSubscriptionCommand with ProviderBillingService and moved to Commercial * Replaced RemovePaymentMethodCommand with SubscriberService * Formatting * Used dotnet format this time * Changing ProviderBillingService to scoped * Found circular dependency' * One more time with feeling * Formatting * Fix error in remove org from provider * Missed test fix in conflit * [AC-1937] Server: Implement endpoint to retrieve provider payment information (#4107) * Move the gettax and paymentmethod from stripepayment class Signed-off-by: Cy Okeke <cokeke@bitwarden.com> * Add the method to retrieve the tax and payment details Signed-off-by: Cy Okeke <cokeke@bitwarden.com> * Add unit tests for the paymentInformation method Signed-off-by: Cy Okeke <cokeke@bitwarden.com> * Add the endpoint to retrieve paymentinformation Signed-off-by: Cy Okeke <cokeke@bitwarden.com> * Add unit tests to the SubscriberService Signed-off-by: Cy Okeke <cokeke@bitwarden.com> * Remove the getTaxInfoAsync update reference Signed-off-by: Cy Okeke <cokeke@bitwarden.com> --------- Signed-off-by: Cy Okeke <cokeke@bitwarden.com> --------- Signed-off-by: Cy Okeke <cokeke@bitwarden.com> Co-authored-by: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com>
This commit is contained in:
444
src/Core/Billing/Services/Implementations/SubscriberService.cs
Normal file
444
src/Core/Billing/Services/Implementations/SubscriberService.cs
Normal file
@ -0,0 +1,444 @@
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Services;
|
||||
using Braintree;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
|
||||
using static Bit.Core.Billing.Utilities;
|
||||
using Customer = Stripe.Customer;
|
||||
using Subscription = Stripe.Subscription;
|
||||
|
||||
namespace Bit.Core.Billing.Services.Implementations;
|
||||
|
||||
public class SubscriberService(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
ILogger<SubscriberService> logger,
|
||||
IStripeAdapter stripeAdapter) : 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 ContactSupport();
|
||||
}
|
||||
|
||||
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<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 ContactSupport();
|
||||
}
|
||||
|
||||
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 ContactSupport();
|
||||
}
|
||||
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);
|
||||
|
||||
throw ContactSupport("An error occurred while trying to retrieve a Stripe Customer", exception);
|
||||
}
|
||||
}
|
||||
|
||||
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 ContactSupport();
|
||||
}
|
||||
|
||||
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 ContactSupport();
|
||||
}
|
||||
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);
|
||||
|
||||
throw ContactSupport("An error occurred while trying to retrieve a Stripe Subscription", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RemovePaymentMethod(
|
||||
ISubscriber subscriber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
|
||||
if (string.IsNullOrEmpty(subscriber.GatewayCustomerId))
|
||||
{
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
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 ContactSupport();
|
||||
}
|
||||
|
||||
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 ContactSupport();
|
||||
}
|
||||
|
||||
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 ContactSupport();
|
||||
}
|
||||
}
|
||||
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<TaxInfo> GetTaxInformationAsync(ISubscriber subscriber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
|
||||
{
|
||||
logger.LogError("Cannot retrieve GatewayCustomerId for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewaySubscriptionId));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions { Expand = ["tax_ids"] });
|
||||
|
||||
if (customer is null)
|
||||
{
|
||||
logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})",
|
||||
subscriber.GatewayCustomerId, subscriber.Id);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var address = customer.Address;
|
||||
|
||||
// Line1 is required, so if missing we're using the subscriber name
|
||||
// see: https://stripe.com/docs/api/customers/create#create_customer-address-line1
|
||||
if (address is not null && string.IsNullOrWhiteSpace(address.Line1))
|
||||
{
|
||||
address.Line1 = null;
|
||||
}
|
||||
|
||||
return MapToTaxInfo(customer);
|
||||
}
|
||||
|
||||
public async Task<BillingInfo.BillingSource> GetPaymentMethodAsync(ISubscriber subscriber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
var customer = await GetCustomerOrThrow(subscriber, GetCustomerPaymentOptions());
|
||||
if (customer == null)
|
||||
{
|
||||
logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})",
|
||||
subscriber.GatewayCustomerId, subscriber.Id);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (customer.Metadata?.ContainsKey("btCustomerId") ?? false)
|
||||
{
|
||||
try
|
||||
{
|
||||
var braintreeCustomer = await braintreeGateway.Customer.FindAsync(
|
||||
customer.Metadata["btCustomerId"]);
|
||||
if (braintreeCustomer?.DefaultPaymentMethod != null)
|
||||
{
|
||||
return new BillingInfo.BillingSource(
|
||||
braintreeCustomer.DefaultPaymentMethod);
|
||||
}
|
||||
}
|
||||
catch (Braintree.Exceptions.NotFoundException ex)
|
||||
{
|
||||
logger.LogError("An error occurred while trying to retrieve braintree customer ({SubscriberID}): {Error}", subscriber.Id, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
if (customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card")
|
||||
{
|
||||
return new BillingInfo.BillingSource(
|
||||
customer.InvoiceSettings.DefaultPaymentMethod);
|
||||
}
|
||||
|
||||
if (customer.DefaultSource != null &&
|
||||
(customer.DefaultSource is Card || customer.DefaultSource is BankAccount))
|
||||
{
|
||||
return new BillingInfo.BillingSource(customer.DefaultSource);
|
||||
}
|
||||
|
||||
var paymentMethod = GetLatestCardPaymentMethod(customer.Id);
|
||||
return paymentMethod != null ? new BillingInfo.BillingSource(paymentMethod) : null;
|
||||
}
|
||||
|
||||
private static CustomerGetOptions GetCustomerPaymentOptions()
|
||||
{
|
||||
var customerOptions = new CustomerGetOptions();
|
||||
customerOptions.AddExpand("default_source");
|
||||
customerOptions.AddExpand("invoice_settings.default_payment_method");
|
||||
return customerOptions;
|
||||
}
|
||||
|
||||
private Stripe.PaymentMethod GetLatestCardPaymentMethod(string customerId)
|
||||
{
|
||||
var cardPaymentMethods = stripeAdapter.PaymentMethodListAutoPaging(
|
||||
new PaymentMethodListOptions { Customer = customerId, Type = "card" });
|
||||
return cardPaymentMethods.MaxBy(m => m.Created);
|
||||
}
|
||||
|
||||
private TaxInfo MapToTaxInfo(Customer customer)
|
||||
{
|
||||
var address = customer.Address;
|
||||
var taxId = customer.TaxIds?.FirstOrDefault();
|
||||
|
||||
return new TaxInfo
|
||||
{
|
||||
TaxIdNumber = taxId?.Value,
|
||||
BillingAddressLine1 = address?.Line1,
|
||||
BillingAddressLine2 = address?.Line2,
|
||||
BillingAddressCity = address?.City,
|
||||
BillingAddressState = address?.State,
|
||||
BillingAddressPostalCode = address?.PostalCode,
|
||||
BillingAddressCountry = address?.Country,
|
||||
};
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user