From 01a08c581476c56289bbc7fc1013f35be2a67d3d Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 16 Apr 2025 13:36:04 -0400 Subject: [PATCH] [PM-19566] Update MSPs to "charge_automatically" with Admin-based opt-out (#5650) * Update provider to charge automatically with Admin Portal-based opt-out * Design feedback * Run dotnet format --- .../Controllers/ProvidersController.cs | 33 +++++- .../AdminConsole/Models/ProviderEditModel.cs | 4 + .../AdminConsole/Views/Providers/Edit.cshtml | 11 ++ src/Billing/Services/IStripeFacade.cs | 6 ++ .../PaymentMethodAttachedHandler.cs | 100 +++++++++++++++++- .../Services/Implementations/StripeFacade.cs | 7 ++ src/Core/Billing/Constants/StripeConstants.cs | 1 + .../Billing/Extensions/CustomerExtensions.cs | 4 + src/Core/Constants.cs | 1 + 9 files changed, 163 insertions(+), 4 deletions(-) diff --git a/src/Admin/AdminConsole/Controllers/ProvidersController.cs b/src/Admin/AdminConsole/Controllers/ProvidersController.cs index 6dc33e4909..264e9df069 100644 --- a/src/Admin/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Admin/AdminConsole/Controllers/ProvidersController.cs @@ -3,11 +3,13 @@ using System.Net; using Bit.Admin.AdminConsole.Models; using Bit.Admin.Enums; using Bit.Admin.Utilities; +using Bit.Core; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Providers.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Entities; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; @@ -23,6 +25,7 @@ using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Stripe; namespace Bit.Admin.AdminConsole.Controllers; @@ -44,6 +47,7 @@ public class ProvidersController : Controller private readonly IProviderPlanRepository _providerPlanRepository; private readonly IProviderBillingService _providerBillingService; private readonly IPricingClient _pricingClient; + private readonly IStripeAdapter _stripeAdapter; private readonly string _stripeUrl; private readonly string _braintreeMerchantUrl; private readonly string _braintreeMerchantId; @@ -63,7 +67,8 @@ public class ProvidersController : Controller IProviderPlanRepository providerPlanRepository, IProviderBillingService providerBillingService, IWebHostEnvironment webHostEnvironment, - IPricingClient pricingClient) + IPricingClient pricingClient, + IStripeAdapter stripeAdapter) { _organizationRepository = organizationRepository; _organizationService = organizationService; @@ -79,6 +84,7 @@ public class ProvidersController : Controller _providerPlanRepository = providerPlanRepository; _providerBillingService = providerBillingService; _pricingClient = pricingClient; + _stripeAdapter = stripeAdapter; _stripeUrl = webHostEnvironment.GetStripeUrl(); _braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl(); _braintreeMerchantId = globalSettings.Braintree.MerchantId; @@ -306,6 +312,23 @@ public class ProvidersController : Controller (Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum) ]); await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand); + + if (_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically)) + { + var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId); + + if (model.PayByInvoice != customer.ApprovedToPayByInvoice()) + { + var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0"; + await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions + { + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.InvoiceApproved] = approvedToPayByInvoice + } + }); + } + } break; case ProviderType.BusinessUnit: { @@ -345,14 +368,18 @@ public class ProvidersController : Controller if (!provider.IsBillable()) { - return new ProviderEditModel(provider, users, providerOrganizations, new List()); + return new ProviderEditModel(provider, users, providerOrganizations, new List(), false); } var providerPlans = await _providerPlanRepository.GetByProviderId(id); + var payByInvoice = + _featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) && + (await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId)).ApprovedToPayByInvoice(); + return new ProviderEditModel( provider, users, providerOrganizations, - providerPlans.ToList(), GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider)); + providerPlans.ToList(), payByInvoice, GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider)); } [RequirePermission(Permission.Provider_ResendEmailInvite)] diff --git a/src/Admin/AdminConsole/Models/ProviderEditModel.cs b/src/Admin/AdminConsole/Models/ProviderEditModel.cs index 7f8ffb224e..44eebb8d7d 100644 --- a/src/Admin/AdminConsole/Models/ProviderEditModel.cs +++ b/src/Admin/AdminConsole/Models/ProviderEditModel.cs @@ -18,6 +18,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject IEnumerable providerUsers, IEnumerable organizations, IReadOnlyCollection providerPlans, + bool payByInvoice, string gatewayCustomerUrl = null, string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations, providerPlans) { @@ -33,6 +34,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject GatewayCustomerUrl = gatewayCustomerUrl; GatewaySubscriptionUrl = gatewaySubscriptionUrl; Type = provider.Type; + PayByInvoice = payByInvoice; if (Type == ProviderType.BusinessUnit) { @@ -62,6 +64,8 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject public string GatewaySubscriptionId { get; set; } public string GatewayCustomerUrl { get; } public string GatewaySubscriptionUrl { get; } + [Display(Name = "Pay By Invoice")] + public bool PayByInvoice { get; set; } [Display(Name = "Provider Type")] public ProviderType Type { get; set; } diff --git a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml index 2f48054600..ce215e1575 100644 --- a/src/Admin/AdminConsole/Views/Providers/Edit.cshtml +++ b/src/Admin/AdminConsole/Views/Providers/Edit.cshtml @@ -136,6 +136,17 @@ + @if (FeatureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) && Model.Provider.Type == ProviderType.Msp && Model.Provider.IsBillable()) + { +
+
+
+ + +
+
+
+ } } @await Html.PartialAsync("Organizations", Model) diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index 77ba9a1ad4..e53d901083 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -16,6 +16,12 @@ public interface IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + Task UpdateCustomer( + string customerId, + CustomerUpdateOptions customerUpdateOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task GetEvent( string eventId, EventGetOptions eventGetOptions = null, diff --git a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs index 6092e001ce..c46429412f 100644 --- a/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs +++ b/src/Billing/Services/Implementations/PaymentMethodAttachedHandler.cs @@ -1,4 +1,8 @@ using Bit.Billing.Constants; +using Bit.Core; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; +using Bit.Core.Services; using Stripe; using Event = Stripe.Event; @@ -10,20 +14,114 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler private readonly IStripeEventService _stripeEventService; private readonly IStripeFacade _stripeFacade; private readonly IStripeEventUtilityService _stripeEventUtilityService; + private readonly IFeatureService _featureService; public PaymentMethodAttachedHandler( ILogger logger, IStripeEventService stripeEventService, IStripeFacade stripeFacade, - IStripeEventUtilityService stripeEventUtilityService) + IStripeEventUtilityService stripeEventUtilityService, + IFeatureService featureService) { _logger = logger; _stripeEventService = stripeEventService; _stripeFacade = stripeFacade; _stripeEventUtilityService = stripeEventUtilityService; + _featureService = featureService; } public async Task HandleAsync(Event parsedEvent) + { + var updateMSPToChargeAutomatically = + _featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically); + + if (updateMSPToChargeAutomatically) + { + await HandleVNextAsync(parsedEvent); + } + else + { + await HandleVCurrentAsync(parsedEvent); + } + } + + private async Task HandleVNextAsync(Event parsedEvent) + { + var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent, true, ["customer.subscriptions.data.latest_invoice"]); + + if (paymentMethod == null) + { + _logger.LogWarning("Attempted to handle the event payment_method.attached but paymentMethod was null"); + return; + } + + var customer = paymentMethod.Customer; + var subscriptions = customer?.Subscriptions; + + // This represents a provider subscription set to "send_invoice" that was paid using a Stripe hosted invoice payment page. + var invoicedProviderSubscription = subscriptions?.Data.FirstOrDefault(subscription => + subscription.Metadata.ContainsKey(StripeConstants.MetadataKeys.ProviderId) && + subscription.Status != StripeConstants.SubscriptionStatus.Canceled && + subscription.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice); + + /* + * If we have an invoiced provider subscription where the customer hasn't been marked as invoice-approved, + * we need to try and set the default payment method and update the collection method to be "charge_automatically". + */ + if (invoicedProviderSubscription != null && !customer.ApprovedToPayByInvoice()) + { + if (customer.InvoiceSettings.DefaultPaymentMethodId != paymentMethod.Id) + { + try + { + await _stripeFacade.UpdateCustomer(customer.Id, + new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + DefaultPaymentMethod = paymentMethod.Id + } + }); + } + catch (Exception exception) + { + _logger.LogWarning(exception, + "Failed to set customer's ({CustomerID}) default payment method during 'payment_method.attached' webhook", + customer.Id); + } + } + + try + { + await _stripeFacade.UpdateSubscription(invoicedProviderSubscription.Id, + new SubscriptionUpdateOptions + { + CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically + }); + } + catch (Exception exception) + { + _logger.LogWarning(exception, + "Failed to set subscription's ({SubscriptionID}) collection method to 'charge_automatically' during 'payment_method.attached' webhook", + customer.Id); + } + } + + var unpaidSubscriptions = subscriptions?.Data.Where(subscription => + subscription.Status == StripeConstants.SubscriptionStatus.Unpaid).ToList(); + + if (unpaidSubscriptions == null || unpaidSubscriptions.Count == 0) + { + return; + } + + foreach (var unpaidSubscription in unpaidSubscriptions) + { + await AttemptToPayOpenSubscriptionAsync(unpaidSubscription); + } + } + + private async Task HandleVCurrentAsync(Event parsedEvent) { var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent); if (paymentMethod is null) diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index 91e0c1c33a..191f84a343 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -33,6 +33,13 @@ public class StripeFacade : IStripeFacade CancellationToken cancellationToken = default) => await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken); + public async Task UpdateCustomer( + string customerId, + CustomerUpdateOptions customerUpdateOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _customerService.UpdateAsync(customerId, customerUpdateOptions, requestOptions, cancellationToken); + public async Task GetInvoice( string invoiceId, InvoiceGetOptions invoiceGetOptions = null, diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 326023e34c..8a4303e378 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -46,6 +46,7 @@ public static class StripeConstants public static class MetadataKeys { + public const string InvoiceApproved = "invoice_approved"; public const string OrganizationId = "organizationId"; public const string ProviderId = "providerId"; public const string UserId = "userId"; diff --git a/src/Core/Billing/Extensions/CustomerExtensions.cs b/src/Core/Billing/Extensions/CustomerExtensions.cs index 8f15f61a7f..3e0c1ea0fb 100644 --- a/src/Core/Billing/Extensions/CustomerExtensions.cs +++ b/src/Core/Billing/Extensions/CustomerExtensions.cs @@ -27,4 +27,8 @@ public static class CustomerExtensions { return customer != null ? customer.Balance / 100M : default; } + + public static bool ApprovedToPayByInvoice(this Customer customer) + => customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.InvoiceApproved, out var value) && + int.TryParse(value, out var invoiceApproved) && invoiceApproved == 1; } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index c57544283b..44962cd0ab 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -149,6 +149,7 @@ public static class FeatureFlagKeys public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; + public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";