1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

[AC-2361] Refactor StripeController (#4136)

* Changes ensures provider_id is handled and stored for Braintree.

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* refactoring of the stripeController class

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* Move the constant variables to utility class

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* Adding comments to the methods

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* Add more comments to describe the method

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* Add the providerId changes

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* Add the missing providerId

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

* Fix the IsSponsoredSubscription bug

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>

---------

Signed-off-by: Cy Okeke <cokeke@bitwarden.com>
This commit is contained in:
cyprain-okeke 2024-06-26 16:34:16 +01:00 committed by GitHub
parent 26575856e6
commit f045d06a9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1705 additions and 1181 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,12 @@
using Event = Stripe.Event;
namespace Bit.Billing.Services;
public interface IStripeEventProcessor
{
/// <summary>
/// Processes the specified Stripe event asynchronously.
/// </summary>
/// <param name="parsedEvent">The Stripe event to be processed.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task ProcessEventAsync(Event parsedEvent);
}

View File

@ -0,0 +1,67 @@
using Stripe;
using Transaction = Bit.Core.Entities.Transaction;
namespace Bit.Billing.Services;
public interface IStripeEventUtilityService
{
/// <summary>
/// Gets the organization or user ID from the metadata of a Stripe Charge object.
/// </summary>
/// <param name="charge"></param>
/// <returns></returns>
Task<(Guid?, Guid?, Guid?)> GetEntityIdsFromChargeAsync(Charge charge);
/// <summary>
/// Gets the organizationId, userId, or providerId from the metadata of a Stripe Subscription object.
/// </summary>
/// <param name="metadata"></param>
/// <returns></returns>
Tuple<Guid?, Guid?, Guid?> GetIdsFromMetadata(Dictionary<string, string> metadata);
/// <summary>
/// Determines whether the specified subscription is a sponsored subscription.
/// </summary>
/// <param name="subscription">The subscription to be evaluated.</param>
/// <returns>
/// A boolean value indicating whether the subscription is a sponsored subscription.
/// Returns <c>true</c> if the subscription matches any of the sponsored plans; otherwise, <c>false</c>.
/// </returns>
bool IsSponsoredSubscription(Subscription subscription);
/// <summary>
/// Converts a Stripe Charge object to a Bitwarden Transaction object.
/// </summary>
/// <param name="charge"></param>
/// <param name="organizationId"></param>
/// <param name="userId"></param>
/// /// <param name="providerId"></param>
/// <returns></returns>
Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId);
/// <summary>
/// Attempts to pay the specified invoice. If a customer is eligible, the invoice is paid using Braintree or Stripe.
/// </summary>
/// <param name="invoice">The invoice to be paid.</param>
/// <param name="attemptToPayWithStripe">Indicates whether to attempt payment with Stripe. Defaults to false.</param>
/// <returns>A task representing the asynchronous operation. The task result contains a boolean value indicating whether the invoice payment attempt was successful.</returns>
Task<bool> AttemptToPayInvoiceAsync(Invoice invoice, bool attemptToPayWithStripe = false);
/// <summary>
/// Determines whether an invoice should be attempted to be paid based on certain criteria.
/// </summary>
/// <param name="invoice">The invoice to be evaluated.</param>
/// <returns>A boolean value indicating whether the invoice should be attempted to be paid.</returns>
bool ShouldAttemptToPayInvoice(Invoice invoice);
/// <summary>
/// The ID for the premium annual plan.
/// </summary>
const string PremiumPlanId = "premium-annually";
/// <summary>
/// The ID for the premium annual plan via the App Store.
/// </summary>
const string PremiumPlanIdAppStore = "premium-annually-app";
}

View File

@ -0,0 +1,67 @@
using Event = Stripe.Event;
namespace Bit.Billing.Services;
public interface IStripeWebhookHandler
{
/// <summary>
/// Handles the specified Stripe event asynchronously.
/// </summary>
/// <param name="parsedEvent">The Stripe event to be handled.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task HandleAsync(Event parsedEvent);
}
/// <summary>
/// Defines the contract for handling Stripe subscription deleted events.
/// </summary>
public interface ISubscriptionDeletedHandler : IStripeWebhookHandler;
/// <summary>
/// Defines the contract for handling Stripe subscription updated events.
/// </summary>
public interface ISubscriptionUpdatedHandler : IStripeWebhookHandler;
/// <summary>
/// Defines the contract for handling Stripe upcoming invoice events.
/// </summary>
public interface IUpcomingInvoiceHandler : IStripeWebhookHandler;
/// <summary>
/// Defines the contract for handling Stripe charge succeeded events.
/// </summary>
public interface IChargeSucceededHandler : IStripeWebhookHandler;
/// <summary>
/// Defines the contract for handling Stripe charge refunded events.
/// </summary>
public interface IChargeRefundedHandler : IStripeWebhookHandler;
/// <summary>
/// Defines the contract for handling Stripe payment succeeded events.
/// </summary>
public interface IPaymentSucceededHandler : IStripeWebhookHandler;
/// <summary>
/// Defines the contract for handling Stripe payment failed events.
/// </summary>
public interface IPaymentFailedHandler : IStripeWebhookHandler;
/// <summary>
/// Defines the contract for handling Stripe invoice created events.
/// </summary>
public interface IInvoiceCreatedHandler : IStripeWebhookHandler;
/// <summary>
/// Defines the contract for handling Stripe payment method attached events.
/// </summary>
public interface IPaymentMethodAttachedHandler : IStripeWebhookHandler;
/// <summary>
/// Defines the contract for handling Stripe customer updated events.
/// </summary>
public interface ICustomerUpdatedHandler : IStripeWebhookHandler;
/// <summary>
/// Defines the contract for handling Stripe Invoice Finalized events.
/// </summary>
public interface IInvoiceFinalizedHandler : IStripeWebhookHandler;

View File

