Skip to content

Commit

Permalink
[AC-1904] Implement endpoint to retrieve Provider subscription (#3921)
Browse files Browse the repository at this point in the history
* Refactor Core.Billing prior to adding new logic

* Add ProviderBillingQueries.GetSubscriptionData

* Add ProviderBillingController.GetSubscriptionAsync
  • Loading branch information
amorask-bitwarden authored Mar 28, 2024
1 parent 46dba15 commit ffd988e
Show file tree
Hide file tree
Showing 31 changed files with 786 additions and 238 deletions.
8 changes: 4 additions & 4 deletions src/Api/AdminConsole/Controllers/OrganizationsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public class OrganizationsController : Controller
private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand;
private readonly IPushNotificationService _pushNotificationService;
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
private readonly IGetSubscriptionQuery _getSubscriptionQuery;
private readonly ISubscriberQueries _subscriberQueries;
private readonly IReferenceEventService _referenceEventService;
private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand;

Expand All @@ -93,7 +93,7 @@ public OrganizationsController(
IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand,
IPushNotificationService pushNotificationService,
ICancelSubscriptionCommand cancelSubscriptionCommand,
IGetSubscriptionQuery getSubscriptionQuery,
ISubscriberQueries subscriberQueries,
IReferenceEventService referenceEventService,
IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand)
{
Expand All @@ -119,7 +119,7 @@ public OrganizationsController(
_addSecretsManagerSubscriptionCommand = addSecretsManagerSubscriptionCommand;
_pushNotificationService = pushNotificationService;
_cancelSubscriptionCommand = cancelSubscriptionCommand;
_getSubscriptionQuery = getSubscriptionQuery;
_subscriberQueries = subscriberQueries;
_referenceEventService = referenceEventService;
_organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand;
}
Expand Down Expand Up @@ -479,7 +479,7 @@ public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequest
throw new NotFoundException();
}

var subscription = await _getSubscriptionQuery.GetSubscription(organization);
var subscription = await _subscriberQueries.GetSubscriptionOrThrow(organization);

await _cancelSubscriptionCommand.CancelSubscription(subscription,
new OffboardingSurveyResponse
Expand Down
8 changes: 4 additions & 4 deletions src/Api/Auth/Controllers/AccountsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public class AccountsController : Controller
private readonly IRotateUserKeyCommand _rotateUserKeyCommand;
private readonly IFeatureService _featureService;
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
private readonly IGetSubscriptionQuery _getSubscriptionQuery;
private readonly ISubscriberQueries _subscriberQueries;
private readonly IReferenceEventService _referenceEventService;
private readonly ICurrentContext _currentContext;

Expand Down Expand Up @@ -104,7 +104,7 @@ public AccountsController(
IRotateUserKeyCommand rotateUserKeyCommand,
IFeatureService featureService,
ICancelSubscriptionCommand cancelSubscriptionCommand,
IGetSubscriptionQuery getSubscriptionQuery,
ISubscriberQueries subscriberQueries,
IReferenceEventService referenceEventService,
ICurrentContext currentContext,
IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator,
Expand Down Expand Up @@ -133,7 +133,7 @@ public AccountsController(
_rotateUserKeyCommand = rotateUserKeyCommand;
_featureService = featureService;
_cancelSubscriptionCommand = cancelSubscriptionCommand;
_getSubscriptionQuery = getSubscriptionQuery;
_subscriberQueries = subscriberQueries;
_referenceEventService = referenceEventService;
_currentContext = currentContext;
_cipherValidator = cipherValidator;
Expand Down Expand Up @@ -831,7 +831,7 @@ public async Task PostCancel([FromBody] SubscriptionCancellationRequestModel req
throw new UnauthorizedAccessException();
}

var subscription = await _getSubscriptionQuery.GetSubscription(user);
var subscription = await _subscriberQueries.GetSubscriptionOrThrow(user);

await _cancelSubscriptionCommand.CancelSubscription(subscription,
new OffboardingSurveyResponse
Expand Down
44 changes: 44 additions & 0 deletions src/Api/Billing/Controllers/ProviderBillingController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using Bit.Api.Billing.Models;
using Bit.Core;
using Bit.Core.Billing.Queries;
using Bit.Core.Context;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Bit.Api.Billing.Controllers;

[Route("providers/{providerId:guid}/billing")]
[Authorize("Application")]
public class ProviderBillingController(
ICurrentContext currentContext,
IFeatureService featureService,
IProviderBillingQueries providerBillingQueries) : Controller
{
[HttpGet("subscription")]
public async Task<IResult> GetSubscriptionAsync([FromRoute] Guid providerId)
{
if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
{
return TypedResults.NotFound();
}

if (!currentContext.ProviderProviderAdmin(providerId))
{
return TypedResults.Unauthorized();
}

var subscriptionData = await providerBillingQueries.GetSubscriptionData(providerId);

if (subscriptionData == null)
{
return TypedResults.NotFound();
}

var (providerPlans, subscription) = subscriptionData;

var providerSubscriptionDTO = ProviderSubscriptionDTO.From(providerPlans, subscription);

return TypedResults.Ok(providerSubscriptionDTO);
}
}
47 changes: 47 additions & 0 deletions src/Api/Billing/Models/ProviderSubscriptionDTO.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Bit.Core.Billing.Models;
using Bit.Core.Utilities;
using Stripe;

namespace Bit.Api.Billing.Models;

public record ProviderSubscriptionDTO(
string Status,
DateTime CurrentPeriodEndDate,
decimal? DiscountPercentage,
IEnumerable<ProviderPlanDTO> Plans)
{
private const string _annualCadence = "Annual";
private const string _monthlyCadence = "Monthly";

public static ProviderSubscriptionDTO From(
IEnumerable<ConfiguredProviderPlan> providerPlans,
Subscription subscription)
{
var providerPlansDTO = providerPlans
.Select(providerPlan =>
{
var plan = StaticStore.GetPlan(providerPlan.PlanType);
var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * plan.PasswordManager.SeatPrice;
var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence;
return new ProviderPlanDTO(
plan.Name,
providerPlan.SeatMinimum,
providerPlan.PurchasedSeats,
cost,
cadence);
});

return new ProviderSubscriptionDTO(
subscription.Status,
subscription.CurrentPeriodEnd,
subscription.Customer?.Discount?.Coupon?.PercentOff,
providerPlansDTO);
}
}

public record ProviderPlanDTO(
string PlanName,
int SeatMinimum,
int PurchasedSeats,
decimal Cost,
string Cadence);
22 changes: 21 additions & 1 deletion src/Core/AdminConsole/Entities/Provider/Provider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

namespace Bit.Core.AdminConsole.Entities.Provider;

public class Provider : ITableObject<Guid>
public class Provider : ITableObject<Guid>, ISubscriber
{
public Guid Id { get; set; }
/// <summary>
Expand Down Expand Up @@ -34,6 +34,26 @@ public class Provider : ITableObject<Guid>
public string GatewayCustomerId { get; set; }
public string GatewaySubscriptionId { get; set; }

public string BillingEmailAddress() => BillingEmail?.ToLowerInvariant().Trim();

public string BillingName() => DisplayBusinessName();

public string SubscriberName() => DisplayName();

public string BraintreeCustomerIdPrefix() => "p";

public string BraintreeIdField() => "provider_id";

public string BraintreeCloudRegionField() => "region";

public bool IsOrganization() => false;

public bool IsUser() => false;

public string SubscriberType() => "Provider";

public bool IsExpired() => false;

public void SetNewId()
{
if (Id == default)
Expand Down
9 changes: 9 additions & 0 deletions src/Core/Billing/BillingException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Bit.Core.Billing;

public class BillingException(
string clientFriendlyMessage,
string internalMessage = null,
Exception innerException = null) : Exception(internalMessage, innerException)
{
public string ClientFriendlyMessage { get; set; } = clientFriendlyMessage;
}
2 changes: 0 additions & 2 deletions src/Core/Billing/Commands/ICancelSubscriptionCommand.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Models;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Stripe;

namespace Bit.Core.Billing.Commands;
Expand All @@ -17,7 +16,6 @@ public interface ICancelSubscriptionCommand
/// <param name="subscription">The <see cref="User"/> or <see cref="Organization"/> with the subscription to cancel.</param>
/// <param name="offboardingSurveyResponse">An <see cref="OffboardingSurveyResponse"/> DTO containing user-provided feedback on why they are cancelling the subscription.</param>
/// <param name="cancelImmediately">A flag indicating whether to cancel the subscription immediately or at the end of the subscription period.</param>
/// <exception cref="GatewayException">Thrown when the provided subscription is already in an inactive state.</exception>
Task CancelSubscription(
Subscription subscription,
OffboardingSurveyResponse offboardingSurveyResponse,
Expand Down
7 changes: 7 additions & 0 deletions src/Core/Billing/Commands/IRemovePaymentMethodCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,12 @@ namespace Bit.Core.Billing.Commands;

public interface IRemovePaymentMethodCommand
{
/// <summary>
/// Attempts to remove an Organization's saved payment method. If the Stripe <see cref="Stripe.Customer"/> representing the
/// <see cref="Organization"/> contains a valid <b>"btCustomerId"</b> key in its <see cref="Stripe.Customer.Metadata"/> property,
/// this command will attempt to remove the Braintree <see cref="Braintree.PaymentMethod"/>. Otherwise, it will attempt to remove the
/// Stripe <see cref="Stripe.PaymentMethod"/>.
/// </summary>
/// <param name="organization">The organization to remove the saved payment method for.</param>
Task RemovePaymentMethod(Organization organization);
}
Original file line number Diff line number Diff line change
@@ -1,55 +1,41 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Services;
using Braintree;
using Microsoft.Extensions.Logging;

using static Bit.Core.Billing.Utilities;

namespace Bit.Core.Billing.Commands.Implementations;

public class RemovePaymentMethodCommand : IRemovePaymentMethodCommand
public class RemovePaymentMethodCommand(
IBraintreeGateway braintreeGateway,
ILogger<RemovePaymentMethodCommand> logger,
IStripeAdapter stripeAdapter)
: IRemovePaymentMethodCommand
{
private readonly IBraintreeGateway _braintreeGateway;
private readonly ILogger<RemovePaymentMethodCommand> _logger;
private readonly IStripeAdapter _stripeAdapter;

public RemovePaymentMethodCommand(
IBraintreeGateway braintreeGateway,
ILogger<RemovePaymentMethodCommand> logger,
IStripeAdapter stripeAdapter)
{
_braintreeGateway = braintreeGateway;
_logger = logger;
_stripeAdapter = stripeAdapter;
}

public async Task RemovePaymentMethod(Organization organization)
{
const string braintreeCustomerIdKey = "btCustomerId";

if (organization == null)
{
throw new ArgumentNullException(nameof(organization));
}
ArgumentNullException.ThrowIfNull(organization);

if (organization.Gateway is not GatewayType.Stripe || string.IsNullOrEmpty(organization.GatewayCustomerId))
{
throw ContactSupport();
}

var stripeCustomer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId, new Stripe.CustomerGetOptions
var stripeCustomer = await stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId, new Stripe.CustomerGetOptions
{
Expand = new List<string> { "invoice_settings.default_payment_method", "sources" }
Expand = ["invoice_settings.default_payment_method", "sources"]
});

if (stripeCustomer == null)
{
_logger.LogError("Could not find Stripe customer ({ID}) when removing payment method", organization.GatewayCustomerId);
logger.LogError("Could not find Stripe customer ({ID}) when removing payment method", organization.GatewayCustomerId);

throw ContactSupport();
}

if (stripeCustomer.Metadata?.TryGetValue(braintreeCustomerIdKey, out var braintreeCustomerId) ?? false)
if (stripeCustomer.Metadata?.TryGetValue(BraintreeCustomerIdKey, out var braintreeCustomerId) ?? false)
{
await RemoveBraintreePaymentMethodAsync(braintreeCustomerId);
}
Expand All @@ -61,11 +47,11 @@ public async Task RemovePaymentMethod(Organization organization)

private async Task RemoveBraintreePaymentMethodAsync(string braintreeCustomerId)
{
var customer = await _braintreeGateway.Customer.FindAsync(braintreeCustomerId);
var customer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId);

if (customer == null)
{
_logger.LogError("Failed to retrieve Braintree customer ({ID}) when removing payment method", braintreeCustomerId);
logger.LogError("Failed to retrieve Braintree customer ({ID}) when removing payment method", braintreeCustomerId);

throw ContactSupport();
}
Expand All @@ -74,27 +60,27 @@ private async Task RemoveBraintreePaymentMethodAsync(string braintreeCustomerId)
{
var existingDefaultPaymentMethod = customer.DefaultPaymentMethod;

var updateCustomerResult = await _braintreeGateway.Customer.UpdateAsync(
var updateCustomerResult = await braintreeGateway.Customer.UpdateAsync(
braintreeCustomerId,
new CustomerRequest { DefaultPaymentMethodToken = null });

if (!updateCustomerResult.IsSuccess())
{
_logger.LogError("Failed to update payment method for Braintree customer ({ID}) | Message: {Message}",
logger.LogError("Failed to update payment method for Braintree customer ({ID}) | Message: {Message}",
braintreeCustomerId, updateCustomerResult.Message);

throw ContactSupport();
}

var deletePaymentMethodResult = await _braintreeGateway.PaymentMethod.DeleteAsync(existingDefaultPaymentMethod.Token);
var deletePaymentMethodResult = await braintreeGateway.PaymentMethod.DeleteAsync(existingDefaultPaymentMethod.Token);

if (!deletePaymentMethodResult.IsSuccess())
{
await _braintreeGateway.Customer.UpdateAsync(
await braintreeGateway.Customer.UpdateAsync(
braintreeCustomerId,
new CustomerRequest { DefaultPaymentMethodToken = existingDefaultPaymentMethod.Token });

_logger.LogError(
logger.LogError(
"Failed to delete Braintree payment method for Customer ({ID}), re-linked payment method. Message: {Message}",
braintreeCustomerId, deletePaymentMethodResult.Message);

Expand All @@ -103,7 +89,7 @@ await _braintreeGateway.Customer.UpdateAsync(
}
else
{
_logger.LogWarning("Tried to remove non-existent Braintree payment method for Customer ({ID})", braintreeCustomerId);
logger.LogWarning("Tried to remove non-existent Braintree payment method for Customer ({ID})", braintreeCustomerId);
}
}

Expand All @@ -116,25 +102,23 @@ private async Task RemoveStripePaymentMethodsAsync(Stripe.Customer customer)
switch (source)
{
case Stripe.BankAccount:
await _stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id);
await stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id);
break;
case Stripe.Card:
await _stripeAdapter.CardDeleteAsync(customer.Id, source.Id);
await stripeAdapter.CardDeleteAsync(customer.Id, source.Id);
break;
}
}
}

var paymentMethods = _stripeAdapter.PaymentMethodListAutoPagingAsync(new Stripe.PaymentMethodListOptions
var paymentMethods = stripeAdapter.PaymentMethodListAutoPagingAsync(new Stripe.PaymentMethodListOptions
{
Customer = customer.Id
});

await foreach (var paymentMethod in paymentMethods)
{
await _stripeAdapter.PaymentMethodDetachAsync(paymentMethod.Id, new Stripe.PaymentMethodDetachOptions());
await stripeAdapter.PaymentMethodDetachAsync(paymentMethod.Id, new Stripe.PaymentMethodDetachOptions());
}
}

private static GatewayException ContactSupport() => new("Could not remove your payment method. Please contact support for assistance.");
}
Loading

0 comments on commit ffd988e

Please sign in to comment.