mirror of
https://github.com/bitwarden/server.git
synced 2024-11-21 12:05:42 +01:00
[PM-7452] Handle PayPal for premium users (#4835)
* Add PremiumUserSale * Add PremiumUserBillingService * Integrate into UserService behind FF * Update invoice.created handler to bill newly created PayPal customers * Run dotnet format
This commit is contained in:
parent
84f7cd262c
commit
594b2a274d
@ -2,34 +2,63 @@
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
public class InvoiceCreatedHandler : IInvoiceCreatedHandler
|
||||
public class InvoiceCreatedHandler(
|
||||
ILogger<InvoiceCreatedHandler> logger,
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeEventUtilityService stripeEventUtilityService,
|
||||
IProviderEventService providerEventService)
|
||||
: IInvoiceCreatedHandler
|
||||
{
|
||||
private readonly IStripeEventService _stripeEventService;
|
||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||
private readonly IProviderEventService _providerEventService;
|
||||
|
||||
public InvoiceCreatedHandler(
|
||||
IStripeEventService stripeEventService,
|
||||
IStripeEventUtilityService stripeEventUtilityService,
|
||||
IProviderEventService providerEventService)
|
||||
{
|
||||
_stripeEventService = stripeEventService;
|
||||
_stripeEventUtilityService = stripeEventUtilityService;
|
||||
_providerEventService = providerEventService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="HandledStripeWebhook.InvoiceCreated"/> event type from Stripe.
|
||||
/// <para>
|
||||
/// This handler processes the `invoice.created` event in <see href="https://docs.stripe.com/api/events/types#event_types-invoice.created">Stripe</see>. It has
|
||||
/// two primary responsibilities.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 1. Checks to see if the newly created invoice belongs to a PayPal customer. If it does, and the invoice is ready to be paid, it will attempt to pay the invoice
|
||||
/// with Braintree and then let Stripe know the invoice can be marked as paid.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 2. If the invoice is for a provider, it records a point-in-time snapshot of the invoice broken down by the provider's client organizations. This is later used in
|
||||
/// the provider invoice export.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="parsedEvent"></param>
|
||||
public async Task HandleAsync(Event parsedEvent)
|
||||
{
|
||||
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
||||
if (_stripeEventUtilityService.ShouldAttemptToPayInvoice(invoice))
|
||||
try
|
||||
{
|
||||
await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
|
||||
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer"]);
|
||||
|
||||
var usingPayPal = invoice.Customer?.Metadata.ContainsKey("btCustomerId") ?? false;
|
||||
|
||||
if (usingPayPal && invoice is
|
||||
{
|
||||
AmountDue: > 0,
|
||||
Paid: false,
|
||||
CollectionMethod: "charge_automatically",
|
||||
BillingReason:
|
||||
"subscription_create" or
|
||||
"subscription_cycle" or
|
||||
"automatic_pending_invoice_item_invoice",
|
||||
SubscriptionId: not null and not ""
|
||||
})
|
||||
{
|
||||
await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to attempt paying for invoice while handling 'invoice.created' event ({EventID})", parsedEvent.Id);
|
||||
}
|
||||
|
||||
await _providerEventService.TryRecordInvoiceLineItems(parsedEvent);
|
||||
try
|
||||
{
|
||||
await providerEventService.TryRecordInvoiceLineItems(parsedEvent);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Failed to record provider invoice line items while handling 'invoice.created' event ({EventID})", parsedEvent.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +28,11 @@ public static class StripeConstants
|
||||
public const string TaxIdInvalid = "tax_id_invalid";
|
||||
}
|
||||
|
||||
public static class PaymentBehavior
|
||||
{
|
||||
public const string DefaultIncomplete = "default_incomplete";
|
||||
}
|
||||
|
||||
public static class PaymentMethodTypes
|
||||
{
|
||||
public const string Card = "card";
|
||||
|
@ -12,6 +12,7 @@ public static class ServiceCollectionExtensions
|
||||
public static void AddBillingOperations(this IServiceCollection services)
|
||||
{
|
||||
services.AddTransient<IOrganizationBillingService, OrganizationBillingService>();
|
||||
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
|
||||
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
|
||||
services.AddTransient<ISubscriberService, SubscriberService>();
|
||||
}
|
||||
|
49
src/Core/Billing/Models/Sales/PremiumUserSale.cs
Normal file
49
src/Core/Billing/Models/Sales/PremiumUserSale.cs
Normal file
@ -0,0 +1,49 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Business;
|
||||
|
||||
namespace Bit.Core.Billing.Models.Sales;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class PremiumUserSale
|
||||
{
|
||||
private PremiumUserSale() { }
|
||||
|
||||
public required User User { get; set; }
|
||||
public required CustomerSetup CustomerSetup { get; set; }
|
||||
public short? Storage { get; set; }
|
||||
|
||||
public void Deconstruct(
|
||||
out User user,
|
||||
out CustomerSetup customerSetup,
|
||||
out short? storage)
|
||||
{
|
||||
user = User;
|
||||
customerSetup = CustomerSetup;
|
||||
storage = Storage;
|
||||
}
|
||||
|
||||
public static PremiumUserSale From(
|
||||
User user,
|
||||
PaymentMethodType paymentMethodType,
|
||||
string paymentMethodToken,
|
||||
TaxInfo taxInfo,
|
||||
short? storage)
|
||||
{
|
||||
var tokenizedPaymentSource = new TokenizedPaymentSource(paymentMethodType, paymentMethodToken);
|
||||
|
||||
var taxInformation = TaxInformation.From(taxInfo);
|
||||
|
||||
return new PremiumUserSale
|
||||
{
|
||||
User = user,
|
||||
CustomerSetup = new CustomerSetup
|
||||
{
|
||||
TokenizedPaymentSource = tokenizedPaymentSource,
|
||||
TaxInformation = taxInformation
|
||||
},
|
||||
Storage = storage
|
||||
};
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using Stripe;
|
||||
using Bit.Core.Models.Business;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
@ -11,6 +12,15 @@ public record TaxInformation(
|
||||
string City,
|
||||
string State)
|
||||
{
|
||||
public static TaxInformation From(TaxInfo taxInfo) => new(
|
||||
taxInfo.BillingAddressCountry,
|
||||
taxInfo.BillingAddressPostalCode,
|
||||
taxInfo.TaxIdNumber,
|
||||
taxInfo.BillingAddressLine1,
|
||||
taxInfo.BillingAddressLine2,
|
||||
taxInfo.BillingAddressCity,
|
||||
taxInfo.BillingAddressState);
|
||||
|
||||
public (AddressOptions, List<CustomerTaxIdDataOptions>) GetStripeOptions()
|
||||
{
|
||||
var address = new AddressOptions
|
||||
|
@ -9,7 +9,7 @@ namespace Bit.Core.Billing.Services;
|
||||
public interface IOrganizationBillingService
|
||||
{
|
||||
/// <summary>
|
||||
/// <para>Establishes the billing configuration for a Bitwarden <see cref="Organization"/> using the provided <paramref name="sale"/>.</para>
|
||||
/// <para>Establishes the Stripe entities necessary for a Bitwarden <see cref="Organization"/> using the provided <paramref name="sale"/>.</para>
|
||||
/// <para>
|
||||
/// The method first checks to see if the
|
||||
/// provided <see cref="OrganizationSale.Organization"/> already has a Stripe <see cref="Stripe.Customer"/> using the <see cref="Organization.GatewayCustomerId"/>.
|
||||
@ -17,7 +17,7 @@ public interface IOrganizationBillingService
|
||||
/// for the created or existing customer using the provided <see cref="OrganizationSale.SubscriptionSetup"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="sale">The purchase details necessary to establish the Stripe entities responsible for billing the organization.</param>
|
||||
/// <param name="sale">The data required to establish the Stripe entities responsible for billing the organization.</param>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var sale = OrganizationSale.From(organization, organizationSignup);
|
||||
|
30
src/Core/Billing/Services/IPremiumUserBillingService.cs
Normal file
30
src/Core/Billing/Services/IPremiumUserBillingService.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Entities;
|
||||
|
||||
namespace Bit.Core.Billing.Services;
|
||||
|
||||
public interface IPremiumUserBillingService
|
||||
{
|
||||
/// <summary>
|
||||
/// <para>Establishes the Stripe entities necessary for a Bitwarden <see cref="User"/> using the provided <paramref name="sale"/>.</para>
|
||||
/// <para>
|
||||
/// The method first checks to see if the
|
||||
/// provided <see cref="PremiumUserSale.User"/> already has a Stripe <see cref="Stripe.Customer"/> using the <see cref="User.GatewayCustomerId"/>.
|
||||
/// If it doesn't, the method creates one using the <paramref name="sale"/>'s <see cref="PremiumUserSale.CustomerSetup"/>. The method then creates a Stripe <see cref="Stripe.Subscription"/>
|
||||
/// for the created or existing customer while appending the provided <paramref name="sale"/>'s <see cref="PremiumUserSale.Storage"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="sale">The data required to establish the Stripe entities responsible for billing the premium user.</param>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var sale = PremiumUserSale.From(
|
||||
/// user,
|
||||
/// paymentMethodType,
|
||||
/// paymentMethodToken,
|
||||
/// taxInfo,
|
||||
/// storage);
|
||||
/// await premiumUserBillingService.Finalize(sale);
|
||||
/// </code>
|
||||
/// </example>
|
||||
Task Finalize(PremiumUserSale sale);
|
||||
}
|
@ -34,11 +34,9 @@ public class OrganizationBillingService(
|
||||
{
|
||||
var (organization, customerSetup, subscriptionSetup) = sale;
|
||||
|
||||
List<string> expand = ["tax"];
|
||||
|
||||
var customer = customerSetup != null
|
||||
? await CreateCustomerAsync(organization, customerSetup, expand)
|
||||
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = expand });
|
||||
var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null
|
||||
? await CreateCustomerAsync(organization, customerSetup)
|
||||
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax"] });
|
||||
|
||||
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
|
||||
|
||||
@ -111,31 +109,31 @@ public class OrganizationBillingService(
|
||||
|
||||
private async Task<Customer> CreateCustomerAsync(
|
||||
Organization organization,
|
||||
CustomerSetup customerSetup,
|
||||
List<string>? expand = null)
|
||||
CustomerSetup customerSetup)
|
||||
{
|
||||
var organizationDisplayName = organization.DisplayName();
|
||||
var displayName = organization.DisplayName();
|
||||
|
||||
var customerCreateOptions = new CustomerCreateOptions
|
||||
{
|
||||
Coupon = customerSetup.Coupon,
|
||||
Description = organization.DisplayBusinessName(),
|
||||
Email = organization.BillingEmail,
|
||||
Expand = expand,
|
||||
Expand = ["tax"],
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
CustomFields = [
|
||||
new CustomerInvoiceSettingsCustomFieldOptions
|
||||
{
|
||||
Name = organization.SubscriberType(),
|
||||
Value = organizationDisplayName.Length <= 30
|
||||
? organizationDisplayName
|
||||
: organizationDisplayName[..30]
|
||||
Value = displayName.Length <= 30
|
||||
? displayName
|
||||
: displayName[..30]
|
||||
}]
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "region", globalSettings.BaseServiceUri.CloudRegion }
|
||||
["organizationId"] = organization.Id.ToString(),
|
||||
["region"] = globalSettings.BaseServiceUri.CloudRegion
|
||||
}
|
||||
};
|
||||
|
||||
@ -174,46 +172,41 @@ public class OrganizationBillingService(
|
||||
};
|
||||
customerCreateOptions.TaxIdData = taxIdData;
|
||||
|
||||
var (type, token) = customerSetup.TokenizedPaymentSource;
|
||||
var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource;
|
||||
|
||||
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
|
||||
switch (type)
|
||||
switch (paymentMethodType)
|
||||
{
|
||||
case PaymentMethodType.BankAccount:
|
||||
{
|
||||
var setupIntent =
|
||||
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
|
||||
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethodToken }))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (setupIntent == null)
|
||||
{
|
||||
logger.LogError("Cannot create customer for organization ({OrganizationID}) without a setup intent for their bank account", organization.Id);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
await setupIntentCache.Set(organization.Id, setupIntent.Id);
|
||||
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.Card:
|
||||
{
|
||||
customerCreateOptions.PaymentMethod = token;
|
||||
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = token;
|
||||
customerCreateOptions.PaymentMethod = paymentMethodToken;
|
||||
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethodToken;
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.PayPal:
|
||||
{
|
||||
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, token);
|
||||
|
||||
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, paymentMethodToken);
|
||||
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
logger.LogError("Cannot create customer for organization ({OrganizationID}) using payment method type ({PaymentMethodType}) as it is not supported", organization.Id, type.ToString());
|
||||
|
||||
logger.LogError("Cannot create customer for organization ({OrganizationID}) using payment method type ({PaymentMethodType}) as it is not supported", organization.Id, paymentMethodType.ToString());
|
||||
throw new BillingException();
|
||||
}
|
||||
}
|
||||
@ -227,7 +220,6 @@ public class OrganizationBillingService(
|
||||
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
|
||||
{
|
||||
await Revert();
|
||||
|
||||
throw new BadRequestException(
|
||||
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
|
||||
}
|
||||
@ -235,7 +227,6 @@ public class OrganizationBillingService(
|
||||
StripeConstants.ErrorCodes.TaxIdInvalid)
|
||||
{
|
||||
await Revert();
|
||||
|
||||
throw new BadRequestException(
|
||||
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
|
||||
}
|
||||
@ -257,7 +248,7 @@ public class OrganizationBillingService(
|
||||
await setupIntentCache.Remove(organization.Id);
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.PayPal:
|
||||
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
|
||||
{
|
||||
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
|
||||
break;
|
||||
|
@ -0,0 +1,260 @@
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Braintree;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
using Customer = Stripe.Customer;
|
||||
using Subscription = Stripe.Subscription;
|
||||
|
||||
namespace Bit.Core.Billing.Services.Implementations;
|
||||
|
||||
using static Utilities;
|
||||
|
||||
public class PremiumUserBillingService(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<PremiumUserBillingService> logger,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService,
|
||||
IUserRepository userRepository) : IPremiumUserBillingService
|
||||
{
|
||||
public async Task Finalize(PremiumUserSale sale)
|
||||
{
|
||||
var (user, customerSetup, storage) = sale;
|
||||
|
||||
List<string> expand = ["tax"];
|
||||
|
||||
var customer = string.IsNullOrEmpty(user.GatewayCustomerId)
|
||||
? await CreateCustomerAsync(user, customerSetup)
|
||||
: await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = expand });
|
||||
|
||||
var subscription = await CreateSubscriptionAsync(user.Id, customer, storage);
|
||||
|
||||
switch (customerSetup.TokenizedPaymentSource)
|
||||
{
|
||||
case { Type: PaymentMethodType.PayPal }
|
||||
when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete:
|
||||
case { Type: not PaymentMethodType.PayPal }
|
||||
when subscription.Status == StripeConstants.SubscriptionStatus.Active:
|
||||
{
|
||||
user.Premium = true;
|
||||
user.PremiumExpirationDate = subscription.CurrentPeriodEnd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
user.Gateway = GatewayType.Stripe;
|
||||
user.GatewayCustomerId = customer.Id;
|
||||
user.GatewaySubscriptionId = subscription.Id;
|
||||
|
||||
await userRepository.ReplaceAsync(user);
|
||||
}
|
||||
|
||||
private async Task<Customer> CreateCustomerAsync(
|
||||
User user,
|
||||
CustomerSetup customerSetup)
|
||||
{
|
||||
if (customerSetup.TokenizedPaymentSource is not
|
||||
{
|
||||
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
|
||||
Token: not null and not ""
|
||||
})
|
||||
{
|
||||
logger.LogError(
|
||||
"Cannot create customer for user ({UserID}) without a valid payment source", user.Id);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
if (customerSetup.TaxInformation is not { Country: not null and not "", PostalCode: not null and not "" })
|
||||
{
|
||||
logger.LogError(
|
||||
"Cannot create customer for user ({UserID}) without valid tax information", user.Id);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions();
|
||||
|
||||
var subscriberName = user.SubscriberName();
|
||||
|
||||
var customerCreateOptions = new CustomerCreateOptions
|
||||
{
|
||||
Address = address,
|
||||
Description = user.Name,
|
||||
Email = user.Email,
|
||||
Expand = ["tax"],
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
CustomFields =
|
||||
[
|
||||
new CustomerInvoiceSettingsCustomFieldOptions
|
||||
{
|
||||
Name = user.SubscriberType(),
|
||||
Value = subscriberName.Length <= 30
|
||||
? subscriberName
|
||||
: subscriberName[..30]
|
||||
}
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["region"] = globalSettings.BaseServiceUri.CloudRegion,
|
||||
["userId"] = user.Id.ToString()
|
||||
},
|
||||
Tax = new CustomerTaxOptions
|
||||
{
|
||||
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
|
||||
},
|
||||
TaxIdData = taxIdData
|
||||
};
|
||||
|
||||
var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource;
|
||||
|
||||
var braintreeCustomerId = "";
|
||||
|
||||
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
|
||||
switch (paymentMethodType)
|
||||
{
|
||||
case PaymentMethodType.BankAccount:
|
||||
{
|
||||
var setupIntent =
|
||||
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethodToken }))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (setupIntent == null)
|
||||
{
|
||||
logger.LogError("Cannot create customer for user ({UserID}) without a setup intent for their bank account", user.Id);
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
await setupIntentCache.Set(user.Id, setupIntent.Id);
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.Card:
|
||||
{
|
||||
customerCreateOptions.PaymentMethod = paymentMethodToken;
|
||||
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethodToken;
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.PayPal:
|
||||
{
|
||||
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethodToken);
|
||||
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethodType.ToString());
|
||||
throw new BillingException();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
|
||||
}
|
||||
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
|
||||
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
|
||||
{
|
||||
await Revert();
|
||||
throw new BadRequestException(
|
||||
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
|
||||
}
|
||||
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
|
||||
StripeConstants.ErrorCodes.TaxIdInvalid)
|
||||
{
|
||||
await Revert();
|
||||
throw new BadRequestException(
|
||||
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
await Revert();
|
||||
throw;
|
||||
}
|
||||
|
||||
async Task Revert()
|
||||
{
|
||||
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
|
||||
switch (customerSetup.TokenizedPaymentSource!.Type)
|
||||
{
|
||||
case PaymentMethodType.BankAccount:
|
||||
{
|
||||
await setupIntentCache.Remove(user.Id);
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
|
||||
{
|
||||
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Subscription> CreateSubscriptionAsync(
|
||||
Guid userId,
|
||||
Customer customer,
|
||||
int? storage)
|
||||
{
|
||||
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Price = "premium-annually",
|
||||
Quantity = 1
|
||||
}
|
||||
};
|
||||
|
||||
if (storage is > 0)
|
||||
{
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = "storage-gb-annually",
|
||||
Quantity = storage
|
||||
});
|
||||
}
|
||||
|
||||
var usingPayPal = customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) ?? false;
|
||||
|
||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported,
|
||||
},
|
||||
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
|
||||
Customer = customer.Id,
|
||||
Items = subscriptionItemOptionsList,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["userId"] = userId.ToString()
|
||||
},
|
||||
PaymentBehavior = usingPayPal
|
||||
? StripeConstants.PaymentBehavior.DefaultIncomplete
|
||||
: null,
|
||||
OffSession = true
|
||||
};
|
||||
|
||||
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
if (usingPayPal)
|
||||
{
|
||||
await stripeAdapter.InvoiceUpdateAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
|
||||
{
|
||||
AutoAdvance = false
|
||||
});
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
}
|
@ -6,6 +6,8 @@ using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -61,6 +63,8 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
private readonly IProviderUserRepository _providerUserRepository;
|
||||
private readonly IStripeSyncService _stripeSyncService;
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IPremiumUserBillingService _premiumUserBillingService;
|
||||
|
||||
public UserService(
|
||||
IUserRepository userRepository,
|
||||
@ -92,7 +96,9 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
IAcceptOrgUserCommand acceptOrgUserCommand,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IStripeSyncService stripeSyncService,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory)
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IFeatureService featureService,
|
||||
IPremiumUserBillingService premiumUserBillingService)
|
||||
: base(
|
||||
store,
|
||||
optionsAccessor,
|
||||
@ -130,6 +136,8 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
_providerUserRepository = providerUserRepository;
|
||||
_stripeSyncService = stripeSyncService;
|
||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||
_featureService = featureService;
|
||||
_premiumUserBillingService = premiumUserBillingService;
|
||||
}
|
||||
|
||||
public Guid? GetProperUserId(ClaimsPrincipal principal)
|
||||
@ -904,8 +912,18 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
}
|
||||
else
|
||||
{
|
||||
paymentIntentClientSecret = await _paymentService.PurchasePremiumAsync(user, paymentMethodType,
|
||||
paymentToken, additionalStorageGb, taxInfo);
|
||||
var deprecateStripeSourcesAPI = _featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI);
|
||||
|
||||
if (deprecateStripeSourcesAPI)
|
||||
{
|
||||
var sale = PremiumUserSale.From(user, paymentMethodType, paymentToken, taxInfo, additionalStorageGb);
|
||||
await _premiumUserBillingService.Finalize(sale);
|
||||
}
|
||||
else
|
||||
{
|
||||
paymentIntentClientSecret = await _paymentService.PurchasePremiumAsync(user, paymentMethodType,
|
||||
paymentToken, additionalStorageGb, taxInfo);
|
||||
}
|
||||
}
|
||||
|
||||
user.Premium = true;
|
||||
|
@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
@ -260,7 +261,9 @@ public class UserServiceTests
|
||||
sutProvider.GetDependency<IAcceptOrgUserCommand>(),
|
||||
sutProvider.GetDependency<IProviderUserRepository>(),
|
||||
sutProvider.GetDependency<IStripeSyncService>(),
|
||||
new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>()
|
||||
new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>(),
|
||||
sutProvider.GetDependency<IFeatureService>(),
|
||||
sutProvider.GetDependency<IPremiumUserBillingService>()
|
||||
);
|
||||
|
||||
var actualIsVerified = await sut.VerifySecretAsync(user, secret);
|
||||
|
Loading…
Reference in New Issue
Block a user