@ -0,0 +1,98 @@
using Bit.Billing.Constants;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Microsoft.Data.SqlClient;
using Event = Stripe.Event;
using Transaction = Bit.Core.Entities.Transaction;
using TransactionType = Bit.Core.Enums.TransactionType;
namespace Bit.Billing.Services.Implementations;
public class ChargeRefundedHandler : IChargeRefundedHandler
{
private readonly ILogger<ChargeRefundedHandler> _logger;
private readonly IStripeEventService _stripeEventService;
private readonly ITransactionRepository _transactionRepository;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
public ChargeRefundedHandler(
ILogger<ChargeRefundedHandler> logger,
IStripeEventService stripeEventService,
ITransactionRepository transactionRepository,
IStripeEventUtilityService stripeEventUtilityService)
{
_logger = logger;
_stripeEventService = stripeEventService;
_transactionRepository = transactionRepository;
_stripeEventUtilityService = stripeEventUtilityService;
}
/// <summary>
/// Handles the <see cref="HandledStripeWebhook.ChargeRefunded"/> event type from Stripe.
/// </summary>
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent)
{
var charge = await _stripeEventService.GetCharge(parsedEvent, true, ["refunds"]);
var parentTransaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.Stripe, charge.Id);
if (parentTransaction == null)
{
// Attempt to create a transaction for the charge if it doesn't exist
var (organizationId, userId, providerId) = await _stripeEventUtilityService.GetEntityIdsFromChargeAsync(charge);
var tx = _stripeEventUtilityService.FromChargeToTransaction(charge, organizationId, userId, providerId);
try
{
parentTransaction = await _transactionRepository.CreateAsync(tx);
}
catch (SqlException e) when (e.Number == 547) // FK constraint violation
{
_logger.LogWarning(
"Charge refund could not create transaction as entity may have been deleted. {ChargeId}",
charge.Id);
return;
}
}
var amountRefunded = charge.AmountRefunded / 100M;
if (parentTransaction.Refunded.GetValueOrDefault() ||
parentTransaction.RefundedAmount.GetValueOrDefault() >= amountRefunded)
{
_logger.LogWarning(
"Charge refund amount doesn't match parent transaction's amount or parent has already been refunded. {ChargeId}",
charge.Id);
return;
}
parentTransaction.RefundedAmount = amountRefunded;
if (charge.Refunded)
{
parentTransaction.Refunded = true;
}
await _transactionRepository.ReplaceAsync(parentTransaction);
foreach (var refund in charge.Refunds)
{
var refundTransaction = await _transactionRepository.GetByGatewayIdAsync(
GatewayType.Stripe, refund.Id);
if (refundTransaction != null)
{
continue;
}
await _transactionRepository.CreateAsync(new Transaction
{
Amount = refund.Amount / 100M,
CreationDate = refund.Created,
OrganizationId = parentTransaction.OrganizationId,
UserId = parentTransaction.UserId,
ProviderId = parentTransaction.ProviderId,
Type = TransactionType.Refund,
Gateway = GatewayType.Stripe,
GatewayId = refund.Id,
PaymentMethodType = parentTransaction.PaymentMethodType,
Details = parentTransaction.Details
});
}
}
}

View File

@ -0,0 +1,67 @@
using Bit.Billing.Constants;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Microsoft.Data.SqlClient;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class ChargeSucceededHandler : IChargeSucceededHandler
{
private readonly ILogger<ChargeSucceededHandler> _logger;
private readonly IStripeEventService _stripeEventService;
private readonly ITransactionRepository _transactionRepository;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
public ChargeSucceededHandler(
ILogger<ChargeSucceededHandler> logger,
IStripeEventService stripeEventService,
ITransactionRepository transactionRepository,
IStripeEventUtilityService stripeEventUtilityService)
{
_logger = logger;
_stripeEventService = stripeEventService;
_transactionRepository = transactionRepository;
_stripeEventUtilityService = stripeEventUtilityService;
}
/// <summary>
/// Handles the <see cref="HandledStripeWebhook.ChargeSucceeded"/> event type from Stripe.
/// </summary>
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent)
{
var charge = await _stripeEventService.GetCharge(parsedEvent);
var existingTransaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.Stripe, charge.Id);
if (existingTransaction is not null)
{
_logger.LogInformation("Charge success already processed. {ChargeId}", charge.Id);
return;
}
var (organizationId, userId, providerId) = await _stripeEventUtilityService.GetEntityIdsFromChargeAsync(charge);
if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue)
{
_logger.LogWarning("Charge success has no subscriber ids. {ChargeId}", charge.Id);
return;
}
var transaction = _stripeEventUtilityService.FromChargeToTransaction(charge, organizationId, userId, providerId);
if (!transaction.PaymentMethodType.HasValue)
{
_logger.LogWarning("Charge success from unsupported source/method. {ChargeId}", charge.Id);
return;
}
try
{
await _transactionRepository.CreateAsync(transaction);
}
catch (SqlException e) when (e.Number == 547)
{
_logger.LogWarning(
"Charge success could not create transaction as entity may have been deleted. {ChargeId}",
charge.Id);
}
}
}

View File

@ -0,0 +1,60 @@
using Bit.Core.Context;
using Bit.Core.Repositories;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class CustomerUpdatedHandler : ICustomerUpdatedHandler
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IReferenceEventService _referenceEventService;
private readonly ICurrentContext _currentContext;
private readonly IStripeEventService _stripeEventService;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
public CustomerUpdatedHandler(
IOrganizationRepository organizationRepository,
IReferenceEventService referenceEventService,
ICurrentContext currentContext,
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService)
{
_organizationRepository = organizationRepository;
_referenceEventService = referenceEventService;
_currentContext = currentContext;
_stripeEventService = stripeEventService;
_stripeEventUtilityService = stripeEventUtilityService;
}
/// <summary>
/// Handles the <see cref="HandledStripeWebhook.CustomerUpdated"/> event type from Stripe.
/// </summary>
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent)
{
var customer = await _stripeEventService.GetCustomer(parsedEvent, true, ["subscriptions"]);
if (customer.Subscriptions == null || !customer.Subscriptions.Any())
{
return;
}
var subscription = customer.Subscriptions.First();
var (organizationId, _, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
if (!organizationId.HasValue)
{
return;
}
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
organization.BillingEmail = customer.Email;
await _organizationRepository.ReplaceAsync(organization);
await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.OrganizationEditedInStripe, organization, _currentContext));
}
}

View File

