1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

[PM-26435] Milestone 3 / F19R (#6574)

* Re-organize UpcomingInvoiceHandler for readability

* Milestone 3 renewal

* Map premium access data from additonal data in pricing

* Feedback

* Fix test
This commit is contained in:
Alex Morask
2025-11-13 09:09:01 -06:00
committed by GitHub
parent de4955a875
commit 59a64af345
4 changed files with 881 additions and 190 deletions

View File

@@ -1,11 +1,8 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core;
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;
@@ -17,11 +14,13 @@ 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;
using Plan = Bit.Core.Models.StaticStore.Plan;
namespace Bit.Billing.Services.Implementations;
using static StripeConstants;
public class UpcomingInvoiceHandler(
IGetPaymentMethodQuery getPaymentMethodQuery,
ILogger<StripeEventProcessor> logger,
@@ -57,204 +56,88 @@ public class UpcomingInvoiceHandler(
if (organizationId.HasValue)
{
var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
if (organization == null)
{
return;
}
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
if (!plan.IsAnnual)
{
return;
}
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
{
var sponsorshipIsValid =
await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
if (!sponsorshipIsValid)
{
/*
* If the sponsorship is invalid, then the subscription was updated to use the regular families plan
* price. Given that this is the case, we need the new invoice amount
*/
invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
}
}
await SendUpcomingInvoiceEmailsAsync(new List<string> { organization.BillingEmail }, invoice);
/*
* 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);
await HandleOrganizationUpcomingInvoiceAsync(
organizationId.Value,
parsedEvent,
invoice,
customer,
subscription);
}
else if (userId.HasValue)
{
var user = await userRepository.GetByIdAsync(userId.Value);
if (user == null)
{
return;
}
if (!subscription.AutomaticTax.Enabled && subscription.Customer.HasRecognizedTaxLocation())
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
user.Id,
parsedEvent.Id);
}
}
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
if (milestone2Feature)
{
await UpdateSubscriptionItemPriceIdAsync(parsedEvent, subscription, user);
}
if (user.Premium)
{
await (milestone2Feature
? SendUpdatedUpcomingInvoiceEmailsAsync(new List<string> { user.Email })
: SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice));
}
await HandlePremiumUsersUpcomingInvoiceAsync(
userId.Value,
parsedEvent,
invoice,
customer,
subscription);
}
else if (providerId.HasValue)
{
var provider = await providerRepository.GetByIdAsync(providerId.Value);
if (provider == null)
{
return;
}
await AlignProviderTaxConcernsAsync(provider, subscription, customer, parsedEvent.Id);
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId.Value);
await HandleProviderUpcomingInvoiceAsync(
providerId.Value,
parsedEvent,
invoice,
customer,
subscription);
}
}
private async Task UpdateSubscriptionItemPriceIdAsync(Event parsedEvent, Subscription subscription, User user)
#region Organizations
private async Task HandleOrganizationUpcomingInvoiceAsync(
Guid organizationId,
Event @event,
Invoice invoice,
Customer customer,
Subscription subscription)
{
var pricingItem =
subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually);
if (pricingItem != null)
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
try
logger.LogWarning("Could not find Organization ({OrganizationID}) for '{EventType}' event ({EventID})",
organizationId, @event.Type, @event.Id);
return;
}
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, @event.Id);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3);
await AlignOrganizationSubscriptionConcernsAsync(
organization,
@event,
subscription,
plan,
milestone3);
// Don't send the upcoming invoice email unless the organization's on an annual plan.
if (!plan.IsAnnual)
{
return;
}
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
{
var sponsorshipIsValid =
await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId);
if (!sponsorshipIsValid)
{
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 }
],
ProrationBehavior = "none"
});
}
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);
/*
* If the sponsorship is invalid, then the subscription was updated to use the regular families plan
* price. Given that this is the case, we need the new invoice amount
*/
invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
}
}
}
private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var items = invoice.Lines.Select(i => i.Description).ToList();
if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
{
await mailService.SendInvoiceUpcoming(
validEmails,
invoice.AmountDue / 100M,
invoice.NextPaymentAttempt.Value,
items,
true);
}
}
private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable<string> 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<string> emails, Invoice invoice,
Subscription subscription, Guid providerId)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var items = invoice.FormatForProvider(subscription);
if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId);
return;
}
var collectionMethod = subscription.CollectionMethod;
var paymentMethod = await getPaymentMethodQuery.Run(provider);
var hasPaymentMethod = paymentMethod != null;
var paymentMethodDescription = paymentMethod?.Match(
bankAccount => $"Bank account ending in {bankAccount.Last4}",
card => $"{card.Brand} ending in {card.Last4}",
payPal => $"PayPal account {payPal.Email}"
);
await mailService.SendProviderInvoiceUpcoming(
validEmails,
invoice.AmountDue / 100M,
invoice.NextPaymentAttempt.Value,
items,
collectionMethod,
hasPaymentMethod,
paymentMethodDescription);
}
await (milestone3
? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail])
: SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice));
}
private async Task AlignOrganizationTaxConcernsAsync(
@@ -305,6 +188,209 @@ public class UpcomingInvoiceHandler(
}
}
private async Task AlignOrganizationSubscriptionConcernsAsync(
Organization organization,
Event @event,
Subscription subscription,
Plan plan,
bool milestone3)
{
if (milestone3 && plan.Type == PlanType.FamiliesAnnually2019)
{
var passwordManagerItem =
subscription.Items.FirstOrDefault(item => item.Price.Id == plan.PasswordManager.StripePlanId);
if (passwordManagerItem == null)
{
logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})",
organization.Id, @event.Type, @event.Id);
return;
}
var families = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually);
organization.PlanType = families.Type;
organization.Plan = families.Name;
organization.UsersGetPremium = families.UsersGetPremium;
organization.Seats = families.PasswordManager.BaseSeats;
var options = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Id = passwordManagerItem.Id, Price = families.PasswordManager.StripePlanId
}
],
Discounts =
[
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount }
],
ProrationBehavior = ProrationBehavior.None
};
var premiumAccessAddOnItem = subscription.Items.FirstOrDefault(item =>
item.Price.Id == plan.PasswordManager.StripePremiumAccessPlanId);
if (premiumAccessAddOnItem != null)
{
options.Items.Add(new SubscriptionItemOptions
{
Id = premiumAccessAddOnItem.Id,
Deleted = true
});
}
try
{
await organizationRepository.ReplaceAsync(organization);
await stripeFacade.UpdateSubscription(subscription.Id, options);
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to align subscription concerns for Organization ({OrganizationID}) while processing '{EventType}' event ({EventID})",
organization.Id,
@event.Type,
@event.Id);
}
}
}
#endregion
#region Premium Users
private async Task HandlePremiumUsersUpcomingInvoiceAsync(
Guid userId,
Event @event,
Invoice invoice,
Customer customer,
Subscription subscription)
{
var user = await userRepository.GetByIdAsync(userId);
if (user == null)
{
logger.LogWarning("Could not find User ({UserID}) for '{EventType}' event ({EventID})",
userId, @event.Type, @event.Id);
return;
}
await AlignPremiumUsersTaxConcernsAsync(user, @event, customer, subscription);
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
if (milestone2Feature)
{
await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription);
}
if (user.Premium)
{
await (milestone2Feature
? SendUpdatedUpcomingInvoiceEmailsAsync(new List<string> { user.Email })
: SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice));
}
}
private async Task AlignPremiumUsersTaxConcernsAsync(
User user,
Event @event,
Customer customer,
Subscription subscription)
{
if (!subscription.AutomaticTax.Enabled && customer.HasRecognizedTaxLocation())
{
try
{
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
user.Id,
@event.Id);
}
}
}
private async Task AlignPremiumUsersSubscriptionConcernsAsync(
User user,
Event @event,
Subscription subscription)
{
var premiumItem = subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually);
if (premiumItem == null)
{
logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})",
user.Id, @event.Type, @event.Id);
return;
}
try
{
var plan = await pricingClient.GetAvailablePremiumPlan();
await stripeFacade.UpdateSubscription(subscription.Id,
new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions { Id = premiumItem.Id, Price = plan.Seat.StripePriceId }
],
Discounts =
[
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount }
],
ProrationBehavior = ProrationBehavior.None
});
}
catch (Exception exception)
{
logger.LogError(
exception,
"Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}",
user.Id,
@event.Id);
}
}
#endregion
#region Providers
private async Task HandleProviderUpcomingInvoiceAsync(
Guid providerId,
Event @event,
Invoice invoice,
Customer customer,
Subscription subscription)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogWarning("Could not find Provider ({ProviderID}) for '{EventType}' event ({EventID})",
providerId, @event.Type, @event.Id);
return;
}
await AlignProviderTaxConcernsAsync(provider, subscription, customer, @event.Id);
if (!string.IsNullOrEmpty(provider.BillingEmail))
{
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId);
}
}
private async Task AlignProviderTaxConcernsAsync(
Provider provider,
Subscription subscription,
@@ -349,4 +435,75 @@ public class UpcomingInvoiceHandler(
}
}
}
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice,
Subscription subscription, Guid providerId)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var items = invoice.FormatForProvider(subscription);
if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
{
var provider = await providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId);
return;
}
var collectionMethod = subscription.CollectionMethod;
var paymentMethod = await getPaymentMethodQuery.Run(provider);
var hasPaymentMethod = paymentMethod != null;
var paymentMethodDescription = paymentMethod?.Match(
bankAccount => $"Bank account ending in {bankAccount.Last4}",
card => $"{card.Brand} ending in {card.Last4}",
payPal => $"PayPal account {payPal.Email}"
);
await mailService.SendProviderInvoiceUpcoming(
validEmails,
invoice.AmountDue / 100M,
invoice.NextPaymentAttempt.Value,
items,
collectionMethod,
hasPaymentMethod,
paymentMethodDescription);
}
}
#endregion
#region Shared
private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var items = invoice.Lines.Select(i => i.Description).ToList();
if (invoice is { NextPaymentAttempt: not null, AmountDue: > 0 })
{
await mailService.SendInvoiceUpcoming(
validEmails,
invoice.AmountDue / 100M,
invoice.NextPaymentAttempt.Value,
items,
true);
}
}
private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable<string> emails)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail
{
ToEmails = validEmails,
View = new UpdatedInvoiceUpcomingView()
};
await mailer.SendEmail(updatedUpcomingEmail);
}
#endregion
}

