mirror of
https://github.com/bitwarden/server.git
synced 2024-11-22 12:15:36 +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:
parent
26575856e6
commit
f045d06a9c
File diff suppressed because it is too large
Load Diff
12
src/Billing/Services/IStripeEventProcessor.cs
Normal file
12
src/Billing/Services/IStripeEventProcessor.cs
Normal 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);
|
||||
}
|
67
src/Billing/Services/IStripeEventUtilityService.cs
Normal file
67
src/Billing/Services/IStripeEventUtilityService.cs
Normal 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";
|
||||
|
||||
}
|
67
src/Billing/Services/IStripeWebhookHandler.cs
Normal file
67
src/Billing/Services/IStripeWebhookHandler.cs
Normal 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;
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
52
src/Billing/Services/Implementations/PaymentFailedHandler.cs
Normal file
52
src/Billing/Services/Implementations/PaymentFailedHandler.cs
Normal 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
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
171
src/Billing/Services/Implementations/PaymentSucceededHandler.cs
Normal file
171
src/Billing/Services/Implementations/PaymentSucceededHandler.cs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
89
src/Billing/Services/Implementations/StripeEventProcessor.cs
Normal file
89
src/Billing/Services/Implementations/StripeEventProcessor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
215
src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs
Normal file
215
src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs
Normal 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;
|
||||
}
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user