@ -0,0 +1,35 @@
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class InvoiceCreatedHandler : IInvoiceCreatedHandler
{
private readonly IStripeEventService _stripeEventService;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IProviderEventService _providerEventService;
public InvoiceCreatedHandler(
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService,
IProviderEventService providerEventService)
{
_stripeEventService = stripeEventService;
_stripeEventUtilityService = stripeEventUtilityService;
_providerEventService = providerEventService;
}
/// <summary>
/// Handles the <see cref="HandledStripeWebhook.InvoiceCreated"/> event type from Stripe.
/// </summary>
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent)
{
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
if (_stripeEventUtilityService.ShouldAttemptToPayInvoice(invoice))
{
await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
}
await _providerEventService.TryRecordInvoiceLineItems(parsedEvent);
}
}

View File

@ -0,0 +1,23 @@
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class InvoiceFinalizedHandler : IInvoiceFinalizedHandler
{
private readonly IProviderEventService _providerEventService;
public InvoiceFinalizedHandler(IProviderEventService providerEventService)
{
_providerEventService = providerEventService;
}
/// <summary>
/// Handles the <see cref="HandledStripeWebhook.InvoiceFinalized"/> event type from Stripe.
/// </summary>
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent)
{
await _providerEventService.TryRecordInvoiceLineItems(parsedEvent);
}
}

View File

@ -0,0 +1,52 @@
using Stripe;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class PaymentFailedHandler : IPaymentFailedHandler
{
private readonly IStripeEventService _stripeEventService;
private readonly IStripeFacade _stripeFacade;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
public PaymentFailedHandler(
IStripeEventService stripeEventService,
IStripeFacade stripeFacade,
IStripeEventUtilityService stripeEventUtilityService)
{
_stripeEventService = stripeEventService;
_stripeFacade = stripeFacade;
_stripeEventUtilityService = stripeEventUtilityService;
}
/// <summary>
/// Handles the <see cref="HandledStripeWebhook.PaymentFailed"/> event type from Stripe.
/// </summary>
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent)
{
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
if (invoice.Paid || invoice.AttemptCount <= 1 || !ShouldAttemptToPayInvoice(invoice))
{
return;
}
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
// attempt count 4 = 11 days after initial failure
if (invoice.AttemptCount <= 3 ||
!subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore))
{
await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
}
}
private static bool ShouldAttemptToPayInvoice(Invoice invoice) =>
invoice is
{
AmountDue: > 0,
Paid: false,
CollectionMethod: "charge_automatically",
BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice",
SubscriptionId: not null
};
}

View File

@ -0,0 +1,96 @@
using Bit.Billing.Constants;
using Stripe;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler
{
private readonly ILogger<PaymentMethodAttachedHandler> _logger;
private readonly IStripeEventService _stripeEventService;
private readonly IStripeFacade _stripeFacade;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
public PaymentMethodAttachedHandler(
ILogger<PaymentMethodAttachedHandler> logger,
IStripeEventService stripeEventService,
IStripeFacade stripeFacade,
IStripeEventUtilityService stripeEventUtilityService)
{
_logger = logger;
_stripeEventService = stripeEventService;
_stripeFacade = stripeFacade;
_stripeEventUtilityService = stripeEventUtilityService;
}
public async Task HandleAsync(Event parsedEvent)
{
var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent);
if (paymentMethod is null)
{
_logger.LogWarning("Attempted to handle the event payment_method.attached but paymentMethod was null");
return;
}
var subscriptionListOptions = new SubscriptionListOptions
{
Customer = paymentMethod.CustomerId,
Status = StripeSubscriptionStatus.Unpaid,
Expand = ["data.latest_invoice"]
};
StripeList<Subscription> unpaidSubscriptions;
try
{
unpaidSubscriptions = await _stripeFacade.ListSubscriptions(subscriptionListOptions);
}
catch (Exception e)
{
_logger.LogError(e,
"Attempted to get unpaid invoices for customer {CustomerId} but encountered an error while calling Stripe",
paymentMethod.CustomerId);
return;
}
foreach (var unpaidSubscription in unpaidSubscriptions)
{
await AttemptToPayOpenSubscriptionAsync(unpaidSubscription);
}
}
private async Task AttemptToPayOpenSubscriptionAsync(Subscription unpaidSubscription)
{
var latestInvoice = unpaidSubscription.LatestInvoice;
if (unpaidSubscription.LatestInvoice is null)
{
_logger.LogWarning(
"Attempted to pay unpaid subscription {SubscriptionId} but latest invoice didn't exist",
unpaidSubscription.Id);
return;
}
if (latestInvoice.Status != StripeInvoiceStatus.Open)
{
_logger.LogWarning(
"Attempted to pay unpaid subscription {SubscriptionId} but latest invoice wasn't \"open\"",
unpaidSubscription.Id);
return;
}
try
{
await _stripeEventUtilityService.AttemptToPayInvoiceAsync(latestInvoice, true);
}
catch (Exception e)
{
_logger.LogError(e,
"Attempted to pay open invoice {InvoiceId} on unpaid subscription {SubscriptionId} but encountered an error",
latestInvoice.Id, unpaidSubscription.Id);
throw;
}
}
}

View File

