diff --git a/src/Exceptionless.Core/Billing/BillingManager.cs b/src/Exceptionless.Core/Billing/BillingManager.cs index 418f58fdc4..81fa7ae37d 100644 --- a/src/Exceptionless.Core/Billing/BillingManager.cs +++ b/src/Exceptionless.Core/Billing/BillingManager.cs @@ -2,6 +2,7 @@ using Exceptionless.Core.Models; using Exceptionless.Core.Models.Billing; using Exceptionless.Core.Repositories; +using Exceptionless.DateTimeExtensions; namespace Exceptionless.Core.Billing; @@ -96,16 +97,23 @@ public async Task CanDownGradeAsync(Organization organization, public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? user = null, bool updateBillingPrice = true) { + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + CaptureOutgoingUsageLimit(organization, plan, utcNow); + organization.PlanId = plan.Id; organization.PlanName = plan.Name; organization.PlanDescription = plan.Description; - organization.BillingChangeDate = _timeProvider.GetUtcNow().UtcDateTime; + organization.BillingChangeDate = utcNow; if (updateBillingPrice) + { organization.BillingPrice = plan.Price; + } if (user is not null) + { organization.BillingChangedByUserId = user.Id; + } organization.MaxUsers = plan.MaxUsers; organization.MaxProjects = plan.MaxProjects; @@ -116,6 +124,29 @@ public void ApplyBillingPlan(Organization organization, BillingPlan plan, User? organization.GetCurrentUsage(_timeProvider).Limit = organization.GetMaxEventsPerMonthWithBonus(_timeProvider); } + private void CaptureOutgoingUsageLimit(Organization organization, BillingPlan plan, DateTime utcNow) + { + if (String.IsNullOrEmpty(organization.PlanId) + || String.Equals(organization.PlanId, plan.Id, StringComparison.OrdinalIgnoreCase) + || organization.MaxEventsPerMonth == 0) + { + return; + } + + var previousMonthUtc = utcNow.StartOfMonth().AddMonths(-1); + var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth(); + if (previousMonthUtc < organizationCreatedMonthUtc) + { + return; + } + + var previousUsage = organization.Usage.GetUsage(previousMonthUtc, organization.MaxEventsPerMonth); + if (previousUsage.Limit == 0) + { + previousUsage.Limit = organization.MaxEventsPerMonth; + } + } + public void ApplyBonus(Organization organization, int bonusEvents, DateTime? expires = null) { organization.BonusEventsPerMonth = bonusEvents; diff --git a/src/Exceptionless.Core/Extensions/UsageExtensions.cs b/src/Exceptionless.Core/Extensions/UsageExtensions.cs index c7cb862958..31ebc3133a 100644 --- a/src/Exceptionless.Core/Extensions/UsageExtensions.cs +++ b/src/Exceptionless.Core/Extensions/UsageExtensions.cs @@ -1,9 +1,29 @@ using Exceptionless.Core.Models; +using Exceptionless.DateTimeExtensions; namespace Exceptionless.Core.Extensions; public static class UsageExtensions { + public static UsageInfo GetUsage(this ICollection usages, DateTime dateUtc, int limit) + { + var startOfMonth = dateUtc.ToUniversalTime().StartOfMonth(); + var usage = usages.FirstOrDefault(u => u.Date.Year == startOfMonth.Year && u.Date.Month == startOfMonth.Month); + if (usage is not null) + { + return usage; + } + + usage = new UsageInfo + { + Date = startOfMonth, + Limit = limit + }; + usages.Add(usage); + + return usage; + } + public static void SetUsage(this ICollection usages, DateTime dateUtc, int total, int blocked, int tooBig, int limit, TimeSpan? maxUsageAge, TimeProvider timeProvider) { var usageInfo = usages.FirstOrDefault(o => o.Date == dateUtc); diff --git a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs index dbc5ed247f..901c28144b 100644 --- a/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/ProjectHandler.cs @@ -513,7 +513,7 @@ private async Task AfterResultMapAsync(ICollection m viewProject.HasPremiumFeatures = organization.HasPremiumFeatures; var realTimeUsage = await usageService.GetUsageAsync(organization.Id, viewProject.Id); - viewProject.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(timeProvider), timeProvider); + viewProject.EnsureUsage(organization.GetMaxEventsPerMonthWithBonus(timeProvider), organization.CreatedUtc, timeProvider); viewProject.TrimUsage(timeProvider); var currentUsage = viewProject.GetCurrentUsage(organization.GetMaxEventsPerMonthWithBonus(timeProvider), timeProvider); diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 90bdd3fffd..f520f959db 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Attributes; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Models; using Exceptionless.DateTimeExtensions; using Foundatio.Repositories.Models; @@ -73,12 +74,36 @@ public static UsageHourInfo GetCurrentHourlyUsage(this ViewOrganization organiza public static void EnsureUsage(this ViewOrganization organization, TimeProvider timeProvider) { - var startDate = timeProvider.GetUtcNow().UtcDateTime.SubtractMonths(11).StartOfMonth(); + var endDateUtc = timeProvider.GetUtcNow().UtcDateTime.StartOfMonth(); + var startDateUtc = endDateUtc.SubtractMonths(11); + var organizationCreatedMonthUtc = organization.CreatedUtc.ToUniversalTime().StartOfMonth(); + if (organizationCreatedMonthUtc > startDateUtc) + { + startDateUtc = organizationCreatedMonthUtc; + } + + var knownUsages = organization.Usage + .Where(u => u.Limit != 0) + .OrderBy(u => u.Date) + .ToList(); + int limit = knownUsages + .LastOrDefault(u => u.Date <= startDateUtc)?.Limit + ?? knownUsages.FirstOrDefault()?.Limit + ?? organization.GetMaxEventsPerMonthWithBonus(timeProvider); - while (startDate <= timeProvider.GetUtcNow().UtcDateTime.StartOfMonth()) + while (startDateUtc <= endDateUtc) { - organization.GetUsage(startDate, timeProvider); - startDate = startDate.AddMonths(1).StartOfMonth(); + var usage = organization.Usage.GetUsage(startDateUtc, limit); + if (usage.Limit == 0) + { + usage.Limit = limit; + } + else + { + limit = usage.Limit; + } + + startDateUtc = startDateUtc.AddMonths(1).StartOfMonth(); } } @@ -89,19 +114,7 @@ public static UsageInfo GetCurrentUsage(this ViewOrganization organization, Time public static UsageInfo GetUsage(this ViewOrganization organization, DateTime date, TimeProvider timeProvider) { - var startOfMonth = date.ToUniversalTime().StartOfMonth(); - var usage = organization.Usage.FirstOrDefault(o => o.Date.Year == startOfMonth.Year && o.Date.Month == startOfMonth.Month); - if (usage is not null) - return usage; - - usage = new UsageInfo - { - Date = startOfMonth, - Limit = organization.GetMaxEventsPerMonthWithBonus(timeProvider) - }; - organization.Usage.Add(usage); - - return usage; + return organization.Usage.GetUsage(date, organization.GetMaxEventsPerMonthWithBonus(timeProvider)); } public static int GetMaxEventsPerMonthWithBonus(this ViewOrganization organization, TimeProvider timeProvider) diff --git a/src/Exceptionless.Web/Models/Project/ViewProject.cs b/src/Exceptionless.Web/Models/Project/ViewProject.cs index 52f5576e9a..34b7539f2c 100644 --- a/src/Exceptionless.Web/Models/Project/ViewProject.cs +++ b/src/Exceptionless.Web/Models/Project/ViewProject.cs @@ -49,14 +49,24 @@ public static UsageHourInfo GetCurrentHourlyUsage(this ViewProject project, Time return project.GetHourlyUsage(timeProvider.GetUtcNow().UtcDateTime); } - public static void EnsureUsage(this ViewProject project, int limit, TimeProvider timeProvider) + public static void EnsureUsage(this ViewProject project, int limit, DateTime organizationCreatedUtc, TimeProvider timeProvider) { - var startDate = timeProvider.GetUtcNow().UtcDateTime.SubtractYears(1).StartOfMonth(); + var endDateUtc = timeProvider.GetUtcNow().UtcDateTime.StartOfMonth(); + var startDateUtc = endDateUtc.SubtractYears(1); + var createdMonthUtc = organizationCreatedUtc.ToUniversalTime().StartOfMonth(); + if (project.CreatedUtc > organizationCreatedUtc) + { + createdMonthUtc = project.CreatedUtc.ToUniversalTime().StartOfMonth(); + } + if (createdMonthUtc > startDateUtc) + { + startDateUtc = createdMonthUtc; + } - while (startDate < timeProvider.GetUtcNow().UtcDateTime.StartOfMonth()) + while (startDateUtc < endDateUtc) { - project.GetUsage(startDate, limit); - startDate = startDate.AddMonths(1).StartOfMonth(); + project.GetUsage(startDateUtc, limit); + startDateUtc = startDateUtc.AddMonths(1).StartOfMonth(); } } diff --git a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs index f46babddc4..9a6f35bb28 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/EventEndpointTests.cs @@ -1473,7 +1473,7 @@ public async Task ShouldRespectEventUsageLimits() Assert.NotNull(viewOrganization); Assert.False(viewOrganization.IsThrottled); Assert.False(viewOrganization.IsOverMonthlyLimit); - Assert.Equal(12, viewOrganization.Usage.Count); + Assert.Single(viewOrganization.Usage); Assert.Single(viewOrganization.UsageHours); // submit bach of events one over limit @@ -1497,7 +1497,7 @@ await SendRequestAsync(r => r Assert.NotNull(viewOrganization); Assert.False(viewOrganization.IsThrottled); Assert.False(viewOrganization.IsOverMonthlyLimit); - Assert.Equal(12, viewOrganization.Usage.Count); + Assert.Single(viewOrganization.Usage); Assert.Single(viewOrganization.UsageHours); // process events @@ -1596,7 +1596,7 @@ await SendRequestAsync(r => r organization = await _organizationRepository.GetByIdAsync(organizationId); Assert.NotNull(organization); - organizationUsage = organization.Usage.Single(); + organizationUsage = organization.GetCurrentUsage(TimeProvider); Assert.Equal(total, organizationUsage.Total); Assert.Equal(blocked, organizationUsage.Blocked); Assert.Equal(0, organizationUsage.TooBig); @@ -1725,7 +1725,7 @@ public async Task PlanChangeShouldAllowEventSubmission() Assert.NotNull(viewOrganization); Assert.False(viewOrganization.IsThrottled); Assert.False(viewOrganization.IsOverMonthlyLimit); - Assert.Equal(12, viewOrganization.Usage.Count); + Assert.Single(viewOrganization.Usage); Assert.Single(viewOrganization.UsageHours); // submit bach of events one over limit @@ -1857,7 +1857,7 @@ await SendRequestAsync(r => r organization = await _organizationRepository.GetByIdAsync(organizationId); Assert.NotNull(organization); - organizationUsage = organization.Usage.Single(); + organizationUsage = organization.GetCurrentUsage(TimeProvider); Assert.Equal(total, organizationUsage.Total); Assert.Equal(blocked, organizationUsage.Blocked); Assert.Equal(0, organizationUsage.TooBig); diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index 72aa184414..92d0549454 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -389,6 +389,47 @@ public async Task GetAsync_ExistingOrganization_MapsToViewOrganization() Assert.NotNull(viewOrg.PlanName); } + [Fact] + public async Task GetAsync_WithSparsePlanHistory_BackfillsFromKnownLimits() + { + // Arrange + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); + Assert.NotNull(organization); + + var user = await _userRepository.GetByEmailAddressAsync(SampleDataService.FREE_USER_EMAIL); + Assert.NotNull(user); + + var currentPlanDate = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(currentPlanDate); + _billingManager.ApplyBillingPlan(organization, _plans.LargePlan, user); + organization.CreatedUtc = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc); + organization.Usage = + [ + new UsageInfo { Date = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), Limit = _plans.MediumPlan.MaxEventsPerMonth }, + new UsageInfo { Date = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Limit = _plans.LargePlan.MaxEventsPerMonth } + ]; + organization.StripeCustomerId = "cus_usage_history"; + organization.CardLast4 = "4242"; + organization.SubscribeDate = currentPlanDate; + organization.BillingStatus = BillingStatus.Active; + await _organizationRepository.SaveAsync(organization, o => o.ImmediateConsistency().Cache().Originals()); + + // Act + var viewOrganization = await SendRequestAsAsync(r => r + .AsFreeOrganizationUser() + .AppendPaths("organizations", SampleDataService.FREE_ORG_ID) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(viewOrganization); + Assert.DoesNotContain(viewOrganization.Usage, usage => usage.Date < new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.Equal(_plans.MediumPlan.MaxEventsPerMonth, viewOrganization.Usage.Single(u => u.Date == new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(_plans.MediumPlan.MaxEventsPerMonth, viewOrganization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(_plans.MediumPlan.MaxEventsPerMonth, viewOrganization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(_plans.LargePlan.MaxEventsPerMonth, viewOrganization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + [Fact] public async Task GetAsync_WithStatsMode_ReturnsPopulatedViewOrganization() { diff --git a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs index b753d27f79..2014db2a42 100644 --- a/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs +++ b/tests/Exceptionless.Tests/Billing/BillingManagerTests.cs @@ -1,4 +1,5 @@ using Exceptionless.Core.Billing; +using Exceptionless.Core.Models; using Xunit; namespace Exceptionless.Tests.Billing; @@ -7,6 +8,222 @@ public class BillingManagerTests : TestWithServices { public BillingManagerTests(ITestOutputHelper output) : base(output) { } + [Fact] + public void ApplyBillingPlan_BonusWithoutUsage_CreatesPreviousPlanAnchorWithoutBonus() + { + // Arrange + var billingManager = GetService(); + var plans = GetService(); + TimeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new Organization + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.SmallPlan.Id, + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth, + BonusEventsPerMonth = 5_000, + BonusExpiration = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc) + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.MediumPlan); + + // Assert + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, + organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void ApplyBillingPlan_ExistingBonus_PreservesPreviousPlanAnchorWithBonus() + { + // Arrange + var billingManager = GetService(); + var plans = GetService(); + TimeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new Organization + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.SmallPlan.Id, + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth, + BonusEventsPerMonth = 5_000, + BonusExpiration = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc), + Usage = + [ + new UsageInfo + { + Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), + Limit = plans.SmallPlan.MaxEventsPerMonth + 5_000 + } + ] + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.MediumPlan); + + // Assert + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth + organization.BonusEventsPerMonth, + organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void ApplyBillingPlan_ExistingPlanWithoutUsage_CreatesPreviousPlanAnchor() + { + // Arrange + var billingManager = GetService(); + var plans = GetService(); + var utcNow = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(utcNow); + var organization = new Organization + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.SmallPlan.Id, + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.MediumPlan); + + // Assert + Assert.Equal(2, organization.Usage.Count); + Assert.DoesNotContain(organization.Usage, usage => usage.Date < new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(plans.MediumPlan.MaxEventsPerMonth, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void ApplyBillingPlan_ExistingZeroLimit_ReplacesPreviousPlanAnchor() + { + // Arrange + var billingManager = GetService(); + var plans = GetService(); + TimeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new Organization + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.SmallPlan.Id, + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth, + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 0 } + ] + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.MediumPlan); + + // Assert + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, + organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void ApplyBillingPlan_MissingPreviousLimit_DoesNotInventPreviousPlanHistory() + { + // Arrange + var billingManager = GetService(); + var plans = GetService(); + TimeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new Organization + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.SmallPlan.Id + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.MediumPlan); + + // Assert + var usage = Assert.Single(organization.Usage); + Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), usage.Date); + Assert.Equal(plans.MediumPlan.MaxEventsPerMonth, usage.Limit); + } + + [Fact] + public void ApplyBillingPlan_NewOrganization_DoesNotInventPreviousPlanHistory() + { + // Arrange + var billingManager = GetService(); + var plans = GetService(); + var utcNow = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); + TimeProvider.SetUtcNow(utcNow); + var organization = new Organization(); + + // Act + billingManager.ApplyBillingPlan(organization, plans.FreePlan); + + // Assert + var usage = Assert.Single(organization.Usage); + Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), usage.Date); + Assert.Equal(plans.FreePlan.MaxEventsPerMonth, usage.Limit); + } + + [Fact] + public void ApplyBillingPlan_OrganizationCreatedThisMonth_DoesNotInventPreviousPlanHistory() + { + // Arrange + var billingManager = GetService(); + var plans = GetService(); + TimeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new Organization + { + CreatedUtc = new DateTime(2026, 6, 10, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.FreePlan.Id, + MaxEventsPerMonth = plans.FreePlan.MaxEventsPerMonth + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.SmallPlan); + + // Assert + var usage = Assert.Single(organization.Usage); + Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), usage.Date); + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, usage.Limit); + } + + [Fact] + public void ApplyBillingPlan_UnchangedPlan_DoesNotCreatePreviousPlanAnchor() + { + // Arrange + var billingManager = GetService(); + var plans = GetService(); + TimeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new Organization + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.SmallPlan.Id.ToUpperInvariant(), + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.SmallPlan); + + // Assert + var usage = Assert.Single(organization.Usage); + Assert.Equal(new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), usage.Date); + Assert.Equal(plans.SmallPlan.MaxEventsPerMonth, usage.Limit); + } + + [Fact] + public void ApplyBillingPlan_UnchangedPlan_UpdatesBillingChangeDate() + { + // Arrange + var billingManager = GetService(); + var plans = GetService(); + TimeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new Organization + { + CreatedUtc = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc), + PlanId = plans.SmallPlan.Id, + MaxEventsPerMonth = plans.SmallPlan.MaxEventsPerMonth, + BillingChangeDate = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc) + }; + + // Act + billingManager.ApplyBillingPlan(organization, plans.SmallPlan); + + // Assert + Assert.Equal(TimeProvider.GetUtcNow().UtcDateTime, organization.BillingChangeDate); + } + [Fact] public void GetBillingPlan() { diff --git a/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs new file mode 100644 index 0000000000..0d6ab350df --- /dev/null +++ b/tests/Exceptionless.Tests/Extensions/ViewOrganizationExtensionsTests.cs @@ -0,0 +1,145 @@ +using Exceptionless.Core.Models; +using Exceptionless.Tests.Utility; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Extensions; + +public class ViewOrganizationExtensionsTests +{ + private readonly ProxyTimeProvider _timeProvider = new(); + + [Fact] + public void EnsureUsage_ActiveBonus_AppliesBonusWhenHistoryIsEmpty() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new ViewOrganization + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = 15_000, + BonusEventsPerMonth = 5_000, + BonusExpiration = new DateTime(2026, 7, 15, 0, 0, 0, DateTimeKind.Utc) + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(3, organization.Usage.Count); + Assert.All(organization.Usage, usage => Assert.Equal(20_000, usage.Limit)); + } + + [Fact] + public void EnsureUsage_ExistingUsage_PrefillsFromOldestKnownLimit() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new ViewOrganization + { + CreatedUtc = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = 15_000, + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 3_000 }, + new UsageInfo { Date = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 15_000 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(3_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(3_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void EnsureUsage_NonzeroUsage_PreservesPersistedLimit() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new ViewOrganization + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = 15_000, + BonusEventsPerMonth = 60_000, + BonusExpiration = new DateTime(2026, 7, 15, 0, 0, 0, DateTimeKind.Utc), + Usage = [new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 }] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void EnsureUsage_SparseHistory_CarriesKnownLimitsBetweenChanges() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new ViewOrganization + { + CreatedUtc = new DateTime(2026, 2, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = 75_000, + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 3_000 }, + new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 75_000 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(3_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(3_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + Assert.Equal(75_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } + + [Fact] + public void EnsureUsage_UnlimitedPlan_CarriesUnlimitedLimit() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new ViewOrganization + { + CreatedUtc = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = -1 + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(2, organization.Usage.Count); + Assert.All(organization.Usage, usage => Assert.Equal(-1, usage.Limit)); + } + + [Fact] + public void EnsureUsage_UnsetLimit_ReplacesItWithKnownLimit() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organization = new ViewOrganization + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), + MaxEventsPerMonth = 15_000, + Usage = + [ + new UsageInfo { Date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 3_000 }, + new UsageInfo { Date = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc), Limit = 0 } + ] + }; + + // Act + organization.EnsureUsage(_timeProvider); + + // Assert + Assert.Equal(3_000, organization.Usage.Single(u => u.Date == new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc)).Limit); + } +} diff --git a/tests/Exceptionless.Tests/Extensions/ViewProjectExtensionsTests.cs b/tests/Exceptionless.Tests/Extensions/ViewProjectExtensionsTests.cs new file mode 100644 index 0000000000..62978e5e00 --- /dev/null +++ b/tests/Exceptionless.Tests/Extensions/ViewProjectExtensionsTests.cs @@ -0,0 +1,29 @@ +using Exceptionless.Tests.Utility; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Extensions; + +public class ViewProjectExtensionsTests +{ + private readonly ProxyTimeProvider _timeProvider = new(); + + [Fact] + public void EnsureUsage_YoungOrganization_DoesNotCreateUsageBeforeProject() + { + // Arrange + _timeProvider.SetUtcNow(new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc)); + var organizationCreatedUtc = new DateTime(2026, 3, 15, 0, 0, 0, DateTimeKind.Utc); + var project = new ViewProject + { + CreatedUtc = new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc) + }; + + // Act + project.EnsureUsage(15_000, organizationCreatedUtc, _timeProvider); + + // Assert + Assert.Equal(2, project.Usage.Count); + Assert.DoesNotContain(project.Usage, usage => usage.Date < new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc)); + } +}