mirror of
https://github.com/bitwarden/server
synced 2026-01-02 16:43:25 +00:00
Merge branch 'main' into billing/pm-28662/Individual-Premium-automatically-disabled-due-to-duplicate-subscription
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
using Bit.Billing.Constants;
|
||||
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.OrganizationFeatures.Organizations.Interfaces;
|
||||
@@ -128,79 +127,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
Arg.Is<ITrigger>(t => t.Key.Name == $"cancel-trigger-{subscriptionId}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task
|
||||
HandleAsync_UnpaidProviderSubscription_WithManualSuspensionViaMetadata_DisablesProviderAndSchedulesCancellation()
|
||||
{
|
||||
// Arrange
|
||||
var providerId = Guid.NewGuid();
|
||||
var subscriptionId = "sub_test123";
|
||||
|
||||
var previousSubscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Active,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["suspend_provider"] = null // This is the key part - metadata exists, but value is null
|
||||
}
|
||||
};
|
||||
|
||||
var currentSubscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["providerId"] = providerId.ToString(),
|
||||
["suspend_provider"] = "true" // Now has a value, indicating manual suspension
|
||||
},
|
||||
TestClock = null
|
||||
};
|
||||
|
||||
var parsedEvent = new Event
|
||||
{
|
||||
Id = "evt_test123",
|
||||
Type = HandledStripeWebhook.SubscriptionUpdated,
|
||||
Data = new EventData
|
||||
{
|
||||
Object = currentSubscription,
|
||||
PreviousAttributes = JObject.FromObject(previousSubscription)
|
||||
}
|
||||
};
|
||||
|
||||
var provider = new Provider { Id = providerId, Enabled = true };
|
||||
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover).Returns(true);
|
||||
_stripeEventService.GetSubscription(parsedEvent, true, Arg.Any<List<string>>()).Returns(currentSubscription);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(currentSubscription.Metadata)
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
_providerRepository.GetByIdAsync(providerId).Returns(provider);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert
|
||||
Assert.False(provider.Enabled);
|
||||
await _providerService.Received(1).UpdateAsync(provider);
|
||||
|
||||
// Verify that UpdateSubscription was called with both CancelAt and the new metadata
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
subscriptionId,
|
||||
Arg.Is<SubscriptionUpdateOptions>(options =>
|
||||
options.CancelAt.HasValue &&
|
||||
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) &&
|
||||
options.Metadata != null &&
|
||||
options.Metadata.ContainsKey("suspended_provider_via_webhook_at")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task
|
||||
HandleAsync_UnpaidProviderSubscription_WithValidTransition_DisablesProviderAndSchedulesCancellation()
|
||||
@@ -245,7 +171,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
|
||||
var provider = new Provider { Id = providerId, Enabled = true };
|
||||
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover).Returns(true);
|
||||
_stripeEventService.GetSubscription(parsedEvent, true, Arg.Any<List<string>>()).Returns(currentSubscription);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(currentSubscription.Metadata)
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
@@ -258,13 +183,12 @@ public class SubscriptionUpdatedHandlerTests
|
||||
Assert.False(provider.Enabled);
|
||||
await _providerService.Received(1).UpdateAsync(provider);
|
||||
|
||||
// Verify that UpdateSubscription was called with CancelAt but WITHOUT suspension metadata
|
||||
// Verify that UpdateSubscription was called with CancelAt
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
subscriptionId,
|
||||
Arg.Is<SubscriptionUpdateOptions>(options =>
|
||||
options.CancelAt.HasValue &&
|
||||
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1) &&
|
||||
(options.Metadata == null || !options.Metadata.ContainsKey("suspended_provider_via_webhook_at"))));
|
||||
options.CancelAt.Value <= DateTime.UtcNow.AddDays(7).AddMinutes(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -308,9 +232,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
_providerRepository.GetByIdAsync(providerId)
|
||||
.Returns(provider);
|
||||
|
||||
@@ -355,9 +276,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
_providerRepository.GetByIdAsync(providerId)
|
||||
.Returns(provider);
|
||||
|
||||
@@ -403,9 +321,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
_providerRepository.GetByIdAsync(providerId)
|
||||
.Returns(provider);
|
||||
|
||||
@@ -418,48 +333,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _stripeFacade.DidNotReceive().UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_UnpaidProviderSubscription_WhenFeatureFlagDisabled_DoesNothing()
|
||||
{
|
||||
// Arrange
|
||||
var providerId = Guid.NewGuid();
|
||||
var subscriptionId = "sub_123";
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
Status = StripeSubscriptionStatus.Unpaid,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
|
||||
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }
|
||||
};
|
||||
|
||||
var parsedEvent = new Event { Data = new EventData() };
|
||||
|
||||
_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
|
||||
.Returns(subscription);
|
||||
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert
|
||||
await _providerRepository.DidNotReceive().GetByIdAsync(Arg.Any<Guid>());
|
||||
await _providerService.DidNotReceive().UpdateAsync(Arg.Any<Provider>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_UnpaidProviderSubscription_WhenProviderNotFound_DoesNothing()
|
||||
{
|
||||
@@ -491,9 +364,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
_providerRepository.GetByIdAsync(providerId)
|
||||
.Returns((Provider)null);
|
||||
|
||||
@@ -848,8 +718,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_stripeFacade
|
||||
.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(newSubscription);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
@@ -871,9 +739,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
.Received(1)
|
||||
.UpdateSubscription(newSubscription.Id,
|
||||
Arg.Is<SubscriptionUpdateOptions>(options => options.CancelAtPeriodEnd == false));
|
||||
_featureService
|
||||
.Received(1)
|
||||
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -894,8 +759,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_providerRepository
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(provider);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
@@ -914,9 +777,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _stripeFacade
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpdateSubscription(Arg.Any<string>());
|
||||
_featureService
|
||||
.Received(1)
|
||||
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -937,8 +797,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_providerRepository
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(provider);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
@@ -957,9 +815,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _stripeFacade
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpdateSubscription(Arg.Any<string>());
|
||||
_featureService
|
||||
.Received(1)
|
||||
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -980,8 +835,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_providerRepository
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(provider);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
@@ -1000,9 +853,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _stripeFacade
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpdateSubscription(Arg.Any<string>());
|
||||
_featureService
|
||||
.Received(1)
|
||||
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1024,8 +874,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_providerRepository
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(provider);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
@@ -1046,9 +894,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _stripeFacade
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpdateSubscription(Arg.Any<string>());
|
||||
_featureService
|
||||
.Received(1)
|
||||
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1068,8 +913,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_providerRepository
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.ReturnsNull();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
@@ -1090,9 +933,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _stripeFacade
|
||||
.DidNotReceive()
|
||||
.UpdateSubscription(Arg.Any<string>());
|
||||
_featureService
|
||||
.Received(1)
|
||||
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1111,8 +951,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
_providerRepository
|
||||
.GetByIdAsync(Arg.Any<Guid>())
|
||||
.Returns(provider);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
@@ -1133,9 +971,6 @@ public class SubscriptionUpdatedHandlerTests
|
||||
await _stripeFacade
|
||||
.DidNotReceive()
|
||||
.UpdateSubscription(Arg.Any<string>());
|
||||
_featureService
|
||||
.Received(1)
|
||||
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
||||
}
|
||||
|
||||
private static (Guid providerId, Subscription newSubscription, Provider provider, Event parsedEvent)
|
||||
|
||||
@@ -11,7 +11,9 @@ 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.Billing.Renewal.Families2019Renewal;
|
||||
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
|
||||
using Bit.Core.Models.Mail.Billing.Renewal.Premium;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -252,6 +254,9 @@ public class UpcomingInvoiceHandlerTests
|
||||
.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2)
|
||||
.Returns(true);
|
||||
|
||||
var coupon = new Coupon { PercentOff = 20, Id = CouponIDs.Milestone2SubscriptionDiscount };
|
||||
|
||||
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
@@ -259,6 +264,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
// Assert
|
||||
await _userRepository.Received(1).GetByIdAsync(_userId);
|
||||
await _pricingClient.Received(1).GetAvailablePremiumPlan();
|
||||
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone2SubscriptionDiscount);
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
Arg.Is("sub_123"),
|
||||
Arg.Is<SubscriptionUpdateOptions>(o =>
|
||||
@@ -268,11 +274,15 @@ public class UpcomingInvoiceHandlerTests
|
||||
o.ProrationBehavior == "none"));
|
||||
|
||||
// Verify the updated invoice email was sent with correct price
|
||||
var discountedPrice = plan.Seat.Price * (100 - coupon.PercentOff.Value) / 100;
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<Families2020RenewalMail>(email =>
|
||||
Arg.Is<PremiumRenewalMail>(email =>
|
||||
email.ToEmails.Contains("user@example.com") &&
|
||||
email.Subject == "Your Bitwarden Families renewal is updating" &&
|
||||
email.View.MonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US"))));
|
||||
email.Subject == "Your Bitwarden Premium renewal is updating" &&
|
||||
email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.DiscountedMonthlyRenewalPrice == (discountedPrice / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.DiscountAmount == $"{coupon.PercentOff}%"
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1006,8 +1016,11 @@ public class UpcomingInvoiceHandlerTests
|
||||
PlanType = PlanType.FamiliesAnnually2019
|
||||
};
|
||||
|
||||
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
|
||||
_stripeEventUtilityService
|
||||
.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||
@@ -1033,6 +1046,8 @@ public class UpcomingInvoiceHandlerTests
|
||||
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
|
||||
o.ProrationBehavior == ProrationBehavior.None));
|
||||
|
||||
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(
|
||||
Arg.Is<Organization>(org =>
|
||||
org.Id == _organizationId &&
|
||||
@@ -1042,10 +1057,13 @@ public class UpcomingInvoiceHandlerTests
|
||||
org.Seats == familiesPlan.PasswordManager.BaseSeats));
|
||||
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<Families2020RenewalMail>(email =>
|
||||
Arg.Is<Families2019RenewalMail>(email =>
|
||||
email.ToEmails.Contains("org@example.com") &&
|
||||
email.Subject == "Your Bitwarden Families renewal is updating" &&
|
||||
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
|
||||
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.DiscountAmount == $"{coupon.PercentOff}%"
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1465,6 +1483,200 @@ public class UpcomingInvoiceHandlerTests
|
||||
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WhenMilestone3Enabled_AndCouponNotFound_LogsErrorAndSendsTraditionalEmail()
|
||||
{
|
||||
// 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() { 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()
|
||||
{
|
||||
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 = [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);
|
||||
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns((Coupon)null);
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert - Exception is caught, error is logged, and traditional email is sent
|
||||
_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.Is<Exception>(e => e is InvalidOperationException && e.Message.Contains("Coupon for sending families 2019 email")),
|
||||
Arg.Any<Func<object, Exception, string>>());
|
||||
|
||||
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2019RenewalMail>());
|
||||
|
||||
await _mailService.Received(1).SendInvoiceUpcoming(
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.Contains("org@example.com")),
|
||||
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
|
||||
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
|
||||
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
|
||||
Arg.Is<bool>(b => b == true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WhenMilestone3Enabled_AndCouponPercentOffIsNull_LogsErrorAndSendsTraditionalEmail()
|
||||
{
|
||||
// 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() { 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()
|
||||
{
|
||||
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 = [subscription] },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = _organizationId,
|
||||
BillingEmail = "org@example.com",
|
||||
PlanType = PlanType.FamiliesAnnually2019
|
||||
};
|
||||
|
||||
var coupon = new Coupon
|
||||
{
|
||||
Id = CouponIDs.Milestone3SubscriptionDiscount,
|
||||
PercentOff = null
|
||||
};
|
||||
|
||||
_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);
|
||||
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert - Exception is caught, error is logged, and traditional email is sent
|
||||
_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.Is<Exception>(e => e is InvalidOperationException && e.Message.Contains("coupon.PercentOff")),
|
||||
Arg.Any<Func<object, Exception, string>>());
|
||||
|
||||
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2019RenewalMail>());
|
||||
|
||||
await _mailService.Received(1).SendInvoiceUpcoming(
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.Contains("org@example.com")),
|
||||
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
|
||||
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
|
||||
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
|
||||
Arg.Is<bool>(b => b == true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WhenMilestone3Enabled_AndSeatAddOnExists_DeletesItem()
|
||||
{
|
||||
@@ -1529,8 +1741,11 @@ public class UpcomingInvoiceHandlerTests
|
||||
PlanType = PlanType.FamiliesAnnually2019
|
||||
};
|
||||
|
||||
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
|
||||
_stripeEventUtilityService
|
||||
.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||
@@ -1556,6 +1771,8 @@ public class UpcomingInvoiceHandlerTests
|
||||
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
|
||||
o.ProrationBehavior == ProrationBehavior.None));
|
||||
|
||||
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(
|
||||
Arg.Is<Organization>(org =>
|
||||
org.Id == _organizationId &&
|
||||
@@ -1565,10 +1782,13 @@ public class UpcomingInvoiceHandlerTests
|
||||
org.Seats == familiesPlan.PasswordManager.BaseSeats));
|
||||
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<Families2020RenewalMail>(email =>
|
||||
Arg.Is<Families2019RenewalMail>(email =>
|
||||
email.ToEmails.Contains("org@example.com") &&
|
||||
email.Subject == "Your Bitwarden Families renewal is updating" &&
|
||||
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
|
||||
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.DiscountAmount == $"{coupon.PercentOff}%"
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1635,8 +1855,11 @@ public class UpcomingInvoiceHandlerTests
|
||||
PlanType = PlanType.FamiliesAnnually2019
|
||||
};
|
||||
|
||||
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
|
||||
_stripeEventUtilityService
|
||||
.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||
@@ -1662,6 +1885,8 @@ public class UpcomingInvoiceHandlerTests
|
||||
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
|
||||
o.ProrationBehavior == ProrationBehavior.None));
|
||||
|
||||
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(
|
||||
Arg.Is<Organization>(org =>
|
||||
org.Id == _organizationId &&
|
||||
@@ -1671,10 +1896,13 @@ public class UpcomingInvoiceHandlerTests
|
||||
org.Seats == familiesPlan.PasswordManager.BaseSeats));
|
||||
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<Families2020RenewalMail>(email =>
|
||||
Arg.Is<Families2019RenewalMail>(email =>
|
||||
email.ToEmails.Contains("org@example.com") &&
|
||||
email.Subject == "Your Bitwarden Families renewal is updating" &&
|
||||
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
|
||||
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.DiscountAmount == $"{coupon.PercentOff}%"
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1748,8 +1976,11 @@ public class UpcomingInvoiceHandlerTests
|
||||
PlanType = PlanType.FamiliesAnnually2019
|
||||
};
|
||||
|
||||
var coupon = new Coupon { PercentOff = 25, Id = CouponIDs.Milestone3SubscriptionDiscount };
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount).Returns(coupon);
|
||||
_stripeEventUtilityService
|
||||
.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||
@@ -1777,6 +2008,8 @@ public class UpcomingInvoiceHandlerTests
|
||||
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
|
||||
o.ProrationBehavior == ProrationBehavior.None));
|
||||
|
||||
await _stripeFacade.Received(1).GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(
|
||||
Arg.Is<Organization>(org =>
|
||||
org.Id == _organizationId &&
|
||||
@@ -1786,10 +2019,13 @@ public class UpcomingInvoiceHandlerTests
|
||||
org.Seats == familiesPlan.PasswordManager.BaseSeats));
|
||||
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<Families2020RenewalMail>(email =>
|
||||
Arg.Is<Families2019RenewalMail>(email =>
|
||||
email.ToEmails.Contains("org@example.com") &&
|
||||
email.Subject == "Your Bitwarden Families renewal is updating" &&
|
||||
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
|
||||
email.View.BaseMonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.BaseAnnualRenewalPrice == familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.DiscountAmount == $"{coupon.PercentOff}%"
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1879,6 +2115,12 @@ public class UpcomingInvoiceHandlerTests
|
||||
org.Plan == familiesPlan.Name &&
|
||||
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
|
||||
org.Seats == familiesPlan.PasswordManager.BaseSeats));
|
||||
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<Families2020RenewalMail>(email =>
|
||||
email.ToEmails.Contains("org@example.com") &&
|
||||
email.Subject == "Your Bitwarden Families renewal is updating" &&
|
||||
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1957,4 +2199,332 @@ public class UpcomingInvoiceHandlerTests
|
||||
await _organizationRepository.DidNotReceive().ReplaceAsync(
|
||||
Arg.Is<Organization>(org => org.PlanType == PlanType.FamiliesAnnually));
|
||||
}
|
||||
|
||||
#region Premium Renewal Email Tests
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WhenMilestone2Enabled_AndCouponNotFound_LogsErrorAndSendsTraditionalEmail()
|
||||
{
|
||||
// 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<InvoiceLineItem>
|
||||
{
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
||||
Customer = new Customer { Id = customerId },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
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<Subscription> { Data = [subscription] }
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
|
||||
_userRepository.GetByIdAsync(_userId).Returns(user);
|
||||
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns((Coupon)null);
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert - Exception is caught, error is logged, and traditional email is sent
|
||||
_logger.Received(1).Log(
|
||||
LogLevel.Error,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Is<object>(o =>
|
||||
o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") &&
|
||||
o.ToString().Contains(parsedEvent.Id)),
|
||||
Arg.Is<Exception>(e => e is InvalidOperationException
|
||||
&& e.Message == $"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found"),
|
||||
Arg.Any<Func<object, Exception, string>>());
|
||||
|
||||
await _mailer.DidNotReceive().SendEmail(Arg.Any<PremiumRenewalMail>());
|
||||
|
||||
await _mailService.Received(1).SendInvoiceUpcoming(
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
|
||||
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
|
||||
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
|
||||
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
|
||||
Arg.Is<bool>(b => b == true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WhenMilestone2Enabled_AndCouponPercentOffIsNull_LogsErrorAndSendsTraditionalEmail()
|
||||
{
|
||||
// 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<InvoiceLineItem>
|
||||
{
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
||||
Customer = new Customer { Id = customerId },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
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<Subscription> { Data = [subscription] }
|
||||
};
|
||||
var coupon = new Coupon
|
||||
{
|
||||
Id = CouponIDs.Milestone2SubscriptionDiscount,
|
||||
PercentOff = null
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
|
||||
_userRepository.GetByIdAsync(_userId).Returns(user);
|
||||
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert - Exception is caught, error is logged, and traditional email is sent
|
||||
_logger.Received(1).Log(
|
||||
LogLevel.Error,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Is<object>(o =>
|
||||
o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") &&
|
||||
o.ToString().Contains(parsedEvent.Id)),
|
||||
Arg.Is<Exception>(e => e is InvalidOperationException
|
||||
&& e.Message == $"coupon.PercentOff for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} is null"),
|
||||
Arg.Any<Func<object, Exception, string>>());
|
||||
|
||||
await _mailer.DidNotReceive().SendEmail(Arg.Any<PremiumRenewalMail>());
|
||||
|
||||
await _mailService.Received(1).SendInvoiceUpcoming(
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
|
||||
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
|
||||
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
|
||||
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
|
||||
Arg.Is<bool>(b => b == true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WhenMilestone2Enabled_AndValidCoupon_SendsPremiumRenewalEmail()
|
||||
{
|
||||
// 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<InvoiceLineItem>
|
||||
{
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
||||
Customer = new Customer { Id = customerId },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
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<Subscription> { Data = [subscription] }
|
||||
};
|
||||
var coupon = new Coupon
|
||||
{
|
||||
Id = CouponIDs.Milestone2SubscriptionDiscount,
|
||||
PercentOff = 30
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
|
||||
_userRepository.GetByIdAsync(_userId).Returns(user);
|
||||
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount).Returns(coupon);
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert
|
||||
var expectedDiscountedPrice = plan.Seat.Price * (100 - coupon.PercentOff.Value) / 100;
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<PremiumRenewalMail>(email =>
|
||||
email.ToEmails.Contains("user@example.com") &&
|
||||
email.Subject == "Your Bitwarden Premium renewal is updating" &&
|
||||
email.View.BaseMonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")) &&
|
||||
email.View.DiscountAmount == "30%" &&
|
||||
email.View.DiscountedMonthlyRenewalPrice == (expectedDiscountedPrice / 12).ToString("C", new CultureInfo("en-US"))
|
||||
));
|
||||
|
||||
await _mailService.DidNotReceive().SendInvoiceUpcoming(
|
||||
Arg.Any<IEnumerable<string>>(),
|
||||
Arg.Any<decimal>(),
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<List<string>>(),
|
||||
Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WhenMilestone2Enabled_AndGetCouponThrowsException_LogsErrorAndSendsTraditionalEmail()
|
||||
{
|
||||
// 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<InvoiceLineItem>
|
||||
{
|
||||
Data = [new() { Description = "Test Item" }]
|
||||
}
|
||||
};
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
|
||||
Customer = new Customer { Id = customerId },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
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<Subscription> { Data = [subscription] }
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
|
||||
_userRepository.GetByIdAsync(_userId).Returns(user);
|
||||
_pricingClient.GetAvailablePremiumPlan().Returns(plan);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||
_stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount)
|
||||
.ThrowsAsync(new StripeException("Stripe API error"));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert - Exception is caught, error is logged, and traditional email is sent
|
||||
_logger.Received(1).Log(
|
||||
LogLevel.Error,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Is<object>(o =>
|
||||
o.ToString().Contains($"Failed to update user's ({user.Id}) subscription price id") &&
|
||||
o.ToString().Contains(parsedEvent.Id)),
|
||||
Arg.Is<Exception>(e => e is StripeException),
|
||||
Arg.Any<Func<object, Exception, string>>());
|
||||
|
||||
await _mailer.DidNotReceive().SendEmail(Arg.Any<PremiumRenewalMail>());
|
||||
|
||||
await _mailService.Received(1).SendInvoiceUpcoming(
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
|
||||
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
|
||||
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
|
||||
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
|
||||
Arg.Is<bool>(b => b == true));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user