@ -0,0 +1,171 @@
using Bit.Billing.Constants;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Context;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class PaymentSucceededHandler : IPaymentSucceededHandler
{
private readonly ILogger<PaymentSucceededHandler> _logger;
private readonly IStripeEventService _stripeEventService;
private readonly IOrganizationService _organizationService;
private readonly IUserService _userService;
private readonly IStripeFacade _stripeFacade;
private readonly IProviderRepository _providerRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IReferenceEventService _referenceEventService;
private readonly ICurrentContext _currentContext;
private readonly IUserRepository _userRepository;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
public PaymentSucceededHandler(
ILogger<PaymentSucceededHandler> logger,
IStripeEventService stripeEventService,
IStripeFacade stripeFacade,
IProviderRepository providerRepository,
IOrganizationRepository organizationRepository,
IReferenceEventService referenceEventService,
ICurrentContext currentContext,
IUserRepository userRepository,
IStripeEventUtilityService stripeEventUtilityService,
IUserService userService,
IOrganizationService organizationService)
{
_logger = logger;
_stripeEventService = stripeEventService;
_stripeFacade = stripeFacade;
_providerRepository = providerRepository;
_organizationRepository = organizationRepository;
_referenceEventService = referenceEventService;
_currentContext = currentContext;
_userRepository = userRepository;
_stripeEventUtilityService = stripeEventUtilityService;
_userService = userService;
_organizationService = organizationService;
}
/// <summary>
/// Handles the <see cref="HandledStripeWebhook.PaymentSucceeded"/> event type from Stripe.
/// </summary>
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent)
{
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
if (!invoice.Paid || invoice.BillingReason != "subscription_create")
{
return;
}
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
if (subscription?.Status != StripeSubscriptionStatus.Active)
{
return;
}
if (DateTime.UtcNow - invoice.Created < TimeSpan.FromMinutes(1))
{
await Task.Delay(5000);
}
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
if (providerId.HasValue)
{
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
if (provider == null)
{
_logger.LogError(
"Received invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) that does not exist",
parsedEvent.Id,
providerId.Value);
return;
}
var teamsMonthly = StaticStore.GetPlan(PlanType.TeamsMonthly);
var enterpriseMonthly = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
var teamsMonthlyLineItem =
subscription.Items.Data.FirstOrDefault(item =>
item.Plan.Id == teamsMonthly.PasswordManager.StripeSeatPlanId);
var enterpriseMonthlyLineItem =
subscription.Items.Data.FirstOrDefault(item =>
item.Plan.Id == enterpriseMonthly.PasswordManager.StripeSeatPlanId);
if (teamsMonthlyLineItem == null || enterpriseMonthlyLineItem == null)
{
_logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items",
parsedEvent.Id,
provider.Id);
return;
}
await _referenceEventService.RaiseEventAsync(new ReferenceEvent
{
Type = ReferenceEventType.Rebilled,
Source = ReferenceEventSource.Provider,
Id = provider.Id,
PlanType = PlanType.TeamsMonthly,
Seats = (int)teamsMonthlyLineItem.Quantity
});
await _referenceEventService.RaiseEventAsync(new ReferenceEvent
{
Type = ReferenceEventType.Rebilled,
Source = ReferenceEventSource.Provider,
Id = provider.Id,
PlanType = PlanType.EnterpriseMonthly,
Seats = (int)enterpriseMonthlyLineItem.Quantity
});
}
else if (organizationId.HasValue)
{
if (!subscription.Items.Any(i =>
StaticStore.Plans.Any(p => p.PasswordManager.StripePlanId == i.Plan.Id)))
{
return;
}
await _organizationService.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.Rebilled, organization, _currentContext)
{
PlanName = organization?.Plan,
PlanType = organization?.PlanType,
Seats = organization?.Seats,
Storage = organization?.MaxStorageGb,
});
}
else if (userId.HasValue)
{
if (subscription.Items.All(i => i.Plan.Id != IStripeEventUtilityService.PremiumPlanId))
{
return;
}
await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
var user = await _userRepository.GetByIdAsync(userId.Value);
await _referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.Rebilled, user, _currentContext)
{
PlanName = IStripeEventUtilityService.PremiumPlanId,
Storage = user?.MaxStorageGb,
});
}
}
}

View File

@ -0,0 +1,89 @@
using Bit.Billing.Constants;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class StripeEventProcessor : IStripeEventProcessor
{
private readonly ILogger<StripeEventProcessor> _logger;
private readonly ISubscriptionDeletedHandler _subscriptionDeletedHandler;
private readonly ISubscriptionUpdatedHandler _subscriptionUpdatedHandler;
private readonly IUpcomingInvoiceHandler _upcomingInvoiceHandler;
private readonly IChargeSucceededHandler _chargeSucceededHandler;
private readonly IChargeRefundedHandler _chargeRefundedHandler;
private readonly IPaymentSucceededHandler _paymentSucceededHandler;
private readonly IPaymentFailedHandler _paymentFailedHandler;
private readonly IInvoiceCreatedHandler _invoiceCreatedHandler;
private readonly IPaymentMethodAttachedHandler _paymentMethodAttachedHandler;
private readonly ICustomerUpdatedHandler _customerUpdatedHandler;
public StripeEventProcessor(
ILogger<StripeEventProcessor> logger,
ISubscriptionDeletedHandler subscriptionDeletedHandler,
ISubscriptionUpdatedHandler subscriptionUpdatedHandler,
IUpcomingInvoiceHandler upcomingInvoiceHandler,
IChargeSucceededHandler chargeSucceededHandler,
IChargeRefundedHandler chargeRefundedHandler,
IPaymentSucceededHandler paymentSucceededHandler,
IPaymentFailedHandler paymentFailedHandler,
IInvoiceCreatedHandler invoiceCreatedHandler,
IPaymentMethodAttachedHandler paymentMethodAttachedHandler,
ICustomerUpdatedHandler customerUpdatedHandler)
{
_logger = logger;
_subscriptionDeletedHandler = subscriptionDeletedHandler;
_subscriptionUpdatedHandler = subscriptionUpdatedHandler;
_upcomingInvoiceHandler = upcomingInvoiceHandler;
_chargeSucceededHandler = chargeSucceededHandler;
_chargeRefundedHandler = chargeRefundedHandler;
_paymentSucceededHandler = paymentSucceededHandler;
_paymentFailedHandler = paymentFailedHandler;
_invoiceCreatedHandler = invoiceCreatedHandler;
_paymentMethodAttachedHandler = paymentMethodAttachedHandler;
_customerUpdatedHandler = customerUpdatedHandler;
}
public async Task ProcessEventAsync(Event parsedEvent)
{
switch (parsedEvent.Type)
{
case HandledStripeWebhook.SubscriptionDeleted:
await _subscriptionDeletedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.SubscriptionUpdated:
await _subscriptionUpdatedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.UpcomingInvoice:
await _upcomingInvoiceHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.ChargeSucceeded:
await _chargeSucceededHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.ChargeRefunded:
await _chargeRefundedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.PaymentSucceeded:
await _paymentSucceededHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.PaymentFailed:
await _paymentFailedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.InvoiceCreated:
await _invoiceCreatedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.PaymentMethodAttached:
await _paymentMethodAttachedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.CustomerUpdated:
await _customerUpdatedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.InvoiceFinalized:
await _customerUpdatedHandler.HandleAsync(parsedEvent);
break;
default:
_logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
break;
}
}
}

