2023-01-10 15:58:41 -05:00
|
|
|
|
using Bit.Billing.Constants;
|
2024-01-31 08:19:29 -05:00
|
|
|
|
using Bit.Billing.Models;
|
2023-10-11 15:57:51 -04:00
|
|
|
|
using Bit.Billing.Services;
|
2024-02-01 13:21:17 -05:00
|
|
|
|
using Bit.Core;
|
2023-11-29 09:18:08 +10:00
|
|
|
|
using Bit.Core.AdminConsole.Entities;
|
2024-05-09 09:09:23 -04:00
|
|
|
|
using Bit.Core.AdminConsole.Repositories;
|
2024-03-05 13:04:26 -05:00
|
|
|
|
using Bit.Core.Billing.Constants;
|
2023-05-16 16:21:57 +02:00
|
|
|
|
using Bit.Core.Context;
|
2021-02-22 15:35:16 -06:00
|
|
|
|
using Bit.Core.Enums;
|
2022-05-10 17:12:09 -04:00
|
|
|
|
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
2018-05-11 08:29:23 -04:00
|
|
|
|
using Bit.Core.Repositories;
|
|
|
|
|
|
using Bit.Core.Services;
|
2021-02-22 15:35:16 -06:00
|
|
|
|
using Bit.Core.Settings;
|
2023-04-18 14:05:17 +02:00
|
|
|
|
using Bit.Core.Tools.Enums;
|
|
|
|
|
|
using Bit.Core.Tools.Models.Business;
|
|
|
|
|
|
using Bit.Core.Tools.Services;
|
2019-08-09 23:56:26 -04:00
|
|
|
|
using Bit.Core.Utilities;
|
2023-08-28 09:22:07 -04:00
|
|
|
|
using Braintree;
|
|
|
|
|
|
using Braintree.Exceptions;
|
2017-04-26 16:14:15 -04:00
|
|
|
|
using Microsoft.AspNetCore.Mvc;
|
2023-01-10 15:58:41 -05:00
|
|
|
|
using Microsoft.Data.SqlClient;
|
2017-04-26 14:29:25 -04:00
|
|
|
|
using Microsoft.Extensions.Options;
|
2017-04-26 16:14:15 -04:00
|
|
|
|
using Stripe;
|
2023-08-28 09:22:07 -04:00
|
|
|
|
using Customer = Stripe.Customer;
|
2023-07-20 17:00:40 -04:00
|
|
|
|
using Event = Stripe.Event;
|
2024-01-31 08:19:29 -05:00
|
|
|
|
using JsonSerializer = System.Text.Json.JsonSerializer;
|
2023-08-28 09:22:07 -04:00
|
|
|
|
using Subscription = Stripe.Subscription;
|
2024-02-01 13:21:17 -05:00
|
|
|
|
using TaxRate = Bit.Core.Entities.TaxRate;
|
2023-08-28 09:22:07 -04:00
|
|
|
|
using Transaction = Bit.Core.Entities.Transaction;
|
|
|
|
|
|
using TransactionType = Bit.Core.Enums.TransactionType;
|
2017-03-18 18:52:44 -04:00
|
|
|
|
|
|
|
|
|
|
namespace Bit.Billing.Controllers;
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2017-03-18 18:52:44 -04:00
|
|
|
|
[Route("stripe")]
|
|
|
|
|
|
public class StripeController : Controller
|
|
|
|
|
|
{
|
|
|
|
|
|
private const string PremiumPlanId = "premium-annually";
|
2022-06-24 14:20:32 -07:00
|
|
|
|
private const string PremiumPlanIdAppStore = "premium-annually-app";
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2017-04-26 14:29:25 -04:00
|
|
|
|
private readonly BillingSettings _billingSettings;
|
2017-03-18 18:52:44 -04:00
|
|
|
|
private readonly IWebHostEnvironment _hostingEnvironment;
|
2017-04-26 16:14:15 -04:00
|
|
|
|
private readonly IOrganizationService _organizationService;
|
2017-03-18 18:52:44 -04:00
|
|
|
|
private readonly IValidateSponsorshipCommand _validateSponsorshipCommand;
|
2022-05-10 17:12:09 -04:00
|
|
|
|
private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand;
|
2018-05-11 08:29:23 -04:00
|
|
|
|
private readonly IOrganizationRepository _organizationRepository;
|
2019-02-01 23:04:51 -05:00
|
|
|
|
private readonly ITransactionRepository _transactionRepository;
|
2017-07-25 09:04:22 -04:00
|
|
|
|
private readonly IUserService _userService;
|
2019-02-22 09:31:05 -05:00
|
|
|
|
private readonly IMailService _mailService;
|
2017-03-18 18:52:44 -04:00
|
|
|
|
private readonly ILogger<StripeController> _logger;
|
2023-10-11 15:57:51 -04:00
|
|
|
|
private readonly BraintreeGateway _btGateway;
|
2020-07-15 12:38:45 -04:00
|
|
|
|
private readonly IReferenceEventService _referenceEventService;
|
2021-03-24 15:27:16 -04:00
|
|
|
|
private readonly ITaxRateRepository _taxRateRepository;
|
|
|
|
|
|
private readonly IUserRepository _userRepository;
|
2023-05-16 16:21:57 +02:00
|
|
|
|
private readonly ICurrentContext _currentContext;
|
2023-07-20 17:00:40 -04:00
|
|
|
|
private readonly GlobalSettings _globalSettings;
|
2023-10-11 15:57:51 -04:00
|
|
|
|
private readonly IStripeEventService _stripeEventService;
|
2023-10-23 13:46:29 -04:00
|
|
|
|
private readonly IStripeFacade _stripeFacade;
|
2024-02-01 13:21:17 -05:00
|
|
|
|
private readonly IFeatureService _featureService;
|
2024-05-09 09:09:23 -04:00
|
|
|
|
private readonly IProviderRepository _providerRepository;
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2017-03-18 18:52:44 -04:00
|
|
|
|
public StripeController(
|
|
|
|
|
|
GlobalSettings globalSettings,
|
2019-02-03 00:00:21 -05:00
|
|
|
|
IOptions<BillingSettings> billingSettings,
|
|
|
|
|
|
IWebHostEnvironment hostingEnvironment,
|
|
|
|
|
|
IOrganizationService organizationService,
|
|
|
|
|
|
IValidateSponsorshipCommand validateSponsorshipCommand,
|
2022-05-10 17:12:09 -04:00
|
|
|
|
IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand,
|
2018-05-11 08:29:23 -04:00
|
|
|
|
IOrganizationRepository organizationRepository,
|
2019-02-03 00:00:21 -05:00
|
|
|
|
ITransactionRepository transactionRepository,
|
2018-05-11 08:29:23 -04:00
|
|
|
|
IUserService userService,
|
2019-02-03 00:00:21 -05:00
|
|
|
|
IMailService mailService,
|
|
|
|
|
|
IReferenceEventService referenceEventService,
|
|
|
|
|
|
ILogger<StripeController> logger,
|
2021-03-24 15:27:16 -04:00
|
|
|
|
ITaxRateRepository taxRateRepository,
|
2023-05-16 16:21:57 +02:00
|
|
|
|
IUserRepository userRepository,
|
2023-10-11 15:57:51 -04:00
|
|
|
|
ICurrentContext currentContext,
|
2023-10-23 13:46:29 -04:00
|
|
|
|
IStripeEventService stripeEventService,
|
2024-02-01 13:21:17 -05:00
|
|
|
|
IStripeFacade stripeFacade,
|
2024-05-09 09:09:23 -04:00
|
|
|
|
IFeatureService featureService,
|
|
|
|
|
|
IProviderRepository providerRepository)
|
2017-03-18 18:52:44 -04:00
|
|
|
|
{
|
2017-04-26 14:29:25 -04:00
|
|
|
|
_billingSettings = billingSettings?.Value;
|
2020-01-10 08:47:58 -05:00
|
|
|
|
_hostingEnvironment = hostingEnvironment;
|
2017-04-26 16:14:15 -04:00
|
|
|
|
_organizationService = organizationService;
|
2022-05-10 17:12:09 -04:00
|
|
|
|
_validateSponsorshipCommand = validateSponsorshipCommand;
|
|
|
|
|
|
_organizationSponsorshipRenewCommand = organizationSponsorshipRenewCommand;
|
2018-05-11 08:29:23 -04:00
|
|
|
|
_organizationRepository = organizationRepository;
|
2019-02-01 23:04:51 -05:00
|
|
|
|
_transactionRepository = transactionRepository;
|
2017-07-25 09:04:22 -04:00
|
|
|
|
_userService = userService;
|
2019-02-03 00:00:21 -05:00
|
|
|
|
_mailService = mailService;
|
|
|
|
|
|
_referenceEventService = referenceEventService;
|
2021-03-24 15:27:16 -04:00
|
|
|
|
_taxRateRepository = taxRateRepository;
|
|
|
|
|
|
_userRepository = userRepository;
|
2019-02-03 00:00:21 -05:00
|
|
|
|
_logger = logger;
|
2023-10-11 15:57:51 -04:00
|
|
|
|
_btGateway = new BraintreeGateway
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2017-08-12 22:30:44 -04:00
|
|
|
|
Environment = globalSettings.Braintree.Production ?
|
2021-11-08 15:55:42 -05:00
|
|
|
|
Braintree.Environment.PRODUCTION : Braintree.Environment.SANDBOX,
|
|
|
|
|
|
MerchantId = globalSettings.Braintree.MerchantId,
|
|
|
|
|
|
PublicKey = globalSettings.Braintree.PublicKey,
|
2022-08-09 09:32:18 -04:00
|
|
|
|
PrivateKey = globalSettings.Braintree.PrivateKey
|
|
|
|
|
|
};
|
2023-05-16 16:21:57 +02:00
|
|
|
|
_currentContext = currentContext;
|
2023-07-20 17:00:40 -04:00
|
|
|
|
_globalSettings = globalSettings;
|
2023-10-11 15:57:51 -04:00
|
|
|
|
_stripeEventService = stripeEventService;
|
2023-10-23 13:46:29 -04:00
|
|
|
|
_stripeFacade = stripeFacade;
|
2024-02-01 13:21:17 -05:00
|
|
|
|
_featureService = featureService;
|
2024-05-09 09:09:23 -04:00
|
|
|
|
_providerRepository = providerRepository;
|
2017-08-12 22:30:44 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2017-03-18 18:52:44 -04:00
|
|
|
|
[HttpPost("webhook")]
|
2020-03-27 14:36:37 -04:00
|
|
|
|
public async Task<IActionResult> PostWebhook([FromQuery] string key)
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2019-02-22 09:31:05 -05:00
|
|
|
|
if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.StripeWebhookKey))
|
2017-04-26 16:14:15 -04:00
|
|
|
|
{
|
|
|
|
|
|
return new BadRequestResult();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
var parsedEvent = await TryParseEventFromRequestBodyAsync();
|
|
|
|
|
|
if (parsedEvent is null)
|
2022-08-29 15:53:48 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
return Ok();
|
2024-01-31 08:19:29 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (StripeConfiguration.ApiVersion != parsedEvent.ApiVersion)
|
|
|
|
|
|
{
|
|
|
|
|
|
_logger.LogWarning(
|
|
|
|
|
|
"Stripe {WebhookType} webhook's API version ({WebhookAPIVersion}) does not match SDK API Version ({SDKAPIVersion})",
|
|
|
|
|
|
parsedEvent.Type,
|
|
|
|
|
|
parsedEvent.ApiVersion,
|
|
|
|
|
|
StripeConfiguration.ApiVersion);
|
|
|
|
|
|
|
|
|
|
|
|
return new OkResult();
|
2017-04-26 16:14:15 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2022-05-31 10:55:56 -04:00
|
|
|
|
if (string.IsNullOrWhiteSpace(parsedEvent?.Id))
|
2022-08-29 15:53:48 -04:00
|
|
|
|
{
|
2022-05-31 10:55:56 -04:00
|
|
|
|
_logger.LogWarning("No event id.");
|
|
|
|
|
|
return new BadRequestResult();
|
2022-08-29 15:53:48 -04:00
|
|
|
|
}
|
2018-04-05 21:56:36 -04:00
|
|
|
|
|
2019-08-20 11:09:02 -04:00
|
|
|
|
if (_hostingEnvironment.IsProduction() && !parsedEvent.Livemode)
|
2022-08-29 15:53:48 -04:00
|
|
|
|
{
|
2017-08-12 22:16:42 -04:00
|
|
|
|
_logger.LogWarning("Getting test events in production.");
|
|
|
|
|
|
return new BadRequestResult();
|
2022-08-29 15:53:48 -04:00
|
|
|
|
}
|
2017-08-12 22:16:42 -04:00
|
|
|
|
|
2023-07-20 17:00:40 -04:00
|
|
|
|
// If the customer and server cloud regions don't match, early return 200 to avoid unnecessary errors
|
2023-10-11 15:57:51 -04:00
|
|
|
|
if (!await _stripeEventService.ValidateCloudRegion(parsedEvent))
|
2023-07-20 17:00:40 -04:00
|
|
|
|
{
|
|
|
|
|
|
return new OkResult();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
switch (parsedEvent.Type)
|
2022-08-29 14:53:16 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
case HandledStripeWebhook.SubscriptionDeleted:
|
|
|
|
|
|
{
|
|
|
|
|
|
await HandleCustomerSubscriptionDeletedEventAsync(parsedEvent);
|
|
|
|
|
|
return Ok();
|
|
|
|
|
|
}
|
|
|
|
|
|
case HandledStripeWebhook.SubscriptionUpdated:
|
|
|
|
|
|
{
|
|
|
|
|
|
await HandleCustomerSubscriptionUpdatedEventAsync(parsedEvent);
|
|
|
|
|
|
return Ok();
|
|
|
|
|
|
}
|
|
|
|
|
|
case HandledStripeWebhook.UpcomingInvoice:
|
|
|
|
|
|
{
|
|
|
|
|
|
await HandleUpcomingInvoiceEventAsync(parsedEvent);
|
|
|
|
|
|
return Ok();
|
|
|
|
|
|
}
|
|
|
|
|
|
case HandledStripeWebhook.ChargeSucceeded:
|
|
|
|
|
|
{
|
|
|
|
|
|
await HandleChargeSucceededEventAsync(parsedEvent);
|
|
|
|
|
|
return Ok();
|
|
|
|
|
|
}
|
|
|
|
|
|
case HandledStripeWebhook.ChargeRefunded:
|
|
|
|
|
|
{
|
|
|
|
|
|
await HandleChargeRefundedEventAsync(parsedEvent);
|
|
|
|
|
|
return Ok();
|
|
|
|
|
|
}
|
|
|
|
|
|
case HandledStripeWebhook.PaymentSucceeded:
|
|
|
|
|
|
{
|
|
|
|
|
|
await HandlePaymentSucceededEventAsync(parsedEvent);
|
|
|
|
|
|
return Ok();
|
|
|
|
|
|
}
|
|
|
|
|
|
case HandledStripeWebhook.PaymentFailed:
|
|
|
|
|
|
{
|
|
|
|
|
|
await HandlePaymentFailedEventAsync(parsedEvent);
|
|
|
|
|
|
return Ok();
|
|
|
|
|
|
}
|
|
|
|
|
|
case HandledStripeWebhook.InvoiceCreated:
|
|
|
|
|
|
{
|
|
|
|
|
|
await HandleInvoiceCreatedEventAsync(parsedEvent);
|
|
|
|
|
|
return Ok();
|
|
|
|
|
|
}
|
|
|
|
|
|
case HandledStripeWebhook.PaymentMethodAttached:
|
|
|
|
|
|
{
|
|
|
|
|
|
await HandlePaymentMethodAttachedAsync(parsedEvent);
|
|
|
|
|
|
return Ok();
|
|
|
|
|
|
}
|
|
|
|
|
|
case HandledStripeWebhook.CustomerUpdated:
|
|
|
|
|
|
{
|
|
|
|
|
|
await HandleCustomerUpdatedEventAsync(parsedEvent);
|
|
|
|
|
|
return Ok();
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
{
|
|
|
|
|
|
_logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
|
|
|
|
|
|
return Ok();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2022-08-29 15:53:48 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Handles the <see cref="HandledStripeWebhook.SubscriptionUpdated"/> event type from Stripe.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="parsedEvent"></param>
|
|
|
|
|
|
private async Task HandleCustomerSubscriptionUpdatedEventAsync(Event parsedEvent)
|
|
|
|
|
|
{
|
|
|
|
|
|
var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts"]);
|
2024-04-19 09:33:26 -04:00
|
|
|
|
var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);
|
2024-04-19 09:15:48 -04:00
|
|
|
|
|
|
|
|
|
|
switch (subscription.Status)
|
|
|
|
|
|
{
|
|
|
|
|
|
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired
|
|
|
|
|
|
when organizationId.HasValue:
|
2017-08-12 22:16:42 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
|
|
|
|
|
break;
|
2017-08-12 22:16:42 -04:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired:
|
2023-04-11 17:09:38 +01:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
if (!userId.HasValue)
|
2023-08-28 09:56:50 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
break;
|
2023-08-28 09:56:50 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
if (subscription.Status is StripeSubscriptionStatus.Unpaid &&
|
|
|
|
|
|
subscription.Items.Any(i => i.Price.Id is PremiumPlanId or PremiumPlanIdAppStore))
|
2023-08-28 09:56:50 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
await CancelSubscription(subscription.Id);
|
|
|
|
|
|
await VoidOpenInvoices(subscription.Id);
|
2023-08-28 09:56:50 -04:00
|
|
|
|
}
|
2023-04-11 17:09:38 +01:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
|
2023-04-11 17:09:38 +01:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
break;
|
2023-04-11 17:09:38 +01:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
case StripeSubscriptionStatus.Active when organizationId.HasValue:
|
2023-04-11 17:09:38 +01:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
await _organizationService.EnableAsync(organizationId.Value);
|
|
|
|
|
|
break;
|
2017-04-26 16:14:15 -04:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
case StripeSubscriptionStatus.Active:
|
2018-04-05 21:56:36 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
if (userId.HasValue)
|
Families for Enterprise (#1714)
* Create common test infrastructure project
* Add helpers to further type PlanTypes
* Enable testing of ASP.net MVC controllers
Controller properties have all kinds of validations in the background.
In general, we don't user properties on our Controllers, so the easiest
way to allow for Autofixture-based testing of our Controllers is to just
omit setting all properties on them.
* Workaround for broken MemberAutoDataAttribute
https://github.com/AutoFixture/AutoFixture/pull/1164 shows that only
the first test case is pulled for this attribute.
This is a workaround that populates the provided parameters, left to
right, using AutoFixture to populate any remaining.
* WIP: Organization sponsorship flow
* Add Attribute to use the Bit Autodata dependency chain
BitAutoDataAttribute is used to mark a Theory as autopopulating
parameters.
Extract common attribute methods to to a helper class. Cannot
inherit a common base, since both require inheriting from different
Xunit base classes to work.
* WIP: scaffolding for families for enterprise sponsorship flow
* Fix broken tests
* Create sponsorship offer (#1688)
* Initial db work (#1687)
* Add organization sponsorship databases to all providers
* Generalize create and update for database, specialize in code
* Add PlanSponsorshipType to db model
* Write valid json for test entries
* Initial scaffolding of emails (#1686)
* Initial scaffolding of emails
* Work on adding models for FamilyForEnterprise emails
* Switch verbage
* Put preliminary copy in emails
* Skip test
* Families for enterprise/stripe integrations (#1699)
* Add PlanSponsorshipType to static store
* Add sponsorship type to token and creates sponsorship
* PascalCase properties
* Require sponsorship for remove
* Create subscription sponsorship helper class
* Handle Sponsored subscription changes
* Add sponsorship id to subscription metadata
* Make sponsoring references nullable
This state indicates that a sponsorship has lapsed, but was not able to
be reverted for billing reasons
* WIP: Validate and remove subscriptions
* Update sponsorships on organization and org user delete
* Add friendly name to organization sponsorship
* Add sponsorship available boolean to orgDetails
* Add sponsorship service to DI
* Use userId to find org users
* Send f4e offer email
* Simplify names of f4e mail messages
* Fix Stripe org default tax rates
* Universal sponsorship redeem api
* Populate user in current context
* Add product type to organization details
* Use upgrade path to change sponsorship
Sponsorships need to be annual to match the GB add-on charge rate
* Use organization and auth to find organization sponsorship
* Add resend sponsorship offer api endpoint
* Fix double email send
* Fix sponsorship upgrade options
* Add is sponsored item to subscription response
* Add sponsorship validation to upcoming invoice webhook
* Add sponsorship validation to upcoming invoice webhook
* Fix organization delete sponsorship hooks
* Test org sponsorship service
* Fix sproc
* Create common test infrastructure project
* Add helpers to further type PlanTypes
* Enable testing of ASP.net MVC controllers
Controller properties have all kinds of validations in the background.
In general, we don't user properties on our Controllers, so the easiest
way to allow for Autofixture-based testing of our Controllers is to just
omit setting all properties on them.
* Workaround for broken MemberAutoDataAttribute
https://github.com/AutoFixture/AutoFixture/pull/1164 shows that only
the first test case is pulled for this attribute.
This is a workaround that populates the provided parameters, left to
right, using AutoFixture to populate any remaining.
* WIP: Organization sponsorship flow
* Add Attribute to use the Bit Autodata dependency chain
BitAutoDataAttribute is used to mark a Theory as autopopulating
parameters.
Extract common attribute methods to to a helper class. Cannot
inherit a common base, since both require inheriting from different
Xunit base classes to work.
* WIP: scaffolding for families for enterprise sponsorship flow
* Fix broken tests
* Create sponsorship offer (#1688)
* Initial db work (#1687)
* Add organization sponsorship databases to all providers
* Generalize create and update for database, specialize in code
* Add PlanSponsorshipType to db model
* Write valid json for test entries
* Initial scaffolding of emails (#1686)
* Initial scaffolding of emails
* Work on adding models for FamilyForEnterprise emails
* Switch verbage
* Put preliminary copy in emails
* Skip test
* Families for enterprise/stripe integrations (#1699)
* Add PlanSponsorshipType to static store
* Add sponsorship type to token and creates sponsorship
* PascalCase properties
* Require sponsorship for remove
* Create subscription sponsorship helper class
* Handle Sponsored subscription changes
* Add sponsorship id to subscription metadata
* Make sponsoring references nullable
This state indicates that a sponsorship has lapsed, but was not able to
be reverted for billing reasons
* WIP: Validate and remove subscriptions
* Update sponsorships on organization and org user delete
* Add friendly name to organization sponsorship
* Add sponsorship available boolean to orgDetails
* Add sponsorship service to DI
* Use userId to find org users
* Send f4e offer email
* Simplify names of f4e mail messages
* Fix Stripe org default tax rates
* Universal sponsorship redeem api
* Populate user in current context
* Add product type to organization details
* Use upgrade path to change sponsorship
Sponsorships need to be annual to match the GB add-on charge rate
* Use organization and auth to find organization sponsorship
* Add resend sponsorship offer api endpoint
* Fix double email send
* Fix sponsorship upgrade options
* Add is sponsored item to subscription response
* Add sponsorship validation to upcoming invoice webhook
* Add sponsorship validation to upcoming invoice webhook
* Fix organization delete sponsorship hooks
* Test org sponsorship service
* Fix sproc
* Fix build error
* Update emails
* Fix tests
* Skip local test
* Add newline
* Fix stripe subscription update
* Finish emails
* Skip test
* Fix unit tests
* Remove unused variable
* Fix unit tests
* Switch to handlebars ifs
* Remove ending email
* Remove reconfirmation template
* Switch naming convention
* Switch naming convention
* Fix migration
* Update copy and links
* Switch to using Guid in the method
* Remove unneeded css styles
* Add sql files to Sql.sqlproj
* Removed old comments
* Made name more verbose
* Fix SQL error
* Move unit tests to service
* Fix sp
* Revert "Move unit tests to service"
This reverts commit 1185bf3ec8ca36ccd75717ed2463adf8885159a6.
* Do repository validation in service layer
* Fix tests
* Fix merge conflicts and remove TODO
* Remove unneeded models
* Fix spacing and formatting
* Switch Org -> Organization
* Remove single use variables
* Switch method name
* Fix Controller
* Switch to obfuscating email
* Fix unit tests
Co-authored-by: Justin Baur <admin@justinbaur.com>
2021-11-19 16:25:06 -06:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
|
2018-05-11 08:29:23 -04:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
|
|
|
|
|
|
break;
|
2018-04-05 21:56:36 -04:00
|
|
|
|
}
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
2023-10-23 13:46:29 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
if (organizationId.HasValue)
|
|
|
|
|
|
{
|
|
|
|
|
|
await _organizationService.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
|
|
|
|
|
if (IsSponsoredSubscription(subscription))
|
2023-10-23 13:46:29 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
2023-10-23 13:46:29 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription);
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (userId.HasValue)
|
|
|
|
|
|
{
|
|
|
|
|
|
await _userService.UpdatePremiumExpirationAsync(userId.Value, subscription.CurrentPeriodEnd);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2023-10-23 13:46:29 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
/// <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;
|
|
|
|
|
|
}
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
var customerHasSecretsManagerTrial = subscription.Customer
|
|
|
|
|
|
?.Discount
|
|
|
|
|
|
?.Coupon
|
|
|
|
|
|
?.Id == "sm-standalone";
|
2024-02-01 13:21:17 -05:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
var subscriptionHasSecretsManagerTrial = subscription.Discount
|
|
|
|
|
|
?.Coupon
|
|
|
|
|
|
?.Id == "sm-standalone";
|
2023-10-23 13:46:29 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
if (customerHasSecretsManagerTrial)
|
|
|
|
|
|
{
|
|
|
|
|
|
await _stripeFacade.DeleteCustomerDiscount(subscription.CustomerId);
|
|
|
|
|
|
}
|
2023-10-23 13:46:29 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
if (subscriptionHasSecretsManagerTrial)
|
|
|
|
|
|
{
|
|
|
|
|
|
await _stripeFacade.DeleteSubscriptionDiscount(subscription.Id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2023-10-23 13:46:29 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Handles the <see cref="HandledStripeWebhook.SubscriptionDeleted"/> event type from Stripe.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="parsedEvent"></param>
|
|
|
|
|
|
private async Task HandleCustomerSubscriptionDeletedEventAsync(Event parsedEvent)
|
|
|
|
|
|
{
|
|
|
|
|
|
var subscription = await _stripeEventService.GetSubscription(parsedEvent, true);
|
2024-04-19 09:33:26 -04:00
|
|
|
|
var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);
|
2024-04-19 09:15:48 -04:00
|
|
|
|
var subCanceled = subscription.Status == StripeSubscriptionStatus.Canceled;
|
2023-10-23 13:46:29 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
if (!subCanceled)
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2018-05-11 08:29:23 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
if (organizationId.HasValue)
|
|
|
|
|
|
{
|
|
|
|
|
|
await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (userId.HasValue)
|
|
|
|
|
|
{
|
|
|
|
|
|
await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2023-10-23 13:46:29 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Handles the <see cref="HandledStripeWebhook.CustomerUpdated"/> event type from Stripe.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="parsedEvent"></param>
|
|
|
|
|
|
private async Task HandleCustomerUpdatedEventAsync(Event parsedEvent)
|
|
|
|
|
|
{
|
|
|
|
|
|
var customer = await _stripeEventService.GetCustomer(parsedEvent, true, ["subscriptions"]);
|
|
|
|
|
|
if (customer.Subscriptions == null || !customer.Subscriptions.Any())
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2023-10-23 13:46:29 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
var subscription = customer.Subscriptions.First();
|
2023-10-23 13:46:29 -04:00
|
|
|
|
|
2024-04-19 09:33:26 -04:00
|
|
|
|
var (organizationId, _, providerId) = GetIdsFromMetadata(subscription.Metadata);
|
2023-10-23 13:46:29 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
if (!organizationId.HasValue)
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2023-11-20 15:33:10 -05:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
|
|
|
|
|
|
organization.BillingEmail = customer.Email;
|
|
|
|
|
|
await _organizationRepository.ReplaceAsync(organization);
|
2023-10-23 13:46:29 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
await _referenceEventService.RaiseEventAsync(
|
|
|
|
|
|
new ReferenceEvent(ReferenceEventType.OrganizationEditedInStripe, organization, _currentContext));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Handles the <see cref="HandledStripeWebhook.InvoiceCreated"/> event type from Stripe.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="parsedEvent"></param>
|
|
|
|
|
|
private async Task HandleInvoiceCreatedEventAsync(Event parsedEvent)
|
|
|
|
|
|
{
|
|
|
|
|
|
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
|
|
|
|
|
if (invoice.Paid || !ShouldAttemptToPayInvoice(invoice))
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
|
|
|
|
|
|
await AttemptToPayInvoiceAsync(invoice);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Handles the <see cref="HandledStripeWebhook.PaymentSucceeded"/> event type from Stripe.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="parsedEvent"></param>
|
|
|
|
|
|
private async Task HandlePaymentSucceededEventAsync(Event parsedEvent)
|
|
|
|
|
|
{
|
|
|
|
|
|
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
|
|
|
|
|
if (!invoice.Paid || invoice.BillingReason != "subscription_create")
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
|
|
|
|
|
|
if (subscription?.Status != StripeSubscriptionStatus.Active)
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2022-08-29 14:53:16 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
if (DateTime.UtcNow - invoice.Created < TimeSpan.FromMinutes(1))
|
|
|
|
|
|
{
|
|
|
|
|
|
await Task.Delay(5000);
|
|
|
|
|
|
}
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2024-04-19 09:33:26 -04:00
|
|
|
|
var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);
|
2024-05-09 09:09:23 -04:00
|
|
|
|
|
|
|
|
|
|
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)
|
2024-04-19 09:15:48 -04:00
|
|
|
|
{
|
|
|
|
|
|
if (!subscription.Items.Any(i =>
|
|
|
|
|
|
StaticStore.Plans.Any(p => p.PasswordManager.StripePlanId == i.Plan.Id)))
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
return;
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
2019-02-01 23:04:51 -05:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
await _organizationService.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
|
|
|
|
|
|
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
|
|
|
|
|
|
|
|
|
|
|
|
await _referenceEventService.RaiseEventAsync(
|
|
|
|
|
|
new ReferenceEvent(ReferenceEventType.Rebilled, organization, _currentContext)
|
2019-02-15 16:18:53 -05:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
PlanName = organization?.Plan,
|
|
|
|
|
|
PlanType = organization?.PlanType,
|
|
|
|
|
|
Seats = organization?.Seats,
|
|
|
|
|
|
Storage = organization?.MaxStorageGb,
|
2019-02-15 16:18:53 -05:00
|
|
|
|
});
|
2024-04-19 09:15:48 -04:00
|
|
|
|
}
|
|
|
|
|
|
else if (userId.HasValue)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (subscription.Items.All(i => i.Plan.Id != PremiumPlanId))
|
2022-08-29 15:53:48 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
return;
|
2019-02-16 22:08:04 -05:00
|
|
|
|
}
|
2022-08-29 14:53:16 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
var user = await _userRepository.GetByIdAsync(userId.Value);
|
|
|
|
|
|
await _referenceEventService.RaiseEventAsync(
|
|
|
|
|
|
new ReferenceEvent(ReferenceEventType.Rebilled, user, _currentContext)
|
|
|
|
|
|
{
|
|
|
|
|
|
PlanName = PremiumPlanId,
|
|
|
|
|
|
Storage = user?.MaxStorageGb,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Handles the <see cref="HandledStripeWebhook.ChargeRefunded"/> event type from Stripe.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="parsedEvent"></param>
|
|
|
|
|
|
private async Task HandleChargeRefundedEventAsync(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) = await GetEntityIdsFromChargeAsync(charge);
|
|
|
|
|
|
var tx = FromChargeToTransaction(charge, organizationId, userId);
|
|
|
|
|
|
try
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
parentTransaction = await _transactionRepository.CreateAsync(tx);
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
catch (SqlException e) when (e.Number == 547) // FK constraint violation
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
_logger.LogWarning(
|
|
|
|
|
|
"Charge refund could not create transaction as entity may have been deleted. {ChargeId}",
|
|
|
|
|
|
charge.Id);
|
|
|
|
|
|
return;
|
2019-02-01 23:04:51 -05:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
}
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
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)
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
continue;
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
await _transactionRepository.CreateAsync(new Transaction
|
2019-02-01 23:04:51 -05:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
Amount = refund.Amount / 100M,
|
|
|
|
|
|
CreationDate = refund.Created,
|
|
|
|
|
|
OrganizationId = parentTransaction.OrganizationId,
|
|
|
|
|
|
UserId = parentTransaction.UserId,
|
|
|
|
|
|
Type = TransactionType.Refund,
|
|
|
|
|
|
Gateway = GatewayType.Stripe,
|
|
|
|
|
|
GatewayId = refund.Id,
|
|
|
|
|
|
PaymentMethodType = parentTransaction.PaymentMethodType,
|
|
|
|
|
|
Details = parentTransaction.Details
|
|
|
|
|
|
});
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Handles the <see cref="HandledStripeWebhook.ChargeSucceeded"/> event type from Stripe.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="parsedEvent"></param>
|
|
|
|
|
|
private async Task HandleChargeSucceededEventAsync(Event parsedEvent)
|
|
|
|
|
|
{
|
|
|
|
|
|
var charge = await _stripeEventService.GetCharge(parsedEvent);
|
|
|
|
|
|
var existingTransaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.Stripe, charge.Id);
|
|
|
|
|
|
if (existingTransaction is not null)
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
_logger.LogInformation("Charge success already processed. {ChargeId}", charge.Id);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
var (organizationId, userId) = await GetEntityIdsFromChargeAsync(charge);
|
|
|
|
|
|
if (!organizationId.HasValue && !userId.HasValue)
|
|
|
|
|
|
{
|
|
|
|
|
|
_logger.LogWarning("Charge success has no subscriber ids. {ChargeId}", charge.Id);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
var transaction = FromChargeToTransaction(charge, organizationId, userId);
|
|
|
|
|
|
if (!transaction.PaymentMethodType.HasValue)
|
|
|
|
|
|
{
|
|
|
|
|
|
_logger.LogWarning("Charge success from unsupported source/method. {ChargeId}", charge.Id);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2019-02-01 23:04:51 -05:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2019-02-01 23:04:51 -05:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Handles the <see cref="HandledStripeWebhook.UpcomingInvoice"/> event type from Stripe.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="parsedEvent"></param>
|
|
|
|
|
|
/// <exception cref="Exception"></exception>
|
|
|
|
|
|
private async Task HandleUpcomingInvoiceEventAsync(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
|
2019-02-01 23:04:51 -05:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
DefaultTaxRates = [],
|
|
|
|
|
|
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
2019-02-01 23:29:12 -05:00
|
|
|
|
});
|
2019-02-01 23:04:51 -05:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var updatedSubscription = pm5766AutomaticTaxIsEnabled
|
|
|
|
|
|
? subscription
|
|
|
|
|
|
: await VerifyCorrectTaxRateForCharge(invoice, subscription);
|
|
|
|
|
|
|
2024-04-19 09:33:26 -04:00
|
|
|
|
var (organizationId, userId, providerId) = GetIdsFromMetadata(updatedSubscription.Metadata);
|
2024-04-19 09:15:48 -04:00
|
|
|
|
|
|
|
|
|
|
var invoiceLineItemDescriptions = invoice.Lines.Select(i => i.Description).ToList();
|
|
|
|
|
|
|
|
|
|
|
|
if (organizationId.HasValue)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (IsSponsoredSubscription(updatedSubscription))
|
|
|
|
|
|
{
|
|
|
|
|
|
await _validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
|
|
|
|
|
|
|
|
|
|
|
|
if (organization == null || !OrgPlanForInvoiceNotifications(organization))
|
2022-08-29 14:53:16 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
return;
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
|
|
|
|
|
|
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);
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
else if (userId.HasValue)
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
var user = await _userService.GetUserByIdAsync(userId.Value);
|
2019-09-02 08:41:06 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
if (user?.Premium == true)
|
|
|
|
|
|
{
|
|
|
|
|
|
await SendEmails(new List<string> { user.Email });
|
2019-08-09 23:56:26 -04:00
|
|
|
|
}
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* Sends emails to the given email addresses.
|
|
|
|
|
|
*/
|
|
|
|
|
|
async Task SendEmails(IEnumerable<string> emails)
|
2019-02-22 09:31:05 -05:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
|
|
|
|
|
|
|
|
|
|
|
if (invoice.NextPaymentAttempt.HasValue)
|
|
|
|
|
|
{
|
|
|
|
|
|
await _mailService.SendInvoiceUpcoming(
|
|
|
|
|
|
validEmails,
|
|
|
|
|
|
invoice.AmountDue / 100M,
|
|
|
|
|
|
invoice.NextPaymentAttempt.Value,
|
|
|
|
|
|
invoiceLineItemDescriptions,
|
|
|
|
|
|
true);
|
|
|
|
|
|
}
|
2022-08-29 15:53:48 -04:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Gets the organization or user ID from the metadata of a Stripe Charge object.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="charge"></param>
|
|
|
|
|
|
/// <returns></returns>
|
|
|
|
|
|
private async Task<(Guid?, Guid?)> GetEntityIdsFromChargeAsync(Charge charge)
|
|
|
|
|
|
{
|
|
|
|
|
|
Guid? organizationId = null;
|
|
|
|
|
|
Guid? userId = null;
|
2024-04-19 09:33:26 -04:00
|
|
|
|
Guid? providerId = null;
|
2024-04-19 09:15:48 -04:00
|
|
|
|
|
|
|
|
|
|
if (charge.InvoiceId != null)
|
2022-08-29 15:53:48 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
var invoice = await _stripeFacade.GetInvoice(charge.InvoiceId);
|
|
|
|
|
|
if (invoice?.SubscriptionId != null)
|
2022-08-29 15:53:48 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
|
2024-04-19 09:33:26 -04:00
|
|
|
|
(organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata);
|
2019-02-22 09:31:05 -05:00
|
|
|
|
}
|
2018-04-05 21:56:36 -04:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
|
|
|
|
|
|
if (organizationId.HasValue || userId.HasValue)
|
2023-08-28 09:56:50 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
return (organizationId, userId);
|
2023-08-28 09:56:50 -04:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
|
|
|
|
|
|
var subscriptions = await _stripeFacade.ListSubscriptions(new SubscriptionListOptions
|
2023-07-20 17:00:40 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
Customer = charge.CustomerId
|
|
|
|
|
|
});
|
2023-08-28 09:22:07 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
foreach (var subscription in subscriptions)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (subscription.Status is StripeSubscriptionStatus.Canceled or StripeSubscriptionStatus.IncompleteExpired)
|
2023-08-28 09:22:07 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
continue;
|
2023-08-28 09:22:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-04-19 09:33:26 -04:00
|
|
|
|
(organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata);
|
2023-07-20 17:00:40 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
if (organizationId.HasValue || userId.HasValue)
|
2023-10-11 15:57:51 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
return (organizationId, userId);
|
2023-10-11 15:57:51 -04:00
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
}
|
2023-08-28 09:22:07 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
return (null, null);
|
|
|
|
|
|
}
|
2023-08-28 09:22:07 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Converts a Stripe Charge object to a Bitwarden Transaction object.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="charge"></param>
|
|
|
|
|
|
/// <param name="organizationId"></param>
|
|
|
|
|
|
/// <param name="userId"></param>
|
|
|
|
|
|
/// <returns></returns>
|
|
|
|
|
|
private static Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId)
|
|
|
|
|
|
{
|
|
|
|
|
|
var transaction = new Transaction
|
|
|
|
|
|
{
|
|
|
|
|
|
Amount = charge.Amount / 100M,
|
|
|
|
|
|
CreationDate = charge.Created,
|
|
|
|
|
|
OrganizationId = organizationId,
|
|
|
|
|
|
UserId = userId,
|
|
|
|
|
|
Type = TransactionType.Charge,
|
|
|
|
|
|
Gateway = GatewayType.Stripe,
|
|
|
|
|
|
GatewayId = charge.Id
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
switch (charge.Source)
|
2023-08-28 09:22:07 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2023-08-28 09:22:07 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
return transaction;
|
2023-07-20 17:00:40 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Handles the <see cref="HandledStripeWebhook.PaymentMethodAttached"/> event type from Stripe.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="parsedEvent"></param>
|
|
|
|
|
|
private async Task HandlePaymentMethodAttachedAsync(Event parsedEvent)
|
2023-08-28 09:56:50 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent);
|
2023-08-28 09:56:50 -04:00
|
|
|
|
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,
|
2024-03-05 13:04:26 -05:00
|
|
|
|
Expand = ["data.latest_invoice"]
|
2023-08-28 09:56:50 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
StripeList<Subscription> unpaidSubscriptions;
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
2024-02-01 13:21:17 -05:00
|
|
|
|
unpaidSubscriptions = await _stripeFacade.ListSubscriptions(subscriptionListOptions);
|
2023-08-28 09:56:50 -04:00
|
|
|
|
}
|
|
|
|
|
|
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 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-04-19 09:33:26 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Gets the organizationId, userId, or providerId from the metadata of a Stripe Subscription object.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="metadata"></param>
|
|
|
|
|
|
/// <returns></returns>
|
|
|
|
|
|
private static Tuple<Guid?, Guid?, Guid?> GetIdsFromMetadata(Dictionary<string, string> metadata)
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2024-04-19 09:33:26 -04:00
|
|
|
|
if (metadata == null || metadata.Count == 0)
|
2017-04-26 16:14:15 -04:00
|
|
|
|
{
|
2024-04-19 09:33:26 -04:00
|
|
|
|
return new Tuple<Guid?, Guid?, Guid?>(null, null, null);
|
2022-08-29 14:53:16 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-04-19 09:33:26 -04:00
|
|
|
|
metadata.TryGetValue("organizationId", out var orgIdString);
|
|
|
|
|
|
metadata.TryGetValue("userId", out var userIdString);
|
|
|
|
|
|
metadata.TryGetValue("providerId", out var providerIdString);
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2024-04-19 09:33:26 -04:00
|
|
|
|
orgIdString ??= metadata.FirstOrDefault(x =>
|
|
|
|
|
|
x.Key.Equals("organizationId", StringComparison.OrdinalIgnoreCase)).Value;
|
2024-03-05 13:04:26 -05:00
|
|
|
|
|
2024-04-19 09:33:26 -04:00
|
|
|
|
userIdString ??= metadata.FirstOrDefault(x =>
|
|
|
|
|
|
x.Key.Equals("userId", StringComparison.OrdinalIgnoreCase)).Value;
|
2024-03-05 13:04:26 -05:00
|
|
|
|
|
2024-04-19 09:33:26 -04:00
|
|
|
|
providerIdString ??= metadata.FirstOrDefault(x =>
|
|
|
|
|
|
x.Key.Equals("providerId", StringComparison.OrdinalIgnoreCase)).Value;
|
2024-03-05 13:04:26 -05:00
|
|
|
|
|
2024-04-19 09:33:26 -04:00
|
|
|
|
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);
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2024-04-19 09:33:26 -04:00
|
|
|
|
return new Tuple<Guid?, Guid?, Guid?>(organizationId, userId, providerId);
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
2018-04-05 21:56:36 -04:00
|
|
|
|
|
2023-11-01 08:43:35 -04:00
|
|
|
|
private static bool OrgPlanForInvoiceNotifications(Organization org) => StaticStore.GetPlan(org.PlanType).IsAnnual;
|
2019-02-03 00:00:21 -05:00
|
|
|
|
|
2023-08-28 09:56:50 -04:00
|
|
|
|
private async Task<bool> AttemptToPayInvoiceAsync(Invoice invoice, bool attemptToPayWithStripe = false)
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2024-02-01 13:21:17 -05:00
|
|
|
|
var customer = await _stripeFacade.GetCustomer(invoice.CustomerId);
|
2023-08-28 09:56:50 -04:00
|
|
|
|
|
|
|
|
|
|
if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false)
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2019-09-23 09:03:18 -04:00
|
|
|
|
return await AttemptToPayInvoiceWithBraintreeAsync(invoice, customer);
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
2023-08-28 09:56:50 -04:00
|
|
|
|
|
|
|
|
|
|
if (attemptToPayWithStripe)
|
|
|
|
|
|
{
|
|
|
|
|
|
return await AttemptToPayInvoiceWithStripeAsync(invoice);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2019-09-23 09:03:18 -04:00
|
|
|
|
return false;
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
2019-09-23 09:03:18 -04:00
|
|
|
|
|
2019-02-03 00:00:21 -05:00
|
|
|
|
private async Task<bool> AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice, Customer customer)
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2023-08-28 09:22:07 -04:00
|
|
|
|
_logger.LogDebug("Attempting to pay invoice with Braintree");
|
2020-01-17 21:11:48 -05:00
|
|
|
|
if (!customer?.Metadata?.ContainsKey("btCustomerId") ?? true)
|
2019-02-03 00:00:21 -05:00
|
|
|
|
{
|
2023-08-28 09:22:07 -04:00
|
|
|
|
_logger.LogWarning(
|
|
|
|
|
|
"Attempted to pay invoice with Braintree but btCustomerId wasn't on Stripe customer metadata");
|
2020-01-17 21:11:48 -05:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-02-01 13:21:17 -05:00
|
|
|
|
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
|
2024-04-19 09:33:26 -04:00
|
|
|
|
var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata);
|
|
|
|
|
|
if (!organizationId.HasValue && !userId.HasValue)
|
2022-08-29 14:53:16 -04:00
|
|
|
|
{
|
2023-08-28 09:22:07 -04:00
|
|
|
|
_logger.LogWarning(
|
|
|
|
|
|
"Attempted to pay invoice with Braintree but Stripe subscription metadata didn't contain either a organizationId or userId");
|
2019-02-03 00:00:21 -05:00
|
|
|
|
return false;
|
2022-08-29 15:53:48 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-04-19 09:33:26 -04:00
|
|
|
|
var orgTransaction = organizationId.HasValue;
|
2020-01-17 21:11:48 -05:00
|
|
|
|
var btObjIdField = orgTransaction ? "organization_id" : "user_id";
|
2024-04-19 09:33:26 -04:00
|
|
|
|
var btObjId = organizationId ?? userId.Value;
|
|
|
|
|
|
var btInvoiceAmount = invoice.AmountDue / 100M;
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2019-02-03 00:00:21 -05:00
|
|
|
|
var existingTransactions = orgTransaction ?
|
2024-04-19 09:33:26 -04:00
|
|
|
|
await _transactionRepository.GetManyByOrganizationIdAsync(organizationId.Value) :
|
|
|
|
|
|
await _transactionRepository.GetManyByUserIdAsync(userId.Value);
|
2020-01-17 21:11:48 -05:00
|
|
|
|
var duplicateTimeSpan = TimeSpan.FromHours(24);
|
|
|
|
|
|
var now = DateTime.UtcNow;
|
|
|
|
|
|
var duplicateTransaction = existingTransactions?
|
|
|
|
|
|
.FirstOrDefault(t => (now - t.CreationDate) < duplicateTimeSpan);
|
2020-03-27 14:36:37 -04:00
|
|
|
|
if (duplicateTransaction != null)
|
2022-08-29 15:53:48 -04:00
|
|
|
|
{
|
2020-01-17 21:11:48 -05:00
|
|
|
|
_logger.LogWarning("There is already a recent PayPal transaction ({0}). " +
|
2019-02-03 00:00:21 -05:00
|
|
|
|
"Do not charge again to prevent possible duplicate.", duplicateTransaction.GatewayId);
|
|
|
|
|
|
return false;
|
2022-08-29 15:53:48 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2023-08-28 09:22:07 -04:00
|
|
|
|
Result<Braintree.Transaction> transactionResult;
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
transactionResult = await _btGateway.Transaction.SaleAsync(
|
|
|
|
|
|
new Braintree.TransactionRequest
|
2019-02-03 00:00:21 -05:00
|
|
|
|
{
|
2023-08-28 09:22:07 -04:00
|
|
|
|
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>
|
2019-02-03 00:00:21 -05:00
|
|
|
|
{
|
2023-08-28 09:22:07 -04:00
|
|
|
|
[btObjIdField] = btObjId.ToString(),
|
|
|
|
|
|
["region"] = _globalSettings.BaseServiceUri.CloudRegion
|
2019-02-03 00:00:21 -05:00
|
|
|
|
}
|
2023-08-28 09:22:07 -04:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2022-08-29 14:53:16 -04:00
|
|
|
|
if (!transactionResult.IsSuccess())
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2019-02-03 00:05:35 -05:00
|
|
|
|
if (invoice.AttemptCount < 4)
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2022-08-29 14:53:16 -04:00
|
|
|
|
await _mailService.SendPaymentFailedAsync(customer.Email, btInvoiceAmount, true);
|
2019-02-03 00:05:35 -05:00
|
|
|
|
}
|
2019-09-23 09:03:18 -04:00
|
|
|
|
return false;
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
2022-08-29 15:53:48 -04:00
|
|
|
|
|
2019-02-03 00:05:35 -05:00
|
|
|
|
try
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2024-02-01 13:21:17 -05:00
|
|
|
|
await _stripeFacade.UpdateInvoice(invoice.Id, new InvoiceUpdateOptions
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2019-02-03 00:05:35 -05:00
|
|
|
|
Metadata = new Dictionary<string, string>
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2019-02-03 00:05:35 -05:00
|
|
|
|
["btTransactionId"] = transactionResult.Target.Id,
|
|
|
|
|
|
["btPayPalTransactionId"] =
|
|
|
|
|
|
transactionResult.Target.PayPalDetails?.AuthorizationId
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2024-02-01 13:21:17 -05:00
|
|
|
|
await _stripeFacade.PayInvoice(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true });
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
2020-03-27 14:36:37 -04:00
|
|
|
|
catch (Exception e)
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2019-02-03 00:05:35 -05:00
|
|
|
|
await _btGateway.Transaction.RefundAsync(transactionResult.Target.Id);
|
2020-03-27 14:36:37 -04:00
|
|
|
|
if (e.Message.Contains("Invoice is already paid"))
|
2019-02-03 00:00:21 -05:00
|
|
|
|
{
|
2024-02-01 13:21:17 -05:00
|
|
|
|
await _stripeFacade.UpdateInvoice(invoice.Id, new InvoiceUpdateOptions
|
2019-02-03 00:00:21 -05:00
|
|
|
|
{
|
2019-02-03 00:05:35 -05:00
|
|
|
|
Metadata = invoice.Metadata
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2020-03-27 14:36:37 -04:00
|
|
|
|
else
|
2019-02-03 00:05:35 -05:00
|
|
|
|
{
|
2021-12-16 15:35:07 -05:00
|
|
|
|
throw;
|
2019-02-03 00:05:35 -05:00
|
|
|
|
}
|
2019-02-03 00:00:21 -05:00
|
|
|
|
}
|
2019-08-20 11:09:02 -04:00
|
|
|
|
|
2019-02-03 00:00:21 -05:00
|
|
|
|
return true;
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2023-08-28 09:56:50 -04:00
|
|
|
|
private async Task<bool> AttemptToPayInvoiceWithStripeAsync(Invoice invoice)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
2024-02-01 13:21:17 -05:00
|
|
|
|
await _stripeFacade.PayInvoice(invoice.Id);
|
2023-08-28 09:56:50 -04:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception e)
|
|
|
|
|
|
{
|
|
|
|
|
|
_logger.LogWarning(
|
|
|
|
|
|
e,
|
|
|
|
|
|
"Exception occurred while trying to pay Stripe invoice with Id: {InvoiceId}",
|
|
|
|
|
|
invoice.Id);
|
|
|
|
|
|
|
|
|
|
|
|
throw;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
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
|
|
|
|
|
|
};
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2024-02-01 13:21:17 -05:00
|
|
|
|
private async Task<Subscription> VerifyCorrectTaxRateForCharge(Invoice invoice, 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-03-05 13:04:26 -05:00
|
|
|
|
subscription.DefaultTaxRates = [stripeTaxRate];
|
2024-02-01 13:21:17 -05:00
|
|
|
|
|
2024-03-05 13:04:26 -05:00
|
|
|
|
var subscriptionOptions = new SubscriptionUpdateOptions { DefaultTaxRates = [stripeTaxRate.Id] };
|
2024-02-01 13:21:17 -05:00
|
|
|
|
subscription = await _stripeFacade.UpdateSubscription(subscription.Id, subscriptionOptions);
|
|
|
|
|
|
|
|
|
|
|
|
return subscription;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2019-09-23 09:03:18 -04:00
|
|
|
|
private static bool IsSponsoredSubscription(Subscription subscription) =>
|
|
|
|
|
|
StaticStore.SponsoredPlans.Any(p => p.StripePlanId == subscription.Id);
|
2022-08-29 15:53:48 -04:00
|
|
|
|
|
2024-04-19 09:15:48 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Handles the <see cref="HandledStripeWebhook.PaymentFailed"/> event type from Stripe.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <param name="parsedEvent"></param>
|
|
|
|
|
|
private async Task HandlePaymentFailedEventAsync(Event parsedEvent)
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
|
|
|
|
|
if (invoice.Paid || invoice.AttemptCount <= 1 || !ShouldAttemptToPayInvoice(invoice))
|
2022-08-29 14:53:16 -04:00
|
|
|
|
{
|
2024-04-19 09:15:48 -04:00
|
|
|
|
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 PremiumPlanId or PremiumPlanIdAppStore))
|
|
|
|
|
|
{
|
|
|
|
|
|
await AttemptToPayInvoiceAsync(invoice);
|
2022-08-29 14:53:16 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2024-02-01 13:21:17 -05:00
|
|
|
|
private async Task CancelSubscription(string subscriptionId) =>
|
|
|
|
|
|
await _stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions());
|
2022-08-29 16:06:55 -04:00
|
|
|
|
|
2022-05-31 10:55:56 -04:00
|
|
|
|
private async Task VoidOpenInvoices(string subscriptionId)
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2019-02-15 16:18:53 -05:00
|
|
|
|
var options = new InvoiceListOptions
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2023-08-28 09:56:50 -04:00
|
|
|
|
Status = StripeInvoiceStatus.Open,
|
2019-09-23 09:03:18 -04:00
|
|
|
|
Subscription = subscriptionId
|
2022-08-29 16:06:55 -04:00
|
|
|
|
};
|
2024-02-01 13:21:17 -05:00
|
|
|
|
var invoices = await _stripeFacade.ListInvoices(options);
|
2022-05-31 10:55:56 -04:00
|
|
|
|
foreach (var invoice in invoices)
|
2022-08-29 16:06:55 -04:00
|
|
|
|
{
|
2024-02-01 13:21:17 -05:00
|
|
|
|
await _stripeFacade.VoidInvoice(invoice.Id);
|
2022-08-29 16:06:55 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-01-31 08:19:29 -05:00
|
|
|
|
|
|
|
|
|
|
private string PickStripeWebhookSecret(string webhookBody)
|
|
|
|
|
|
{
|
|
|
|
|
|
var versionContainer = JsonSerializer.Deserialize<StripeWebhookVersionContainer>(webhookBody);
|
|
|
|
|
|
|
|
|
|
|
|
return versionContainer.ApiVersion switch
|
|
|
|
|
|
{
|
|
|
|
|
|
"2023-10-16" => _billingSettings.StripeWebhookSecret20231016,
|
|
|
|
|
|
"2022-08-01" => _billingSettings.StripeWebhookSecret,
|
|
|
|
|
|
_ => HandleDefault(versionContainer.ApiVersion)
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
string HandleDefault(string version)
|
|
|
|
|
|
{
|
|
|
|
|
|
_logger.LogWarning(
|
|
|
|
|
|
"Stripe webhook contained an recognized 'api_version': {ApiVersion}",
|
|
|
|
|
|
version);
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-04-19 09:15:48 -04:00
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Attempts to pick the Stripe webhook secret from the JSON payload.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
/// <returns>Returns the event if the event was parsed, otherwise, null</returns>
|
|
|
|
|
|
private async Task<Event> TryParseEventFromRequestBodyAsync()
|
|
|
|
|
|
{
|
|
|
|
|
|
using var sr = new StreamReader(HttpContext.Request.Body);
|
|
|
|
|
|
|
|
|
|
|
|
var json = await sr.ReadToEndAsync();
|
|
|
|
|
|
var webhookSecret = PickStripeWebhookSecret(json);
|
|
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrEmpty(webhookSecret))
|
|
|
|
|
|
{
|
|
|
|
|
|
_logger.LogDebug("Unable to parse event. No webhook secret.");
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var parsedEvent = EventUtility.ConstructEvent(
|
|
|
|
|
|
json,
|
|
|
|
|
|
Request.Headers["Stripe-Signature"],
|
|
|
|
|
|
webhookSecret,
|
|
|
|
|
|
throwOnApiVersionMismatch: false);
|
|
|
|
|
|
|
|
|
|
|
|
if (parsedEvent is not null)
|
|
|
|
|
|
{
|
|
|
|
|
|
return parsedEvent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_logger.LogDebug("Stripe-Signature request header doesn't match configured Stripe webhook secret");
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2017-03-18 18:52:44 -04:00
|
|
|
|
}
|