View File

@@ -23,6 +23,7 @@ public static class StripeConstants
public const string LegacyMSPDiscount = "msp-discount-35";
public const string SecretsManagerStandalone = "sm-standalone";
public const string Milestone2SubscriptionDiscount = "milestone-2c";
public const string Milestone3SubscriptionDiscount = "milestone-3";
public static class MSPDiscounts
{

View File

@@ -104,6 +104,14 @@ public record PlanAdapter : Core.Models.StaticStore.Plan
var additionalStoragePricePerGb = plan.Storage?.Price ?? 0;
var stripeStoragePlanId = plan.Storage?.StripePriceId;
short? maxCollections = plan.AdditionalData.TryGetValue("passwordManager.maxCollections", out var value) ? short.Parse(value) : null;
var stripePremiumAccessPlanId =
plan.AdditionalData.TryGetValue("premiumAccessAddOnPriceId", out var premiumAccessAddOnPriceIdValue)
? premiumAccessAddOnPriceIdValue
: null;
var premiumAccessOptionPrice =
plan.AdditionalData.TryGetValue("premiumAccessAddOnPriceAmount", out var premiumAccessAddOnPriceAmountValue)
? decimal.Parse(premiumAccessAddOnPriceAmountValue)
: 0;
return new PasswordManagerPlanFeatures
{
@@ -121,7 +129,9 @@ public record PlanAdapter : Core.Models.StaticStore.Plan
HasAdditionalStorageOption = hasAdditionalStorageOption,
AdditionalStoragePricePerGb = additionalStoragePricePerGb,
StripeStoragePlanId = stripeStoragePlanId,
MaxCollections = maxCollections
MaxCollections = maxCollections,
StripePremiumAccessPlanId = stripePremiumAccessPlanId,
PremiumAccessOptionPrice = premiumAccessOptionPrice
};
}