View File

@ -0,0 +1,401 @@
using Bit.Billing.Constants;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Braintree;
using Stripe;
using Customer = Stripe.Customer;
using Subscription = Stripe.Subscription;
using Transaction = Bit.Core.Entities.Transaction;
using TransactionType = Bit.Core.Enums.TransactionType;
namespace Bit.Billing.Services.Implementations;
public class StripeEventUtilityService : IStripeEventUtilityService
{
private readonly IStripeFacade _stripeFacade;
private readonly ILogger<StripeEventUtilityService> _logger;
private readonly ITransactionRepository _transactionRepository;
private readonly IMailService _mailService;
private readonly BraintreeGateway _btGateway;
private readonly GlobalSettings _globalSettings;
public StripeEventUtilityService(
IStripeFacade stripeFacade,
ILogger<StripeEventUtilityService> logger,
ITransactionRepository transactionRepository,
IMailService mailService,
GlobalSettings globalSettings)
{
_stripeFacade = stripeFacade;
_logger = logger;
_transactionRepository = transactionRepository;
_mailService = mailService;
_btGateway = new BraintreeGateway
{
Environment = globalSettings.Braintree.Production ?
Braintree.Environment.PRODUCTION : Braintree.Environment.SANDBOX,
MerchantId = globalSettings.Braintree.MerchantId,
PublicKey = globalSettings.Braintree.PublicKey,
PrivateKey = globalSettings.Braintree.PrivateKey
};
_globalSettings = globalSettings;
}
/// <summary>
/// Gets the organizationId, userId, or providerId from the metadata of a Stripe Subscription object.
/// </summary>
/// <param name="metadata"></param>
/// <returns></returns>
public Tuple<Guid?, Guid?, Guid?> GetIdsFromMetadata(Dictionary<string, string> metadata)
{
if (metadata == null || metadata.Count == 0)
{
return new Tuple<Guid?, Guid?, Guid?>(null, null, null);
}
metadata.TryGetValue("organizationId", out var orgIdString);
metadata.TryGetValue("userId", out var userIdString);
metadata.TryGetValue("providerId", out var providerIdString);
orgIdString ??= metadata.FirstOrDefault(x =>
x.Key.Equals("organizationId", StringComparison.OrdinalIgnoreCase)).Value;
userIdString ??= metadata.FirstOrDefault(x =>
x.Key.Equals("userId", StringComparison.OrdinalIgnoreCase)).Value;
providerIdString ??= metadata.FirstOrDefault(x =>
x.Key.Equals("providerId", StringComparison.OrdinalIgnoreCase)).Value;
Guid? organizationId = string.IsNullOrWhiteSpace(orgIdString) ? null : new Guid(orgIdString);
Guid? userId = string.IsNullOrWhiteSpace(userIdString) ? null : new Guid(userIdString);
Guid? providerId = string.IsNullOrWhiteSpace(providerIdString) ? null : new Guid(providerIdString);
return new Tuple<Guid?, Guid?, Guid?>(organizationId, userId, providerId);
}
/// <summary>
/// Gets the organization or user ID from the metadata of a Stripe Charge object.
/// </summary>
/// <param name="charge"></param>
/// <returns></returns>
public async Task<(Guid?, Guid?, Guid?)> GetEntityIdsFromChargeAsync(Charge charge)
{
Guid? organizationId = null;
Guid? userId = null;
Guid? providerId = null;
if (charge.InvoiceId != null)
{
var invoice = await _stripeFacade.GetInvoice(charge.InvoiceId);
if (invoice?.SubscriptionId != null)
{
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
(organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata);
}
}
if (organizationId.HasValue || userId.HasValue || providerId.HasValue)
{
return (organizationId, userId, providerId);
}
var subscriptions = await _stripeFacade.ListSubscriptions(new SubscriptionListOptions
{
Customer = charge.CustomerId
});
foreach (var subscription in subscriptions)
{
if (subscription.Status is StripeSubscriptionStatus.Canceled or StripeSubscriptionStatus.IncompleteExpired)
{
continue;
}
(organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);
if (organizationId.HasValue || userId.HasValue || providerId.HasValue)
{
return (organizationId, userId, providerId);
}
}
return (null, null, null);
}
public bool IsSponsoredSubscription(Subscription subscription) =>
StaticStore.SponsoredPlans
.Any(p => subscription.Items
.Any(i => i.Plan.Id == p.StripePlanId));
/// <summary>
/// Converts a Stripe Charge object to a Bitwarden Transaction object.
/// </summary>
/// <param name="charge"></param>
/// <param name="organizationId"></param>
/// <param name="userId"></param>
/// /// <param name="providerId"></param>
/// <returns></returns>
public Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId)
{
var transaction = new Transaction
{
Amount = charge.Amount / 100M,
CreationDate = charge.Created,
OrganizationId = organizationId,
UserId = userId,
ProviderId = providerId,
Type = TransactionType.Charge,
Gateway = GatewayType.Stripe,
GatewayId = charge.Id
};
switch (charge.Source)
{
case Card card:
{
transaction.PaymentMethodType = PaymentMethodType.Card;
transaction.Details = $"{card.Brand}, *{card.Last4}";
break;
}
case BankAccount bankAccount:
{
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = $"{bankAccount.BankName}, *{bankAccount.Last4}";
break;
}
case Source { Card: not null } source:
{
transaction.PaymentMethodType = PaymentMethodType.Card;
transaction.Details = $"{source.Card.Brand}, *{source.Card.Last4}";
break;
}
case Source { AchDebit: not null } source:
{
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = $"{source.AchDebit.BankName}, *{source.AchDebit.Last4}";
break;
}
case Source source:
{
if (source.AchCreditTransfer == null)
{
break;
}
var achCreditTransfer = source.AchCreditTransfer;
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = $"ACH => {achCreditTransfer.BankName}, {achCreditTransfer.AccountNumber}";
break;
}
default:
{
if (charge.PaymentMethodDetails == null)
{
break;
}
if (charge.PaymentMethodDetails.Card != null)
{
var card = charge.PaymentMethodDetails.Card;
transaction.PaymentMethodType = PaymentMethodType.Card;
transaction.Details = $"{card.Brand?.ToUpperInvariant()}, *{card.Last4}";
}
else if (charge.PaymentMethodDetails.AchDebit != null)
{
var achDebit = charge.PaymentMethodDetails.AchDebit;
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = $"{achDebit.BankName}, *{achDebit.Last4}";
}
else if (charge.PaymentMethodDetails.AchCreditTransfer != null)
{
var achCreditTransfer = charge.PaymentMethodDetails.AchCreditTransfer;
transaction.PaymentMethodType = PaymentMethodType.BankAccount;
transaction.Details = $"ACH => {achCreditTransfer.BankName}, {achCreditTransfer.AccountNumber}";
}
break;
}
}
return transaction;
}
public async Task<bool> AttemptToPayInvoiceAsync(Invoice invoice, bool attemptToPayWithStripe = false)
{
var customer = await _stripeFacade.GetCustomer(invoice.CustomerId);
if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false)
{
return await AttemptToPayInvoiceWithBraintreeAsync(invoice, customer);
}
if (attemptToPayWithStripe)
{
return await AttemptToPayInvoiceWithStripeAsync(invoice);
}
return false;
}
public bool ShouldAttemptToPayInvoice(Invoice invoice) =>
invoice is
{
AmountDue: > 0,
Paid: false,
CollectionMethod: "charge_automatically",
BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice",
SubscriptionId: not null
};
private async Task<bool> AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice, Customer customer)
{
_logger.LogDebug("Attempting to pay invoice with Braintree");
if (!customer?.Metadata?.ContainsKey("btCustomerId") ?? true)
{
_logger.LogWarning(
"Attempted to pay invoice with Braintree but btCustomerId wasn't on Stripe customer metadata");
return false;
}
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata);
if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue)
{
_logger.LogWarning(
"Attempted to pay invoice with Braintree but Stripe subscription metadata didn't contain either a organizationId or userId or ");
return false;
}
var orgTransaction = organizationId.HasValue;
string btObjIdField;
Guid btObjId;
if (organizationId.HasValue)
{
btObjIdField = "organization_id";
btObjId = organizationId.Value;
}
else if (userId.HasValue)
{
btObjIdField = "user_id";
btObjId = userId.Value;
}
else
{
btObjIdField = "provider_id";
btObjId = providerId.Value;
}
var btInvoiceAmount = invoice.AmountDue / 100M;
var existingTransactions = organizationId.HasValue
? await _transactionRepository.GetManyByOrganizationIdAsync(organizationId.Value)
: userId.HasValue
? await _transactionRepository.GetManyByUserIdAsync(userId.Value)
: await _transactionRepository.GetManyByProviderIdAsync(providerId.Value);
var duplicateTimeSpan = TimeSpan.FromHours(24);
var now = DateTime.UtcNow;
var duplicateTransaction = existingTransactions?
.FirstOrDefault(t => (now - t.CreationDate) < duplicateTimeSpan);
if (duplicateTransaction != null)
{
_logger.LogWarning("There is already a recent PayPal transaction ({0}). " +
"Do not charge again to prevent possible duplicate.", duplicateTransaction.GatewayId);
return false;
}
Result<Braintree.Transaction> transactionResult;
try
{
transactionResult = await _btGateway.Transaction.SaleAsync(
new Braintree.TransactionRequest
{
Amount = btInvoiceAmount,
CustomerId = customer.Metadata["btCustomerId"],
Options = new Braintree.TransactionOptionsRequest
{
SubmitForSettlement = true,
PayPal = new Braintree.TransactionOptionsPayPalRequest
{
CustomField =
$"{btObjIdField}:{btObjId},region:{_globalSettings.BaseServiceUri.CloudRegion}"
}
},
CustomFields = new Dictionary<string, string>
{
[btObjIdField] = btObjId.ToString(),
["region"] = _globalSettings.BaseServiceUri.CloudRegion
}
});
}
catch (NotFoundException e)
{
_logger.LogError(e,
"Attempted to make a payment with Braintree, but customer did not exist for the given btCustomerId present on the Stripe metadata");
throw;
}
if (!transactionResult.IsSuccess())
{
if (invoice.AttemptCount < 4)
{
await _mailService.SendPaymentFailedAsync(customer.Email, btInvoiceAmount, true);
}
return false;
}
try
{
await _stripeFacade.UpdateInvoice(invoice.Id, new InvoiceUpdateOptions
{
Metadata = new Dictionary<string, string>
{
["btTransactionId"] = transactionResult.Target.Id,
["btPayPalTransactionId"] =
transactionResult.Target.PayPalDetails?.AuthorizationId
}
});
await _stripeFacade.PayInvoice(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true });
}
catch (Exception e)
{
await _btGateway.Transaction.RefundAsync(transactionResult.Target.Id);
if (e.Message.Contains("Invoice is already paid"))
{
await _stripeFacade.UpdateInvoice(invoice.Id, new InvoiceUpdateOptions
{
Metadata = invoice.Metadata
});
}
else
{
throw;
}
}
return true;
}
private async Task<bool> AttemptToPayInvoiceWithStripeAsync(Invoice invoice)
{
try
{
await _stripeFacade.PayInvoice(invoice.Id);
return true;
}
catch (Exception e)
{
_logger.LogWarning(
e,
"Exception occurred while trying to pay Stripe invoice with Id: {InvoiceId}",
invoice.Id);
throw;
}
}
}

