From 5dbce33f749ca33ed31dbabcf0ae664b800f705e Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 6 Nov 2025 13:21:29 -0500 Subject: [PATCH] [PM-24273] Milestone 2C (#6544) * feat(billing): add mjml template and updated templates * feat(billing): update maileservices * feat(billing): add milestone2 discount * feat(billing): add milestone 2 updates and stripe constants * tests(billing): add handler tests * fix(billing): update mailer view and templates * fix(billing): revert mailservice changes * fix(billing): swap mailer service in handler * test(billing): update handler tests --- .../Implementations/UpcomingInvoiceHandler.cs | 81 +- src/Core/Billing/Constants/StripeConstants.cs | 1 + .../Mjml/emails/invoice-upcoming.mjml | 27 + .../UpdatedInvoiceUpcomingView.cs | 10 + .../UpdatedInvoiceUpcomingView.html.hbs | 30 + .../UpdatedInvoiceUpcomingView.text.hbs | 3 + .../Services/UpcomingInvoiceHandlerTests.cs | 947 ++++++++++++++++++ 7 files changed, 1089 insertions(+), 10 deletions(-) create mode 100644 src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml create mode 100644 src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs create mode 100644 src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs create mode 100644 src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs create mode 100644 test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 4260d67dfa..f24229f151 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -2,18 +2,22 @@ #nullable disable +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; +using Bit.Core.Entities; +using Bit.Core.Models.Mail.UpdatedInvoiceIncoming; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; using Bit.Core.Services; using Stripe; +using static Bit.Core.Billing.Constants.StripeConstants; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -29,7 +33,9 @@ public class UpcomingInvoiceHandler( IStripeEventService stripeEventService, IStripeEventUtilityService stripeEventUtilityService, IUserRepository userRepository, - IValidateSponsorshipCommand validateSponsorshipCommand) + IValidateSponsorshipCommand validateSponsorshipCommand, + IMailer mailer, + IFeatureService featureService) : IUpcomingInvoiceHandler { public async Task HandleAsync(Event parsedEvent) @@ -37,7 +43,8 @@ public class UpcomingInvoiceHandler( var invoice = await stripeEventService.GetInvoice(parsedEvent); var customer = - await stripeFacade.GetCustomer(invoice.CustomerId, new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] }); + await stripeFacade.GetCustomer(invoice.CustomerId, + new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] }); var subscription = customer.Subscriptions.FirstOrDefault(); @@ -68,7 +75,8 @@ public class UpcomingInvoiceHandler( if (stripeEventUtilityService.IsSponsoredSubscription(subscription)) { - var sponsorshipIsValid = await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); + var sponsorshipIsValid = + await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); if (!sponsorshipIsValid) { @@ -122,9 +130,17 @@ public class UpcomingInvoiceHandler( } } + var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); + if (milestone2Feature) + { + await UpdateSubscriptionItemPriceIdAsync(parsedEvent, subscription, user); + } + if (user.Premium) { - await SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice); + await (milestone2Feature + ? SendUpdatedUpcomingInvoiceEmailsAsync(new List { user.Email }) + : SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice)); } } else if (providerId.HasValue) @@ -142,6 +158,39 @@ public class UpcomingInvoiceHandler( } } + private async Task UpdateSubscriptionItemPriceIdAsync(Event parsedEvent, Subscription subscription, User user) + { + var pricingItem = + subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually); + if (pricingItem != null) + { + try + { + var plan = await pricingClient.GetAvailablePremiumPlan(); + await stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + Items = + [ + new SubscriptionItemOptions { Id = pricingItem.Id, Price = plan.Seat.StripePriceId } + ], + Discounts = + [ + new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount } + ] + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}", + user.Id, + parsedEvent.Id); + } + } + } + private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice) { var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); @@ -159,7 +208,19 @@ public class UpcomingInvoiceHandler( } } - private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice, Subscription subscription, Guid providerId) + private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable emails) + { + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); + var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail + { + ToEmails = validEmails, + View = new UpdatedInvoiceUpcomingView() + }; + await mailer.SendEmail(updatedUpcomingEmail); + } + + private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice, + Subscription subscription, Guid providerId) { var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); @@ -205,12 +266,12 @@ public class UpcomingInvoiceHandler( organization.PlanType.GetProductTier() != ProductTierType.Families && customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates; - if (nonUSBusinessUse && customer.TaxExempt != StripeConstants.TaxExempt.Reverse) + if (nonUSBusinessUse && customer.TaxExempt != TaxExempt.Reverse) { try { await stripeFacade.UpdateCustomer(subscription.CustomerId, - new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse }); } catch (Exception exception) { @@ -250,12 +311,12 @@ public class UpcomingInvoiceHandler( string eventId) { if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates && - customer.TaxExempt != StripeConstants.TaxExempt.Reverse) + customer.TaxExempt != TaxExempt.Reverse) { try { await stripeFacade.UpdateCustomer(subscription.CustomerId, - new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse }); } catch (Exception exception) { diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 131adfedf8..517273db4e 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -22,6 +22,7 @@ public static class StripeConstants { public const string LegacyMSPDiscount = "msp-discount-35"; public const string SecretsManagerStandalone = "sm-standalone"; + public const string Milestone2SubscriptionDiscount = "cm3nHfO1"; public static class MSPDiscounts { diff --git a/src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml b/src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml new file mode 100644 index 0000000000..c50a5d1292 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc. + + + + + + + + + diff --git a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs new file mode 100644 index 0000000000..aeca436dbb --- /dev/null +++ b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs @@ -0,0 +1,10 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.Models.Mail.UpdatedInvoiceIncoming; + +public class UpdatedInvoiceUpcomingView : BaseMailView; + +public class UpdatedInvoiceUpcomingMail : BaseMail +{ + public override string Subject { get => "Your Subscription Will Renew Soon"; } +} diff --git a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs new file mode 100644 index 0000000000..a044171fe5 --- /dev/null +++ b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs @@ -0,0 +1,30 @@ +
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc.

© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA

Always confirm you are on a trusted Bitwarden domain before logging in:
bitwarden.com | Learn why we include this

\ No newline at end of file diff --git a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs new file mode 100644 index 0000000000..a2db92bac2 --- /dev/null +++ b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs @@ -0,0 +1,3 @@ +{{#>BasicTextLayout}} + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc. +{{/BasicTextLayout}} diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs new file mode 100644 index 0000000000..899df4ea53 --- /dev/null +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -0,0 +1,947 @@ +using Bit.Billing.Services; +using Bit.Billing.Services.Implementations; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Pricing.Premium; +using Bit.Core.Entities; +using Bit.Core.Models.Mail.UpdatedInvoiceIncoming; +using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Stripe; +using Xunit; +using static Bit.Core.Billing.Constants.StripeConstants; +using Address = Stripe.Address; +using Event = Stripe.Event; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; + +namespace Bit.Billing.Test.Services; + +public class UpcomingInvoiceHandlerTests +{ + private readonly IGetPaymentMethodQuery _getPaymentMethodQuery; + private readonly ILogger _logger; + private readonly IMailService _mailService; + private readonly IOrganizationRepository _organizationRepository; + private readonly IPricingClient _pricingClient; + private readonly IProviderRepository _providerRepository; + private readonly IStripeFacade _stripeFacade; + private readonly IStripeEventService _stripeEventService; + private readonly IStripeEventUtilityService _stripeEventUtilityService; + private readonly IUserRepository _userRepository; + private readonly IValidateSponsorshipCommand _validateSponsorshipCommand; + private readonly IMailer _mailer; + private readonly IFeatureService _featureService; + + private readonly UpcomingInvoiceHandler _sut; + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _organizationId = Guid.NewGuid(); + private readonly Guid _providerId = Guid.NewGuid(); + + + public UpcomingInvoiceHandlerTests() + { + _getPaymentMethodQuery = Substitute.For(); + _logger = Substitute.For>(); + _mailService = Substitute.For(); + _organizationRepository = Substitute.For(); + _pricingClient = Substitute.For(); + _providerRepository = Substitute.For(); + _stripeFacade = Substitute.For(); + _stripeEventService = Substitute.For(); + _stripeEventUtilityService = Substitute.For(); + _userRepository = Substitute.For(); + _validateSponsorshipCommand = Substitute.For(); + _mailer = Substitute.For(); + _featureService = Substitute.For(); + + _sut = new UpcomingInvoiceHandler( + _getPaymentMethodQuery, + _logger, + _mailService, + _organizationRepository, + _pricingClient, + _providerRepository, + _stripeFacade, + _stripeEventService, + _stripeEventUtilityService, + _userRepository, + _validateSponsorshipCommand, + _mailer, + _featureService); + } + + [Fact] + public async Task HandleAsync_WhenNullSubscription_DoesNothing() + { + // Arrange + var parsedEvent = new Event(); + var invoice = new Invoice { CustomerId = "cus_123" }; + var customer = new Customer { Id = "cus_123", Subscriptions = new StripeList { Data = [] } }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.DidNotReceive() + .UpdateCustomer(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenValidUser_SendsEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var customerId = "cus_123"; + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = customerId }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }, + Subscriptions = new StripeList { Data = [subscription] } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(customerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + + _userRepository.GetByIdAsync(_userId).Returns(user); + _pricingClient.GetAvailablePremiumPlan().Returns(plan); + + // If milestone 2 is disabled, the default email is sent + _featureService + .IsEnabled(FeatureFlagKeys.PM23341_Milestone_2) + .Returns(false); + + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userRepository.Received(1).GetByIdAsync(_userId); + + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("user@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + } + + [Fact] + public async Task + HandleAsync_WhenUserValid_AndMilestone2Enabled_UpdatesPriceId_AndSendsUpdatedInvoiceUpcomingEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var customerId = "cus_123"; + var priceSubscriptionId = "sub-1"; + var priceId = "price-id-2"; + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer + { + Id = customerId, + Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported } + }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = priceId }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(customerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + + _userRepository.GetByIdAsync(_userId).Returns(user); + _pricingClient.GetAvailablePremiumPlan().Returns(plan); + _stripeFacade.UpdateSubscription( + subscription.Id, + Arg.Any()) + .Returns(subscription); + + // If milestone 2 is true, the updated invoice email is sent + _featureService + .IsEnabled(FeatureFlagKeys.PM23341_Milestone_2) + .Returns(true); + + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userRepository.Received(1).GetByIdAsync(_userId); + await _pricingClient.Received(1).GetAvailablePremiumPlan(); + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is("sub_123"), + Arg.Is(o => + o.Items[0].Id == priceSubscriptionId && + o.Items[0].Price == priceId)); + + // Verify the updated invoice email was sent + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("user@example.com") && + email.Subject == "Your Subscription Will Renew Soon")); + } + + [Fact] + public async Task HandleAsync_WhenOrganizationHasSponsorship_SendsEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary(), + LatestInvoiceId = "inv_latest" + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.EnterpriseAnnually + }; + var plan = new FamiliesPlan(); + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + + _organizationRepository + .GetByIdAsync(_organizationId) + .Returns(organization); + + _pricingClient + .GetPlanOrThrow(organization.PlanType) + .Returns(plan); + + _stripeEventUtilityService + .IsSponsoredSubscription(subscription) + .Returns(true); + // Configure that this is a sponsored subscription + _stripeEventUtilityService + .IsSponsoredSubscription(subscription) + .Returns(true); + _validateSponsorshipCommand + .ValidateSponsorshipAsync(_organizationId) + .Returns(true); + + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationRepository.Received(1).GetByIdAsync(_organizationId); + await _validateSponsorshipCommand.Received(1).ValidateSponsorshipAsync(_organizationId); + + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("org@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + } + + [Fact] + public async Task + HandleAsync_WhenOrganizationHasSponsorship_ButInvalidSponsorship_RetrievesUpdatedInvoice_SendsEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList + { + Data = + [new SubscriptionItem { Price = new Price { Id = "2021-family-for-enterprise-annually" } }] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary(), + LatestInvoiceId = "inv_latest" + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.EnterpriseAnnually + }; + var plan = new FamiliesPlan(); + + var paymentMethod = new Card { Last4 = "4242", Brand = "visa" }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + + _organizationRepository + .GetByIdAsync(_organizationId) + .Returns(organization); + + _pricingClient + .GetPlanOrThrow(organization.PlanType) + .Returns(plan); + + // Configure that this is not a sponsored subscription + _stripeEventUtilityService + .IsSponsoredSubscription(subscription) + .Returns(true); + + // Validate sponsorship should return false + _validateSponsorshipCommand + .ValidateSponsorshipAsync(_organizationId) + .Returns(false); + _stripeFacade + .GetInvoice(subscription.LatestInvoiceId) + .Returns(invoice); + + _getPaymentMethodQuery.Run(organization).Returns(MaskedPaymentMethod.From(paymentMethod)); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationRepository.Received(1).GetByIdAsync(_organizationId); + _stripeEventUtilityService.Received(1).IsSponsoredSubscription(subscription); + await _validateSponsorshipCommand.Received(1).ValidateSponsorshipAsync(_organizationId); + await _stripeFacade.Received(1).GetInvoice(Arg.Is("inv_latest")); + + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("org@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + } + + [Fact] + public async Task HandleAsync_WhenValidOrganization_SendsEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList + { + Data = + [new SubscriptionItem { Price = new Price { Id = "enterprise-annually" } }] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary(), + LatestInvoiceId = "inv_latest" + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.EnterpriseAnnually + }; + var plan = new FamiliesPlan(); + + var paymentMethod = new Card { Last4 = "4242", Brand = "visa" }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + + _organizationRepository + .GetByIdAsync(_organizationId) + .Returns(organization); + + _pricingClient + .GetPlanOrThrow(organization.PlanType) + .Returns(plan); + + _stripeEventUtilityService + .IsSponsoredSubscription(subscription) + .Returns(false); + + _stripeFacade + .GetInvoice(subscription.LatestInvoiceId) + .Returns(invoice); + + _getPaymentMethodQuery.Run(organization).Returns(MaskedPaymentMethod.From(paymentMethod)); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationRepository.Received(1).GetByIdAsync(_organizationId); + _stripeEventUtilityService.Received(1).IsSponsoredSubscription(subscription); + + // Should not validate sponsorship for non-sponsored subscription + await _validateSponsorshipCommand.DidNotReceive().ValidateSponsorshipAsync(Arg.Any()); + + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("org@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + } + + + [Fact] + public async Task HandleAsync_WhenValidProviderSubscription_SendsEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary(), + CollectionMethod = "charge_automatically" + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "UK" }, + TaxExempt = TaxExempt.None + }; + var provider = new Provider { Id = _providerId, BillingEmail = "provider@example.com" }; + + var paymentMethod = new Card { Last4 = "4242", Brand = "visa" }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, null, _providerId)); + + _providerRepository.GetByIdAsync(_providerId).Returns(provider); + _getPaymentMethodQuery.Run(provider).Returns(MaskedPaymentMethod.From(paymentMethod)); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _providerRepository.Received(2).GetByIdAsync(_providerId); + + // Verify tax exempt was set to reverse for non-US providers + await _stripeFacade.Received(1).UpdateCustomer( + Arg.Is("cus_123"), + Arg.Is(o => o.TaxExempt == TaxExempt.Reverse)); + + // Verify automatic tax was enabled + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is("sub_123"), + Arg.Is(o => o.AutomaticTax.Enabled == true)); + + // Verify provider invoice email was sent + await _mailService.Received(1).SendProviderInvoiceUpcoming( + Arg.Is>(e => e.Contains("provider@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(s => s == subscription.CollectionMethod), + Arg.Is(b => b == true), + Arg.Is(s => s == $"{paymentMethod.Brand} ending in {paymentMethod.Last4}")); + } + + [Fact] + public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAndSendsEmail() + { + // Arrange + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var customerId = "cus_123"; + var priceSubscriptionId = "sub-1"; + var priceId = "price-id-2"; + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Customer = new Customer + { + Id = customerId, + Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported } + }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = priceId }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + + _userRepository.GetByIdAsync(_userId).Returns(user); + + _featureService + .IsEnabled(FeatureFlagKeys.PM23341_Milestone_2) + .Returns(true); + + _pricingClient.GetAvailablePremiumPlan().Returns(plan); + + // Setup exception when updating subscription + _stripeFacade + .UpdateSubscription(Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception()); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => + o.ToString() + .Contains( + $"Failed to update user's ({user.Id}) subscription price id while processing event with ID {parsedEvent.Id}")), + Arg.Any(), + Arg.Any>()); + + // Verify that email was still sent despite the exception + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("user@example.com") && + email.Subject == "Your Subscription Will Renew Soon")); + } + + [Fact] + public async Task HandleAsync_WhenOrganizationNotFound_DoesNothing() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary(), + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + + // Organization not found + _organizationRepository.GetByIdAsync(_organizationId).Returns((Organization)null); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationRepository.Received(1).GetByIdAsync(_organizationId); + + // Verify no emails were sent + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenZeroAmountInvoice_DoesNothing() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 0, // Zero amount due + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Free Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + + _userRepository.GetByIdAsync(_userId).Returns(user); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userRepository.Received(1).GetByIdAsync(_userId); + + // Should not + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenUserNotFound_DoesNothing() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary() + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + + // User not found + _userRepository.GetByIdAsync(_userId).Returns((User)null); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userRepository.Received(1).GetByIdAsync(_userId); + + // Verify no emails were sent + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + + await _mailer.DidNotReceive().SendEmail(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenProviderNotFound_DoesNothing() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary() + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, null, _providerId)); + + // Provider not found + _providerRepository.GetByIdAsync(_providerId).Returns((Provider)null); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _providerRepository.Received(1).GetByIdAsync(_providerId); + + // Verify no provider emails were sent + await _mailService.DidNotReceive().SendProviderInvoiceUpcoming( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } +}