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;
|
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>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="parsedEvent"></param>
|
|
||||||
public async Task HandleAsync(Event parsedEvent)
|
public async Task HandleAsync(Event parsedEvent)
|
||||||
{
|
{
|
||||||
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
try
|
||||||
if (_stripeEventUtilityService.ShouldAttemptToPayInvoice(invoice))
|
|
||||||
{
|
{
|
||||||
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 const string TaxIdInvalid = "tax_id_invalid";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class PaymentBehavior
|
||||||
|
{
|
||||||
|
public const string DefaultIncomplete = "default_incomplete";
|
||||||
|
}
|
||||||
|
|
||||||
public static class PaymentMethodTypes
|
public static class PaymentMethodTypes
|
||||||
{
|
{
|
||||||
public const string Card = "card";
|
public const string Card = "card";
|
||||||
|
@ -12,6 +12,7 @@ public static class ServiceCollectionExtensions
|
|||||||
public static void AddBillingOperations(this IServiceCollection services)
|
public static void AddBillingOperations(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddTransient<IOrganizationBillingService, OrganizationBillingService>();
|
services.AddTransient<IOrganizationBillingService, OrganizationBillingService>();
|
||||||
|
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
|
||||||
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
|
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
|
||||||
services.AddTransient<ISubscriberService, SubscriberService>();
|
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;
|
namespace Bit.Core.Billing.Models;
|
||||||
|
|
||||||
@ -11,6 +12,15 @@ public record TaxInformation(
|
|||||||
string City,
|
string City,
|
||||||
string State)
|
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()
|
public (AddressOptions, List<CustomerTaxIdDataOptions>) GetStripeOptions()
|
||||||
{
|
{
|
||||||
var address = new AddressOptions
|
var address = new AddressOptions
|
||||||
|
@ -9,7 +9,7 @@ namespace Bit.Core.Billing.Services;
|
|||||||
public interface IOrganizationBillingService
|
public interface IOrganizationBillingService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <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>
|
/// <para>
|
||||||
/// The method first checks to see if the
|
/// 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"/>.
|
/// 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"/>.
|
/// for the created or existing customer using the provided <see cref="OrganizationSale.SubscriptionSetup"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </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>
|
/// <example>
|
||||||
/// <code>
|
/// <code>
|
||||||
/// var sale = OrganizationSale.From(organization, organizationSignup);
|
/// 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;
|
var (organization, customerSetup, subscriptionSetup) = sale;
|
||||||
|
|
||||||
List<string> expand = ["tax"];
|
var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null
|
||||||
|
? await CreateCustomerAsync(organization, customerSetup)
|
||||||
var customer = customerSetup != null
|
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax"] });
|
||||||
? await CreateCustomerAsync(organization, customerSetup, expand)
|
|
||||||
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = expand });
|
|
||||||
|
|
||||||
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
|
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
|
||||||
|
|
||||||
@ -111,31 +109,31 @@ public class OrganizationBillingService(
|
|||||||
|
|
||||||
private async Task<Customer> CreateCustomerAsync(
|
private async Task<Customer> CreateCustomerAsync(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
CustomerSetup customerSetup,
|
CustomerSetup customerSetup)
|
||||||
List<string>? expand = null)
|
|
||||||
{
|
{
|
||||||
var organizationDisplayName = organization.DisplayName();
|
var displayName = organization.DisplayName();
|
||||||
|
|
||||||
var customerCreateOptions = new CustomerCreateOptions
|
var customerCreateOptions = new CustomerCreateOptions
|
||||||
{
|
{
|
||||||
Coupon = customerSetup.Coupon,
|
Coupon = customerSetup.Coupon,
|
||||||
Description = organization.DisplayBusinessName(),
|
Description = organization.DisplayBusinessName(),
|
||||||
Email = organization.BillingEmail,
|
Email = organization.BillingEmail,
|
||||||
Expand = expand,
|
Expand = ["tax"],
|
||||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||||
{
|
{
|
||||||
CustomFields = [
|
CustomFields = [
|
||||||
new CustomerInvoiceSettingsCustomFieldOptions
|
new CustomerInvoiceSettingsCustomFieldOptions
|
||||||
{
|
{
|
||||||
Name = organization.SubscriberType(),
|
Name = organization.SubscriberType(),
|
||||||
Value = organizationDisplayName.Length <= 30
|
Value = displayName.Length <= 30
|
||||||
? organizationDisplayName
|
? displayName
|
||||||
: organizationDisplayName[..30]
|
: displayName[..30]
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
Metadata = new Dictionary<string, string>
|
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;
|
customerCreateOptions.TaxIdData = taxIdData;
|
||||||
|
|
||||||
var (type, token) = customerSetup.TokenizedPaymentSource;
|
var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource;
|
||||||
|
|
||||||
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
|
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
|
||||||
switch (type)
|
switch (paymentMethodType)
|
||||||
{
|
{
|
||||||
case PaymentMethodType.BankAccount:
|
case PaymentMethodType.BankAccount:
|
||||||
{
|
{
|
||||||
var setupIntent =
|
var setupIntent =
|
||||||
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
|
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethodToken }))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
if (setupIntent == null)
|
if (setupIntent == null)
|
||||||
{
|
{
|
||||||
logger.LogError("Cannot create customer for organization ({OrganizationID}) without a setup intent for their bank account", organization.Id);
|
logger.LogError("Cannot create customer for organization ({OrganizationID}) without a setup intent for their bank account", organization.Id);
|
||||||
|
|
||||||
throw new BillingException();
|
throw new BillingException();
|
||||||
}
|
}
|
||||||
|
|
||||||
await setupIntentCache.Set(organization.Id, setupIntent.Id);
|
await setupIntentCache.Set(organization.Id, setupIntent.Id);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case PaymentMethodType.Card:
|
case PaymentMethodType.Card:
|
||||||
{
|
{
|
||||||
customerCreateOptions.PaymentMethod = token;
|
customerCreateOptions.PaymentMethod = paymentMethodToken;
|
||||||
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = token;
|
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethodToken;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case PaymentMethodType.PayPal:
|
case PaymentMethodType.PayPal:
|
||||||
{
|
{
|
||||||
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, token);
|
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, paymentMethodToken);
|
||||||
|
|
||||||
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
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();
|
throw new BillingException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -227,7 +220,6 @@ public class OrganizationBillingService(
|
|||||||
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
|
StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
|
||||||
{
|
{
|
||||||
await Revert();
|
await Revert();
|
||||||
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
|
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
|
||||||
}
|
}
|
||||||
@ -235,7 +227,6 @@ public class OrganizationBillingService(
|
|||||||
StripeConstants.ErrorCodes.TaxIdInvalid)
|
StripeConstants.ErrorCodes.TaxIdInvalid)
|
||||||
{
|
{
|
||||||
await Revert();
|
await Revert();
|
||||||
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
|
"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);
|
await setupIntentCache.Remove(organization.Id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case PaymentMethodType.PayPal:
|
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
|
||||||
{
|
{
|
||||||
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
|
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
|
||||||
break;
|
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.Enums;
|
||||||
using Bit.Core.Auth.Models;
|
using Bit.Core.Auth.Models;
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
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.Context;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
@ -61,6 +63,8 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
private readonly IProviderUserRepository _providerUserRepository;
|
private readonly IProviderUserRepository _providerUserRepository;
|
||||||
private readonly IStripeSyncService _stripeSyncService;
|
private readonly IStripeSyncService _stripeSyncService;
|
||||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
private readonly IPremiumUserBillingService _premiumUserBillingService;
|
||||||
|
|
||||||
public UserService(
|
public UserService(
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
@ -92,7 +96,9 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
IAcceptOrgUserCommand acceptOrgUserCommand,
|
IAcceptOrgUserCommand acceptOrgUserCommand,
|
||||||
IProviderUserRepository providerUserRepository,
|
IProviderUserRepository providerUserRepository,
|
||||||
IStripeSyncService stripeSyncService,
|
IStripeSyncService stripeSyncService,
|
||||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory)
|
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||||
|
IFeatureService featureService,
|
||||||
|
IPremiumUserBillingService premiumUserBillingService)
|
||||||
: base(
|
: base(
|
||||||
store,
|
store,
|
||||||
optionsAccessor,
|
optionsAccessor,
|
||||||
@ -130,6 +136,8 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
_providerUserRepository = providerUserRepository;
|
_providerUserRepository = providerUserRepository;
|
||||||
_stripeSyncService = stripeSyncService;
|
_stripeSyncService = stripeSyncService;
|
||||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||||
|
_featureService = featureService;
|
||||||
|
_premiumUserBillingService = premiumUserBillingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Guid? GetProperUserId(ClaimsPrincipal principal)
|
public Guid? GetProperUserId(ClaimsPrincipal principal)
|
||||||
@ -904,8 +912,18 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
paymentIntentClientSecret = await _paymentService.PurchasePremiumAsync(user, paymentMethodType,
|
var deprecateStripeSourcesAPI = _featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI);
|
||||||
paymentToken, additionalStorageGb, taxInfo);
|
|
||||||
|
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;
|
user.Premium = true;
|
||||||
|
@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Services;
|
|||||||
using Bit.Core.Auth.Enums;
|
using Bit.Core.Auth.Enums;
|
||||||
using Bit.Core.Auth.Models;
|
using Bit.Core.Auth.Models;
|
||||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||||
|
using Bit.Core.Billing.Services;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
@ -260,7 +261,9 @@ public class UserServiceTests
|
|||||||
sutProvider.GetDependency<IAcceptOrgUserCommand>(),
|
sutProvider.GetDependency<IAcceptOrgUserCommand>(),
|
||||||
sutProvider.GetDependency<IProviderUserRepository>(),
|
sutProvider.GetDependency<IProviderUserRepository>(),
|
||||||
sutProvider.GetDependency<IStripeSyncService>(),
|
sutProvider.GetDependency<IStripeSyncService>(),
|
||||||
new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>()
|
new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>(),
|
||||||
|
sutProvider.GetDependency<IFeatureService>(),
|
||||||
|
sutProvider.GetDependency<IPremiumUserBillingService>()
|
||||||
);
|
);
|
||||||
|
|
||||||
var actualIsVerified = await sut.VerifySecretAsync(user, secret);
|
var actualIsVerified = await sut.VerifySecretAsync(user, secret);
|
||||||
|
Loading…
Reference in New Issue
Block a user