View File

@ -0,0 +1,49 @@
using Bit.Billing.Constants;
using Bit.Core.Services;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
{
private readonly IStripeEventService _stripeEventService;
private readonly IOrganizationService _organizationService;
private readonly IUserService _userService;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
public SubscriptionDeletedHandler(
IStripeEventService stripeEventService,
IOrganizationService organizationService,
IUserService userService,
IStripeEventUtilityService stripeEventUtilityService)
{
_stripeEventService = stripeEventService;
_organizationService = organizationService;
_userService = userService;
_stripeEventUtilityService = stripeEventUtilityService;
}
/// <summary>
/// Handles the <see cref="HandledStripeWebhook.SubscriptionDeleted"/> event type from Stripe.
/// </summary>
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent)
{
var subscription = await _stripeEventService.GetSubscription(parsedEvent, true);
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
var subCanceled = subscription.Status == StripeSubscriptionStatus.Canceled;
if (!subCanceled)
{
return;
}
if (organizationId.HasValue)
{
await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
}
else if (userId.HasValue)
{
await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
}
}
}

View File

@ -0,0 +1,176 @@
using Bit.Billing.Constants;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Stripe;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
{
private readonly IStripeEventService _stripeEventService;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IOrganizationService _organizationService;
private readonly IStripeFacade _stripeFacade;
private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand;
private readonly IUserService _userService;
public SubscriptionUpdatedHandler(
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService,
IOrganizationService organizationService,
IStripeFacade stripeFacade,
IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand,
IUserService userService)
{
_stripeEventService = stripeEventService;
_stripeEventUtilityService = stripeEventUtilityService;
_organizationService = organizationService;
_stripeFacade = stripeFacade;
_organizationSponsorshipRenewCommand = organizationSponsorshipRenewCommand;
_userService = userService;
}
/// <summary>
/// Handles the <see cref="HandledStripeWebhook.SubscriptionUpdated"/> event type from Stripe.
/// </summary>
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent)
{
var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts"]);
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
switch (subscription.Status)
{
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired
when organizationId.HasValue:
{
await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
break;
}
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired:
{
if (!userId.HasValue)
{
break;
}
if (subscription.Status is StripeSubscriptionStatus.Unpaid &&
subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore))
{
await CancelSubscription(subscription.Id);
await VoidOpenInvoices(subscription.Id);
}
await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
break;
}
case StripeSubscriptionStatus.Active when organizationId.HasValue:
{
await _organizationService.EnableAsync(organizationId.Value);
break;
}
case StripeSubscriptionStatus.Active:
{
if (userId.HasValue)
{
await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
}
break;
}
}
if (organizationId.HasValue)
{
await _organizationService.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd);
if (_stripeEventUtilityService.IsSponsoredSubscription(subscription))
{
await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd);
}
await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription);
}
else if (userId.HasValue)
{
await _userService.UpdatePremiumExpirationAsync(userId.Value, subscription.CurrentPeriodEnd);
}
}
private async Task CancelSubscription(string subscriptionId) =>
await _stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions());
private async Task VoidOpenInvoices(string subscriptionId)
{
var options = new InvoiceListOptions
{
Status = StripeInvoiceStatus.Open,
Subscription = subscriptionId
};
var invoices = await _stripeFacade.ListInvoices(options);
foreach (var invoice in invoices)
{
await _stripeFacade.VoidInvoice(invoice.Id);
}
}
/// <summary>
/// Removes the Password Manager coupon if the organization is removing the Secrets Manager trial.
/// Only applies to organizations that have a subscription from the Secrets Manager trial.
/// </summary>
/// <param name="parsedEvent"></param>
/// <param name="subscription"></param>
private async Task RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(Event parsedEvent,
Subscription subscription)
{
if (parsedEvent.Data.PreviousAttributes?.items is null)
{
return;
}
var previousSubscription = parsedEvent.Data
.PreviousAttributes
.ToObject<Subscription>() as Subscription;
// This being false doesn't necessarily mean that the organization doesn't subscribe to Secrets Manager.
// If there are changes to any subscription item, Stripe sends every item in the subscription, both
// changed and unchanged.
var previousSubscriptionHasSecretsManager = previousSubscription?.Items is not null &&
previousSubscription.Items.Any(previousItem =>
StaticStore.Plans.Any(p =>
p.SecretsManager is not null &&
p.SecretsManager.StripeSeatPlanId ==
previousItem.Plan.Id));
var currentSubscriptionHasSecretsManager = subscription.Items.Any(i =>
StaticStore.Plans.Any(p =>
p.SecretsManager is not null &&
p.SecretsManager.StripeSeatPlanId == i.Plan.Id));
if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager)
{
return;
}
var customerHasSecretsManagerTrial = subscription.Customer
?.Discount
?.Coupon
?.Id == "sm-standalone";
var subscriptionHasSecretsManagerTrial = subscription.Discount
?.Coupon
?.Id == "sm-standalone";
if (customerHasSecretsManagerTrial)
{
await _stripeFacade.DeleteCustomerDiscount(subscription.CustomerId);
}
if (subscriptionHasSecretsManagerTrial)
{
await _stripeFacade.DeleteSubscriptionDiscount(subscription.Id);
}
}
}