View File

@@ -945,4 +945,527 @@ public class UpcomingInvoiceHandlerTests
Arg.Any<bool>(),
Arg.Any<string>());
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesSubscriptionAndOrganization()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var premiumAccessItemId = "si_premium_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var families2019Plan = new Families2019Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
},
new()
{
Id = premiumAccessItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId }
}
}
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is(subscriptionId),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 2 &&
o.Items[0].Id == passwordManagerItemId &&
o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId &&
o.Items[1].Id == premiumAccessItemId &&
o.Items[1].Deleted == true &&
o.Discounts.Count == 1 &&
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
org.PlanType == PlanType.FamiliesAnnually &&
org.Plan == familiesPlan.Name &&
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_WithoutPremiumAccess_UpdatesSubscriptionAndOrganization()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var families2019Plan = new Families2019Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
}
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
Arg.Is(subscriptionId),
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 1 &&
o.Items[0].Id == passwordManagerItemId &&
o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId &&
o.Discounts.Count == 1 &&
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
o.ProrationBehavior == ProrationBehavior.None));
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(org =>
org.Id == _organizationId &&
org.PlanType == PlanType.FamiliesAnnually &&
org.Plan == familiesPlan.Name &&
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
org.Seats == familiesPlan.PasswordManager.BaseSeats));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Disabled_DoesNotUpdateSubscription()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var families2019Plan = new Families2019Plan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
}
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - should not update subscription or organization when feature flag is disabled
await _stripeFacade.DidNotReceive().UpdateSubscription(
Arg.Any<string>(),
Arg.Is<SubscriptionUpdateOptions>(o => o.Discounts != null));
await _organizationRepository.DidNotReceive().ReplaceAsync(
Arg.Is<Organization>(org => org.PlanType == PlanType.FamiliesAnnually));
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_ButNotFamilies2019Plan_DoesNotUpdateSubscription()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new()
{
Id = "si_pm_123",
Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId }
}
}
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually // Already on the new plan
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert - should not update subscription when not on FamiliesAnnually2019 plan
await _stripeFacade.DidNotReceive().UpdateSubscription(
Arg.Any<string>(),
Arg.Is<SubscriptionUpdateOptions>(o => o.Discounts != null));
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndPasswordManagerItemNotFound_LogsWarning()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var families2019Plan = new Families2019Plan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new()
{
Id = "si_different_item",
Price = new Price { Id = "different-price-id" }
}
}
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
_logger.Received(1).Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Could not find Organization's ({_organizationId}) password manager item") &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
// Should not update subscription or organization when password manager item not found
await _stripeFacade.DidNotReceive().UpdateSubscription(
Arg.Any<string>(),
Arg.Is<SubscriptionUpdateOptions>(o => o.Discounts != null));
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsError()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
var customerId = "cus_123";
var subscriptionId = "sub_123";
var passwordManagerItemId = "si_pm_123";
var invoice = new Invoice
{
CustomerId = customerId,
AmountDue = 40000,
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
}
};
var families2019Plan = new Families2019Plan();
var familiesPlan = new FamiliesPlan();
var subscription = new Subscription
{
Id = subscriptionId,
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
}
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Address = new Address { Country = "US" }
};
var organization = new Organization
{
Id = _organizationId,
BillingEmail = "org@example.com",
PlanType = PlanType.FamiliesAnnually2019
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
_stripeEventUtilityService
.GetIdsFromMetadata(subscription.Metadata)
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
// Simulate update failure
_stripeFacade
.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.ThrowsAsync(new Exception("Stripe API error"));
// Act
await _sut.HandleAsync(parsedEvent);
// Assert
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") &&
o.ToString().Contains(parsedEvent.Type) &&
o.ToString().Contains(parsedEvent.Id)),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
// Should still attempt to send email despite the failure
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
}
}