1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-07 11:40:31 -05:00

[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
This commit is contained in:
Alex Morask 2025-04-16 13:36:04 -04:00 committed by GitHub
parent 3d59f5522d
commit 01a08c5814
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 163 additions and 4 deletions

View File

@ -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<string, string>
{
[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<ProviderPlan>());
return new ProviderEditModel(provider, users, providerOrganizations, new List<ProviderPlan>(), 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)]

View File

@ -18,6 +18,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
IEnumerable<ProviderUserUserDetails> providerUsers,
IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
IReadOnlyCollection<ProviderPlan> 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; }

View File

@ -136,6 +136,17 @@
</div>
</div>
</div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) && Model.Provider.Type == ProviderType.Msp && Model.Provider.IsBillable())
{
<div class="row">
<div class="col-sm">
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" asp-for="PayByInvoice">
<label class="form-check-label" asp-for="PayByInvoice"></label>
</div>
</div>
</div>
}
}
</form>
@await Html.PartialAsync("Organizations", Model)

View File

@ -16,6 +16,12 @@ public interface IStripeFacade
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Customer> UpdateCustomer(
string customerId,
CustomerUpdateOptions customerUpdateOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Event> GetEvent(
string eventId,
EventGetOptions eventGetOptions = null,

View File

@ -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<PaymentMethodAttachedHandler> 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)

View File

@ -33,6 +33,13 @@ public class StripeFacade : IStripeFacade
CancellationToken cancellationToken = default) =>
await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken);
public async Task<Customer> UpdateCustomer(
string customerId,
CustomerUpdateOptions customerUpdateOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
await _customerService.UpdateAsync(customerId, customerUpdateOptions, requestOptions, cancellationToken);
public async Task<Invoice> GetInvoice(
string invoiceId,
InvoiceGetOptions invoiceGetOptions = null,

View File

@ -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";

View File

@ -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;
}

View File

@ -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";