View File

@ -0,0 +1,215 @@
using Bit.Billing.Constants;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Stripe;
using Event = Stripe.Event;
using TaxRate = Bit.Core.Entities.TaxRate;
namespace Bit.Billing.Services.Implementations;
public class UpcomingInvoiceHandler : IUpcomingInvoiceHandler
{
private readonly ILogger<StripeEventProcessor> _logger;
private readonly IStripeEventService _stripeEventService;
private readonly IUserService _userService;
private readonly IStripeFacade _stripeFacade;
private readonly IFeatureService _featureService;
private readonly IMailService _mailService;
private readonly IProviderRepository _providerRepository;
private readonly IValidateSponsorshipCommand _validateSponsorshipCommand;
private readonly IOrganizationRepository _organizationRepository;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly ITaxRateRepository _taxRateRepository;
public UpcomingInvoiceHandler(
ILogger<StripeEventProcessor> logger,
IStripeEventService stripeEventService,
IUserService userService,
IStripeFacade stripeFacade,
IFeatureService featureService,
IMailService mailService,
IProviderRepository providerRepository,
IValidateSponsorshipCommand validateSponsorshipCommand,
IOrganizationRepository organizationRepository,
IStripeEventUtilityService stripeEventUtilityService,
ITaxRateRepository taxRateRepository)
{
_logger = logger;
_stripeEventService = stripeEventService;
_userService = userService;
_stripeFacade = stripeFacade;
_featureService = featureService;
_mailService = mailService;
_providerRepository = providerRepository;
_validateSponsorshipCommand = validateSponsorshipCommand;
_organizationRepository = organizationRepository;
_stripeEventUtilityService = stripeEventUtilityService;
_taxRateRepository = taxRateRepository;
}
/// <summary>
/// Handles the <see cref="HandledStripeWebhook.UpcomingInvoice"/> event type from Stripe.
/// </summary>
/// <param name="parsedEvent"></param>
/// <exception cref="Exception"></exception>
public async Task HandleAsync(Event parsedEvent)
{
var invoice = await _stripeEventService.GetInvoice(parsedEvent);
if (string.IsNullOrEmpty(invoice.SubscriptionId))
{
_logger.LogWarning("Received 'invoice.upcoming' Event with ID '{eventId}' that did not include a Subscription ID", parsedEvent.Id);
return;
}
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
if (subscription == null)
{
throw new Exception(
$"Received null Subscription from Stripe for ID '{invoice.SubscriptionId}' while processing Event with ID '{parsedEvent.Id}'");
}
var pm5766AutomaticTaxIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax);
if (pm5766AutomaticTaxIsEnabled)
{
var customerGetOptions = new CustomerGetOptions();
customerGetOptions.AddExpand("tax");
var customer = await _stripeFacade.GetCustomer(subscription.CustomerId, customerGetOptions);
if (!subscription.AutomaticTax.Enabled &&
customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported)
{
subscription = await _stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
DefaultTaxRates = [],
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
}
var updatedSubscription = pm5766AutomaticTaxIsEnabled
? subscription
: await VerifyCorrectTaxRateForChargeAsync(invoice, subscription);
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(updatedSubscription.Metadata);
var invoiceLineItemDescriptions = invoice.Lines.Select(i => i.Description).ToList();
if (organizationId.HasValue)
{
if (_stripeEventUtilityService.IsSponsoredSubscription(updatedSubscription))
{
await _validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
}
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
if (organization == null || !OrgPlanForInvoiceNotifications(organization))
{
return;
}
await SendEmails(new List<string> { organization.BillingEmail });
/*
* TODO: https://bitwarden.atlassian.net/browse/PM-4862
* Disabling this as part of a hot fix. It needs to check whether the organization
* belongs to a Reseller provider and only send an email to the organization owners if it does.
* It also requires a new email template as the current one contains too much billing information.
*/
// var ownerEmails = await _organizationRepository.GetOwnerEmailAddressesById(organization.Id);
// await SendEmails(ownerEmails);
}
else if (userId.HasValue)
{
var user = await _userService.GetUserByIdAsync(userId.Value);
if (user?.Premium == true)
{
await SendEmails(new List<string> { user.Email });
}
}
else if (providerId.HasValue)
{
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
if (provider == null)
{
_logger.LogError(
"Received invoice.Upcoming webhook ({EventID}) for Provider ({ProviderID}) that does not exist",
parsedEvent.Id,
providerId.Value);
return;
}
await SendEmails(new List<string> { provider.BillingEmail });
}
return;
/*
* Sends emails to the given email addresses.
*/
async Task SendEmails(IEnumerable<string> emails)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
if (invoice.NextPaymentAttempt.HasValue)
{
await _mailService.SendInvoiceUpcoming(
validEmails,
invoice.AmountDue / 100M,
invoice.NextPaymentAttempt.Value,
invoiceLineItemDescriptions,
true);
}
}
}
private async Task<Stripe.Subscription> VerifyCorrectTaxRateForChargeAsync(Invoice invoice, Stripe.Subscription subscription)
{
if (string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.Country) ||
string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.PostalCode))
{
return subscription;
}
var localBitwardenTaxRates = await _taxRateRepository.GetByLocationAsync(
new TaxRate()
{
Country = invoice.CustomerAddress.Country,
PostalCode = invoice.CustomerAddress.PostalCode
}
);
if (!localBitwardenTaxRates.Any())
{
return subscription;
}
var stripeTaxRate = await _stripeFacade.GetTaxRate(localBitwardenTaxRates.First().Id);
if (stripeTaxRate == null || subscription.DefaultTaxRates.Any(x => x == stripeTaxRate))
{
return subscription;
}
subscription.DefaultTaxRates = [stripeTaxRate];
var subscriptionOptions = new SubscriptionUpdateOptions { DefaultTaxRates = [stripeTaxRate.Id] };
subscription = await _stripeFacade.UpdateSubscription(subscription.Id, subscriptionOptions);
return subscription;
}
private static bool OrgPlanForInvoiceNotifications(Organization org) => StaticStore.GetPlan(org.PlanType).IsAnnual;
}

