mirror of
https://github.com/bitwarden/server.git
synced 2025-07-05 10:02:47 -05:00
[AC-1938] Update provider payment method (#4140)
* Refactored GET provider subscription Refactoring this endpoint and its associated tests in preparation for the addition of more endpoints that share similar patterns * Replaced StripePaymentService call in AccountsController, OrganizationsController This was made in error during a previous PR. Since this is not related to Consolidated Billing, we want to try not to include it in these changes. * Removing GetPaymentInformation call from ProviderBillingService This method is a good call for the SubscriberService as we'll want to extend the functionality to all subscriber types * Refactored GetTaxInformation to use Billing owned DTO * Add UpdateTaxInformation to SubscriberService * Added GetTaxInformation and UpdateTaxInformation endpoints to ProviderBillingController * Added controller to manage creation of Stripe SetupIntents With the deprecation of the Sources API, we need to move the bank account creation process to using SetupIntents. This controller brings both the creation of "card" and "us_bank_account" SetupIntents under billing management. * Added UpdatePaymentMethod method to SubscriberService This method utilizes the SetupIntents created by the StripeController from the previous commit when a customer adds a card or us_bank_account payment method (Stripe). We need to cache the most recent SetupIntent for the subscriber so that we know which PaymentMethod is their most recent even when it hasn't been confirmed yet. * Refactored GetPaymentMethod to use billing owned DTO and check setup intents * Added GetPaymentMethod and UpdatePaymentMethod endpoints to ProviderBillingController * Re-added GetPaymentInformation endpoint to consolidate API calls on the payment method page * Added VerifyBankAccount endpoint to ProviderBillingController in order to finalize bank account payment methods * Updated BitPayInvoiceRequestModel to support providers * run dotnet format * Conner's feedback * Run dotnet format'
This commit is contained in:
10
src/Core/Billing/Caches/ISetupIntentCache.cs
Normal file
10
src/Core/Billing/Caches/ISetupIntentCache.cs
Normal file
@ -0,0 +1,10 @@
|
||||
namespace Bit.Core.Billing.Caches;
|
||||
|
||||
public interface ISetupIntentCache
|
||||
{
|
||||
Task<string> Get(Guid subscriberId);
|
||||
|
||||
Task Remove(Guid subscriberId);
|
||||
|
||||
Task Set(Guid subscriberId, string setupIntentId);
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Billing.Caches.Implementations;
|
||||
|
||||
public class SetupIntentDistributedCache(
|
||||
[FromKeyedServices("persistent")]
|
||||
IDistributedCache distributedCache) : ISetupIntentCache
|
||||
{
|
||||
public async Task<string> Get(Guid subscriberId)
|
||||
{
|
||||
var cacheKey = GetCacheKey(subscriberId);
|
||||
|
||||
return await distributedCache.GetStringAsync(cacheKey);
|
||||
}
|
||||
|
||||
public async Task Remove(Guid subscriberId)
|
||||
{
|
||||
var cacheKey = GetCacheKey(subscriberId);
|
||||
|
||||
await distributedCache.RemoveAsync(cacheKey);
|
||||
}
|
||||
|
||||
public async Task Set(Guid subscriberId, string setupIntentId)
|
||||
{
|
||||
var cacheKey = GetCacheKey(subscriberId);
|
||||
|
||||
await distributedCache.SetStringAsync(cacheKey, setupIntentId);
|
||||
}
|
||||
|
||||
private static string GetCacheKey(Guid subscriberId) => $"pending_bank_account_{subscriberId}";
|
||||
}
|
@ -21,6 +21,12 @@ public static class StripeConstants
|
||||
public const string SecretsManagerStandalone = "sm-standalone";
|
||||
}
|
||||
|
||||
public static class PaymentMethodTypes
|
||||
{
|
||||
public const string Card = "card";
|
||||
public const string USBankAccount = "us_bank_account";
|
||||
}
|
||||
|
||||
public static class ProrationBehavior
|
||||
{
|
||||
public const string AlwaysInvoice = "always_invoice";
|
||||
|
@ -2,6 +2,7 @@
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.Enums;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
|
||||
@ -26,6 +27,20 @@ public static class BillingExtensions
|
||||
=> !string.IsNullOrEmpty(organization.GatewayCustomerId) &&
|
||||
!string.IsNullOrEmpty(organization.GatewaySubscriptionId);
|
||||
|
||||
public static bool IsUnverifiedBankAccount(this SetupIntent setupIntent) =>
|
||||
setupIntent is
|
||||
{
|
||||
Status: "requires_action",
|
||||
NextAction:
|
||||
{
|
||||
VerifyWithMicrodeposits: not null
|
||||
},
|
||||
PaymentMethod:
|
||||
{
|
||||
UsBankAccount: not null
|
||||
}
|
||||
};
|
||||
|
||||
public static bool SupportsConsolidatedBilling(this PlanType planType)
|
||||
=> planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly;
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Caches.Implementations;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Services.Implementations;
|
||||
|
||||
namespace Bit.Core.Billing.Extensions;
|
||||
@ -10,6 +12,7 @@ public static class ServiceCollectionExtensions
|
||||
public static void AddBillingOperations(this IServiceCollection services)
|
||||
{
|
||||
services.AddTransient<IOrganizationBillingService, OrganizationBillingService>();
|
||||
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
|
||||
services.AddTransient<ISubscriberService, SubscriberService>();
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,6 @@
|
||||
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public record ProviderSubscriptionDTO(
|
||||
public record ConsolidatedBillingSubscriptionDTO(
|
||||
List<ConfiguredProviderPlanDTO> ProviderPlans,
|
||||
Subscription Subscription);
|
156
src/Core/Billing/Models/MaskedPaymentMethodDTO.cs
Normal file
156
src/Core/Billing/Models/MaskedPaymentMethodDTO.cs
Normal file
@ -0,0 +1,156 @@
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public record MaskedPaymentMethodDTO(
|
||||
PaymentMethodType Type,
|
||||
string Description,
|
||||
bool NeedsVerification)
|
||||
{
|
||||
public static MaskedPaymentMethodDTO From(Stripe.Customer customer)
|
||||
{
|
||||
var defaultPaymentMethod = customer.InvoiceSettings?.DefaultPaymentMethod;
|
||||
|
||||
if (defaultPaymentMethod == null)
|
||||
{
|
||||
return customer.DefaultSource != null ? FromStripeLegacyPaymentSource(customer.DefaultSource) : null;
|
||||
}
|
||||
|
||||
return defaultPaymentMethod.Type switch
|
||||
{
|
||||
"card" => FromStripeCardPaymentMethod(defaultPaymentMethod.Card),
|
||||
"us_bank_account" => FromStripeBankAccountPaymentMethod(defaultPaymentMethod.UsBankAccount),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public static MaskedPaymentMethodDTO From(Stripe.SetupIntent setupIntent)
|
||||
{
|
||||
if (!setupIntent.IsUnverifiedBankAccount())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bankAccount = setupIntent.PaymentMethod.UsBankAccount;
|
||||
|
||||
var description = $"{bankAccount.BankName}, *{bankAccount.Last4}";
|
||||
|
||||
return new MaskedPaymentMethodDTO(
|
||||
PaymentMethodType.BankAccount,
|
||||
description,
|
||||
true);
|
||||
}
|
||||
|
||||
public static MaskedPaymentMethodDTO From(Braintree.Customer customer)
|
||||
{
|
||||
var defaultPaymentMethod = customer.DefaultPaymentMethod;
|
||||
|
||||
if (defaultPaymentMethod == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (defaultPaymentMethod)
|
||||
{
|
||||
case Braintree.PayPalAccount payPalAccount:
|
||||
{
|
||||
return new MaskedPaymentMethodDTO(
|
||||
PaymentMethodType.PayPal,
|
||||
payPalAccount.Email,
|
||||
false);
|
||||
}
|
||||
case Braintree.CreditCard creditCard:
|
||||
{
|
||||
var paddedExpirationMonth = creditCard.ExpirationMonth.PadLeft(2, '0');
|
||||
|
||||
var description =
|
||||
$"{creditCard.CardType}, *{creditCard.LastFour}, {paddedExpirationMonth}/{creditCard.ExpirationYear}";
|
||||
|
||||
return new MaskedPaymentMethodDTO(
|
||||
PaymentMethodType.Card,
|
||||
description,
|
||||
false);
|
||||
}
|
||||
case Braintree.UsBankAccount bankAccount:
|
||||
{
|
||||
return new MaskedPaymentMethodDTO(
|
||||
PaymentMethodType.BankAccount,
|
||||
$"{bankAccount.BankName}, *{bankAccount.Last4}",
|
||||
false);
|
||||
}
|
||||
default:
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static MaskedPaymentMethodDTO FromStripeBankAccountPaymentMethod(
|
||||
Stripe.PaymentMethodUsBankAccount bankAccount)
|
||||
{
|
||||
var description = $"{bankAccount.BankName}, *{bankAccount.Last4}";
|
||||
|
||||
return new MaskedPaymentMethodDTO(
|
||||
PaymentMethodType.BankAccount,
|
||||
description,
|
||||
false);
|
||||
}
|
||||
|
||||
private static MaskedPaymentMethodDTO FromStripeCardPaymentMethod(Stripe.PaymentMethodCard card)
|
||||
=> new(
|
||||
PaymentMethodType.Card,
|
||||
GetCardDescription(card.Brand, card.Last4, card.ExpMonth, card.ExpYear),
|
||||
false);
|
||||
|
||||
#region Legacy Source Payments
|
||||
|
||||
private static MaskedPaymentMethodDTO FromStripeLegacyPaymentSource(Stripe.IPaymentSource paymentSource)
|
||||
=> paymentSource switch
|
||||
{
|
||||
Stripe.BankAccount bankAccount => FromStripeBankAccountLegacySource(bankAccount),
|
||||
Stripe.Card card => FromStripeCardLegacySource(card),
|
||||
Stripe.Source { Card: not null } source => FromStripeSourceCardLegacySource(source.Card),
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static MaskedPaymentMethodDTO FromStripeBankAccountLegacySource(Stripe.BankAccount bankAccount)
|
||||
{
|
||||
var status = bankAccount.Status switch
|
||||
{
|
||||
"verified" => "Verified",
|
||||
"errored" => "Invalid",
|
||||
"verification_failed" => "Verification failed",
|
||||
_ => "Unverified"
|
||||
};
|
||||
|
||||
var description = $"{bankAccount.BankName}, *{bankAccount.Last4} - {status}";
|
||||
|
||||
var needsVerification = bankAccount.Status is "new" or "validated";
|
||||
|
||||
return new MaskedPaymentMethodDTO(
|
||||
PaymentMethodType.BankAccount,
|
||||
description,
|
||||
needsVerification);
|
||||
}
|
||||
|
||||
private static MaskedPaymentMethodDTO FromStripeCardLegacySource(Stripe.Card card)
|
||||
=> new(
|
||||
PaymentMethodType.Card,
|
||||
GetCardDescription(card.Brand, card.Last4, card.ExpMonth, card.ExpYear),
|
||||
false);
|
||||
|
||||
private static MaskedPaymentMethodDTO FromStripeSourceCardLegacySource(Stripe.SourceCard card)
|
||||
=> new(
|
||||
PaymentMethodType.Card,
|
||||
GetCardDescription(card.Brand, card.Last4, card.ExpMonth, card.ExpYear),
|
||||
false);
|
||||
|
||||
#endregion
|
||||
|
||||
private static string GetCardDescription(
|
||||
string brand,
|
||||
string last4,
|
||||
long expirationMonth,
|
||||
long expirationYear) => $"{brand.ToUpperInvariant()}, *{last4}, {expirationMonth:00}/{expirationYear}";
|
||||
}
|
6
src/Core/Billing/Models/PaymentInformationDTO.cs
Normal file
6
src/Core/Billing/Models/PaymentInformationDTO.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public record PaymentInformationDTO(
|
||||
long AccountCredit,
|
||||
MaskedPaymentMethodDTO PaymentMethod,
|
||||
TaxInformationDTO TaxInformation);
|
@ -1,6 +0,0 @@
|
||||
using Bit.Core.Models.Business;
|
||||
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public record ProviderPaymentInfoDTO(BillingInfo.BillingSource billingSource,
|
||||
TaxInfo taxInfo);
|
149
src/Core/Billing/Models/TaxInformationDTO.cs
Normal file
149
src/Core/Billing/Models/TaxInformationDTO.cs
Normal file
@ -0,0 +1,149 @@
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public record TaxInformationDTO(
|
||||
string Country,
|
||||
string PostalCode,
|
||||
string TaxId,
|
||||
string Line1,
|
||||
string Line2,
|
||||
string City,
|
||||
string State)
|
||||
{
|
||||
public string GetTaxIdType()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Country) || string.IsNullOrEmpty(TaxId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (Country.ToUpper())
|
||||
{
|
||||
case "AD":
|
||||
return "ad_nrt";
|
||||
case "AE":
|
||||
return "ae_trn";
|
||||
case "AR":
|
||||
return "ar_cuit";
|
||||
case "AU":
|
||||
return "au_abn";
|
||||
case "BO":
|
||||
return "bo_tin";
|
||||
case "BR":
|
||||
return "br_cnpj";
|
||||
case "CA":
|
||||
// May break for those in Québec given the assumption of QST
|
||||
if (State?.Contains("bec") ?? false)
|
||||
{
|
||||
return "ca_qst";
|
||||
}
|
||||
return "ca_bn";
|
||||
case "CH":
|
||||
return "ch_vat";
|
||||
case "CL":
|
||||
return "cl_tin";
|
||||
case "CN":
|
||||
return "cn_tin";
|
||||
case "CO":
|
||||
return "co_nit";
|
||||
case "CR":
|
||||
return "cr_tin";
|
||||
case "DO":
|
||||
return "do_rcn";
|
||||
case "EC":
|
||||
return "ec_ruc";
|
||||
case "EG":
|
||||
return "eg_tin";
|
||||
case "GE":
|
||||
return "ge_vat";
|
||||
case "ID":
|
||||
return "id_npwp";
|
||||
case "IL":
|
||||
return "il_vat";
|
||||
case "IS":
|
||||
return "is_vat";
|
||||
case "KE":
|
||||
return "ke_pin";
|
||||
case "AT":
|
||||
case "BE":
|
||||
case "BG":
|
||||
case "CY":
|
||||
case "CZ":
|
||||
case "DE":
|
||||
case "DK":
|
||||
case "EE":
|
||||
case "ES":
|
||||
case "FI":
|
||||
case "FR":
|
||||
case "GB":
|
||||
case "GR":
|
||||
case "HR":
|
||||
case "HU":
|
||||
case "IE":
|
||||
case "IT":
|
||||
case "LT":
|
||||
case "LU":
|
||||
case "LV":
|
||||
case "MT":
|
||||
case "NL":
|
||||
case "PL":
|
||||
case "PT":
|
||||
case "RO":
|
||||
case "SE":
|
||||
case "SI":
|
||||
case "SK":
|
||||
return "eu_vat";
|
||||
case "HK":
|
||||
return "hk_br";
|
||||
case "IN":
|
||||
return "in_gst";
|
||||
case "JP":
|
||||
return "jp_cn";
|
||||
case "KR":
|
||||
return "kr_brn";
|
||||
case "LI":
|
||||
return "li_uid";
|
||||
case "MX":
|
||||
return "mx_rfc";
|
||||
case "MY":
|
||||
return "my_sst";
|
||||
case "NO":
|
||||
return "no_vat";
|
||||
case "NZ":
|
||||
return "nz_gst";
|
||||
case "PE":
|
||||
return "pe_ruc";
|
||||
case "PH":
|
||||
return "ph_tin";
|
||||
case "RS":
|
||||
return "rs_pib";
|
||||
case "RU":
|
||||
return "ru_inn";
|
||||
case "SA":
|
||||
return "sa_vat";
|
||||
case "SG":
|
||||
return "sg_gst";
|
||||
case "SV":
|
||||
return "sv_nit";
|
||||
case "TH":
|
||||
return "th_vat";
|
||||
case "TR":
|
||||
return "tr_tin";
|
||||
case "TW":
|
||||
return "tw_vat";
|
||||
case "UA":
|
||||
return "ua_vat";
|
||||
case "US":
|
||||
return "us_ein";
|
||||
case "UY":
|
||||
return "uy_ruc";
|
||||
case "VE":
|
||||
return "ve_rif";
|
||||
case "VN":
|
||||
return "vn_tin";
|
||||
case "ZA":
|
||||
return "za_vat";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
7
src/Core/Billing/Models/TokenizedPaymentMethodDTO.cs
Normal file
7
src/Core/Billing/Models/TokenizedPaymentMethodDTO.cs
Normal file
@ -0,0 +1,7 @@
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.Billing.Models;
|
||||
|
||||
public record TokenizedPaymentMethodDTO(
|
||||
PaymentMethodType Type,
|
||||
string Token);
|
@ -56,13 +56,13 @@ public interface IProviderBillingService
|
||||
PlanType planType);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a provider's billing subscription data.
|
||||
/// Retrieves the <paramref name="provider"/>'s consolidated billing subscription, which includes their Stripe subscription and configured provider plans.
|
||||
/// </summary>
|
||||
/// <param name="providerId">The ID of the provider to retrieve subscription data for.</param>
|
||||
/// <returns>A <see cref="ProviderSubscriptionDTO"/> object containing the provider's Stripe <see cref="Stripe.Subscription"/> and their <see cref="ConfiguredProviderPlanDTO"/>s.</returns>
|
||||
/// <param name="provider">The provider to retrieve the consolidated billing subscription for.</param>
|
||||
/// <returns>A <see cref="ConsolidatedBillingSubscriptionDTO"/> containing the provider's Stripe <see cref="Stripe.Subscription"/> and a list of <see cref="ConfiguredProviderPlanDTO"/>s representing their configured plans.</returns>
|
||||
/// <remarks>This method opts for returning <see langword="null"/> rather than throwing exceptions, making it ideal for surfacing data from API endpoints.</remarks>
|
||||
Task<ProviderSubscriptionDTO> GetSubscriptionDTO(
|
||||
Guid providerId);
|
||||
Task<ConsolidatedBillingSubscriptionDTO> GetConsolidatedBillingSubscription(
|
||||
Provider provider);
|
||||
|
||||
/// <summary>
|
||||
/// Scales the <paramref name="provider"/>'s seats for the specified <paramref name="planType"/> using the provided <paramref name="seatAdjustment"/>.
|
||||
@ -85,12 +85,4 @@ public interface IProviderBillingService
|
||||
/// <param name="provider">The provider to create the <see cref="Stripe.Subscription"/> for.</param>
|
||||
Task StartSubscription(
|
||||
Provider provider);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a provider's billing payment information.
|
||||
/// </summary>
|
||||
/// <param name="providerId">The ID of the provider to retrieve payment information for.</param>
|
||||
/// <returns>A <see cref="ProviderPaymentInfoDTO"/> object containing the provider's Stripe <see cref="Stripe.PaymentMethod"/> and their <see cref="TaxInfo"/>s.</returns>
|
||||
/// <remarks>This method opts for returning <see langword="null"/> rather than throwing exceptions, making it ideal for surfacing data from API endpoints.</remarks>
|
||||
Task<ProviderPaymentInfoDTO> GetPaymentInformationAsync(Guid providerId);
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Enums;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Core.Billing.Services;
|
||||
@ -46,6 +46,24 @@ public interface ISubscriberService
|
||||
ISubscriber subscriber,
|
||||
CustomerGetOptions customerGetOptions = null);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the account credit, a masked representation of the default payment method and the tax information for the
|
||||
/// provided <paramref name="subscriber"/>. This is essentially a consolidated invocation of the <see cref="GetPaymentMethod"/>
|
||||
/// and <see cref="GetTaxInformation"/> methods with a response that includes the customer's <see cref="Stripe.Customer.Balance"/> as account credit in order to cut down on Stripe API calls.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The subscriber to retrieve payment information for.</param>
|
||||
/// <returns>A <see cref="PaymentInformationDTO"/> containing the subscriber's account credit, masked payment method and tax information.</returns>
|
||||
Task<PaymentInformationDTO> GetPaymentInformation(
|
||||
ISubscriber subscriber);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a masked representation of the subscriber's payment method for presentation to a client.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The subscriber to retrieve the masked payment method for.</param>
|
||||
/// <returns>A <see cref="MaskedPaymentMethodDTO"/> containing a non-identifiable description of the subscriber's payment method.</returns>
|
||||
Task<MaskedPaymentMethodDTO> GetPaymentMethod(
|
||||
ISubscriber subscriber);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a Stripe <see cref="Subscription"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewaySubscriptionId"/> property.
|
||||
/// </summary>
|
||||
@ -71,6 +89,16 @@ public interface ISubscriberService
|
||||
ISubscriber subscriber,
|
||||
SubscriptionGetOptions subscriptionGetOptions = null);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the <see cref="subscriber"/>'s tax information using their Stripe <see cref="Stripe.Customer"/>'s <see cref="Stripe.Customer.Address"/>.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The subscriber to retrieve the tax information for.</param>
|
||||
/// <returns>A <see cref="TaxInformationDTO"/> representing the <paramref name="subscriber"/>'s tax information.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="subscriber"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>This method opts for returning <see langword="null"/> rather than throwing exceptions, making it ideal for surfacing data from API endpoints.</remarks>
|
||||
Task<TaxInformationDTO> GetTaxInformation(
|
||||
ISubscriber subscriber);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to remove a subscriber's saved payment method. If the Stripe <see cref="Stripe.Customer"/> representing the
|
||||
/// <paramref name="subscriber"/> contains a valid <b>"btCustomerId"</b> key in its <see cref="Stripe.Customer.Metadata"/> property,
|
||||
@ -81,20 +109,34 @@ public interface ISubscriberService
|
||||
Task RemovePaymentMethod(ISubscriber subscriber);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a Stripe <see cref="TaxInfo"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> property.
|
||||
/// Updates the payment method for the provided <paramref name="subscriber"/> using the <paramref name="tokenizedPaymentMethod"/>.
|
||||
/// The following payment method types are supported: [<see cref="PaymentMethodType.Card"/>, <see cref="PaymentMethodType.BankAccount"/>, <see cref="PaymentMethodType.PayPal"/>].
|
||||
/// For each type, updating the payment method will attempt to establish a new payment method using the token in the <see cref="TokenizedPaymentMethodDTO"/>. Then, it will
|
||||
/// remove the exising payment method(s) linked to the subscriber's customer.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The subscriber to retrieve the Stripe customer for.</param>
|
||||
/// <returns>A Stripe <see cref="TaxInfo"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="subscriber"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>This method opts for returning <see langword="null"/> rather than throwing exceptions, making it ideal for surfacing data from API endpoints.</remarks>
|
||||
Task<TaxInfo> GetTaxInformationAsync(ISubscriber subscriber);
|
||||
/// <param name="subscriber">The subscriber to update the payment method for.</param>
|
||||
/// <param name="tokenizedPaymentMethod">A DTO representing a tokenized payment method.</param>
|
||||
Task UpdatePaymentMethod(
|
||||
ISubscriber subscriber,
|
||||
TokenizedPaymentMethodDTO tokenizedPaymentMethod);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a Stripe <see cref="BillingInfo.BillingSource"/> using the <paramref name="subscriber"/>'s <see cref="ISubscriber.GatewayCustomerId"/> property.
|
||||
/// Updates the tax information for the provided <paramref name="subscriber"/>.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The subscriber to retrieve the Stripe customer for.</param>
|
||||
/// <returns>A Stripe <see cref="BillingInfo.BillingSource"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="subscriber"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>This method opts for returning <see langword="null"/> rather than throwing exceptions, making it ideal for surfacing data from API endpoints.</remarks>
|
||||
Task<BillingInfo.BillingSource> GetPaymentMethodAsync(ISubscriber subscriber);
|
||||
/// <param name="subscriber">The <paramref name="subscriber"/> to update the tax information for.</param>
|
||||
/// <param name="taxInformation">A <see cref="TaxInformationDTO"/> representing the <paramref name="subscriber"/>'s updated tax information.</param>
|
||||
Task UpdateTaxInformation(
|
||||
ISubscriber subscriber,
|
||||
TaxInformationDTO taxInformation);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the subscriber's pending bank account using the provided <paramref name="microdeposits"/>.
|
||||
/// </summary>
|
||||
/// <param name="subscriber">The subscriber to verify the bank account for.</param>
|
||||
/// <param name="microdeposits">Deposits made to the subscriber's bank account in order to ensure they have access to it.
|
||||
/// <a href="https://docs.stripe.com/payments/ach-debit/set-up-payment">Learn more.</a></param>
|
||||
/// <returns></returns>
|
||||
Task VerifyBankAccount(
|
||||
ISubscriber subscriber,
|
||||
(long, long) microdeposits);
|
||||
}
|
||||
|
@ -1,7 +1,10 @@
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Braintree;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
@ -14,7 +17,9 @@ namespace Bit.Core.Billing.Services.Implementations;
|
||||
|
||||
public class SubscriberService(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<SubscriberService> logger,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter) : ISubscriberService
|
||||
{
|
||||
public async Task CancelSubscription(
|
||||
@ -132,6 +137,46 @@ public class SubscriberService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PaymentInformationDTO> GetPaymentInformation(
|
||||
ISubscriber subscriber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
|
||||
var customer = await GetCustomer(subscriber, new CustomerGetOptions
|
||||
{
|
||||
Expand = ["default_source", "invoice_settings.default_payment_method", "tax_ids"]
|
||||
});
|
||||
|
||||
if (customer == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var accountCredit = customer.Balance * -1 / 100;
|
||||
|
||||
var paymentMethod = await GetMaskedPaymentMethodDTOAsync(subscriber.Id, customer);
|
||||
|
||||
var taxInformation = GetTaxInformationDTOFrom(customer);
|
||||
|
||||
return new PaymentInformationDTO(
|
||||
accountCredit,
|
||||
paymentMethod,
|
||||
taxInformation);
|
||||
}
|
||||
|
||||
public async Task<MaskedPaymentMethodDTO> GetPaymentMethod(
|
||||
ISubscriber subscriber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
|
||||
var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions
|
||||
{
|
||||
Expand = ["default_source", "invoice_settings.default_payment_method"]
|
||||
});
|
||||
|
||||
return await GetMaskedPaymentMethodDTOAsync(subscriber.Id, customer);
|
||||
}
|
||||
|
||||
public async Task<Customer> GetCustomerOrThrow(
|
||||
ISubscriber subscriber,
|
||||
CustomerGetOptions customerGetOptions = null)
|
||||
@ -240,6 +285,16 @@ public class SubscriberService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TaxInformationDTO> GetTaxInformation(
|
||||
ISubscriber subscriber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
|
||||
var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions { Expand = ["tax_ids"] });
|
||||
|
||||
return GetTaxInformationDTOFrom(customer);
|
||||
}
|
||||
|
||||
public async Task RemovePaymentMethod(
|
||||
ISubscriber subscriber)
|
||||
{
|
||||
@ -332,113 +387,438 @@ public class SubscriberService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TaxInfo> GetTaxInformationAsync(ISubscriber subscriber)
|
||||
public async Task UpdatePaymentMethod(
|
||||
ISubscriber subscriber,
|
||||
TokenizedPaymentMethodDTO tokenizedPaymentMethod)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
ArgumentNullException.ThrowIfNull(tokenizedPaymentMethod);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId))
|
||||
var customer = await GetCustomerOrThrow(subscriber);
|
||||
|
||||
var (type, token) = tokenizedPaymentMethod;
|
||||
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
logger.LogError("Cannot retrieve GatewayCustomerId for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewaySubscriptionId));
|
||||
logger.LogError("Updated payment method for ({SubscriberID}) must contain a token", subscriber.Id);
|
||||
|
||||
return null;
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions { Expand = ["tax_ids"] });
|
||||
|
||||
if (customer is null)
|
||||
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
|
||||
switch (type)
|
||||
{
|
||||
logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})",
|
||||
subscriber.GatewayCustomerId, subscriber.Id);
|
||||
case PaymentMethodType.BankAccount:
|
||||
{
|
||||
var getSetupIntentsForUpdatedPaymentMethod = stripeAdapter.SetupIntentList(new SetupIntentListOptions
|
||||
{
|
||||
PaymentMethod = token
|
||||
});
|
||||
|
||||
return null;
|
||||
var getExistingSetupIntentsForCustomer = stripeAdapter.SetupIntentList(new SetupIntentListOptions
|
||||
{
|
||||
Customer = subscriber.GatewayCustomerId
|
||||
});
|
||||
|
||||
// Find the setup intent for the incoming payment method token.
|
||||
var setupIntentsForUpdatedPaymentMethod = await getSetupIntentsForUpdatedPaymentMethod;
|
||||
|
||||
if (setupIntentsForUpdatedPaymentMethod.Count != 1)
|
||||
{
|
||||
logger.LogError("There were more than 1 setup intents for subscriber's ({SubscriberID}) updated payment method", subscriber.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
var matchingSetupIntent = setupIntentsForUpdatedPaymentMethod.First();
|
||||
|
||||
// Find the customer's existing setup intents that should be cancelled.
|
||||
var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer)
|
||||
.Where(si =>
|
||||
si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action");
|
||||
|
||||
// Store the incoming payment method's setup intent ID in the cache for the subscriber so it can be verified later.
|
||||
await setupIntentCache.Set(subscriber.Id, matchingSetupIntent.Id);
|
||||
|
||||
// Cancel the customer's other open setup intents.
|
||||
var postProcessing = existingSetupIntentsForCustomer.Select(si =>
|
||||
stripeAdapter.SetupIntentCancel(si.Id,
|
||||
new SetupIntentCancelOptions { CancellationReason = "abandoned" })).ToList();
|
||||
|
||||
// Remove the customer's other attached Stripe payment methods.
|
||||
postProcessing.Add(RemoveStripePaymentMethodsAsync(customer));
|
||||
|
||||
// Remove the customer's Braintree customer ID.
|
||||
postProcessing.Add(RemoveBraintreeCustomerIdAsync(customer));
|
||||
|
||||
await Task.WhenAll(postProcessing);
|
||||
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.Card:
|
||||
{
|
||||
var getExistingSetupIntentsForCustomer = stripeAdapter.SetupIntentList(new SetupIntentListOptions
|
||||
{
|
||||
Customer = subscriber.GatewayCustomerId
|
||||
});
|
||||
|
||||
// Remove the customer's other attached Stripe payment methods.
|
||||
await RemoveStripePaymentMethodsAsync(customer);
|
||||
|
||||
// Attach the incoming payment method.
|
||||
await stripeAdapter.PaymentMethodAttachAsync(token,
|
||||
new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId });
|
||||
|
||||
// Find the customer's existing setup intents that should be cancelled.
|
||||
var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer)
|
||||
.Where(si =>
|
||||
si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action");
|
||||
|
||||
// Cancel the customer's other open setup intents.
|
||||
var postProcessing = existingSetupIntentsForCustomer.Select(si =>
|
||||
stripeAdapter.SetupIntentCancel(si.Id,
|
||||
new SetupIntentCancelOptions { CancellationReason = "abandoned" })).ToList();
|
||||
|
||||
var metadata = customer.Metadata;
|
||||
|
||||
if (metadata.ContainsKey(BraintreeCustomerIdKey))
|
||||
{
|
||||
metadata[BraintreeCustomerIdKey] = null;
|
||||
}
|
||||
|
||||
// Set the customer's default payment method in Stripe and remove their Braintree customer ID.
|
||||
postProcessing.Add(stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
DefaultPaymentMethod = token
|
||||
},
|
||||
Metadata = metadata
|
||||
}));
|
||||
|
||||
await Task.WhenAll(postProcessing);
|
||||
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.PayPal:
|
||||
{
|
||||
string braintreeCustomerId;
|
||||
|
||||
if (customer.Metadata != null)
|
||||
{
|
||||
var hasBraintreeCustomerId = customer.Metadata.TryGetValue(BraintreeCustomerIdKey, out braintreeCustomerId);
|
||||
|
||||
if (hasBraintreeCustomerId)
|
||||
{
|
||||
var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);
|
||||
|
||||
if (braintreeCustomer == null)
|
||||
{
|
||||
logger.LogError("Failed to retrieve Braintree customer ({BraintreeCustomerId}) when updating payment method for subscriber ({SubscriberID})", braintreeCustomerId, subscriber.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
await ReplaceBraintreePaymentMethodAsync(braintreeCustomer, token);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
braintreeCustomerId = await CreateBraintreeCustomerAsync(subscriber, token);
|
||||
|
||||
await AddBraintreeCustomerIdAsync(customer, braintreeCustomerId);
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
logger.LogError("Cannot update subscriber's ({SubscriberID}) payment method to type ({PaymentMethodType}) as it is not supported", subscriber.Id, type.ToString());
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
public async Task UpdateTaxInformation(
|
||||
ISubscriber subscriber,
|
||||
TaxInformationDTO taxInformation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
var customer = await GetCustomerOrThrow(subscriber, GetCustomerPaymentOptions());
|
||||
if (customer == null)
|
||||
ArgumentNullException.ThrowIfNull(taxInformation);
|
||||
|
||||
var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions
|
||||
{
|
||||
Expand = ["tax_ids"]
|
||||
});
|
||||
|
||||
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
|
||||
{
|
||||
Address = new AddressOptions
|
||||
{
|
||||
Country = taxInformation.Country,
|
||||
PostalCode = taxInformation.PostalCode,
|
||||
Line1 = taxInformation.Line1 ?? string.Empty,
|
||||
Line2 = taxInformation.Line2,
|
||||
City = taxInformation.City,
|
||||
State = taxInformation.State
|
||||
}
|
||||
});
|
||||
|
||||
if (!subscriber.IsUser())
|
||||
{
|
||||
var taxId = customer.TaxIds?.FirstOrDefault();
|
||||
|
||||
if (taxId != null)
|
||||
{
|
||||
await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id);
|
||||
}
|
||||
|
||||
var taxIdType = taxInformation.GetTaxIdType();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(taxInformation.TaxId) &&
|
||||
!string.IsNullOrWhiteSpace(taxIdType))
|
||||
{
|
||||
await stripeAdapter.TaxIdCreateAsync(customer.Id, new TaxIdCreateOptions
|
||||
{
|
||||
Type = taxIdType,
|
||||
Value = taxInformation.TaxId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task VerifyBankAccount(
|
||||
ISubscriber subscriber,
|
||||
(long, long) microdeposits)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subscriber);
|
||||
|
||||
var setupIntentId = await setupIntentCache.Get(subscriber.Id);
|
||||
|
||||
if (string.IsNullOrEmpty(setupIntentId))
|
||||
{
|
||||
logger.LogError("No setup intent ID exists to verify for subscriber with ID ({SubscriberID})", subscriber.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
var (amount1, amount2) = microdeposits;
|
||||
|
||||
await stripeAdapter.SetupIntentVerifyMicroDeposit(setupIntentId, new SetupIntentVerifyMicrodepositsOptions
|
||||
{
|
||||
Amounts = [amount1, amount2]
|
||||
});
|
||||
|
||||
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId);
|
||||
|
||||
await stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId, new PaymentMethodAttachOptions
|
||||
{
|
||||
Customer = subscriber.GatewayCustomerId
|
||||
});
|
||||
|
||||
await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId,
|
||||
new CustomerUpdateOptions
|
||||
{
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
DefaultPaymentMethod = setupIntent.PaymentMethodId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#region Shared Utilities
|
||||
|
||||
private async Task AddBraintreeCustomerIdAsync(
|
||||
Customer customer,
|
||||
string braintreeCustomerId)
|
||||
{
|
||||
var metadata = customer.Metadata ?? new Dictionary<string, string>();
|
||||
|
||||
metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
||||
|
||||
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
|
||||
{
|
||||
Metadata = metadata
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string> CreateBraintreeCustomerAsync(
|
||||
ISubscriber subscriber,
|
||||
string paymentMethodNonce)
|
||||
{
|
||||
var braintreeCustomerId =
|
||||
subscriber.BraintreeCustomerIdPrefix() +
|
||||
subscriber.Id.ToString("N").ToLower() +
|
||||
CoreHelpers.RandomString(3, upper: false, numeric: false);
|
||||
|
||||
var customerResult = await braintreeGateway.Customer.CreateAsync(new CustomerRequest
|
||||
{
|
||||
Id = braintreeCustomerId,
|
||||
CustomFields = new Dictionary<string, string>
|
||||
{
|
||||
[subscriber.BraintreeIdField()] = subscriber.Id.ToString(),
|
||||
[subscriber.BraintreeCloudRegionField()] = globalSettings.BaseServiceUri.CloudRegion
|
||||
},
|
||||
Email = subscriber.BillingEmailAddress(),
|
||||
PaymentMethodNonce = paymentMethodNonce,
|
||||
});
|
||||
|
||||
if (customerResult.IsSuccess())
|
||||
{
|
||||
return customerResult.Target.Id;
|
||||
}
|
||||
|
||||
logger.LogError("Failed to create Braintree customer for subscriber ({ID})", subscriber.Id);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
private async Task<MaskedPaymentMethodDTO> GetMaskedPaymentMethodDTOAsync(
|
||||
Guid subscriberId,
|
||||
Customer customer)
|
||||
{
|
||||
if (customer.Metadata != null)
|
||||
{
|
||||
var hasBraintreeCustomerId = customer.Metadata.TryGetValue(BraintreeCustomerIdKey, out var braintreeCustomerId);
|
||||
|
||||
if (hasBraintreeCustomerId)
|
||||
{
|
||||
var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);
|
||||
|
||||
return MaskedPaymentMethodDTO.From(braintreeCustomer);
|
||||
}
|
||||
}
|
||||
|
||||
var attachedPaymentMethodDTO = MaskedPaymentMethodDTO.From(customer);
|
||||
|
||||
if (attachedPaymentMethodDTO != null)
|
||||
{
|
||||
return attachedPaymentMethodDTO;
|
||||
}
|
||||
|
||||
/*
|
||||
* attachedPaymentMethodDTO being null represents a case where we could be looking for the SetupIntent for an unverified "us_bank_account".
|
||||
* We store the ID of this SetupIntent in the cache when we originally update the payment method.
|
||||
*/
|
||||
var setupIntentId = await setupIntentCache.Get(subscriberId);
|
||||
|
||||
if (string.IsNullOrEmpty(setupIntentId))
|
||||
{
|
||||
logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})",
|
||||
subscriber.GatewayCustomerId, subscriber.Id);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (customer.Metadata?.ContainsKey("btCustomerId") ?? false)
|
||||
var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
|
||||
{
|
||||
try
|
||||
Expand = ["payment_method"]
|
||||
});
|
||||
|
||||
return MaskedPaymentMethodDTO.From(setupIntent);
|
||||
}
|
||||
|
||||
private static TaxInformationDTO GetTaxInformationDTOFrom(
|
||||
Customer customer)
|
||||
{
|
||||
if (customer.Address == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new TaxInformationDTO(
|
||||
customer.Address.Country,
|
||||
customer.Address.PostalCode,
|
||||
customer.TaxIds?.FirstOrDefault()?.Value,
|
||||
customer.Address.Line1,
|
||||
customer.Address.Line2,
|
||||
customer.Address.City,
|
||||
customer.Address.State);
|
||||
}
|
||||
|
||||
private async Task RemoveBraintreeCustomerIdAsync(
|
||||
Customer customer)
|
||||
{
|
||||
var metadata = customer.Metadata ?? new Dictionary<string, string>();
|
||||
|
||||
if (metadata.ContainsKey(BraintreeCustomerIdKey))
|
||||
{
|
||||
metadata[BraintreeCustomerIdKey] = null;
|
||||
|
||||
await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
|
||||
{
|
||||
var braintreeCustomer = await braintreeGateway.Customer.FindAsync(
|
||||
customer.Metadata["btCustomerId"]);
|
||||
if (braintreeCustomer?.DefaultPaymentMethod != null)
|
||||
Metadata = metadata
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveStripePaymentMethodsAsync(
|
||||
Customer customer)
|
||||
{
|
||||
if (customer.Sources != null && customer.Sources.Any())
|
||||
{
|
||||
foreach (var source in customer.Sources)
|
||||
{
|
||||
switch (source)
|
||||
{
|
||||
return new BillingInfo.BillingSource(
|
||||
braintreeCustomer.DefaultPaymentMethod);
|
||||
case BankAccount:
|
||||
await stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id);
|
||||
break;
|
||||
case Card:
|
||||
await stripeAdapter.CardDeleteAsync(customer.Id, source.Id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Braintree.Exceptions.NotFoundException ex)
|
||||
}
|
||||
|
||||
var paymentMethods = await stripeAdapter.CustomerListPaymentMethods(customer.Id);
|
||||
|
||||
await Task.WhenAll(paymentMethods.Select(pm => stripeAdapter.PaymentMethodDetachAsync(pm.Id)));
|
||||
}
|
||||
|
||||
private async Task ReplaceBraintreePaymentMethodAsync(
|
||||
Braintree.Customer customer,
|
||||
string defaultPaymentMethodToken)
|
||||
{
|
||||
var existingDefaultPaymentMethod = customer.DefaultPaymentMethod;
|
||||
|
||||
var createPaymentMethodResult = await braintreeGateway.PaymentMethod.CreateAsync(new PaymentMethodRequest
|
||||
{
|
||||
CustomerId = customer.Id,
|
||||
PaymentMethodNonce = defaultPaymentMethodToken
|
||||
});
|
||||
|
||||
if (!createPaymentMethodResult.IsSuccess())
|
||||
{
|
||||
logger.LogError("Failed to replace payment method for Braintree customer ({ID}) - Creation of new payment method failed | Error: {Error}", customer.Id, createPaymentMethodResult.Message);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
var updateCustomerResult = await braintreeGateway.Customer.UpdateAsync(
|
||||
customer.Id,
|
||||
new CustomerRequest { DefaultPaymentMethodToken = createPaymentMethodResult.Target.Token });
|
||||
|
||||
if (!updateCustomerResult.IsSuccess())
|
||||
{
|
||||
logger.LogError("Failed to replace payment method for Braintree customer ({ID}) - Customer update failed | Error: {Error}",
|
||||
customer.Id, updateCustomerResult.Message);
|
||||
|
||||
await braintreeGateway.PaymentMethod.DeleteAsync(createPaymentMethodResult.Target.Token);
|
||||
|
||||
throw ContactSupport();
|
||||
}
|
||||
|
||||
if (existingDefaultPaymentMethod != null)
|
||||
{
|
||||
var deletePaymentMethodResult = await braintreeGateway.PaymentMethod.DeleteAsync(existingDefaultPaymentMethod.Token);
|
||||
|
||||
if (!deletePaymentMethodResult.IsSuccess())
|
||||
{
|
||||
logger.LogError("An error occurred while trying to retrieve braintree customer ({SubscriberID}): {Error}", subscriber.Id, ex.Message);
|
||||
logger.LogWarning(
|
||||
"Failed to delete replaced payment method for Braintree customer ({ID}) - outdated payment method still exists | Error: {Error}",
|
||||
customer.Id, deletePaymentMethodResult.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,
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
Reference in New Issue
Block a user