mirror of
https://github.com/bitwarden/server.git
synced 2025-06-07 19:50:32 -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:
parent
3d59f5522d
commit
01a08c5814
@ -3,11 +3,13 @@ using System.Net;
|
|||||||
using Bit.Admin.AdminConsole.Models;
|
using Bit.Admin.AdminConsole.Models;
|
||||||
using Bit.Admin.Enums;
|
using Bit.Admin.Enums;
|
||||||
using Bit.Admin.Utilities;
|
using Bit.Admin.Utilities;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Enums.Provider;
|
using Bit.Core.AdminConsole.Enums.Provider;
|
||||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
using Bit.Core.Billing.Entities;
|
using Bit.Core.Billing.Entities;
|
||||||
using Bit.Core.Billing.Enums;
|
using Bit.Core.Billing.Enums;
|
||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
@ -23,6 +25,7 @@ using Bit.Core.Settings;
|
|||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
namespace Bit.Admin.AdminConsole.Controllers;
|
namespace Bit.Admin.AdminConsole.Controllers;
|
||||||
|
|
||||||
@ -44,6 +47,7 @@ public class ProvidersController : Controller
|
|||||||
private readonly IProviderPlanRepository _providerPlanRepository;
|
private readonly IProviderPlanRepository _providerPlanRepository;
|
||||||
private readonly IProviderBillingService _providerBillingService;
|
private readonly IProviderBillingService _providerBillingService;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
|
private readonly IStripeAdapter _stripeAdapter;
|
||||||
private readonly string _stripeUrl;
|
private readonly string _stripeUrl;
|
||||||
private readonly string _braintreeMerchantUrl;
|
private readonly string _braintreeMerchantUrl;
|
||||||
private readonly string _braintreeMerchantId;
|
private readonly string _braintreeMerchantId;
|
||||||
@ -63,7 +67,8 @@ public class ProvidersController : Controller
|
|||||||
IProviderPlanRepository providerPlanRepository,
|
IProviderPlanRepository providerPlanRepository,
|
||||||
IProviderBillingService providerBillingService,
|
IProviderBillingService providerBillingService,
|
||||||
IWebHostEnvironment webHostEnvironment,
|
IWebHostEnvironment webHostEnvironment,
|
||||||
IPricingClient pricingClient)
|
IPricingClient pricingClient,
|
||||||
|
IStripeAdapter stripeAdapter)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationService = organizationService;
|
_organizationService = organizationService;
|
||||||
@ -79,6 +84,7 @@ public class ProvidersController : Controller
|
|||||||
_providerPlanRepository = providerPlanRepository;
|
_providerPlanRepository = providerPlanRepository;
|
||||||
_providerBillingService = providerBillingService;
|
_providerBillingService = providerBillingService;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
|
_stripeAdapter = stripeAdapter;
|
||||||
_stripeUrl = webHostEnvironment.GetStripeUrl();
|
_stripeUrl = webHostEnvironment.GetStripeUrl();
|
||||||
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
|
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
|
||||||
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
|
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
|
||||||
@ -306,6 +312,23 @@ public class ProvidersController : Controller
|
|||||||
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
|
(Plan: PlanType.EnterpriseMonthly, SeatsMinimum: model.EnterpriseMonthlySeatMinimum)
|
||||||
]);
|
]);
|
||||||
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
|
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;
|
break;
|
||||||
case ProviderType.BusinessUnit:
|
case ProviderType.BusinessUnit:
|
||||||
{
|
{
|
||||||
@ -345,14 +368,18 @@ public class ProvidersController : Controller
|
|||||||
|
|
||||||
if (!provider.IsBillable())
|
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 providerPlans = await _providerPlanRepository.GetByProviderId(id);
|
||||||
|
|
||||||
|
var payByInvoice =
|
||||||
|
_featureService.IsEnabled(FeatureFlagKeys.PM199566_UpdateMSPToChargeAutomatically) &&
|
||||||
|
(await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId)).ApprovedToPayByInvoice();
|
||||||
|
|
||||||
return new ProviderEditModel(
|
return new ProviderEditModel(
|
||||||
provider, users, providerOrganizations,
|
provider, users, providerOrganizations,
|
||||||
providerPlans.ToList(), GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider));
|
providerPlans.ToList(), payByInvoice, GetGatewayCustomerUrl(provider), GetGatewaySubscriptionUrl(provider));
|
||||||
}
|
}
|
||||||
|
|
||||||
[RequirePermission(Permission.Provider_ResendEmailInvite)]
|
[RequirePermission(Permission.Provider_ResendEmailInvite)]
|
||||||
|
@ -18,6 +18,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
|||||||
IEnumerable<ProviderUserUserDetails> providerUsers,
|
IEnumerable<ProviderUserUserDetails> providerUsers,
|
||||||
IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
|
IEnumerable<ProviderOrganizationOrganizationDetails> organizations,
|
||||||
IReadOnlyCollection<ProviderPlan> providerPlans,
|
IReadOnlyCollection<ProviderPlan> providerPlans,
|
||||||
|
bool payByInvoice,
|
||||||
string gatewayCustomerUrl = null,
|
string gatewayCustomerUrl = null,
|
||||||
string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations, providerPlans)
|
string gatewaySubscriptionUrl = null) : base(provider, providerUsers, organizations, providerPlans)
|
||||||
{
|
{
|
||||||
@ -33,6 +34,7 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
|||||||
GatewayCustomerUrl = gatewayCustomerUrl;
|
GatewayCustomerUrl = gatewayCustomerUrl;
|
||||||
GatewaySubscriptionUrl = gatewaySubscriptionUrl;
|
GatewaySubscriptionUrl = gatewaySubscriptionUrl;
|
||||||
Type = provider.Type;
|
Type = provider.Type;
|
||||||
|
PayByInvoice = payByInvoice;
|
||||||
|
|
||||||
if (Type == ProviderType.BusinessUnit)
|
if (Type == ProviderType.BusinessUnit)
|
||||||
{
|
{
|
||||||
@ -62,6 +64,8 @@ public class ProviderEditModel : ProviderViewModel, IValidatableObject
|
|||||||
public string GatewaySubscriptionId { get; set; }
|
public string GatewaySubscriptionId { get; set; }
|
||||||
public string GatewayCustomerUrl { get; }
|
public string GatewayCustomerUrl { get; }
|
||||||
public string GatewaySubscriptionUrl { get; }
|
public string GatewaySubscriptionUrl { get; }
|
||||||
|
[Display(Name = "Pay By Invoice")]
|
||||||
|
public bool PayByInvoice { get; set; }
|
||||||
[Display(Name = "Provider Type")]
|
[Display(Name = "Provider Type")]
|
||||||
public ProviderType Type { get; set; }
|
public ProviderType Type { get; set; }
|
||||||
|
|
||||||
|
@ -136,6 +136,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
@await Html.PartialAsync("Organizations", Model)
|
@await Html.PartialAsync("Organizations", Model)
|
||||||
|
@ -16,6 +16,12 @@ public interface IStripeFacade
|
|||||||
RequestOptions requestOptions = null,
|
RequestOptions requestOptions = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<Customer> UpdateCustomer(
|
||||||
|
string customerId,
|
||||||
|
CustomerUpdateOptions customerUpdateOptions = null,
|
||||||
|
RequestOptions requestOptions = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<Event> GetEvent(
|
Task<Event> GetEvent(
|
||||||
string eventId,
|
string eventId,
|
||||||
EventGetOptions eventGetOptions = null,
|
EventGetOptions eventGetOptions = null,
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
using Bit.Billing.Constants;
|
using Bit.Billing.Constants;
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Billing.Extensions;
|
||||||
|
using Bit.Core.Services;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using Event = Stripe.Event;
|
using Event = Stripe.Event;
|
||||||
|
|
||||||
@ -10,20 +14,114 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler
|
|||||||
private readonly IStripeEventService _stripeEventService;
|
private readonly IStripeEventService _stripeEventService;
|
||||||
private readonly IStripeFacade _stripeFacade;
|
private readonly IStripeFacade _stripeFacade;
|
||||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
|
||||||
public PaymentMethodAttachedHandler(
|
public PaymentMethodAttachedHandler(
|
||||||
ILogger<PaymentMethodAttachedHandler> logger,
|
ILogger<PaymentMethodAttachedHandler> logger,
|
||||||
IStripeEventService stripeEventService,
|
IStripeEventService stripeEventService,
|
||||||
IStripeFacade stripeFacade,
|
IStripeFacade stripeFacade,
|
||||||
IStripeEventUtilityService stripeEventUtilityService)
|
IStripeEventUtilityService stripeEventUtilityService,
|
||||||
|
IFeatureService featureService)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_stripeEventService = stripeEventService;
|
_stripeEventService = stripeEventService;
|
||||||
_stripeFacade = stripeFacade;
|
_stripeFacade = stripeFacade;
|
||||||
_stripeEventUtilityService = stripeEventUtilityService;
|
_stripeEventUtilityService = stripeEventUtilityService;
|
||||||
|
_featureService = featureService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task HandleAsync(Event parsedEvent)
|
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);
|
var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent);
|
||||||
if (paymentMethod is null)
|
if (paymentMethod is null)
|
||||||
|
@ -33,6 +33,13 @@ public class StripeFacade : IStripeFacade
|
|||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken);
|
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(
|
public async Task<Invoice> GetInvoice(
|
||||||
string invoiceId,
|
string invoiceId,
|
||||||
InvoiceGetOptions invoiceGetOptions = null,
|
InvoiceGetOptions invoiceGetOptions = null,
|
||||||
|
@ -46,6 +46,7 @@ public static class StripeConstants
|
|||||||
|
|
||||||
public static class MetadataKeys
|
public static class MetadataKeys
|
||||||
{
|
{
|
||||||
|
public const string InvoiceApproved = "invoice_approved";
|
||||||
public const string OrganizationId = "organizationId";
|
public const string OrganizationId = "organizationId";
|
||||||
public const string ProviderId = "providerId";
|
public const string ProviderId = "providerId";
|
||||||
public const string UserId = "userId";
|
public const string UserId = "userId";
|
||||||
|
@ -27,4 +27,8 @@ public static class CustomerExtensions
|
|||||||
{
|
{
|
||||||
return customer != null ? customer.Balance / 100M : default;
|
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;
|
||||||
}
|
}
|
||||||
|
@ -149,6 +149,7 @@ public static class FeatureFlagKeys
|
|||||||
public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements";
|
public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements";
|
||||||
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
|
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 PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion";
|
||||||
|
public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically";
|
||||||
|
|
||||||
/* Key Management Team */
|
/* Key Management Team */
|
||||||
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
|
||||||
|
Loading…
x
Reference in New Issue
Block a user