View File

@ -52,6 +52,21 @@ public class Startup
// Context
services.AddScoped<ICurrentContext, CurrentContext>();
//Handlers
services.AddScoped<IStripeEventUtilityService, StripeEventUtilityService>();
services.AddScoped<ISubscriptionDeletedHandler, SubscriptionDeletedHandler>();
services.AddScoped<ISubscriptionUpdatedHandler, SubscriptionUpdatedHandler>();
services.AddScoped<IUpcomingInvoiceHandler, UpcomingInvoiceHandler>();
services.AddScoped<IChargeSucceededHandler, ChargeSucceededHandler>();
services.AddScoped<IChargeRefundedHandler, ChargeRefundedHandler>();
services.AddScoped<ICustomerUpdatedHandler, CustomerUpdatedHandler>();
services.AddScoped<IInvoiceCreatedHandler, InvoiceCreatedHandler>();
services.AddScoped<IPaymentFailedHandler, PaymentFailedHandler>();
services.AddScoped<IPaymentMethodAttachedHandler, PaymentMethodAttachedHandler>();
services.AddScoped<IPaymentSucceededHandler, PaymentSucceededHandler>();
services.AddScoped<IInvoiceFinalizedHandler, InvoiceFinalizedHandler>();
services.AddScoped<IStripeEventProcessor, StripeEventProcessor>();
// Identity
services.AddCustomIdentityServices(globalSettings);
//services.AddPasswordlessIdentityServices<ReadOnlyDatabaseIdentityUserStore>(globalSettings);